orfeas_pam_dsl 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 +84 -0
- data/MIT-LICENSE +21 -0
- data/README.md +1365 -0
- data/Rakefile +11 -0
- data/lib/pam_dsl/consent.rb +110 -0
- data/lib/pam_dsl/field.rb +76 -0
- data/lib/pam_dsl/gdpr_compliance.rb +560 -0
- data/lib/pam_dsl/pii_detector.rb +442 -0
- data/lib/pam_dsl/pii_masker.rb +121 -0
- data/lib/pam_dsl/policy.rb +175 -0
- data/lib/pam_dsl/policy_comparator.rb +296 -0
- data/lib/pam_dsl/policy_generator.rb +558 -0
- data/lib/pam_dsl/purpose.rb +78 -0
- data/lib/pam_dsl/railtie.rb +25 -0
- data/lib/pam_dsl/registry.rb +50 -0
- data/lib/pam_dsl/reporter.rb +789 -0
- data/lib/pam_dsl/retention.rb +102 -0
- data/lib/pam_dsl/tasks/privacy.rake +139 -0
- data/lib/pam_dsl/version.rb +3 -0
- data/lib/pam_dsl.rb +67 -0
- metadata +136 -0
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PamDsl
|
|
4
|
+
# Generates PAM DSL policy files with sensible defaults
|
|
5
|
+
#
|
|
6
|
+
# Can generate a basic policy template or scan ActiveRecord models
|
|
7
|
+
# to detect PII fields and generate a policy based on them.
|
|
8
|
+
#
|
|
9
|
+
# @example Generate basic policy
|
|
10
|
+
# generator = PamDsl::PolicyGenerator.new("my_app")
|
|
11
|
+
# generator.generate # Creates config/initializers/pam_dsl_policy.rb
|
|
12
|
+
#
|
|
13
|
+
# @example Generate from models
|
|
14
|
+
# generator = PamDsl::PolicyGenerator.new("my_app")
|
|
15
|
+
# generator.generate_from_models # Scans models for PII
|
|
16
|
+
#
|
|
17
|
+
class PolicyGenerator
|
|
18
|
+
# Patterns that should be excluded (timestamps, counts, amounts, flags, etc.)
|
|
19
|
+
EXCLUDE_PATTERNS = [
|
|
20
|
+
/_at\z/i, # Timestamps: created_at, updated_at, email_sent_at, cancelled_at
|
|
21
|
+
/_on\z/i, # Date fields: published_on, expires_on
|
|
22
|
+
/_count\z/i, # Counters: login_count, failed_attempts_count
|
|
23
|
+
/_amount\z/i, # Amounts: vat_amount, total_amount, discount_amount
|
|
24
|
+
/_total\z/i, # Totals: grand_total, subtotal
|
|
25
|
+
/_reason\z/i, # Reason text: cancellation_reason, rejection_reason
|
|
26
|
+
/_notes?\z/i, # Notes: admin_notes, internal_note
|
|
27
|
+
/_status\z/i, # Status flags: payment_status, order_status
|
|
28
|
+
/_type\z/i, # Type flags: payment_type, user_type
|
|
29
|
+
/_id\z/i, # Foreign keys: user_id, order_id
|
|
30
|
+
/_uuid\z/i, # UUIDs: order_uuid
|
|
31
|
+
/_code\z/i, # Codes: country_code, currency_code (but not postal_code)
|
|
32
|
+
/\A(id|uuid)\z/i, # Primary keys
|
|
33
|
+
/\Ais_/i, # Boolean flags: is_active, is_verified
|
|
34
|
+
/\Ahas_/i, # Boolean flags: has_consent
|
|
35
|
+
/_enabled\z/i, # Flags: two_factor_enabled
|
|
36
|
+
/_verified\z/i, # Flags: email_verified
|
|
37
|
+
/_confirmed\z/i, # Flags: payment_confirmed
|
|
38
|
+
/encrypted_/i, # Already encrypted: encrypted_password
|
|
39
|
+
/_digest\z/i, # Hashes: password_digest
|
|
40
|
+
/_token\z/i, # Tokens: reset_token, auth_token
|
|
41
|
+
/_hash\z/i, # Hashes: password_hash
|
|
42
|
+
].freeze
|
|
43
|
+
|
|
44
|
+
# Common PII field patterns and their types
|
|
45
|
+
# More specific patterns to reduce false positives
|
|
46
|
+
PII_PATTERNS = {
|
|
47
|
+
# Email - must be the actual email field, not email_sent_at, email_verified, etc.
|
|
48
|
+
/\A(email|e_mail|mail_address|user_email|contact_email|billing_email)\z/i => { type: :email, sensitivity: :confidential },
|
|
49
|
+
|
|
50
|
+
# Names - exact matches only
|
|
51
|
+
/\A(first_?name|given_?name|fname)\z/i => { type: :name, sensitivity: :internal },
|
|
52
|
+
/\A(last_?name|surname|family_?name|lname)\z/i => { type: :name, sensitivity: :internal },
|
|
53
|
+
/\A(full_?name|display_?name)\z/i => { type: :name, sensitivity: :internal },
|
|
54
|
+
/\A(father_?name|fathername|mother_?name|parent_?name)\z/i => { type: :name, sensitivity: :internal },
|
|
55
|
+
/\A(name|firstname|lastname)\z/i => { type: :name, sensitivity: :internal },
|
|
56
|
+
|
|
57
|
+
# Phone - exact field names only
|
|
58
|
+
/\A(phone|telephone|mobile|cell|fax|phone_number|mobile_number|cell_phone)\z/i => { type: :phone, sensitivity: :confidential },
|
|
59
|
+
|
|
60
|
+
# Address - exact matches
|
|
61
|
+
/\A(address|street|city|state|province|country|postal|zip|postcode|postal_code|zip_code)\z/i => { type: :address, sensitivity: :confidential },
|
|
62
|
+
/\A(address_line_?\d?|street_address|billing_address|shipping_address|home_address|work_address)\z/i => { type: :address, sensitivity: :confidential },
|
|
63
|
+
|
|
64
|
+
# Financial - exact matches for account identifiers
|
|
65
|
+
/\A(iban|swift|bic|bank_account|account_number|routing_number)\z/i => { type: :financial, sensitivity: :restricted },
|
|
66
|
+
/\A(credit_card|card_number|card_number_first\d|card_number_last\d|cvv|cvc)\z/i => { type: :credit_card, sensitivity: :restricted },
|
|
67
|
+
|
|
68
|
+
# Tax/VAT identifiers - the actual number, not amounts
|
|
69
|
+
# AFM = Greek tax identification number (ΑΦΜ - Αριθμός Φορολογικού Μητρώου)
|
|
70
|
+
/\A(vat_number|vat_id|tax_id|tin|tax_number|vat_reg_number|afm)\z/i => { type: :identifier, sensitivity: :restricted },
|
|
71
|
+
|
|
72
|
+
# Personal identifiers - exact matches
|
|
73
|
+
/\A(ssn|social_security|social_security_number|national_id|passport|passport_number|driver_license|drivers_license|license_number)\z/i => { type: :ssn, sensitivity: :restricted },
|
|
74
|
+
/\A(dob|date_of_birth|birth_date|birthday|birthdate)\z/i => { type: :date_of_birth, sensitivity: :confidential },
|
|
75
|
+
|
|
76
|
+
# Technical - IP addresses
|
|
77
|
+
/\A(ip|ip_address|remote_ip|client_ip|source_ip)\z/i => { type: :ip_address, sensitivity: :internal },
|
|
78
|
+
|
|
79
|
+
# Health - exact matches to avoid false positives
|
|
80
|
+
/\A(health_condition|medical_record|diagnosis|prescription|medical_history)\z/i => { type: :health, sensitivity: :restricted },
|
|
81
|
+
|
|
82
|
+
# Biometric - exact matches
|
|
83
|
+
/\A(fingerprint|face_id|biometric|biometric_data|retina_scan)\z/i => { type: :biometric, sensitivity: :restricted },
|
|
84
|
+
|
|
85
|
+
# Location - exact matches for coordinates
|
|
86
|
+
/\A(latitude|longitude|lat|lng|geo_location|gps_coordinates)\z/i => { type: :location, sensitivity: :confidential },
|
|
87
|
+
/\A(location)\z/i => { type: :location, sensitivity: :confidential },
|
|
88
|
+
|
|
89
|
+
# Credentials and tokens - security sensitive (usually excluded from auto-detection)
|
|
90
|
+
# These patterns are for manual matching when tokens are explicitly included
|
|
91
|
+
/\A(encrypted_password|password_salt|password_digest)\z/i => { type: :credential, sensitivity: :restricted },
|
|
92
|
+
/\A(api_key|spree_api_key|secret_key|access_key)\z/i => { type: :credential, sensitivity: :restricted },
|
|
93
|
+
/\A(reset_password_token|remember_token|confirmation_token|unlock_token)\z/i => { type: :token, sensitivity: :restricted },
|
|
94
|
+
/\A(authentication_token|auth_token|session_token|bearer_token)\z/i => { type: :token, sensitivity: :restricted },
|
|
95
|
+
/\A(guest_token|persistence_token|perishable_token)\z/i => { type: :token, sensitivity: :restricted },
|
|
96
|
+
/\A(gateway_customer_profile_id|gateway_payment_profile_id)\z/i => { type: :payment_token, sensitivity: :restricted }
|
|
97
|
+
}.freeze
|
|
98
|
+
|
|
99
|
+
# Common purposes with sensible defaults
|
|
100
|
+
DEFAULT_PURPOSES = {
|
|
101
|
+
service_delivery: {
|
|
102
|
+
description: "Core service delivery and functionality",
|
|
103
|
+
basis: :contract,
|
|
104
|
+
requires: [:email, :name]
|
|
105
|
+
},
|
|
106
|
+
account_management: {
|
|
107
|
+
description: "User account creation and management",
|
|
108
|
+
basis: :contract,
|
|
109
|
+
requires: [:email]
|
|
110
|
+
},
|
|
111
|
+
communication: {
|
|
112
|
+
description: "Sending transactional and service-related communications",
|
|
113
|
+
basis: :contract,
|
|
114
|
+
requires: [:email]
|
|
115
|
+
},
|
|
116
|
+
billing: {
|
|
117
|
+
description: "Processing payments and generating invoices",
|
|
118
|
+
basis: :contract,
|
|
119
|
+
requires: [:email, :address]
|
|
120
|
+
},
|
|
121
|
+
legal_compliance: {
|
|
122
|
+
description: "Compliance with legal and regulatory requirements",
|
|
123
|
+
basis: :legal_obligation,
|
|
124
|
+
requires: [:email, :address]
|
|
125
|
+
},
|
|
126
|
+
audit_trail: {
|
|
127
|
+
description: "Maintaining security and audit logs",
|
|
128
|
+
basis: :legal_obligation,
|
|
129
|
+
requires: [:ip_address]
|
|
130
|
+
},
|
|
131
|
+
analytics: {
|
|
132
|
+
description: "Analyzing usage patterns to improve services",
|
|
133
|
+
basis: :legitimate_interests,
|
|
134
|
+
requires: []
|
|
135
|
+
},
|
|
136
|
+
marketing: {
|
|
137
|
+
description: "Marketing communications and promotions",
|
|
138
|
+
basis: :consent,
|
|
139
|
+
requires: [:email]
|
|
140
|
+
}
|
|
141
|
+
}.freeze
|
|
142
|
+
|
|
143
|
+
attr_reader :name, :output_path
|
|
144
|
+
|
|
145
|
+
def initialize(name, output_path: nil)
|
|
146
|
+
@name = name.to_s.underscore.to_sym
|
|
147
|
+
@output_path = output_path || default_output_path
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Generate a basic policy template
|
|
151
|
+
def generate
|
|
152
|
+
content = generate_basic_policy
|
|
153
|
+
write_file(content)
|
|
154
|
+
print_summary
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Generate policy by scanning ActiveRecord models
|
|
158
|
+
def generate_from_models
|
|
159
|
+
detected_fields = scan_models
|
|
160
|
+
content = generate_policy_from_fields(detected_fields)
|
|
161
|
+
write_file(content)
|
|
162
|
+
print_summary(detected_fields)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
private
|
|
166
|
+
|
|
167
|
+
def default_output_path
|
|
168
|
+
if defined?(Rails)
|
|
169
|
+
Rails.root.join("config", "initializers", "pam_dsl_policy.rb")
|
|
170
|
+
else
|
|
171
|
+
"pam_dsl_policy.rb"
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def generate_basic_policy
|
|
176
|
+
<<~RUBY
|
|
177
|
+
# frozen_string_literal: true
|
|
178
|
+
|
|
179
|
+
# PAM DSL Privacy Policy for #{@name.to_s.titleize}
|
|
180
|
+
#
|
|
181
|
+
# This file defines PII fields, processing purposes, retention rules,
|
|
182
|
+
# and consent requirements for GDPR compliance.
|
|
183
|
+
#
|
|
184
|
+
# Generated: #{Time.current.strftime('%Y-%m-%d %H:%M:%S')}
|
|
185
|
+
#
|
|
186
|
+
# Documentation: https://github.com/mpantel/lyra-engine/tree/main/gems/pam_dsl
|
|
187
|
+
|
|
188
|
+
PamDsl.define_policy :#{@name} do
|
|
189
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
190
|
+
# PII FIELD DEFINITIONS
|
|
191
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
192
|
+
#
|
|
193
|
+
# Sensitivity levels:
|
|
194
|
+
# :public - Publicly accessible
|
|
195
|
+
# :internal - Internal use only (low risk)
|
|
196
|
+
# :confidential - Sensitive, requires protection
|
|
197
|
+
# :restricted - Highly restricted (financial, health, etc.)
|
|
198
|
+
|
|
199
|
+
# Names
|
|
200
|
+
field :first_name, type: :name, sensitivity: :internal
|
|
201
|
+
field :last_name, type: :name, sensitivity: :internal
|
|
202
|
+
|
|
203
|
+
# Contact
|
|
204
|
+
field :email, type: :email, sensitivity: :confidential do
|
|
205
|
+
transform :display do |value|
|
|
206
|
+
value&.gsub(/(.{2})(.*)(@.*)/) { "\#{$1}" + "*" * $2.length + "\#{$3}" }
|
|
207
|
+
end
|
|
208
|
+
transform :log do |_value|
|
|
209
|
+
"[EMAIL]"
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
field :phone, type: :phone, sensitivity: :confidential do
|
|
214
|
+
transform :display do |value|
|
|
215
|
+
value ? "\#{value[0..3]}****\#{value[-2..]}" : nil
|
|
216
|
+
end
|
|
217
|
+
transform :log do |_value|
|
|
218
|
+
"[PHONE]"
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Address
|
|
223
|
+
field :address, type: :address, sensitivity: :confidential
|
|
224
|
+
|
|
225
|
+
# Technical
|
|
226
|
+
field :ip_address, type: :ip_address, sensitivity: :internal
|
|
227
|
+
|
|
228
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
229
|
+
# PROCESSING PURPOSES
|
|
230
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
231
|
+
#
|
|
232
|
+
# Legal bases (GDPR Article 6):
|
|
233
|
+
# :consent - Data subject has given consent
|
|
234
|
+
# :contract - Processing necessary for contract
|
|
235
|
+
# :legal_obligation - Compliance with legal obligation
|
|
236
|
+
# :vital_interests - Protection of vital interests
|
|
237
|
+
# :public_task - Task in public interest
|
|
238
|
+
# :legitimate_interests - Legitimate interests
|
|
239
|
+
|
|
240
|
+
purpose :service_delivery do
|
|
241
|
+
describe "Core service delivery and functionality"
|
|
242
|
+
basis :contract
|
|
243
|
+
requires :email, :first_name, :last_name
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
purpose :communication do
|
|
247
|
+
describe "Sending transactional and service-related communications"
|
|
248
|
+
basis :contract
|
|
249
|
+
requires :email
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
purpose :audit_trail do
|
|
253
|
+
describe "Maintaining security and audit logs"
|
|
254
|
+
basis :legal_obligation
|
|
255
|
+
requires :ip_address, :email
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Uncomment if you need marketing with consent
|
|
259
|
+
# purpose :marketing do
|
|
260
|
+
# describe "Marketing communications and promotions"
|
|
261
|
+
# basis :consent
|
|
262
|
+
# requires :email
|
|
263
|
+
# end
|
|
264
|
+
|
|
265
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
266
|
+
# RETENTION RULES
|
|
267
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
retention do
|
|
270
|
+
default 7.years
|
|
271
|
+
|
|
272
|
+
# Add model-specific retention rules
|
|
273
|
+
# for_model "User" do
|
|
274
|
+
# keep_for 7.years
|
|
275
|
+
# on_expiry :anonymize
|
|
276
|
+
# end
|
|
277
|
+
|
|
278
|
+
# for_model "Transaction" do
|
|
279
|
+
# keep_for 10.years # Financial records
|
|
280
|
+
# end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
284
|
+
# CONSENT REQUIREMENTS
|
|
285
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
consent do
|
|
288
|
+
# Uncomment if you have marketing purpose
|
|
289
|
+
# for_purpose :marketing do
|
|
290
|
+
# required!
|
|
291
|
+
# granular!
|
|
292
|
+
# withdrawable!
|
|
293
|
+
# expires_in 2.years
|
|
294
|
+
# describe "We'll send you product updates and promotional offers"
|
|
295
|
+
# end
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
300
|
+
# RAILS CONFIGURATION
|
|
301
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
Rails.application.config.pam_dsl.default_policy = :#{@name}
|
|
304
|
+
Rails.application.config.pam_dsl.organization = "Your Organization Name"
|
|
305
|
+
Rails.application.config.pam_dsl.dpo_contact = "dpo@example.com"
|
|
306
|
+
RUBY
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def scan_models
|
|
310
|
+
detected = {}
|
|
311
|
+
|
|
312
|
+
if defined?(ActiveRecord::Base)
|
|
313
|
+
# Get all ActiveRecord models
|
|
314
|
+
Rails.application.eager_load! if defined?(Rails) && Rails.application
|
|
315
|
+
|
|
316
|
+
ActiveRecord::Base.descendants.each do |model|
|
|
317
|
+
next if model.abstract_class?
|
|
318
|
+
next if model.name.start_with?("ActiveRecord::")
|
|
319
|
+
next unless model.table_exists?
|
|
320
|
+
|
|
321
|
+
model.column_names.each do |column|
|
|
322
|
+
# Skip excluded patterns (timestamps, amounts, flags, etc.)
|
|
323
|
+
next if excluded_field?(column)
|
|
324
|
+
|
|
325
|
+
PII_PATTERNS.each do |pattern, config|
|
|
326
|
+
if column.match?(pattern)
|
|
327
|
+
detected[column.to_sym] ||= config.merge(models: [])
|
|
328
|
+
detected[column.to_sym][:models] << model.name unless detected[column.to_sym][:models].include?(model.name)
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
detected
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def excluded_field?(column)
|
|
339
|
+
EXCLUDE_PATTERNS.any? { |pattern| column.match?(pattern) }
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def generate_policy_from_fields(detected_fields)
|
|
343
|
+
fields_code = generate_fields_code(detected_fields)
|
|
344
|
+
purposes_code = generate_purposes_code(detected_fields)
|
|
345
|
+
retention_code = generate_retention_code(detected_fields)
|
|
346
|
+
|
|
347
|
+
<<~RUBY
|
|
348
|
+
# frozen_string_literal: true
|
|
349
|
+
|
|
350
|
+
# PAM DSL Privacy Policy for #{@name.to_s.titleize}
|
|
351
|
+
#
|
|
352
|
+
# Auto-generated from ActiveRecord models
|
|
353
|
+
# Generated: #{Time.current.strftime('%Y-%m-%d %H:%M:%S')}
|
|
354
|
+
#
|
|
355
|
+
# Detected PII fields: #{detected_fields.keys.count}
|
|
356
|
+
# Models scanned: #{detected_fields.values.flat_map { |v| v[:models] }.uniq.count}
|
|
357
|
+
|
|
358
|
+
PamDsl.define_policy :#{@name} do
|
|
359
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
360
|
+
# PII FIELD DEFINITIONS (Auto-detected)
|
|
361
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
#{fields_code}
|
|
364
|
+
|
|
365
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
366
|
+
# PROCESSING PURPOSES
|
|
367
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
368
|
+
|
|
369
|
+
#{purposes_code}
|
|
370
|
+
|
|
371
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
372
|
+
# RETENTION RULES
|
|
373
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
374
|
+
|
|
375
|
+
#{retention_code}
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# Configuration
|
|
379
|
+
Rails.application.config.pam_dsl.default_policy = :#{@name}
|
|
380
|
+
Rails.application.config.pam_dsl.organization = "Your Organization Name"
|
|
381
|
+
Rails.application.config.pam_dsl.dpo_contact = "dpo@example.com"
|
|
382
|
+
RUBY
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def generate_fields_code(detected_fields)
|
|
386
|
+
lines = []
|
|
387
|
+
|
|
388
|
+
detected_fields.sort_by { |name, _| name }.each do |name, config|
|
|
389
|
+
models_comment = "# Found in: #{config[:models].join(', ')}"
|
|
390
|
+
field_def = " field :#{name}, type: :#{config[:type]}, sensitivity: :#{config[:sensitivity]}"
|
|
391
|
+
|
|
392
|
+
# Add masking transforms for sensitive fields
|
|
393
|
+
if config[:sensitivity] == :confidential || config[:sensitivity] == :restricted
|
|
394
|
+
lines << models_comment
|
|
395
|
+
lines << "#{field_def} do"
|
|
396
|
+
lines << generate_transform_code(name, config[:type])
|
|
397
|
+
lines << " end"
|
|
398
|
+
lines << ""
|
|
399
|
+
else
|
|
400
|
+
lines << models_comment
|
|
401
|
+
lines << field_def
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
lines.join("\n")
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def generate_transform_code(name, type)
|
|
409
|
+
indent = " "
|
|
410
|
+
case type
|
|
411
|
+
when :email
|
|
412
|
+
lines = []
|
|
413
|
+
lines << "#{indent}transform :display do |value|"
|
|
414
|
+
lines << '#{indent} value&.gsub(/(.{2})(.*)(@.*)/) { "#' + '{$1}" + \'*\' * $2.length + "#' + '{$3}" }'
|
|
415
|
+
lines << "#{indent}end"
|
|
416
|
+
lines << "#{indent}transform :log do |_value|"
|
|
417
|
+
lines << '#{indent} "[EMAIL]"'
|
|
418
|
+
lines << "#{indent}end"
|
|
419
|
+
lines.map { |l| l.gsub('#{indent}', indent) }.join("\n")
|
|
420
|
+
when :phone
|
|
421
|
+
lines = []
|
|
422
|
+
lines << "#{indent}transform :display do |value|"
|
|
423
|
+
lines << '#{indent} value ? "#' + '{value[0..3]}****#' + '{value[-2..]}" : nil'
|
|
424
|
+
lines << "#{indent}end"
|
|
425
|
+
lines << "#{indent}transform :log do |_value|"
|
|
426
|
+
lines << '#{indent} "[PHONE]"'
|
|
427
|
+
lines << "#{indent}end"
|
|
428
|
+
lines.map { |l| l.gsub('#{indent}', indent) }.join("\n")
|
|
429
|
+
when :identifier, :ssn, :credit_card
|
|
430
|
+
type_label = type.to_s.upcase
|
|
431
|
+
lines = []
|
|
432
|
+
lines << "#{indent}transform :display do |value|"
|
|
433
|
+
lines << '#{indent} value ? "#' + '{value[0..2]}*****#' + '{value[-2..]}" : nil'
|
|
434
|
+
lines << "#{indent}end"
|
|
435
|
+
lines << "#{indent}transform :log do |_value|"
|
|
436
|
+
lines << "#{indent} \"[#{type_label}]\""
|
|
437
|
+
lines << "#{indent}end"
|
|
438
|
+
lines.map { |l| l.gsub('#{indent}', indent) }.join("\n")
|
|
439
|
+
when :credential
|
|
440
|
+
lines = []
|
|
441
|
+
lines << "#{indent}transform :display do |_value|"
|
|
442
|
+
lines << "#{indent} \"[HIDDEN]\""
|
|
443
|
+
lines << "#{indent}end"
|
|
444
|
+
lines << "#{indent}transform :log do |_value|"
|
|
445
|
+
lines << "#{indent} \"[CREDENTIAL]\""
|
|
446
|
+
lines << "#{indent}end"
|
|
447
|
+
lines.join("\n")
|
|
448
|
+
when :token, :payment_token
|
|
449
|
+
type_label = type == :payment_token ? "PAYMENT_TOKEN" : "TOKEN"
|
|
450
|
+
lines = []
|
|
451
|
+
lines << "#{indent}transform :display do |value|"
|
|
452
|
+
lines << "#{indent} value ? \"\#{value[0..3]}...\" : nil"
|
|
453
|
+
lines << "#{indent}end"
|
|
454
|
+
lines << "#{indent}transform :log do |_value|"
|
|
455
|
+
lines << "#{indent} \"[#{type_label}]\""
|
|
456
|
+
lines << "#{indent}end"
|
|
457
|
+
lines.join("\n")
|
|
458
|
+
else
|
|
459
|
+
lines = []
|
|
460
|
+
lines << "#{indent}transform :log do |_value|"
|
|
461
|
+
lines << '#{indent} "[REDACTED]"'
|
|
462
|
+
lines << "#{indent}end"
|
|
463
|
+
lines.map { |l| l.gsub('#{indent}', indent) }.join("\n")
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def generate_purposes_code(detected_fields)
|
|
468
|
+
# Determine which purposes make sense based on detected fields
|
|
469
|
+
field_names = detected_fields.keys
|
|
470
|
+
|
|
471
|
+
purposes = []
|
|
472
|
+
|
|
473
|
+
# Service delivery if we have names/emails
|
|
474
|
+
if field_names.any? { |f| f.to_s.include?("name") || f.to_s.include?("email") }
|
|
475
|
+
purposes << <<~RUBY
|
|
476
|
+
purpose :service_delivery do
|
|
477
|
+
describe "Core service delivery and functionality"
|
|
478
|
+
basis :contract
|
|
479
|
+
requires #{([:email] & field_names).map { |f| ":#{f}" }.join(", ")}
|
|
480
|
+
end
|
|
481
|
+
RUBY
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# Billing if we have financial fields
|
|
485
|
+
if field_names.any? { |f| f.to_s.include?("address") || f.to_s.include?("vat") }
|
|
486
|
+
purposes << <<~RUBY
|
|
487
|
+
purpose :billing do
|
|
488
|
+
describe "Processing payments and generating invoices"
|
|
489
|
+
basis :contract
|
|
490
|
+
requires #{([:email, :address, :vat_number] & field_names).map { |f| ":#{f}" }.join(", ")}
|
|
491
|
+
end
|
|
492
|
+
RUBY
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# Audit trail if we have IP address
|
|
496
|
+
if field_names.include?(:ip_address)
|
|
497
|
+
purposes << <<~RUBY
|
|
498
|
+
purpose :audit_trail do
|
|
499
|
+
describe "Maintaining security and audit logs"
|
|
500
|
+
basis :legal_obligation
|
|
501
|
+
requires :ip_address
|
|
502
|
+
end
|
|
503
|
+
RUBY
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
purposes.join("\n")
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
def generate_retention_code(detected_fields)
|
|
510
|
+
models = detected_fields.values.flat_map { |v| v[:models] }.uniq
|
|
511
|
+
|
|
512
|
+
lines = [" retention do", " default 7.years"]
|
|
513
|
+
|
|
514
|
+
models.sort.each do |model|
|
|
515
|
+
# Financial models get longer retention
|
|
516
|
+
if model.match?(/payment|transaction|invoice|order/i)
|
|
517
|
+
lines << ""
|
|
518
|
+
lines << " for_model \"#{model}\" do"
|
|
519
|
+
lines << " keep_for 10.years # Financial records"
|
|
520
|
+
lines << " end"
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
lines << " end"
|
|
525
|
+
lines.join("\n")
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
def write_file(content)
|
|
529
|
+
FileUtils.mkdir_p(File.dirname(@output_path))
|
|
530
|
+
File.write(@output_path, content)
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
def print_summary(detected_fields = nil)
|
|
534
|
+
puts "\n" + "=" * 60
|
|
535
|
+
puts " PAM DSL Policy Generated"
|
|
536
|
+
puts "=" * 60
|
|
537
|
+
puts "Policy name: #{@name}"
|
|
538
|
+
puts "Output file: #{@output_path}"
|
|
539
|
+
|
|
540
|
+
if detected_fields
|
|
541
|
+
puts "\nDetected PII fields: #{detected_fields.keys.count}"
|
|
542
|
+
puts "Models scanned: #{detected_fields.values.flat_map { |v| v[:models] }.uniq.count}"
|
|
543
|
+
|
|
544
|
+
puts "\nFields by sensitivity:"
|
|
545
|
+
detected_fields.group_by { |_, v| v[:sensitivity] }.each do |sens, fields|
|
|
546
|
+
puts " #{sens}: #{fields.count} (#{fields.map(&:first).join(', ')})"
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
puts "\nNext steps:"
|
|
551
|
+
puts "1. Review and customize the generated policy"
|
|
552
|
+
puts "2. Update organization name and DPO contact"
|
|
553
|
+
puts "3. Add model-specific retention rules"
|
|
554
|
+
puts "4. Test with: rake privacy:policy"
|
|
555
|
+
puts "=" * 60 + "\n"
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
module PamDsl
|
|
2
|
+
# Represents a processing purpose
|
|
3
|
+
class Purpose
|
|
4
|
+
attr_reader :name, :description, :legal_basis, :required_fields, :optional_fields, :metadata
|
|
5
|
+
|
|
6
|
+
LEGAL_BASES = [
|
|
7
|
+
:consent, # Article 6(1)(a) - Data subject has given consent
|
|
8
|
+
:contract, # Article 6(1)(b) - Processing necessary for contract
|
|
9
|
+
:legal_obligation, # Article 6(1)(c) - Compliance with legal obligation
|
|
10
|
+
:vital_interests, # Article 6(1)(d) - Protection of vital interests
|
|
11
|
+
:public_task, # Article 6(1)(e) - Task in public interest
|
|
12
|
+
:legitimate_interests # Article 6(1)(f) - Legitimate interests
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(name)
|
|
16
|
+
@name = name.to_sym
|
|
17
|
+
@description = ""
|
|
18
|
+
@legal_basis = :consent
|
|
19
|
+
@required_fields = []
|
|
20
|
+
@optional_fields = []
|
|
21
|
+
@metadata = {}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Set description
|
|
25
|
+
def describe(text)
|
|
26
|
+
@description = text
|
|
27
|
+
self
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Set legal basis
|
|
31
|
+
def basis(legal_basis)
|
|
32
|
+
legal_basis_sym = legal_basis.to_sym
|
|
33
|
+
unless LEGAL_BASES.include?(legal_basis_sym)
|
|
34
|
+
raise Error, "Invalid legal basis: #{legal_basis}. Must be one of #{LEGAL_BASES.join(', ')}"
|
|
35
|
+
end
|
|
36
|
+
@legal_basis = legal_basis_sym
|
|
37
|
+
self
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Define required fields
|
|
41
|
+
def requires(*field_names)
|
|
42
|
+
@required_fields.concat(field_names.map(&:to_sym))
|
|
43
|
+
self
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Define optional fields
|
|
47
|
+
def optionally(*field_names)
|
|
48
|
+
@optional_fields.concat(field_names.map(&:to_sym))
|
|
49
|
+
self
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Add metadata
|
|
53
|
+
def meta(key, value)
|
|
54
|
+
@metadata[key] = value
|
|
55
|
+
self
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Check if purpose requires consent
|
|
59
|
+
def requires_consent?
|
|
60
|
+
@legal_basis == :consent
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Get all fields (required + optional)
|
|
64
|
+
def all_fields
|
|
65
|
+
(@required_fields + @optional_fields).uniq
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Check if a field is required for this purpose
|
|
69
|
+
def requires_field?(field_name)
|
|
70
|
+
@required_fields.include?(field_name.to_sym)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Check if a field is allowed for this purpose
|
|
74
|
+
def allows_field?(field_name)
|
|
75
|
+
all_fields.include?(field_name.to_sym)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
module PamDsl
|
|
6
|
+
class Railtie < Rails::Railtie
|
|
7
|
+
railtie_name :pam_dsl
|
|
8
|
+
|
|
9
|
+
# Load rake tasks
|
|
10
|
+
rake_tasks do
|
|
11
|
+
load File.expand_path("tasks/privacy.rake", __dir__)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Configuration for Rails apps
|
|
15
|
+
config.pam_dsl = ActiveSupport::OrderedOptions.new
|
|
16
|
+
config.pam_dsl.default_policy = nil
|
|
17
|
+
config.pam_dsl.organization = "Organization Name"
|
|
18
|
+
config.pam_dsl.dpo_contact = "dpo@example.com"
|
|
19
|
+
|
|
20
|
+
# Make configuration available
|
|
21
|
+
initializer "pam_dsl.configuration" do |app|
|
|
22
|
+
PamDsl.rails_config = app.config.pam_dsl
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|