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,175 @@
|
|
|
1
|
+
module PamDsl
|
|
2
|
+
# Main policy class that combines all privacy aspects
|
|
3
|
+
class Policy
|
|
4
|
+
attr_reader :name, :fields, :purposes, :retention_policy, :consent_policy, :metadata
|
|
5
|
+
|
|
6
|
+
def initialize(name)
|
|
7
|
+
@name = name.to_sym
|
|
8
|
+
@fields = {}
|
|
9
|
+
@purposes = {}
|
|
10
|
+
@retention_policy = RetentionPolicy.new
|
|
11
|
+
@consent_policy = ConsentPolicy.new
|
|
12
|
+
@metadata = {}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Define a PII field
|
|
16
|
+
def field(name, type:, sensitivity: :internal, &block)
|
|
17
|
+
field = Field.new(name, type: type, sensitivity: sensitivity)
|
|
18
|
+
field.instance_eval(&block) if block_given?
|
|
19
|
+
@fields[field.name] = field
|
|
20
|
+
field
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Define a processing purpose
|
|
24
|
+
def purpose(name, &block)
|
|
25
|
+
purpose = Purpose.new(name)
|
|
26
|
+
purpose.instance_eval(&block) if block_given?
|
|
27
|
+
@purposes[purpose.name] = purpose
|
|
28
|
+
purpose
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Configure retention policy
|
|
32
|
+
def retention(&block)
|
|
33
|
+
@retention_policy.instance_eval(&block) if block_given?
|
|
34
|
+
@retention_policy
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Configure consent policy
|
|
38
|
+
def consent(&block)
|
|
39
|
+
@consent_policy.instance_eval(&block) if block_given?
|
|
40
|
+
@consent_policy
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Add metadata
|
|
44
|
+
def meta(key, value)
|
|
45
|
+
@metadata[key] = value
|
|
46
|
+
self
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Get a field by name
|
|
50
|
+
def get_field(name)
|
|
51
|
+
@fields[name.to_sym] || raise(InvalidFieldError, "Field '#{name}' not defined in policy '#{@name}'")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Get a purpose by name
|
|
55
|
+
def get_purpose(name)
|
|
56
|
+
@purposes[name.to_sym] || raise(Error, "Purpose '#{name}' not defined in policy '#{@name}'")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Check if field is allowed for purpose
|
|
60
|
+
def allowed?(field_name, purpose_name)
|
|
61
|
+
field = @fields[field_name.to_sym]
|
|
62
|
+
purpose = @purposes[purpose_name.to_sym]
|
|
63
|
+
|
|
64
|
+
return false unless field && purpose
|
|
65
|
+
|
|
66
|
+
# Check if field allows this purpose
|
|
67
|
+
return false unless field.allowed_for?(purpose_name)
|
|
68
|
+
|
|
69
|
+
# Check if purpose allows this field
|
|
70
|
+
return false unless purpose.allows_field?(field_name)
|
|
71
|
+
|
|
72
|
+
true
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Validate data access
|
|
76
|
+
def validate_access!(field_names, purpose_name, consent_granted: false, consent_granted_at: nil)
|
|
77
|
+
purpose = get_purpose(purpose_name)
|
|
78
|
+
|
|
79
|
+
# Check consent if required
|
|
80
|
+
if purpose.requires_consent?
|
|
81
|
+
@consent_policy.validate!(
|
|
82
|
+
purpose_name,
|
|
83
|
+
granted: consent_granted,
|
|
84
|
+
granted_at: consent_granted_at
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Check if all fields are allowed for this purpose
|
|
89
|
+
field_names.each do |field_name|
|
|
90
|
+
unless allowed?(field_name, purpose_name)
|
|
91
|
+
raise Error, "Field '#{field_name}' not allowed for purpose '#{purpose_name}'"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
true
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Get all sensitive fields
|
|
99
|
+
def sensitive_fields
|
|
100
|
+
@fields.values.select(&:sensitive?)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Get all restricted fields
|
|
104
|
+
def restricted_fields
|
|
105
|
+
@fields.values.select(&:restricted?)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Get retention duration for a model
|
|
109
|
+
def retention_for(model_class, field_name: nil)
|
|
110
|
+
@retention_policy.duration_for(model_class, field_name: field_name)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Export policy as hash
|
|
114
|
+
def to_h
|
|
115
|
+
{
|
|
116
|
+
name: @name,
|
|
117
|
+
fields: @fields.transform_values { |f| field_to_h(f) },
|
|
118
|
+
purposes: @purposes.transform_values { |p| purpose_to_h(p) },
|
|
119
|
+
retention: retention_to_h,
|
|
120
|
+
consent: consent_to_h,
|
|
121
|
+
metadata: @metadata
|
|
122
|
+
}
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def field_to_h(field)
|
|
128
|
+
{
|
|
129
|
+
type: field.type,
|
|
130
|
+
sensitivity: field.sensitivity,
|
|
131
|
+
purposes: field.purposes,
|
|
132
|
+
metadata: field.metadata
|
|
133
|
+
}
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def purpose_to_h(purpose)
|
|
137
|
+
{
|
|
138
|
+
description: purpose.description,
|
|
139
|
+
legal_basis: purpose.legal_basis,
|
|
140
|
+
required_fields: purpose.required_fields,
|
|
141
|
+
optional_fields: purpose.optional_fields,
|
|
142
|
+
metadata: purpose.metadata
|
|
143
|
+
}
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def retention_to_h
|
|
147
|
+
{
|
|
148
|
+
default_duration: @retention_policy.default_duration,
|
|
149
|
+
rules: @retention_policy.rules.map { |rule|
|
|
150
|
+
{
|
|
151
|
+
model_class: rule.model_class,
|
|
152
|
+
duration: rule.duration,
|
|
153
|
+
field_overrides: rule.field_overrides,
|
|
154
|
+
deletion_strategy: rule.deletion_strategy
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def consent_to_h
|
|
161
|
+
{
|
|
162
|
+
requirements: @consent_policy.requirements.map { |req|
|
|
163
|
+
{
|
|
164
|
+
purpose: req.purpose,
|
|
165
|
+
required: req.required?,
|
|
166
|
+
granular: req.granular?,
|
|
167
|
+
withdrawable: req.withdrawable?,
|
|
168
|
+
description: req.description,
|
|
169
|
+
expires_after: req.expires_after
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PamDsl
|
|
4
|
+
# Compares two PAM DSL policies and generates comparison reports
|
|
5
|
+
#
|
|
6
|
+
# @example Basic usage
|
|
7
|
+
# comparator = PolicyComparator.new(:policy1, :policy2)
|
|
8
|
+
# report = comparator.generate_report
|
|
9
|
+
#
|
|
10
|
+
# @example With output path
|
|
11
|
+
# comparator = PolicyComparator.new(:policy1, :policy2)
|
|
12
|
+
# comparator.generate_report(output_path: "reports/comparison.md")
|
|
13
|
+
#
|
|
14
|
+
class PolicyComparator
|
|
15
|
+
attr_reader :policy1, :policy2, :name1, :name2
|
|
16
|
+
|
|
17
|
+
# Initialize comparator with two policy names
|
|
18
|
+
#
|
|
19
|
+
# @param policy1_name [Symbol] Name of the first policy
|
|
20
|
+
# @param policy2_name [Symbol] Name of the second policy
|
|
21
|
+
# @raise [PolicyNotFoundError] if either policy doesn't exist
|
|
22
|
+
def initialize(policy1_name, policy2_name)
|
|
23
|
+
@name1 = policy1_name
|
|
24
|
+
@name2 = policy2_name
|
|
25
|
+
@policy1 = PamDsl.policy(policy1_name)
|
|
26
|
+
@policy2 = PamDsl.policy(policy2_name)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Generate a markdown comparison report
|
|
30
|
+
#
|
|
31
|
+
# @param output_path [String, nil] Optional path to write the report
|
|
32
|
+
# @return [String] The generated markdown report
|
|
33
|
+
def generate_report(output_path: nil)
|
|
34
|
+
report = build_report
|
|
35
|
+
|
|
36
|
+
if output_path
|
|
37
|
+
FileUtils.mkdir_p(File.dirname(output_path))
|
|
38
|
+
File.write(output_path, report)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
report
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Get field comparison data
|
|
45
|
+
#
|
|
46
|
+
# @return [Hash] Hash with :common, :only_in_first, :only_in_second keys
|
|
47
|
+
def field_comparison
|
|
48
|
+
fields1 = policy1.fields.keys.to_set
|
|
49
|
+
fields2 = policy2.fields.keys.to_set
|
|
50
|
+
|
|
51
|
+
{
|
|
52
|
+
common: (fields1 & fields2).sort,
|
|
53
|
+
only_in_first: (fields1 - fields2).sort,
|
|
54
|
+
only_in_second: (fields2 - fields1).sort
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Get purpose comparison data
|
|
59
|
+
#
|
|
60
|
+
# @return [Hash] Hash with :first and :second purpose arrays
|
|
61
|
+
def purpose_comparison
|
|
62
|
+
{
|
|
63
|
+
first: policy1.purposes.values.map { |p| purpose_to_hash(p) },
|
|
64
|
+
second: policy2.purposes.values.map { |p| purpose_to_hash(p) }
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Get retention comparison data
|
|
69
|
+
#
|
|
70
|
+
# @return [Hash] Hash with default durations and rules
|
|
71
|
+
def retention_comparison
|
|
72
|
+
{
|
|
73
|
+
first: {
|
|
74
|
+
default_duration: policy1.retention&.default_duration,
|
|
75
|
+
rules_count: policy1.retention&.rules&.count || 0
|
|
76
|
+
},
|
|
77
|
+
second: {
|
|
78
|
+
default_duration: policy2.retention&.default_duration,
|
|
79
|
+
rules_count: policy2.retention&.rules&.count || 0
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Check if a common field matches between policies
|
|
85
|
+
#
|
|
86
|
+
# @param field_name [Symbol] The field name to check
|
|
87
|
+
# @return [Boolean] True if type and sensitivity match
|
|
88
|
+
def field_matches?(field_name)
|
|
89
|
+
f1 = policy1.fields[field_name]
|
|
90
|
+
f2 = policy2.fields[field_name]
|
|
91
|
+
return false unless f1 && f2
|
|
92
|
+
|
|
93
|
+
f1.type == f2.type && f1.sensitivity == f2.sensitivity
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Get summary statistics
|
|
97
|
+
#
|
|
98
|
+
# @return [Hash] Summary with field, purpose, and retention counts
|
|
99
|
+
def summary
|
|
100
|
+
{
|
|
101
|
+
first: {
|
|
102
|
+
name: name1,
|
|
103
|
+
fields: policy1.fields.count,
|
|
104
|
+
purposes: policy1.purposes.count,
|
|
105
|
+
retention_rules: policy1.retention&.rules&.count || 0
|
|
106
|
+
},
|
|
107
|
+
second: {
|
|
108
|
+
name: name2,
|
|
109
|
+
fields: policy2.fields.count,
|
|
110
|
+
purposes: policy2.purposes.count,
|
|
111
|
+
retention_rules: policy2.retention&.rules&.count || 0
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Export comparison as hash
|
|
117
|
+
#
|
|
118
|
+
# @return [Hash] Complete comparison data
|
|
119
|
+
def to_h
|
|
120
|
+
{
|
|
121
|
+
generated_at: Time.now.utc.iso8601,
|
|
122
|
+
policies: [name1.to_s, name2.to_s],
|
|
123
|
+
summary: summary,
|
|
124
|
+
fields: field_comparison_detail,
|
|
125
|
+
purposes: purpose_comparison,
|
|
126
|
+
retention: retention_comparison
|
|
127
|
+
}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def build_report
|
|
133
|
+
lines = []
|
|
134
|
+
lines << "# PAM DSL Policy Comparison Report"
|
|
135
|
+
lines << ""
|
|
136
|
+
lines << "Generated: #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"
|
|
137
|
+
lines << ""
|
|
138
|
+
lines << "Comparing:"
|
|
139
|
+
lines << "- **#{name1}**"
|
|
140
|
+
lines << "- **#{name2}**"
|
|
141
|
+
lines << ""
|
|
142
|
+
|
|
143
|
+
append_summary(lines)
|
|
144
|
+
append_field_comparison(lines)
|
|
145
|
+
append_purpose_comparison(lines)
|
|
146
|
+
append_retention_comparison(lines)
|
|
147
|
+
|
|
148
|
+
lines.join("\n")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def append_summary(lines)
|
|
152
|
+
lines << "## Summary"
|
|
153
|
+
lines << ""
|
|
154
|
+
lines << "| Metric | #{name1} | #{name2} |"
|
|
155
|
+
lines << "|--------|#{'-' * name1.to_s.length}--|#{'-' * name2.to_s.length}--|"
|
|
156
|
+
lines << "| Fields | #{policy1.fields.count} | #{policy2.fields.count} |"
|
|
157
|
+
lines << "| Purposes | #{policy1.purposes.count} | #{policy2.purposes.count} |"
|
|
158
|
+
lines << "| Retention Rules | #{policy1.retention&.rules&.count || 0} | #{policy2.retention&.rules&.count || 0} |"
|
|
159
|
+
lines << ""
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def append_field_comparison(lines)
|
|
163
|
+
comparison = field_comparison
|
|
164
|
+
|
|
165
|
+
lines << "## Field Comparison"
|
|
166
|
+
lines << ""
|
|
167
|
+
|
|
168
|
+
# Common fields
|
|
169
|
+
lines << "### Common Fields (#{comparison[:common].count})"
|
|
170
|
+
lines << ""
|
|
171
|
+
if comparison[:common].any?
|
|
172
|
+
lines << "| Field | #{name1} Type | #{name2} Type | #{name1} Sensitivity | #{name2} Sensitivity | Match? |"
|
|
173
|
+
lines << "|-------|--------------|--------------|---------------------|---------------------|--------|"
|
|
174
|
+
comparison[:common].each do |f|
|
|
175
|
+
f1 = policy1.fields[f]
|
|
176
|
+
f2 = policy2.fields[f]
|
|
177
|
+
match = field_matches?(f) ? "✓" : "✗"
|
|
178
|
+
lines << "| #{f} | #{f1.type} | #{f2.type} | #{f1.sensitivity} | #{f2.sensitivity} | #{match} |"
|
|
179
|
+
end
|
|
180
|
+
else
|
|
181
|
+
lines << "_No common fields_"
|
|
182
|
+
end
|
|
183
|
+
lines << ""
|
|
184
|
+
|
|
185
|
+
# Only in first policy
|
|
186
|
+
lines << "### Only in #{name1} (#{comparison[:only_in_first].count})"
|
|
187
|
+
lines << ""
|
|
188
|
+
if comparison[:only_in_first].any?
|
|
189
|
+
lines << "| Field | Type | Sensitivity |"
|
|
190
|
+
lines << "|-------|------|-------------|"
|
|
191
|
+
comparison[:only_in_first].each do |f|
|
|
192
|
+
field = policy1.fields[f]
|
|
193
|
+
lines << "| #{f} | #{field.type} | #{field.sensitivity} |"
|
|
194
|
+
end
|
|
195
|
+
else
|
|
196
|
+
lines << "_None_"
|
|
197
|
+
end
|
|
198
|
+
lines << ""
|
|
199
|
+
|
|
200
|
+
# Only in second policy
|
|
201
|
+
lines << "### Only in #{name2} (#{comparison[:only_in_second].count})"
|
|
202
|
+
lines << ""
|
|
203
|
+
if comparison[:only_in_second].any?
|
|
204
|
+
lines << "| Field | Type | Sensitivity |"
|
|
205
|
+
lines << "|-------|------|-------------|"
|
|
206
|
+
comparison[:only_in_second].each do |f|
|
|
207
|
+
field = policy2.fields[f]
|
|
208
|
+
lines << "| #{f} | #{field.type} | #{field.sensitivity} |"
|
|
209
|
+
end
|
|
210
|
+
else
|
|
211
|
+
lines << "_None_"
|
|
212
|
+
end
|
|
213
|
+
lines << ""
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def append_purpose_comparison(lines)
|
|
217
|
+
lines << "## Purpose Comparison"
|
|
218
|
+
lines << ""
|
|
219
|
+
lines << "### #{name1} Purposes"
|
|
220
|
+
lines << ""
|
|
221
|
+
lines << "| Purpose | Legal Basis | Required Fields |"
|
|
222
|
+
lines << "|---------|-------------|-----------------|"
|
|
223
|
+
policy1.purposes.values.each do |p|
|
|
224
|
+
lines << "| #{p.name} | #{p.legal_basis} | #{p.required_fields.join(', ')} |"
|
|
225
|
+
end
|
|
226
|
+
lines << ""
|
|
227
|
+
|
|
228
|
+
lines << "### #{name2} Purposes"
|
|
229
|
+
lines << ""
|
|
230
|
+
lines << "| Purpose | Legal Basis | Required Fields |"
|
|
231
|
+
lines << "|---------|-------------|-----------------|"
|
|
232
|
+
policy2.purposes.values.each do |p|
|
|
233
|
+
lines << "| #{p.name} | #{p.legal_basis} | #{p.required_fields.join(', ')} |"
|
|
234
|
+
end
|
|
235
|
+
lines << ""
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def append_retention_comparison(lines)
|
|
239
|
+
lines << "## Retention Comparison"
|
|
240
|
+
lines << ""
|
|
241
|
+
lines << "| Policy | Default Duration |"
|
|
242
|
+
lines << "|--------|-----------------|"
|
|
243
|
+
lines << "| #{name1} | #{format_duration(policy1.retention&.default_duration)} |"
|
|
244
|
+
lines << "| #{name2} | #{format_duration(policy2.retention&.default_duration)} |"
|
|
245
|
+
lines << ""
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def format_duration(duration)
|
|
249
|
+
return "N/A" unless duration
|
|
250
|
+
|
|
251
|
+
if duration >= 1.year
|
|
252
|
+
years = (duration / 1.year).to_i
|
|
253
|
+
"#{years} year#{'s' if years != 1}"
|
|
254
|
+
elsif duration >= 1.day
|
|
255
|
+
days = (duration / 1.day).to_i
|
|
256
|
+
"#{days} day#{'s' if days != 1}"
|
|
257
|
+
else
|
|
258
|
+
"#{duration} seconds"
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def purpose_to_hash(purpose)
|
|
263
|
+
{
|
|
264
|
+
name: purpose.name,
|
|
265
|
+
legal_basis: purpose.legal_basis,
|
|
266
|
+
required_fields: purpose.required_fields,
|
|
267
|
+
optional_fields: purpose.optional_fields
|
|
268
|
+
}
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def field_comparison_detail
|
|
272
|
+
comparison = field_comparison
|
|
273
|
+
|
|
274
|
+
{
|
|
275
|
+
common: comparison[:common].map do |f|
|
|
276
|
+
{
|
|
277
|
+
name: f,
|
|
278
|
+
first: field_to_hash(policy1.fields[f]),
|
|
279
|
+
second: field_to_hash(policy2.fields[f]),
|
|
280
|
+
matches: field_matches?(f)
|
|
281
|
+
}
|
|
282
|
+
end,
|
|
283
|
+
only_in_first: comparison[:only_in_first].map { |f| field_to_hash(policy1.fields[f]) },
|
|
284
|
+
only_in_second: comparison[:only_in_second].map { |f| field_to_hash(policy2.fields[f]) }
|
|
285
|
+
}
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def field_to_hash(field)
|
|
289
|
+
{
|
|
290
|
+
name: field.name,
|
|
291
|
+
type: field.type,
|
|
292
|
+
sensitivity: field.sensitivity
|
|
293
|
+
}
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|