orfeas_lyra 0.6.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 +7 -0
- data/CHANGELOG.md +222 -0
- data/LICENSE +21 -0
- data/README.md +1165 -0
- data/Rakefile +728 -0
- data/app/controllers/lyra/application_controller.rb +23 -0
- data/app/controllers/lyra/dashboard_controller.rb +624 -0
- data/app/controllers/lyra/flow_controller.rb +224 -0
- data/app/controllers/lyra/privacy_controller.rb +182 -0
- data/app/views/lyra/dashboard/audit_trail.html.erb +324 -0
- data/app/views/lyra/dashboard/discrepancies.html.erb +125 -0
- data/app/views/lyra/dashboard/event_graph_view.html.erb +525 -0
- data/app/views/lyra/dashboard/heatmap_view.html.erb +155 -0
- data/app/views/lyra/dashboard/index.html.erb +119 -0
- data/app/views/lyra/dashboard/model_overview.html.erb +115 -0
- data/app/views/lyra/dashboard/projections.html.erb +302 -0
- data/app/views/lyra/dashboard/schema.html.erb +283 -0
- data/app/views/lyra/dashboard/schema_history.html.erb +78 -0
- data/app/views/lyra/dashboard/schema_version.html.erb +340 -0
- data/app/views/lyra/dashboard/verification.html.erb +370 -0
- data/app/views/lyra/flow/crud_mapping.html.erb +125 -0
- data/app/views/lyra/flow/timeline.html.erb +260 -0
- data/app/views/lyra/privacy/pii_detection.html.erb +148 -0
- data/app/views/lyra/privacy/policy.html.erb +188 -0
- data/app/workflows/es_async_mode_workflow.rb +80 -0
- data/app/workflows/es_sync_mode_workflow.rb +64 -0
- data/app/workflows/hijack_mode_workflow.rb +54 -0
- data/app/workflows/lifecycle_workflow.rb +43 -0
- data/app/workflows/monitor_mode_workflow.rb +39 -0
- data/config/privacy_policies.rb +273 -0
- data/config/routes.rb +48 -0
- data/lib/lyra/aggregate.rb +131 -0
- data/lib/lyra/associations/event_aware.rb +225 -0
- data/lib/lyra/command.rb +81 -0
- data/lib/lyra/command_handler.rb +155 -0
- data/lib/lyra/configuration.rb +124 -0
- data/lib/lyra/consistency/read_your_writes.rb +91 -0
- data/lib/lyra/correlation.rb +144 -0
- data/lib/lyra/dual_view.rb +231 -0
- data/lib/lyra/engine.rb +67 -0
- data/lib/lyra/event.rb +71 -0
- data/lib/lyra/event_analyzer.rb +135 -0
- data/lib/lyra/event_flow.rb +449 -0
- data/lib/lyra/event_mapper.rb +106 -0
- data/lib/lyra/event_store_adapter.rb +72 -0
- data/lib/lyra/id_generator.rb +137 -0
- data/lib/lyra/interceptors/association_interceptor.rb +169 -0
- data/lib/lyra/interceptors/crud_interceptor.rb +543 -0
- data/lib/lyra/privacy/gdpr_compliance.rb +161 -0
- data/lib/lyra/privacy/pii_detector.rb +85 -0
- data/lib/lyra/privacy/pii_masker.rb +66 -0
- data/lib/lyra/privacy/policy_integration.rb +253 -0
- data/lib/lyra/projection.rb +94 -0
- data/lib/lyra/projections/async_projection_job.rb +63 -0
- data/lib/lyra/projections/cached_projection.rb +322 -0
- data/lib/lyra/projections/cached_relation.rb +757 -0
- data/lib/lyra/projections/event_store_reader.rb +127 -0
- data/lib/lyra/projections/model_projection.rb +143 -0
- data/lib/lyra/schema/diff.rb +331 -0
- data/lib/lyra/schema/event_class_registrar.rb +63 -0
- data/lib/lyra/schema/generator.rb +190 -0
- data/lib/lyra/schema/reporter.rb +188 -0
- data/lib/lyra/schema/store.rb +156 -0
- data/lib/lyra/schema/validator.rb +100 -0
- data/lib/lyra/strict_data_access.rb +363 -0
- data/lib/lyra/verification/crud_lifecycle_workflow.rb +456 -0
- data/lib/lyra/verification/workflow_generator.rb +540 -0
- data/lib/lyra/version.rb +3 -0
- data/lib/lyra/visualization/activity_heatmap.rb +215 -0
- data/lib/lyra/visualization/event_graph.rb +310 -0
- data/lib/lyra/visualization/timeline.rb +398 -0
- data/lib/lyra.rb +150 -0
- data/lib/tasks/dist.rake +391 -0
- data/lib/tasks/gems.rake +185 -0
- data/lib/tasks/lyra_schema.rake +231 -0
- data/lib/tasks/lyra_workflows.rake +452 -0
- data/lib/tasks/public_release.rake +351 -0
- data/lib/tasks/stats.rake +175 -0
- data/lib/tasks/testbed.rake +479 -0
- data/lib/tasks/version.rake +159 -0
- metadata +221 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
<style>
|
|
2
|
+
.lyra-timeline { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 1200px; margin: 0 auto; padding: 20px; }
|
|
3
|
+
.lyra-timeline h1 { color: #1a1a2e; border-bottom: 2px solid #4a4e69; padding-bottom: 10px; }
|
|
4
|
+
.lyra-timeline h2 { color: #4a4e69; margin-top: 30px; }
|
|
5
|
+
.lyra-timeline .back-link { display: inline-block; margin-bottom: 20px; color: #4a4e69; text-decoration: none; }
|
|
6
|
+
.lyra-timeline .back-link:hover { text-decoration: underline; }
|
|
7
|
+
.lyra-timeline .summary-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 15px; margin: 20px 0; }
|
|
8
|
+
.lyra-timeline .summary-card { background: #fff; border: 1px solid #dee2e6; border-radius: 8px; padding: 15px; text-align: center; box-shadow: 0 2px 4px rgba(0,0,0,0.05); }
|
|
9
|
+
.lyra-timeline .summary-card .value { font-size: 28px; font-weight: 700; color: #1a1a2e; }
|
|
10
|
+
.lyra-timeline .summary-card .label { color: #6c757d; font-size: 13px; margin-top: 5px; }
|
|
11
|
+
.lyra-timeline .stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; margin-top: 20px; }
|
|
12
|
+
.lyra-timeline .stats-card { background: #fff; border: 1px solid #dee2e6; border-radius: 8px; padding: 20px; }
|
|
13
|
+
.lyra-timeline .stats-card h3 { margin: 0 0 15px 0; color: #1a1a2e; font-size: 16px; }
|
|
14
|
+
.lyra-timeline .stats-list { list-style: none; padding: 0; margin: 0; }
|
|
15
|
+
.lyra-timeline .stats-list li { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #eee; }
|
|
16
|
+
.lyra-timeline .stats-list li:last-child { border-bottom: none; }
|
|
17
|
+
.lyra-timeline .stats-list .stat-name { color: #495057; }
|
|
18
|
+
.lyra-timeline .stats-list .stat-value { font-weight: 600; color: #1a1a2e; }
|
|
19
|
+
.lyra-timeline .events-list { margin-top: 20px; }
|
|
20
|
+
.lyra-timeline .event-card { background: #fff; border: 1px solid #dee2e6; border-radius: 8px; margin-bottom: 12px; overflow: hidden; }
|
|
21
|
+
.lyra-timeline .event-header { background: #f8f9fa; padding: 12px 15px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #dee2e6; }
|
|
22
|
+
.lyra-timeline .event-header .operation { padding: 4px 10px; border-radius: 4px; font-size: 12px; font-weight: 600; text-transform: uppercase; }
|
|
23
|
+
.lyra-timeline .operation-created { background: #d4edda; color: #155724; }
|
|
24
|
+
.lyra-timeline .operation-updated { background: #fff3cd; color: #856404; }
|
|
25
|
+
.lyra-timeline .operation-destroyed { background: #f8d7da; color: #721c24; }
|
|
26
|
+
.lyra-timeline .event-header .timestamp { color: #6c757d; font-size: 13px; }
|
|
27
|
+
.lyra-timeline .event-body { padding: 15px; }
|
|
28
|
+
.lyra-timeline .event-meta { display: flex; flex-wrap: wrap; gap: 15px; margin-bottom: 10px; }
|
|
29
|
+
.lyra-timeline .event-meta-item { font-size: 13px; }
|
|
30
|
+
.lyra-timeline .event-meta-item .label { color: #6c757d; }
|
|
31
|
+
.lyra-timeline .event-meta-item .value { color: #1a1a2e; font-weight: 500; }
|
|
32
|
+
.lyra-timeline .changes-table { width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 10px; }
|
|
33
|
+
.lyra-timeline .changes-table th { text-align: left; padding: 8px; background: #f8f9fa; border-bottom: 1px solid #dee2e6; }
|
|
34
|
+
.lyra-timeline .changes-table td { padding: 8px; border-bottom: 1px solid #eee; }
|
|
35
|
+
.lyra-timeline .pii-badge { background: #f8d7da; color: #721c24; padding: 2px 6px; border-radius: 3px; font-size: 11px; font-weight: 600; margin-left: 5px; }
|
|
36
|
+
.lyra-timeline .empty-state { text-align: center; padding: 40px; color: #6c757d; background: #fff; border: 1px solid #dee2e6; border-radius: 8px; }
|
|
37
|
+
.lyra-timeline .correlation-badge { background: #e9ecef; color: #495057; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-family: monospace; }
|
|
38
|
+
.lyra-timeline .filter-bar { background: #fff; border: 1px solid #dee2e6; border-radius: 8px; padding: 15px 20px; margin-bottom: 20px; display: flex; gap: 15px; align-items: center; flex-wrap: wrap; }
|
|
39
|
+
.lyra-timeline .filter-bar label { font-size: 13px; color: #495057; font-weight: 500; }
|
|
40
|
+
.lyra-timeline .filter-bar select { padding: 6px 12px; border: 1px solid #dee2e6; border-radius: 4px; font-size: 13px; background: #fff; min-width: 150px; }
|
|
41
|
+
.lyra-timeline .filter-bar button { padding: 6px 16px; background: #4a4e69; color: #fff; border: none; border-radius: 4px; font-size: 13px; cursor: pointer; }
|
|
42
|
+
.lyra-timeline .filter-bar button:hover { background: #22223b; }
|
|
43
|
+
.lyra-timeline .filter-bar .clear-link { color: #6c757d; font-size: 13px; text-decoration: none; }
|
|
44
|
+
.lyra-timeline .filter-bar .clear-link:hover { text-decoration: underline; }
|
|
45
|
+
.lyra-timeline .filter-active { background: #fff3cd; border-color: #ffc107; }
|
|
46
|
+
.lyra-timeline .filter-count { font-size: 13px; color: #6c757d; }
|
|
47
|
+
.lyra-timeline .expandable { position: relative; }
|
|
48
|
+
.lyra-timeline .expandable .truncated { display: inline; }
|
|
49
|
+
.lyra-timeline .expandable .full { display: none; word-break: break-all; }
|
|
50
|
+
.lyra-timeline .expandable.expanded .truncated { display: none; }
|
|
51
|
+
.lyra-timeline .expandable.expanded .full { display: inline; }
|
|
52
|
+
.lyra-timeline .expand-link { color: #4a4e69; font-size: 11px; cursor: pointer; margin-left: 4px; text-decoration: underline; }
|
|
53
|
+
.lyra-timeline .expand-link:hover { color: #22223b; }
|
|
54
|
+
</style>
|
|
55
|
+
|
|
56
|
+
<script>
|
|
57
|
+
function toggleExpand(el) {
|
|
58
|
+
var container = el.closest('.expandable');
|
|
59
|
+
container.classList.toggle('expanded');
|
|
60
|
+
el.textContent = container.classList.contains('expanded') ? 'less' : 'more';
|
|
61
|
+
}
|
|
62
|
+
</script>
|
|
63
|
+
|
|
64
|
+
<div class="lyra-timeline">
|
|
65
|
+
<%= link_to "← Back to Dashboard".html_safe, dashboard_path, class: "back-link" %>
|
|
66
|
+
|
|
67
|
+
<h1>Event Timeline</h1>
|
|
68
|
+
<p style="color: #6c757d; margin-bottom: 20px;">
|
|
69
|
+
Chronological view of all events captured by Lyra. Each event represents a CRUD operation
|
|
70
|
+
on a monitored model, including field changes and metadata like correlation IDs
|
|
71
|
+
for tracing related operations.
|
|
72
|
+
</p>
|
|
73
|
+
|
|
74
|
+
<h2 style="margin-top: 0;">Overview</h2>
|
|
75
|
+
<p style="color: #6c757d; font-size: 13px; margin-bottom: 15px;">
|
|
76
|
+
Summary of event activity. Each CRUD operation (Create, Update, Delete) on monitored models generates an event.
|
|
77
|
+
</p>
|
|
78
|
+
<div class="summary-cards">
|
|
79
|
+
<div class="summary-card">
|
|
80
|
+
<div class="value"><%= @statistics[:total_events] || @timeline.count %></div>
|
|
81
|
+
<div class="label">Total Events</div>
|
|
82
|
+
</div>
|
|
83
|
+
<% (@statistics[:operations] || {}).each do |op, count| %>
|
|
84
|
+
<div class="summary-card">
|
|
85
|
+
<div class="value"><%= count %></div>
|
|
86
|
+
<div class="label"><%= op.to_s.titleize %></div>
|
|
87
|
+
</div>
|
|
88
|
+
<% end %>
|
|
89
|
+
<div class="summary-card">
|
|
90
|
+
<div class="value"><%= @statistics[:users] || 0 %></div>
|
|
91
|
+
<div class="label">Unique Users</div>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<h2>Statistics</h2>
|
|
96
|
+
<p style="color: #6c757d; font-size: 13px; margin-bottom: 15px;">
|
|
97
|
+
Breakdown by model and privacy impact. Use this to identify which models are most active and which contain sensitive data.
|
|
98
|
+
</p>
|
|
99
|
+
<div class="stats-grid">
|
|
100
|
+
<% if (@statistics[:models] || {}).any? %>
|
|
101
|
+
<div class="stats-card">
|
|
102
|
+
<h3>Events by Model</h3>
|
|
103
|
+
<ul class="stats-list">
|
|
104
|
+
<% (@statistics[:models] || {}).each do |model, count| %>
|
|
105
|
+
<li>
|
|
106
|
+
<span class="stat-name"><%= model %></span>
|
|
107
|
+
<span class="stat-value"><%= count %></span>
|
|
108
|
+
</li>
|
|
109
|
+
<% end %>
|
|
110
|
+
</ul>
|
|
111
|
+
</div>
|
|
112
|
+
<% end %>
|
|
113
|
+
|
|
114
|
+
<% if @privacy_impact[:pii_categories]&.any? %>
|
|
115
|
+
<div class="stats-card">
|
|
116
|
+
<h3>Privacy Impact</h3>
|
|
117
|
+
<ul class="stats-list">
|
|
118
|
+
<li>
|
|
119
|
+
<span class="stat-name">PII Categories</span>
|
|
120
|
+
<span class="stat-value"><%= @privacy_impact[:pii_categories].count %></span>
|
|
121
|
+
</li>
|
|
122
|
+
<li>
|
|
123
|
+
<span class="stat-name">Total PII Fields</span>
|
|
124
|
+
<span class="stat-value"><%= @privacy_impact[:total_pii_fields] || 0 %></span>
|
|
125
|
+
</li>
|
|
126
|
+
<li>
|
|
127
|
+
<span class="stat-name">Sensitive Data</span>
|
|
128
|
+
<span class="stat-value"><%= @privacy_impact[:sensitive_data_present] ? 'Yes' : 'No' %></span>
|
|
129
|
+
</li>
|
|
130
|
+
</ul>
|
|
131
|
+
</div>
|
|
132
|
+
<% end %>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
<h2>Recent Events</h2>
|
|
136
|
+
<p style="color: #6c757d; font-size: 13px; margin-bottom: 15px;">
|
|
137
|
+
Individual events with full change details. Use filters to narrow down by model or operation type.
|
|
138
|
+
<% if pam_dsl_available? %>
|
|
139
|
+
Events with <span class="pii-badge" style="display: inline;">PII</span> badge contain personally identifiable information.
|
|
140
|
+
<% end %>
|
|
141
|
+
</p>
|
|
142
|
+
|
|
143
|
+
<% has_filters = @filter_model.present? || @filter_operation.present? %>
|
|
144
|
+
<div class="filter-bar <%= 'filter-active' if has_filters %>">
|
|
145
|
+
<%= form_tag(timeline_path, method: :get) do %>
|
|
146
|
+
<label>Model:</label>
|
|
147
|
+
<%= select_tag :model,
|
|
148
|
+
options_for_select([['All Models', '']] + @available_models.map { |m| [m, m] }, @filter_model),
|
|
149
|
+
onchange: 'this.form.submit()' %>
|
|
150
|
+
|
|
151
|
+
<label>Operation:</label>
|
|
152
|
+
<%= select_tag :operation,
|
|
153
|
+
options_for_select([['All Operations', '']] + @available_operations.map { |o| [o.to_s.titleize, o] }, @filter_operation),
|
|
154
|
+
onchange: 'this.form.submit()' %>
|
|
155
|
+
|
|
156
|
+
<% if has_filters %>
|
|
157
|
+
<%= link_to 'Clear filters', timeline_path, class: 'clear-link' %>
|
|
158
|
+
<span class="filter-count">Showing <%= @timeline.count %> events</span>
|
|
159
|
+
<% end %>
|
|
160
|
+
<% end %>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<% if @timeline.any? %>
|
|
164
|
+
<div class="events-list">
|
|
165
|
+
<% @timeline.first(50).each do |event| %>
|
|
166
|
+
<div class="event-card">
|
|
167
|
+
<div class="event-header">
|
|
168
|
+
<div>
|
|
169
|
+
<span class="operation operation-<%= event[:operation] %>"><%= event[:operation] %></span>
|
|
170
|
+
<strong style="margin-left: 10px;"><%= event[:model_class] %>#<%= event[:model_id] %></strong>
|
|
171
|
+
<% if event[:pii_fields]&.any? %>
|
|
172
|
+
<span class="pii-badge">PII</span>
|
|
173
|
+
<% end %>
|
|
174
|
+
</div>
|
|
175
|
+
<span class="timestamp"><%= event[:timestamp] %></span>
|
|
176
|
+
</div>
|
|
177
|
+
<div class="event-body">
|
|
178
|
+
<div class="event-meta">
|
|
179
|
+
<div class="event-meta-item">
|
|
180
|
+
<span class="label">Event ID: </span>
|
|
181
|
+
<span class="value" style="font-family: monospace; font-size: 11px;"><%= event[:event_id] %></span>
|
|
182
|
+
</div>
|
|
183
|
+
<% if event[:correlation_id] %>
|
|
184
|
+
<div class="event-meta-item">
|
|
185
|
+
<span class="label">Correlation: </span>
|
|
186
|
+
<span class="correlation-badge"><%= event[:correlation_id] %></span>
|
|
187
|
+
</div>
|
|
188
|
+
<% end %>
|
|
189
|
+
<% if event[:user_action] %>
|
|
190
|
+
<div class="event-meta-item">
|
|
191
|
+
<span class="label">Action: </span>
|
|
192
|
+
<span class="value"><%= event[:user_action] %></span>
|
|
193
|
+
</div>
|
|
194
|
+
<% end %>
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
<% if event[:changes].present? && event[:changes].any? %>
|
|
198
|
+
<table class="changes-table">
|
|
199
|
+
<thead>
|
|
200
|
+
<tr>
|
|
201
|
+
<th>Field</th>
|
|
202
|
+
<th>Old Value</th>
|
|
203
|
+
<th>New Value</th>
|
|
204
|
+
</tr>
|
|
205
|
+
</thead>
|
|
206
|
+
<tbody>
|
|
207
|
+
<% event[:changes].each do |field, (old_val, new_val)| %>
|
|
208
|
+
<tr>
|
|
209
|
+
<td>
|
|
210
|
+
<strong><%= field %></strong>
|
|
211
|
+
<% if event[:pii_fields]&.key?(field.to_sym) || event[:pii_fields]&.key?(field.to_s) %>
|
|
212
|
+
<% pii_info = event[:pii_fields][field.to_sym] || event[:pii_fields][field.to_s] %>
|
|
213
|
+
<span class="pii-badge" title="<%= pii_info[:type].to_s.titleize %> - PII"><%= pii_info[:type] %></span>
|
|
214
|
+
<% end %>
|
|
215
|
+
</td>
|
|
216
|
+
<td>
|
|
217
|
+
<% old_str = old_val.to_s %>
|
|
218
|
+
<% if old_str.length > 40 %>
|
|
219
|
+
<span class="expandable">
|
|
220
|
+
<span class="truncated"><%= truncate(old_str, length: 40) %></span>
|
|
221
|
+
<span class="full"><%= old_str %></span>
|
|
222
|
+
<a class="expand-link" onclick="toggleExpand(this)">more</a>
|
|
223
|
+
</span>
|
|
224
|
+
<% else %>
|
|
225
|
+
<%= old_str %>
|
|
226
|
+
<% end %>
|
|
227
|
+
</td>
|
|
228
|
+
<td>
|
|
229
|
+
<% new_str = new_val.to_s %>
|
|
230
|
+
<% if new_str.length > 40 %>
|
|
231
|
+
<span class="expandable">
|
|
232
|
+
<span class="truncated"><%= truncate(new_str, length: 40) %></span>
|
|
233
|
+
<span class="full"><%= new_str %></span>
|
|
234
|
+
<a class="expand-link" onclick="toggleExpand(this)">more</a>
|
|
235
|
+
</span>
|
|
236
|
+
<% else %>
|
|
237
|
+
<%= new_str %>
|
|
238
|
+
<% end %>
|
|
239
|
+
</td>
|
|
240
|
+
</tr>
|
|
241
|
+
<% end %>
|
|
242
|
+
</tbody>
|
|
243
|
+
</table>
|
|
244
|
+
<% end %>
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
<% end %>
|
|
248
|
+
</div>
|
|
249
|
+
<% if @timeline.count > 50 %>
|
|
250
|
+
<p style="text-align: center; color: #6c757d; margin-top: 20px;">
|
|
251
|
+
Showing 50 of <%= @timeline.count %> events. Use the JSON API for full data.
|
|
252
|
+
</p>
|
|
253
|
+
<% end %>
|
|
254
|
+
<% else %>
|
|
255
|
+
<div class="empty-state">
|
|
256
|
+
<p>No events found in the timeline.</p>
|
|
257
|
+
<p>Events will appear here as CRUD operations are performed on monitored models.</p>
|
|
258
|
+
</div>
|
|
259
|
+
<% end %>
|
|
260
|
+
</div>
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
<style>
|
|
2
|
+
.lyra-pii-detection { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 1200px; margin: 0 auto; padding: 20px; }
|
|
3
|
+
.lyra-pii-detection h1 { color: #1a1a2e; border-bottom: 2px solid #4a4e69; padding-bottom: 10px; }
|
|
4
|
+
.lyra-pii-detection h2 { color: #4a4e69; margin-top: 30px; }
|
|
5
|
+
.lyra-pii-detection .summary-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 20px; margin: 20px 0; }
|
|
6
|
+
.lyra-pii-detection .summary-card { background: #fff; border: 1px solid #dee2e6; border-radius: 8px; padding: 20px; text-align: center; box-shadow: 0 2px 4px rgba(0,0,0,0.05); }
|
|
7
|
+
.lyra-pii-detection .summary-card .value { font-size: 36px; font-weight: 700; color: #1a1a2e; }
|
|
8
|
+
.lyra-pii-detection .summary-card .label { color: #6c757d; font-size: 14px; margin-top: 5px; }
|
|
9
|
+
.lyra-pii-detection .pii-category { background: #fff; border: 1px solid #dee2e6; border-radius: 8px; margin-bottom: 15px; overflow: hidden; }
|
|
10
|
+
.lyra-pii-detection .pii-category-header { background: #f8f9fa; padding: 15px 20px; border-bottom: 1px solid #dee2e6; display: flex; justify-content: space-between; align-items: center; }
|
|
11
|
+
.lyra-pii-detection .pii-category-header h3 { margin: 0; color: #1a1a2e; font-size: 16px; }
|
|
12
|
+
.lyra-pii-detection .pii-category-header .count { background: #4a4e69; color: #fff; padding: 4px 12px; border-radius: 12px; font-size: 12px; }
|
|
13
|
+
.lyra-pii-detection .pii-fields { padding: 15px 20px; }
|
|
14
|
+
.lyra-pii-detection .pii-field { display: flex; align-items: center; padding: 10px 0; border-bottom: 1px solid #eee; flex-wrap: wrap; gap: 8px; }
|
|
15
|
+
.lyra-pii-detection .pii-field:last-child { border-bottom: none; }
|
|
16
|
+
.lyra-pii-detection .field-name { font-weight: 500; color: #1a1a2e; min-width: 150px; }
|
|
17
|
+
.lyra-pii-detection .field-model { background: #e9ecef; color: #495057; padding: 2px 8px; border-radius: 4px; font-size: 12px; }
|
|
18
|
+
.lyra-pii-detection .field-occurrences { color: #6c757d; font-size: 13px; margin-left: auto; }
|
|
19
|
+
.lyra-pii-detection .badge-sensitive { background: #f8d7da; color: #721c24; }
|
|
20
|
+
.lyra-pii-detection .badge-contact { background: #cce5ff; color: #004085; }
|
|
21
|
+
.lyra-pii-detection .badge-identifier { background: #fff3cd; color: #856404; }
|
|
22
|
+
.lyra-pii-detection .back-link { display: inline-block; margin-bottom: 20px; color: #4a4e69; text-decoration: none; }
|
|
23
|
+
.lyra-pii-detection .back-link:hover { text-decoration: underline; }
|
|
24
|
+
.lyra-pii-detection .empty-state { text-align: center; padding: 40px; color: #6c757d; }
|
|
25
|
+
.lyra-pii-detection .category-badges { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 15px; }
|
|
26
|
+
.lyra-pii-detection .category-badge { padding: 6px 12px; border-radius: 6px; font-size: 13px; font-weight: 500; }
|
|
27
|
+
.lyra-pii-detection .sample-value { font-family: monospace; background: #f8f9fa; padding: 2px 6px; border-radius: 3px; font-size: 12px; color: #495057; }
|
|
28
|
+
.lyra-pii-detection .expandable { position: relative; }
|
|
29
|
+
.lyra-pii-detection .expandable .truncated { display: inline; }
|
|
30
|
+
.lyra-pii-detection .expandable .full { display: none; word-break: break-all; }
|
|
31
|
+
.lyra-pii-detection .expandable.expanded .truncated { display: none; }
|
|
32
|
+
.lyra-pii-detection .expandable.expanded .full { display: inline; }
|
|
33
|
+
.lyra-pii-detection .expand-link { color: #4a4e69; font-size: 11px; cursor: pointer; margin-left: 4px; text-decoration: underline; }
|
|
34
|
+
.lyra-pii-detection .expand-link:hover { color: #22223b; }
|
|
35
|
+
</style>
|
|
36
|
+
|
|
37
|
+
<script>
|
|
38
|
+
function toggleExpand(el) {
|
|
39
|
+
var container = el.closest('.expandable');
|
|
40
|
+
container.classList.toggle('expanded');
|
|
41
|
+
el.textContent = container.classList.contains('expanded') ? 'less' : 'more';
|
|
42
|
+
}
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<div class="lyra-pii-detection">
|
|
46
|
+
<%= link_to "← Back to Dashboard".html_safe, dashboard_path, class: "back-link" %>
|
|
47
|
+
|
|
48
|
+
<h1>PII Detection</h1>
|
|
49
|
+
<p style="color: #6c757d; margin-bottom: 20px;">
|
|
50
|
+
Automatically detects Personally Identifiable Information (PII) in your event store.
|
|
51
|
+
Fields like email, phone, address, and financial data are identified and categorized
|
|
52
|
+
for GDPR compliance tracking.
|
|
53
|
+
</p>
|
|
54
|
+
|
|
55
|
+
<h2 style="margin-top: 0;">Overview</h2>
|
|
56
|
+
<p class="section-desc" style="color: #6c757d; font-size: 13px; margin-bottom: 15px;">
|
|
57
|
+
Key metrics about PII in your event store. Higher numbers may indicate increased GDPR compliance requirements.
|
|
58
|
+
</p>
|
|
59
|
+
<div class="summary-cards">
|
|
60
|
+
<div class="summary-card">
|
|
61
|
+
<div class="value"><%= @total_events %></div>
|
|
62
|
+
<div class="label">Total Events</div>
|
|
63
|
+
</div>
|
|
64
|
+
<div class="summary-card">
|
|
65
|
+
<div class="value"><%= @events_with_pii %></div>
|
|
66
|
+
<div class="label">Events with PII</div>
|
|
67
|
+
</div>
|
|
68
|
+
<div class="summary-card">
|
|
69
|
+
<div class="value"><%= @pii_categories.count %></div>
|
|
70
|
+
<div class="label">PII Categories</div>
|
|
71
|
+
</div>
|
|
72
|
+
<div class="summary-card">
|
|
73
|
+
<div class="value"><%= @pii_inventory.values.sum { |v| v.is_a?(Array) ? v.count : 0 } %></div>
|
|
74
|
+
<div class="label">Total PII Occurrences</div>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<% if @sensitive_pii.any? || @contact_pii.any? %>
|
|
79
|
+
<h2>PII Summary</h2>
|
|
80
|
+
<p class="section-desc" style="color: #6c757d; font-size: 13px; margin-bottom: 15px;">
|
|
81
|
+
Categories of PII found. <span style="background: #f8d7da; padding: 2px 6px; border-radius: 3px;">Red</span> indicates sensitive data requiring extra protection.
|
|
82
|
+
<span style="background: #cce5ff; padding: 2px 6px; border-radius: 3px;">Blue</span> indicates contact information.
|
|
83
|
+
</p>
|
|
84
|
+
<div class="category-badges">
|
|
85
|
+
<% @sensitive_pii.each do |cat| %>
|
|
86
|
+
<span class="category-badge badge-sensitive"><%= cat.to_s.titleize %></span>
|
|
87
|
+
<% end %>
|
|
88
|
+
<% @contact_pii.each do |cat| %>
|
|
89
|
+
<span class="category-badge badge-contact"><%= cat.to_s.titleize %></span>
|
|
90
|
+
<% end %>
|
|
91
|
+
</div>
|
|
92
|
+
<% end %>
|
|
93
|
+
|
|
94
|
+
<h2>PII Inventory by Category</h2>
|
|
95
|
+
<p class="section-desc" style="color: #6c757d; font-size: 13px; margin-bottom: 15px;">
|
|
96
|
+
Detailed breakdown of each PII field detected, grouped by category. Shows the field name, model, sample value, and occurrence count.
|
|
97
|
+
</p>
|
|
98
|
+
|
|
99
|
+
<% if @pii_inventory.any? %>
|
|
100
|
+
<% @pii_inventory.each do |category, occurrences| %>
|
|
101
|
+
<% next unless occurrences.is_a?(Array) && occurrences.any? %>
|
|
102
|
+
<%
|
|
103
|
+
# Group by field and model_class to show unique field/model combinations
|
|
104
|
+
grouped = occurrences.group_by { |o| [o[:field] || o['field'], o[:model_class] || o['model_class']] }
|
|
105
|
+
badge_class = case category
|
|
106
|
+
when :ssn, :credit_card, :health, :biometric then 'badge-sensitive'
|
|
107
|
+
when :email, :phone, :address then 'badge-contact'
|
|
108
|
+
else 'badge-identifier'
|
|
109
|
+
end
|
|
110
|
+
%>
|
|
111
|
+
<div class="pii-category">
|
|
112
|
+
<div class="pii-category-header">
|
|
113
|
+
<h3>
|
|
114
|
+
<span class="field-model <%= badge_class %>"><%= category.to_s.titleize %></span>
|
|
115
|
+
</h3>
|
|
116
|
+
<span class="count"><%= occurrences.count %> occurrence<%= occurrences.count != 1 ? 's' : '' %></span>
|
|
117
|
+
</div>
|
|
118
|
+
<div class="pii-fields">
|
|
119
|
+
<% grouped.each do |(field, model_class), items| %>
|
|
120
|
+
<div class="pii-field">
|
|
121
|
+
<span class="field-name"><%= field %></span>
|
|
122
|
+
<span class="field-model"><%= model_class || 'Unknown' %></span>
|
|
123
|
+
<% sample = items.first[:value] || items.first['value'] %>
|
|
124
|
+
<% if sample.present? %>
|
|
125
|
+
<% sample_str = sample.to_s %>
|
|
126
|
+
<% if sample_str.length > 30 %>
|
|
127
|
+
<span class="sample-value expandable">
|
|
128
|
+
<span class="truncated"><%= truncate(sample_str, length: 30) %></span>
|
|
129
|
+
<span class="full"><%= sample_str %></span>
|
|
130
|
+
<a class="expand-link" onclick="toggleExpand(this)">more</a>
|
|
131
|
+
</span>
|
|
132
|
+
<% else %>
|
|
133
|
+
<span class="sample-value"><%= sample_str %></span>
|
|
134
|
+
<% end %>
|
|
135
|
+
<% end %>
|
|
136
|
+
<span class="field-occurrences"><%= items.count %> occurrence<%= items.count != 1 ? 's' : '' %></span>
|
|
137
|
+
</div>
|
|
138
|
+
<% end %>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
<% end %>
|
|
142
|
+
<% else %>
|
|
143
|
+
<div class="empty-state">
|
|
144
|
+
<p>No PII detected in the event store.</p>
|
|
145
|
+
<p>This could mean your data doesn't contain personal information, or the PII detection patterns may need adjustment.</p>
|
|
146
|
+
</div>
|
|
147
|
+
<% end %>
|
|
148
|
+
</div>
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
<style>
|
|
2
|
+
.lyra-policy { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 1200px; margin: 0 auto; padding: 20px; }
|
|
3
|
+
.lyra-policy h1 { color: #1a1a2e; border-bottom: 2px solid #4a4e69; padding-bottom: 10px; }
|
|
4
|
+
.lyra-policy h2 { color: #4a4e69; margin-top: 30px; }
|
|
5
|
+
.lyra-policy h3 { color: #495057; margin-top: 20px; font-size: 16px; }
|
|
6
|
+
.lyra-policy .back-link { display: inline-block; margin-bottom: 20px; color: #4a4e69; text-decoration: none; }
|
|
7
|
+
.lyra-policy .back-link:hover { text-decoration: underline; }
|
|
8
|
+
.lyra-policy .policy-card { background: #fff; border: 1px solid #dee2e6; border-radius: 8px; margin-bottom: 20px; overflow: hidden; }
|
|
9
|
+
.lyra-policy .policy-header { background: #f8f9fa; padding: 15px 20px; border-bottom: 1px solid #dee2e6; display: flex; justify-content: space-between; align-items: center; }
|
|
10
|
+
.lyra-policy .policy-header h3 { margin: 0; font-size: 18px; }
|
|
11
|
+
.lyra-policy .policy-header .badge { padding: 4px 12px; border-radius: 4px; font-size: 12px; font-weight: 600; }
|
|
12
|
+
.lyra-policy .policy-header .badge-default { background: #d4edda; color: #155724; }
|
|
13
|
+
.lyra-policy .policy-body { padding: 20px; }
|
|
14
|
+
.lyra-policy .section { margin-bottom: 25px; }
|
|
15
|
+
.lyra-policy .section:last-child { margin-bottom: 0; }
|
|
16
|
+
.lyra-policy .field-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 15px; }
|
|
17
|
+
.lyra-policy .field-item { background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 6px; padding: 12px 15px; }
|
|
18
|
+
.lyra-policy .field-item .field-name { font-weight: 600; color: #1a1a2e; margin-bottom: 5px; }
|
|
19
|
+
.lyra-policy .field-item .field-meta { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
20
|
+
.lyra-policy .field-item .field-meta span { font-size: 11px; padding: 2px 8px; border-radius: 3px; }
|
|
21
|
+
.lyra-policy .type-badge { background: #cce5ff; color: #004085; }
|
|
22
|
+
.lyra-policy .sensitivity-internal { background: #d4edda; color: #155724; }
|
|
23
|
+
.lyra-policy .sensitivity-confidential { background: #fff3cd; color: #856404; }
|
|
24
|
+
.lyra-policy .sensitivity-restricted { background: #f8d7da; color: #721c24; }
|
|
25
|
+
.lyra-policy .purpose-item { background: #fff; border: 1px solid #e9ecef; border-radius: 6px; padding: 15px; margin-bottom: 10px; }
|
|
26
|
+
.lyra-policy .purpose-item h4 { margin: 0 0 8px 0; color: #1a1a2e; font-size: 15px; }
|
|
27
|
+
.lyra-policy .purpose-item p { margin: 0 0 10px 0; color: #6c757d; font-size: 13px; }
|
|
28
|
+
.lyra-policy .purpose-item .legal-basis { display: inline-block; background: #e9ecef; padding: 3px 10px; border-radius: 4px; font-size: 12px; color: #495057; }
|
|
29
|
+
.lyra-policy .purpose-item .required-fields { margin-top: 10px; font-size: 12px; color: #6c757d; }
|
|
30
|
+
.lyra-policy .retention-table { width: 100%; border-collapse: collapse; }
|
|
31
|
+
.lyra-policy .retention-table th { background: #f8f9fa; padding: 10px 15px; text-align: left; border-bottom: 1px solid #dee2e6; font-weight: 600; }
|
|
32
|
+
.lyra-policy .retention-table td { padding: 10px 15px; border-bottom: 1px solid #eee; }
|
|
33
|
+
.lyra-policy .retention-table tr:last-child td { border-bottom: none; }
|
|
34
|
+
.lyra-policy .empty-state { text-align: center; padding: 40px; color: #6c757d; background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 8px; }
|
|
35
|
+
.lyra-policy .summary-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 15px; margin: 20px 0; }
|
|
36
|
+
.lyra-policy .summary-card { background: #fff; border: 1px solid #dee2e6; border-radius: 8px; padding: 15px; text-align: center; }
|
|
37
|
+
.lyra-policy .summary-card .value { font-size: 28px; font-weight: 700; color: #1a1a2e; }
|
|
38
|
+
.lyra-policy .summary-card .label { color: #6c757d; font-size: 12px; margin-top: 5px; }
|
|
39
|
+
</style>
|
|
40
|
+
|
|
41
|
+
<div class="lyra-policy">
|
|
42
|
+
<%= link_to "← Back to Dashboard".html_safe, dashboard_path, class: "back-link" %>
|
|
43
|
+
|
|
44
|
+
<h1>Privacy Policy (PAM DSL)</h1>
|
|
45
|
+
<p style="color: #6c757d; margin-bottom: 20px;">
|
|
46
|
+
Privacy Access Management policies defined using PAM DSL. These policies control data access,
|
|
47
|
+
define PII field types, processing purposes, and data retention rules.
|
|
48
|
+
</p>
|
|
49
|
+
|
|
50
|
+
<% if !@pam_dsl_available %>
|
|
51
|
+
<div class="empty-state" style="background: #fff3cd; border-color: #ffc107;">
|
|
52
|
+
<h3>PAM DSL Not Available</h3>
|
|
53
|
+
<p>Privacy policy features require PAM DSL to be installed.</p>
|
|
54
|
+
<p style="margin-top: 15px; font-size: 13px;">
|
|
55
|
+
Add <code>gem 'pam_dsl'</code> to your Gemfile and run <code>bundle install</code>.
|
|
56
|
+
</p>
|
|
57
|
+
</div>
|
|
58
|
+
<% elsif @policies.any? %>
|
|
59
|
+
<h2 style="margin-top: 0;">Registered Policies</h2>
|
|
60
|
+
<p style="color: #6c757d; font-size: 13px; margin-bottom: 15px;">
|
|
61
|
+
Overview of all privacy policies defined in the application.
|
|
62
|
+
</p>
|
|
63
|
+
|
|
64
|
+
<% @policies.each do |name, policy| %>
|
|
65
|
+
<div class="policy-card">
|
|
66
|
+
<div class="policy-header">
|
|
67
|
+
<h3><%= name.to_s.titleize %></h3>
|
|
68
|
+
<% if @default_policy_name&.to_sym == name %>
|
|
69
|
+
<span class="badge badge-default">Default Policy</span>
|
|
70
|
+
<% end %>
|
|
71
|
+
</div>
|
|
72
|
+
<div class="policy-body">
|
|
73
|
+
<% policy_data = policy.respond_to?(:to_h) ? policy.to_h : {} %>
|
|
74
|
+
|
|
75
|
+
<div class="summary-cards">
|
|
76
|
+
<div class="summary-card">
|
|
77
|
+
<div class="value"><%= policy.respond_to?(:fields) ? policy.fields.count : 0 %></div>
|
|
78
|
+
<div class="label">PII Fields</div>
|
|
79
|
+
</div>
|
|
80
|
+
<div class="summary-card">
|
|
81
|
+
<div class="value"><%= policy.respond_to?(:purposes) ? policy.purposes.count : 0 %></div>
|
|
82
|
+
<div class="label">Purposes</div>
|
|
83
|
+
</div>
|
|
84
|
+
<div class="summary-card">
|
|
85
|
+
<div class="value"><%= policy.respond_to?(:sensitive_fields) ? policy.sensitive_fields.count : 0 %></div>
|
|
86
|
+
<div class="label">Sensitive Fields</div>
|
|
87
|
+
</div>
|
|
88
|
+
<div class="summary-card">
|
|
89
|
+
<div class="value"><%= policy.respond_to?(:restricted_fields) ? policy.restricted_fields.count : 0 %></div>
|
|
90
|
+
<div class="label">Restricted Fields</div>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<% if policy.respond_to?(:fields) && policy.fields.any? %>
|
|
95
|
+
<div class="section">
|
|
96
|
+
<h3>PII Fields</h3>
|
|
97
|
+
<p style="color: #6c757d; font-size: 13px; margin-bottom: 15px;">
|
|
98
|
+
Fields containing personally identifiable information with their type and sensitivity level.
|
|
99
|
+
</p>
|
|
100
|
+
<div class="field-grid">
|
|
101
|
+
<% policy.fields.each do |field_name, field| %>
|
|
102
|
+
<div class="field-item">
|
|
103
|
+
<div class="field-name"><%= field_name %></div>
|
|
104
|
+
<div class="field-meta">
|
|
105
|
+
<span class="type-badge"><%= field.respond_to?(:type) ? field.type : 'unknown' %></span>
|
|
106
|
+
<% sensitivity = field.respond_to?(:sensitivity) ? field.sensitivity : :internal %>
|
|
107
|
+
<span class="sensitivity-<%= sensitivity %>"><%= sensitivity.to_s.titleize %></span>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
<% end %>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
<% end %>
|
|
114
|
+
|
|
115
|
+
<% if policy.respond_to?(:purposes) && policy.purposes.any? %>
|
|
116
|
+
<div class="section">
|
|
117
|
+
<h3>Processing Purposes</h3>
|
|
118
|
+
<p style="color: #6c757d; font-size: 13px; margin-bottom: 15px;">
|
|
119
|
+
Legal bases and purposes for processing personal data under GDPR.
|
|
120
|
+
</p>
|
|
121
|
+
<% policy.purposes.each do |purpose_name, purpose| %>
|
|
122
|
+
<div class="purpose-item">
|
|
123
|
+
<h4><%= purpose_name.to_s.titleize %></h4>
|
|
124
|
+
<% if purpose.respond_to?(:description) && purpose.description.present? %>
|
|
125
|
+
<p><%= purpose.description %></p>
|
|
126
|
+
<% end %>
|
|
127
|
+
<% if purpose.respond_to?(:legal_basis) %>
|
|
128
|
+
<span class="legal-basis">Legal Basis: <%= purpose.legal_basis.to_s.titleize %></span>
|
|
129
|
+
<% end %>
|
|
130
|
+
<% if purpose.respond_to?(:required_fields) && purpose.required_fields.any? %>
|
|
131
|
+
<div class="required-fields">
|
|
132
|
+
<strong>Required fields:</strong> <%= purpose.required_fields.join(', ') %>
|
|
133
|
+
</div>
|
|
134
|
+
<% end %>
|
|
135
|
+
</div>
|
|
136
|
+
<% end %>
|
|
137
|
+
</div>
|
|
138
|
+
<% end %>
|
|
139
|
+
|
|
140
|
+
<% if policy.respond_to?(:retention_policy) %>
|
|
141
|
+
<% retention = policy.retention_policy %>
|
|
142
|
+
<% if retention.respond_to?(:rules) && retention.rules.any? %>
|
|
143
|
+
<div class="section">
|
|
144
|
+
<h3>Data Retention</h3>
|
|
145
|
+
<p style="color: #6c757d; font-size: 13px; margin-bottom: 15px;">
|
|
146
|
+
How long personal data is retained before deletion or anonymization.
|
|
147
|
+
</p>
|
|
148
|
+
<table class="retention-table">
|
|
149
|
+
<thead>
|
|
150
|
+
<tr>
|
|
151
|
+
<th>Model</th>
|
|
152
|
+
<th>Retention Period</th>
|
|
153
|
+
<th>On Expiry</th>
|
|
154
|
+
</tr>
|
|
155
|
+
</thead>
|
|
156
|
+
<tbody>
|
|
157
|
+
<% if retention.respond_to?(:default_duration) && retention.default_duration %>
|
|
158
|
+
<tr>
|
|
159
|
+
<td><em>Default</em></td>
|
|
160
|
+
<td><%= retention.default_duration.inspect %></td>
|
|
161
|
+
<td>Delete</td>
|
|
162
|
+
</tr>
|
|
163
|
+
<% end %>
|
|
164
|
+
<% retention.rules.each do |rule| %>
|
|
165
|
+
<tr>
|
|
166
|
+
<td><%= rule.respond_to?(:model_class) ? rule.model_class : 'Unknown' %></td>
|
|
167
|
+
<td><%= rule.respond_to?(:duration) ? rule.duration.inspect : 'N/A' %></td>
|
|
168
|
+
<td><%= rule.respond_to?(:deletion_strategy) ? rule.deletion_strategy.to_s.titleize : 'Delete' %></td>
|
|
169
|
+
</tr>
|
|
170
|
+
<% end %>
|
|
171
|
+
</tbody>
|
|
172
|
+
</table>
|
|
173
|
+
</div>
|
|
174
|
+
<% end %>
|
|
175
|
+
<% end %>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
<% end %>
|
|
179
|
+
<% else %>
|
|
180
|
+
<div class="empty-state">
|
|
181
|
+
<h3>No Privacy Policies Defined</h3>
|
|
182
|
+
<p>Use PAM DSL to define privacy policies for your application.</p>
|
|
183
|
+
<p style="margin-top: 15px; font-size: 13px;">
|
|
184
|
+
Add <code>PamDsl.define_policy :my_policy do ... end</code> in an initializer.
|
|
185
|
+
</p>
|
|
186
|
+
</div>
|
|
187
|
+
<% end %>
|
|
188
|
+
</div>
|