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.
@@ -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