orfeas_pam_dsl 0.6.0 → 0.8.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 +4 -4
- data/CHANGELOG.md +34 -0
- data/README.md +160 -26
- data/Rakefile +19 -0
- data/lib/pam_dsl/consent.rb +143 -13
- data/lib/pam_dsl/core_ext.rb +67 -0
- data/lib/pam_dsl/field.rb +17 -1
- data/lib/pam_dsl/gdpr_compliance.rb +47 -21
- data/lib/pam_dsl/pii_masker.rb +1 -0
- data/lib/pam_dsl/policy.rb +45 -19
- data/lib/pam_dsl/policy_generator.rb +20 -29
- data/lib/pam_dsl/purpose.rb +48 -1
- data/lib/pam_dsl/reporter.rb +1 -1
- data/lib/pam_dsl/version.rb +1 -1
- data/lib/pam_dsl.rb +25 -2
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cbc7589ac5d89e024056bc8ba7c7cd24344accf0774d110841d7df67c063d12d
|
|
4
|
+
data.tar.gz: 513b8a259f2f1b98802152f0358bfc4d0b615aa6175e83780fc92a42fd426bbe
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2f4205ae19ef26ca957a28104833d26e567b1ba5ed74623393eea7e5a9a80492b5378c7952a6159edeb4a2e06de5c2ee731a719002f169c131b52898c559e1be
|
|
7
|
+
data.tar.gz: 58506a1d1c9e4106c570fcdb4513c7928b0b8f1207a97e5807f5f1b779eaf5e030c7aa78fe5d9a0093e0efffcd9a26a8f7437ca948254c862ce332ebd3e2f411
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,40 @@ All notable changes to PAM DSL will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.8.0] - 2026-06-15
|
|
8
|
+
|
|
9
|
+
The first release to RubyGems since 0.6.0, and the version the PAM article (Requirements
|
|
10
|
+
Engineering, 2026) cites. It also contains 0.7.0 (below), which was never published.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- **ActiveSupport is now optional.** `pam_dsl` previously declared a hard runtime dependency on `activesupport` and unconditionally `require`d it. It now loads ActiveSupport only if present and otherwise falls back to a minimal standard-library polyfill (`lib/pam_dsl/core_ext.rb`), so the gem is self-contained when used standalone (outside Rails). `activesupport` moved from a runtime to a development dependency (still exercised by the test suite). The ActiveSupport surface the gem uses is small: `Numeric` duration helpers (`.years`/`.months`/`.weeks`/`.days`, `.ago`/`.from_now`), `Time.current`, `String#underscore`/`#titleize`, and `Object#present?`.
|
|
14
|
+
- The polyfill durations use a **fixed-length approximation** (30-day month, 365-day year), matching the gem's report tooling. When ActiveSupport is present its calendar-aware durations are used instead, unchanged.
|
|
15
|
+
- `Reporter#format_duration` now matches `PamDsl::DURATION_CLASS` (`ActiveSupport::Duration` when available, else `Numeric`) instead of referencing `ActiveSupport::Duration` directly, so it works under both paths.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- `PAM_DSL_FORCE_POLYFILL` environment variable forces the stdlib polyfill even when ActiveSupport is installed, so both code paths can be tested.
|
|
19
|
+
- Rake tasks `test:polyfill` (run the suite forcing the polyfill) and `test:both` (run it under both ActiveSupport and the polyfill). The same 425 tests pass under both paths; `test_duration_formatting` was made tolerant of the 30-day month boundary (the only assertion that differs between the calendar and fixed-length duration models).
|
|
20
|
+
|
|
21
|
+
## [0.7.0] - 2026-06-02 (never published; part of 0.8.0)
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
- `ConsentRecord` class: full four-state lifecycle (P0 Pending, P1 Granted, P2 Expired, P3 Withdrawn) matching the formal consent state diagram (paper §4.2). `expires_at` is stored on the record at grant time so `state` derives `:expired` autonomously without external requirement lookup. `grant!` (P0→P1) and `withdraw!` (P1→P3) enforce valid transitions; P2 and P3 are absorbing.
|
|
25
|
+
- `ConsentStore#request` creates a Pending record (P0); replaces absorbing records to support re-consent flow
|
|
26
|
+
- `ConsentPolicy#request_consent` exposes the Pending flow at the policy level
|
|
27
|
+
- `ConsentStore` class: runtime consent state store (the $CS$ component of the formal runtime context); indexed by `[purpose, subject]` pairs; supports `grant`, `withdraw`, `record_for`, `granted?`
|
|
28
|
+
- `ConsentPolicy#grant_consent(purpose:, subject:, granted_at:)` and `#withdraw_consent(purpose:, subject:)` for populating the consent store
|
|
29
|
+
- `ConsentPolicy#validate!(purpose, subject:)` now performs per-subject consent lookup against the store, implementing `consent_active(u, s, C)` from Definition 5
|
|
30
|
+
- `Policy#validate_access!` signature changed to `(field_names, purpose_name, subject:)` — subject identifier is now required, eliminating the caller-supplied boolean anti-pattern
|
|
31
|
+
- `UndeclaredPurposeError` and `PurposeFieldMismatchError` exception classes — completes the five-class violation taxonomy from Definition 4 (`InvalidFieldError`, `UndeclaredPurposeError`, `PurposeFieldMismatchError`, `ConsentRequiredError`, `SensitivityViolationError`)
|
|
32
|
+
- `Policy#validate_access!` now calls `get_field` before `allowed?` so undeclared fields raise `InvalidFieldError` and purpose-field mismatches raise `PurposeFieldMismatchError`, making violation classes unambiguous
|
|
33
|
+
- `Purpose#lia_documented!` DSL method and `lia_documented?` predicate — records that a Legitimate Interests Assessment has been conducted for an Art. 6(1)(f) purpose
|
|
34
|
+
- `Policy#lia_compliance_gaps` — returns all `:legitimate_interests` purposes lacking a documented LIA; surfaces Definition 2's basis_ok condition as a compliance query rather than an enforcement gate (per paper §3.2: LIA is a human-judgment obligation, not automatable)
|
|
35
|
+
- `Purpose#art9_basis(*bases)` DSL method for declaring GDPR Art. 9(2) sub-clauses on a purpose; multiple calls accumulate
|
|
36
|
+
- `Purpose#art9_basis?` predicate returning true when at least one Art. 9(2) basis is declared
|
|
37
|
+
- `SensitivityViolationError` exception class (Definition 4, Sensitivity Violation class)
|
|
38
|
+
- `Field::SPECIAL_CATEGORY_TYPES` constant (`:health`, `:biometric`) and `Field#special_category?` predicate — identify GDPR Article 9 special-category data by **type**, independently of the `:restricted` sensitivity (Article 32 risk) level
|
|
39
|
+
- `Policy#validate_access!` enforces Definition 1 Condition 5 by **data type**: raises `SensitivityViolationError` when any requested field is an Article 9 special-category type (`special_category?`) and the purpose declares no Art. 9(2) basis. This deliberately decouples the Art. 9(2) requirement from the `:restricted` risk tier, so high-risk-but-ordinary data (e.g. financial or national-identifier fields marked `:restricted`) no longer spuriously requires an Art. 9(2) basis, while a special-category type triggers the check at any sensitivity level.
|
|
40
|
+
|
|
7
41
|
## [0.6.0] - 2026-01-05
|
|
8
42
|
|
|
9
43
|
### Added
|
data/README.md
CHANGED
|
@@ -144,9 +144,9 @@ PamDsl.define_policy :my_policy do
|
|
|
144
144
|
purpose :analytics do
|
|
145
145
|
describe "Improving service quality and user experience"
|
|
146
146
|
basis :legitimate_interests # GDPR Article 6(1)(f)
|
|
147
|
+
lia_documented! # balancing test conducted and on record
|
|
147
148
|
requires :user_id
|
|
148
149
|
optionally :session_data, :interaction_events
|
|
149
|
-
meta :balancing_test_performed, true
|
|
150
150
|
meta :data_minimization, true
|
|
151
151
|
end
|
|
152
152
|
|
|
@@ -166,6 +166,58 @@ end
|
|
|
166
166
|
- `:public_task` - Task in public interest
|
|
167
167
|
- `:legitimate_interests` - Legitimate interests
|
|
168
168
|
|
|
169
|
+
#### Legitimate Interests Assessment (LIA)
|
|
170
|
+
|
|
171
|
+
When the basis is `:legitimate_interests`, GDPR Art. 6(1)(f) requires a balancing test — a human-judgment obligation PAM cannot automate. Instead, PAM verifies that one has been recorded. Use `lia_documented!` to mark the assessment as done; `lia_compliance_gaps` surfaces any `:legitimate_interests` purposes where it is missing.
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
purpose :analytics do
|
|
175
|
+
basis :legitimate_interests
|
|
176
|
+
lia_documented! # records that the LIA balancing test has been conducted
|
|
177
|
+
requires :user_id
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Check which purposes still need an LIA
|
|
181
|
+
policy.lia_compliance_gaps.each do |p|
|
|
182
|
+
puts "#{p.name}: LIA not documented"
|
|
183
|
+
end
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
`lia_compliance_gaps` is a compliance query, not an enforcement gate — access is not blocked when `lia_documented!` is absent, because the balancing test is an organizational process, not a runtime condition.
|
|
187
|
+
|
|
188
|
+
#### Art. 9(2) Basis for Special-Category Data
|
|
189
|
+
|
|
190
|
+
When a purpose accesses **special-category fields** — those whose `type` is a GDPR Article 9 category (`:health`, `:biometric`) — you **must** also declare an Art. 9(2) basis. Without it, `validate_access!` raises `SensitivityViolationError`. Multiple bases are accepted. Note that this is driven by the field **type**, not by the `:restricted` sensitivity level: `:restricted` is an Article 32 *risk* tier (used for high-risk-but-ordinary data such as financial or national-identifier fields), so a `:restricted` field of a non-Article-9 type does **not** require an Art. 9(2) basis, while an Article-9-type field requires one even below `:restricted`.
|
|
191
|
+
|
|
192
|
+
```ruby
|
|
193
|
+
purpose :medical_diagnosis do
|
|
194
|
+
describe "Processing health data for treatment"
|
|
195
|
+
basis :contract
|
|
196
|
+
art9_basis :health_care # Art. 9(2)(h)
|
|
197
|
+
art9_basis :explicit_consent # Art. 9(2)(a) — multiple allowed
|
|
198
|
+
requires :diagnosis, :prescription
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Or pass multiple in one call
|
|
202
|
+
purpose :health_research do
|
|
203
|
+
basis :public_task
|
|
204
|
+
art9_basis :health_care, :research_archiving
|
|
205
|
+
requires :diagnosis
|
|
206
|
+
end
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
**Art. 9(2) Bases:**
|
|
210
|
+
- `:explicit_consent` - 9(2)(a): explicit consent from the data subject
|
|
211
|
+
- `:employment_law` - 9(2)(b): employment / social security law obligation
|
|
212
|
+
- `:vital_interests` - 9(2)(c): vital interests, subject incapable of consenting
|
|
213
|
+
- `:non_profit` - 9(2)(d): legitimate non-profit body, members only
|
|
214
|
+
- `:made_public` - 9(2)(e): data manifestly made public by the subject
|
|
215
|
+
- `:legal_claims` - 9(2)(f): legal claims or judicial acts
|
|
216
|
+
- `:substantial_public_interest` - 9(2)(g): substantial public interest (Union/Member State law)
|
|
217
|
+
- `:health_care` - 9(2)(h): medical diagnosis, health or social care
|
|
218
|
+
- `:public_health` - 9(2)(i): public health
|
|
219
|
+
- `:research_archiving` - 9(2)(j): scientific/historical research or statistics
|
|
220
|
+
|
|
169
221
|
### Retention Policies
|
|
170
222
|
|
|
171
223
|
Define how long data should be retained:
|
|
@@ -207,7 +259,12 @@ end
|
|
|
207
259
|
|
|
208
260
|
### Consent Management
|
|
209
261
|
|
|
210
|
-
|
|
262
|
+
Consent has two distinct layers in PAM:
|
|
263
|
+
|
|
264
|
+
1. **Policy-time spec** (`consent` DSL block) — declares *what* consent is required per purpose
|
|
265
|
+
2. **Runtime consent store** — records *whether* each data subject has actually granted or withdrawn consent
|
|
266
|
+
|
|
267
|
+
#### Policy-time spec
|
|
211
268
|
|
|
212
269
|
```ruby
|
|
213
270
|
PamDsl.define_policy :my_policy do
|
|
@@ -229,6 +286,41 @@ PamDsl.define_policy :my_policy do
|
|
|
229
286
|
end
|
|
230
287
|
```
|
|
231
288
|
|
|
289
|
+
#### Runtime consent store
|
|
290
|
+
|
|
291
|
+
Before calling `validate_access!` for a consent-based purpose, populate the store with the subject's actual consent decision. The store is the runtime $CS$ component from the formal model — indexed by `(purpose, subject)` pairs.
|
|
292
|
+
|
|
293
|
+
```ruby
|
|
294
|
+
policy = PamDsl.policy(:my_policy)
|
|
295
|
+
|
|
296
|
+
# Subject 42 grants marketing consent
|
|
297
|
+
policy.consent_policy.grant_consent(purpose: :marketing, subject: 42)
|
|
298
|
+
|
|
299
|
+
# Subject 42 grants with a backdated timestamp (e.g. loaded from DB)
|
|
300
|
+
policy.consent_policy.grant_consent(
|
|
301
|
+
purpose: :marketing,
|
|
302
|
+
subject: 42,
|
|
303
|
+
granted_at: 6.months.ago
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# Subject 42 withdraws consent
|
|
307
|
+
policy.consent_policy.withdraw_consent(purpose: :marketing, subject: 42)
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
`validate_access!` then looks up the store automatically — no need to pass a boolean:
|
|
311
|
+
|
|
312
|
+
```ruby
|
|
313
|
+
# Passes: record exists and is not withdrawn/expired
|
|
314
|
+
policy.validate_access!([:email], :marketing, subject: 42)
|
|
315
|
+
|
|
316
|
+
# Raises ConsentRequiredError: no record for subject 99
|
|
317
|
+
policy.validate_access!([:email], :marketing, subject: 99)
|
|
318
|
+
|
|
319
|
+
# Raises ConsentRequiredError: record is withdrawn
|
|
320
|
+
policy.consent_policy.withdraw_consent(purpose: :marketing, subject: 42)
|
|
321
|
+
policy.validate_access!([:email], :marketing, subject: 42)
|
|
322
|
+
```
|
|
323
|
+
|
|
232
324
|
### Custom Attributes (Metadata)
|
|
233
325
|
|
|
234
326
|
PAM DSL supports custom attributes via the `meta(key, value)` method at three levels: **policy**, **field**, and **purpose**. This allows you to extend policies with application-specific data without modifying the DSL core.
|
|
@@ -244,8 +336,8 @@ PamDsl.define_policy :my_app do
|
|
|
244
336
|
meta :dpo_email, "dpo@aegean.gr"
|
|
245
337
|
meta :policy_version, "2.1"
|
|
246
338
|
meta :gdpr_compliant, true
|
|
247
|
-
meta :last_review_date, "
|
|
248
|
-
meta :next_review_date, "
|
|
339
|
+
meta :last_review_date, "2026-06-02"
|
|
340
|
+
meta :next_review_date, "2027-06-02"
|
|
249
341
|
|
|
250
342
|
# ... fields, purposes, etc.
|
|
251
343
|
end
|
|
@@ -300,9 +392,9 @@ purpose :analytics do
|
|
|
300
392
|
requires :usage_data
|
|
301
393
|
optionally :device_info
|
|
302
394
|
|
|
303
|
-
# Legitimate Interest Assessment (LIA)
|
|
304
|
-
|
|
305
|
-
meta :lia_date, "
|
|
395
|
+
# Legitimate Interest Assessment (LIA) — use lia_documented! to record completion
|
|
396
|
+
lia_documented!
|
|
397
|
+
meta :lia_date, "2026-06-02"
|
|
306
398
|
meta :lia_outcome, "Approved - minimal privacy impact"
|
|
307
399
|
meta :balancing_test, "User benefit outweighs minimal data use"
|
|
308
400
|
|
|
@@ -369,16 +461,16 @@ policy = PamDsl.policy(:user_data)
|
|
|
369
461
|
policy.allowed?(:email, :marketing) # => true
|
|
370
462
|
policy.allowed?(:ssn, :marketing) # => false
|
|
371
463
|
|
|
372
|
-
#
|
|
464
|
+
# Record consent for subject before validating (required for consent-based purposes)
|
|
465
|
+
policy.consent_policy.grant_consent(purpose: :marketing, subject: current_user.id)
|
|
466
|
+
|
|
467
|
+
# Validate data access — subject: is always required
|
|
373
468
|
begin
|
|
374
|
-
policy.validate_access!(
|
|
375
|
-
[:email, :name],
|
|
376
|
-
:marketing,
|
|
377
|
-
consent_granted: true,
|
|
378
|
-
consent_granted_at: 6.months.ago
|
|
379
|
-
)
|
|
469
|
+
policy.validate_access!([:email, :name], :marketing, subject: current_user.id)
|
|
380
470
|
rescue PamDsl::ConsentRequiredError => e
|
|
381
471
|
puts "Consent error: #{e.message}"
|
|
472
|
+
rescue PamDsl::SensitivityViolationError => e
|
|
473
|
+
puts "Art. 9 violation: #{e.message}"
|
|
382
474
|
end
|
|
383
475
|
|
|
384
476
|
# Get field and apply transformation
|
|
@@ -874,7 +966,7 @@ The `GDPRCompliance` class provides comprehensive GDPR data subject rights funct
|
|
|
874
966
|
compliance = PamDsl::GDPRCompliance.new(
|
|
875
967
|
subject_id: user.id,
|
|
876
968
|
subject_type: 'User',
|
|
877
|
-
|
|
969
|
+
record_reader: ->(subject_id, subject_type) {
|
|
878
970
|
# Return events for this subject from your event store
|
|
879
971
|
EventStore.events_for_user(subject_id)
|
|
880
972
|
}
|
|
@@ -906,7 +998,7 @@ For non-standard event formats, provide custom extractors:
|
|
|
906
998
|
# RubyEventStore example
|
|
907
999
|
compliance = PamDsl::GDPRCompliance.new(
|
|
908
1000
|
subject_id: user.id,
|
|
909
|
-
|
|
1001
|
+
record_reader: ->(sid, stype) {
|
|
910
1002
|
event_store.read.stream("User$#{sid}").to_a
|
|
911
1003
|
},
|
|
912
1004
|
attribute_extractor: ->(e) { e.data[:attributes] || {} },
|
|
@@ -1069,7 +1161,9 @@ end
|
|
|
1069
1161
|
- `consent(&block)` - Configure consent
|
|
1070
1162
|
- `meta(key, value)` - Add custom metadata to policy
|
|
1071
1163
|
- `allowed?(field, purpose)` - Check if field is allowed for purpose
|
|
1072
|
-
- `validate_access!(fields, purpose,
|
|
1164
|
+
- `validate_access!(fields, purpose, subject:)` - Validate access for a data subject; raises `ConsentRequiredError`, `SensitivityViolationError`, or `InvalidFieldError`
|
|
1165
|
+
- `consent_policy` - Access the `ConsentPolicy` to populate the runtime consent store
|
|
1166
|
+
- `lia_compliance_gaps` - Returns purposes with `:legitimate_interests` basis that have not called `lia_documented!`
|
|
1073
1167
|
- `sensitive_fields` - Get all fields with confidential/restricted sensitivity
|
|
1074
1168
|
- `restricted_fields` - Get all fields with restricted sensitivity
|
|
1075
1169
|
- `metadata` - Access policy metadata hash
|
|
@@ -1089,12 +1183,17 @@ end
|
|
|
1089
1183
|
### Purpose
|
|
1090
1184
|
|
|
1091
1185
|
- `describe(text)` - Set description
|
|
1092
|
-
- `basis(legal_basis)` - Set legal basis
|
|
1186
|
+
- `basis(legal_basis)` - Set legal basis (GDPR Art. 6)
|
|
1187
|
+
- `lia_documented!(value = true)` - Record that a Legitimate Interests Assessment has been conducted for this `:legitimate_interests` purpose; pass `false` to unset
|
|
1188
|
+
- `lia_documented?` - True when LIA has been recorded
|
|
1189
|
+
- `art9_basis(*bases)` - Declare one or more Art. 9(2) bases; required when the purpose accesses Article-9 special-category-**type** fields (e.g. `:health`, `:biometric`), independently of the `:restricted` risk level; multiple calls accumulate
|
|
1093
1190
|
- `requires(*fields)` - Define required fields
|
|
1094
1191
|
- `optionally(*fields)` - Define optional fields
|
|
1095
1192
|
- `meta(key, value)` - Add custom metadata
|
|
1096
1193
|
- `metadata` - Access purpose metadata hash
|
|
1097
1194
|
- `requires_consent?` - Check if purpose requires consent (basis is :consent)
|
|
1195
|
+
- `art9_basis?` - True when at least one Art. 9(2) basis is declared
|
|
1196
|
+
- `art9_bases` - Array of declared Art. 9(2) bases
|
|
1098
1197
|
- `all_fields` - Get all fields (required + optional)
|
|
1099
1198
|
- `requires_field?(field)` - Check if field is required
|
|
1100
1199
|
- `allows_field?(field)` - Check if field is allowed
|
|
@@ -1107,13 +1206,37 @@ end
|
|
|
1107
1206
|
- `field(name, duration:)` - Set field retention
|
|
1108
1207
|
- `on_expiry(strategy)` - Set deletion strategy
|
|
1109
1208
|
|
|
1110
|
-
###
|
|
1209
|
+
### ConsentPolicy
|
|
1111
1210
|
|
|
1112
|
-
|
|
1211
|
+
DSL configuration (policy-time):
|
|
1212
|
+
- `for_purpose(purpose, &block)` - Declare a consent requirement for a purpose
|
|
1113
1213
|
- `required!(value)` - Set if required
|
|
1114
1214
|
- `granular!(value)` - Enable granular consent
|
|
1115
1215
|
- `withdrawable!(value)` - Set if withdrawable
|
|
1116
|
-
- `expires_in(duration)` - Set expiration
|
|
1216
|
+
- `expires_in(duration)` - Set expiration window
|
|
1217
|
+
|
|
1218
|
+
Runtime consent store (per-subject, accessed via `policy.consent_policy`):
|
|
1219
|
+
- `grant_consent(purpose:, subject:, granted_at: Time.current)` - Record that subject granted consent
|
|
1220
|
+
- `withdraw_consent(purpose:, subject:)` - Record that subject withdrew consent
|
|
1221
|
+
- `requirement_for(purpose)` - Look up the `ConsentRequirement` for a purpose
|
|
1222
|
+
- `required_for?(purpose)` - True if consent is required for the purpose
|
|
1223
|
+
- `store` - The underlying `ConsentStore`
|
|
1224
|
+
|
|
1225
|
+
### ConsentStore
|
|
1226
|
+
|
|
1227
|
+
- `grant(purpose:, subject:, granted_at:)` - Add a consent record to CS
|
|
1228
|
+
- `withdraw(purpose:, subject:)` - Mark a record as withdrawn
|
|
1229
|
+
- `record_for(purpose, subject)` - Returns the `ConsentRecord` or nil
|
|
1230
|
+
- `granted?(purpose, subject)` - True when a non-withdrawn record exists
|
|
1231
|
+
|
|
1232
|
+
### ConsentRecord
|
|
1233
|
+
|
|
1234
|
+
- `purpose` - Purpose symbol
|
|
1235
|
+
- `subject` - Subject identifier
|
|
1236
|
+
- `granted_at` - Time consent was granted
|
|
1237
|
+
- `withdrawn_at` - Time consent was withdrawn, or nil
|
|
1238
|
+
- `withdrawn?` - True if consent has been withdrawn
|
|
1239
|
+
- `state` - `:granted` or `:withdrawn`
|
|
1117
1240
|
|
|
1118
1241
|
### Reporter
|
|
1119
1242
|
|
|
@@ -1155,7 +1278,7 @@ end
|
|
|
1155
1278
|
|
|
1156
1279
|
### GDPRCompliance
|
|
1157
1280
|
|
|
1158
|
-
- `GDPRCompliance.new(subject_id:, subject_type:,
|
|
1281
|
+
- `GDPRCompliance.new(subject_id:, subject_type:, record_reader:, **extractors)` - Create compliance handler
|
|
1159
1282
|
- `data_export` - Right to Access (Art. 15) - Full data export
|
|
1160
1283
|
- `right_to_be_forgotten_report` - Right to Erasure (Art. 17) - Deletion analysis
|
|
1161
1284
|
- `portable_export(format:)` - Right to Portability (Art. 20) - Export as JSON/CSV/XML
|
|
@@ -1304,12 +1427,16 @@ The following table shows the default sensitivity assignments in PAM DSL:
|
|
|
1304
1427
|
|
|
1305
1428
|
### Legal Bases by Sensitivity
|
|
1306
1429
|
|
|
1307
|
-
|
|
1430
|
+
`sensitivity` is an Article 32 **risk** tier; it determines the strength of protection, not the legal basis. The Article 9(2) requirement is driven separately by a field's **type** (see below), not by its sensitivity level.
|
|
1431
|
+
|
|
1432
|
+
| Sensitivity (risk tier) | Typical Article 6 Legal Bases | GDPR Articles |
|
|
1308
1433
|
|-------------|---------------------|---------------|
|
|
1309
1434
|
| `:public` | Not applicable | N/A |
|
|
1310
1435
|
| `:internal` | Contract, Legitimate Interest | Art. 6(1)(b), (f) |
|
|
1311
1436
|
| `:confidential` | Contract, Consent, Legal Obligation | Art. 6(1)(a), (b), (c) |
|
|
1312
|
-
| `:restricted` |
|
|
1437
|
+
| `:restricted` | Contract, Legal Obligation, Consent (highest-risk ordinary data, e.g. financial/national-ID) | Art. 6(1)(a), (b), (c) |
|
|
1438
|
+
|
|
1439
|
+
**Article 9 special categories** (independent of the table above): a field whose **type** is `:health` or `:biometric` additionally requires an `art9_basis` on every purpose that accesses it — at any sensitivity level — under Art. 9(2)(a)–(j).
|
|
1313
1440
|
|
|
1314
1441
|
### Practical Implementation
|
|
1315
1442
|
|
|
@@ -1329,15 +1456,22 @@ PamDsl.define_policy :gdpr_compliant do
|
|
|
1329
1456
|
meta :retention_period, "Account lifetime + 2 years"
|
|
1330
1457
|
end
|
|
1331
1458
|
|
|
1332
|
-
#
|
|
1459
|
+
# Special-category TYPE (Art. 9) at the restricted risk tier
|
|
1333
1460
|
field :health_status, type: :health, sensitivity: :restricted do
|
|
1334
1461
|
allow_for :medical_services
|
|
1335
|
-
meta :gdpr_basis, "Art. 9(2)(a) - Explicit consent"
|
|
1336
1462
|
meta :dpia_required, true
|
|
1337
1463
|
meta :encryption_algorithm, "AES-256"
|
|
1338
1464
|
meta :access_approval_required, true
|
|
1339
1465
|
end
|
|
1340
1466
|
|
|
1467
|
+
purpose :medical_services do
|
|
1468
|
+
describe "Health data processing for care provision"
|
|
1469
|
+
basis :contract
|
|
1470
|
+
art9_basis :health_care # Art. 9(2)(h) — mandatory for Art. 9 type fields (:health/:biometric)
|
|
1471
|
+
art9_basis :explicit_consent # Art. 9(2)(a) — additional basis
|
|
1472
|
+
requires :health_status
|
|
1473
|
+
end
|
|
1474
|
+
|
|
1341
1475
|
# Restricted - financial identifier
|
|
1342
1476
|
field :vat_number, type: :identifier, sensitivity: :restricted do
|
|
1343
1477
|
allow_for :invoicing, :tax_compliance
|
data/Rakefile
CHANGED
|
@@ -8,4 +8,23 @@ Rake::TestTask.new(:test) do |t|
|
|
|
8
8
|
t.verbose = true
|
|
9
9
|
end
|
|
10
10
|
|
|
11
|
+
# Same suite, forcing the ActiveSupport-free stdlib polyfill (lib/pam_dsl/core_ext.rb).
|
|
12
|
+
desc "Run the test suite forcing the ActiveSupport-free polyfill path"
|
|
13
|
+
task "test:polyfill" do
|
|
14
|
+
sh({ "PAM_DSL_FORCE_POLYFILL" => "1" },
|
|
15
|
+
RbConfig.ruby, "-Ilib", "-Itest",
|
|
16
|
+
"-e", 'Dir.glob("test/**/*_test.rb").sort.each { |f| require File.expand_path(f) }')
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Run the whole suite twice: once with ActiveSupport, once with the polyfill.
|
|
20
|
+
desc "Run the test suite under BOTH ActiveSupport and the polyfill"
|
|
21
|
+
task "test:both" => :test do
|
|
22
|
+
Rake::Task["test:polyfill"].invoke
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
desc "Run the validation microbenchmark (QUICK=1 for a fast sweep)"
|
|
26
|
+
task :bench do
|
|
27
|
+
ruby "-Ilib", "benchmark/validation_benchmark.rb"
|
|
28
|
+
end
|
|
29
|
+
|
|
11
30
|
task default: :test
|
data/lib/pam_dsl/consent.rb
CHANGED
|
@@ -1,4 +1,110 @@
|
|
|
1
1
|
module PamDsl
|
|
2
|
+
# Runtime consent record for a single (purpose, subject) pair — one entry in CS.
|
|
3
|
+
#
|
|
4
|
+
# Implements the four-state lifecycle from the formal model (paper §4.2, Fig. consent-cpn):
|
|
5
|
+
# P0 Pending — consent requested, not yet granted
|
|
6
|
+
# P1 Granted — consent active; data processing permitted
|
|
7
|
+
# P2 Expired — consent passed its expiry time (absorbing)
|
|
8
|
+
# P3 Withdrawn — subject revoked consent (absorbing)
|
|
9
|
+
#
|
|
10
|
+
# Transitions:
|
|
11
|
+
# t1 grant — P0 → P1, fires on subject approval
|
|
12
|
+
# t2 expire — P1 → P2, guard [now >= expires_at] (evaluated lazily in #state)
|
|
13
|
+
# t3 withdraw — P1 → P3, fires on explicit revocation
|
|
14
|
+
#
|
|
15
|
+
# Re-consent after P2 or P3 requires a new record entering P0.
|
|
16
|
+
class ConsentRecord
|
|
17
|
+
attr_reader :purpose, :subject, :granted_at, :withdrawn_at, :expires_at
|
|
18
|
+
|
|
19
|
+
def initialize(purpose:, subject:)
|
|
20
|
+
@purpose = purpose.to_sym
|
|
21
|
+
@subject = subject
|
|
22
|
+
@internal = :pending
|
|
23
|
+
@granted_at = nil
|
|
24
|
+
@withdrawn_at = nil
|
|
25
|
+
@expires_at = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# t1: P0 → P1
|
|
29
|
+
def grant!(granted_at: Time.current, expires_at: nil)
|
|
30
|
+
unless @internal == :pending
|
|
31
|
+
raise Error, "Cannot grant: consent for (#{@purpose}, #{@subject}) is in state '#{state}'"
|
|
32
|
+
end
|
|
33
|
+
@granted_at = granted_at
|
|
34
|
+
@expires_at = expires_at
|
|
35
|
+
@internal = :granted
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# t3: P1 → P3 (absorbing)
|
|
39
|
+
def withdraw!(at: Time.current)
|
|
40
|
+
unless state == :granted
|
|
41
|
+
raise Error, "Cannot withdraw: consent for (#{@purpose}, #{@subject}) is in state '#{state}'"
|
|
42
|
+
end
|
|
43
|
+
@withdrawn_at = at
|
|
44
|
+
@internal = :withdrawn
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Derives the current state, applying the t2 expiry guard lazily (paper: [now >= t_exp])
|
|
48
|
+
def state
|
|
49
|
+
return @internal unless @internal == :granted
|
|
50
|
+
return :expired if @expires_at && Time.current >= @expires_at
|
|
51
|
+
:granted
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def pending?; state == :pending; end
|
|
55
|
+
def granted?; state == :granted; end
|
|
56
|
+
def expired?; state == :expired; end
|
|
57
|
+
def withdrawn?; state == :withdrawn; end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Runtime consent state store — the CS component of the formal runtime context C.
|
|
61
|
+
# Indexed by [purpose, subject] pairs.
|
|
62
|
+
class ConsentStore
|
|
63
|
+
def initialize
|
|
64
|
+
@records = {}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Create a Pending record (P0) for (purpose, subject).
|
|
68
|
+
# Replaces an absorbing record (Expired/Withdrawn) to model re-consent entering P0.
|
|
69
|
+
# Raises if an active (Pending/Granted) record already exists.
|
|
70
|
+
def request(purpose:, subject:)
|
|
71
|
+
key = [purpose.to_sym, subject]
|
|
72
|
+
existing = @records[key]
|
|
73
|
+
if existing && %i[pending granted].include?(existing.state)
|
|
74
|
+
raise Error,
|
|
75
|
+
"Cannot request consent: record for (#{purpose}, #{subject}) already in state '#{existing.state}'"
|
|
76
|
+
end
|
|
77
|
+
@records[key] = ConsentRecord.new(purpose: purpose, subject: subject)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Grant consent (t1). Transitions an existing Pending record, or creates one and grants it
|
|
81
|
+
# immediately when no prior record exists (convenience shortcut for the common case).
|
|
82
|
+
def grant(purpose:, subject:, granted_at: Time.current, expires_at: nil)
|
|
83
|
+
key = [purpose.to_sym, subject]
|
|
84
|
+
record = @records[key]
|
|
85
|
+
if record&.state == :pending
|
|
86
|
+
record.grant!(granted_at: granted_at, expires_at: expires_at)
|
|
87
|
+
else
|
|
88
|
+
fresh = ConsentRecord.new(purpose: purpose, subject: subject)
|
|
89
|
+
fresh.grant!(granted_at: granted_at, expires_at: expires_at)
|
|
90
|
+
@records[key] = fresh
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Withdraw consent (t3). No-op if no record exists.
|
|
95
|
+
def withdraw(purpose:, subject:)
|
|
96
|
+
@records[[purpose.to_sym, subject]]&.withdraw!
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def record_for(purpose, subject)
|
|
100
|
+
@records[[purpose.to_sym, subject]]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def granted?(purpose, subject)
|
|
104
|
+
record_for(purpose, subject)&.state == :granted
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
2
108
|
# Represents a consent requirement
|
|
3
109
|
class ConsentRequirement
|
|
4
110
|
attr_reader :purpose, :required, :granular, :withdrawable, :description, :expires_after
|
|
@@ -64,15 +170,16 @@ module PamDsl
|
|
|
64
170
|
end
|
|
65
171
|
end
|
|
66
172
|
|
|
67
|
-
# Container for consent requirements
|
|
173
|
+
# Container for consent requirements (policy-time spec) and the runtime consent store (CS).
|
|
68
174
|
class ConsentPolicy
|
|
69
|
-
attr_reader :requirements
|
|
175
|
+
attr_reader :requirements, :store
|
|
70
176
|
|
|
71
177
|
def initialize
|
|
72
178
|
@requirements = []
|
|
179
|
+
@store = ConsentStore.new
|
|
73
180
|
end
|
|
74
181
|
|
|
75
|
-
#
|
|
182
|
+
# DSL: declare consent requirement for a purpose
|
|
76
183
|
def for_purpose(purpose, &block)
|
|
77
184
|
requirement = ConsentRequirement.new(purpose)
|
|
78
185
|
requirement.instance_eval(&block) if block_given?
|
|
@@ -80,28 +187,51 @@ module PamDsl
|
|
|
80
187
|
requirement
|
|
81
188
|
end
|
|
82
189
|
|
|
83
|
-
#
|
|
190
|
+
# Runtime: create a Pending record — call before grant_consent when you need to
|
|
191
|
+
# track that a consent request was sent to the subject (P0 state)
|
|
192
|
+
def request_consent(purpose:, subject:)
|
|
193
|
+
@store.request(purpose: purpose, subject: subject)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Runtime: grant consent for (purpose, subject), computing expires_at from the
|
|
197
|
+
# requirement's expires_after so the record can derive its own Expired state
|
|
198
|
+
def grant_consent(purpose:, subject:, granted_at: Time.current)
|
|
199
|
+
req = requirement_for(purpose)
|
|
200
|
+
expires_at = req&.expires_after ? granted_at + req.expires_after : nil
|
|
201
|
+
@store.grant(purpose: purpose, subject: subject, granted_at: granted_at, expires_at: expires_at)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Runtime: withdraw consent for (purpose, subject) — transitions to Withdrawn (P3)
|
|
205
|
+
def withdraw_consent(purpose:, subject:)
|
|
206
|
+
@store.withdraw(purpose: purpose, subject: subject)
|
|
207
|
+
end
|
|
208
|
+
|
|
84
209
|
def requirement_for(purpose)
|
|
85
210
|
@requirements.find { |req| req.purpose == purpose.to_sym }
|
|
86
211
|
end
|
|
87
212
|
|
|
88
|
-
# Check if consent is required for a purpose
|
|
89
213
|
def required_for?(purpose)
|
|
90
214
|
requirement = requirement_for(purpose)
|
|
91
215
|
requirement ? requirement.required? : false
|
|
92
216
|
end
|
|
93
217
|
|
|
94
|
-
# Validate consent for
|
|
95
|
-
|
|
218
|
+
# Validate consent for (purpose, subject) — implements consent_active(u, s, C) from Def. 5.
|
|
219
|
+
# consent_active holds iff (u,s) ∈ CS and state(u,s) = Granted.
|
|
220
|
+
def validate!(purpose, subject:)
|
|
96
221
|
requirement = requirement_for(purpose)
|
|
97
222
|
return true unless requirement&.required?
|
|
98
223
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
224
|
+
current_state = @store.record_for(purpose, subject)&.state
|
|
225
|
+
|
|
226
|
+
unless current_state == :granted
|
|
227
|
+
reason = case current_state
|
|
228
|
+
when :pending then "consent requested but not yet granted"
|
|
229
|
+
when :expired then "consent has expired"
|
|
230
|
+
when :withdrawn then "consent has been withdrawn"
|
|
231
|
+
else "no consent record found"
|
|
232
|
+
end
|
|
233
|
+
raise ConsentRequiredError,
|
|
234
|
+
"Consent not active for purpose '#{purpose}', subject '#{subject}': #{reason}"
|
|
105
235
|
end
|
|
106
236
|
|
|
107
237
|
true
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Minimal standard-library polyfills for the small subset of ActiveSupport that
|
|
4
|
+
# pam_dsl uses. Loaded only when ActiveSupport is not available (see pam_dsl.rb),
|
|
5
|
+
# so the gem runs with no external dependencies when used standalone.
|
|
6
|
+
#
|
|
7
|
+
# Durations use a FIXED-LENGTH approximation: 1 minute = 60 s, 1 hour = 3600 s,
|
|
8
|
+
# 1 day = 86 400 s, 1 week = 7 days, 1 month = 30 days, 1 year = 365 days. This is
|
|
9
|
+
# the same approximation the gem's report tooling assumes. If exact calendar
|
|
10
|
+
# arithmetic is required (e.g. leap years, real month lengths), install
|
|
11
|
+
# activesupport and pam_dsl will use it in preference to this polyfill.
|
|
12
|
+
|
|
13
|
+
# Numeric duration helpers — return a number of seconds.
|
|
14
|
+
class Numeric
|
|
15
|
+
def seconds = self
|
|
16
|
+
def minutes = self * 60
|
|
17
|
+
def hours = self * 3_600
|
|
18
|
+
def days = self * 86_400
|
|
19
|
+
def weeks = self * 7 * 86_400
|
|
20
|
+
def months = self * 30 * 86_400
|
|
21
|
+
def years = self * 365 * 86_400
|
|
22
|
+
alias_method :second, :seconds
|
|
23
|
+
alias_method :minute, :minutes
|
|
24
|
+
alias_method :hour, :hours
|
|
25
|
+
alias_method :day, :days
|
|
26
|
+
alias_method :week, :weeks
|
|
27
|
+
alias_method :month, :months
|
|
28
|
+
alias_method :year, :years
|
|
29
|
+
|
|
30
|
+
def ago(time = Time.now) = time - self
|
|
31
|
+
def from_now(time = Time.now) = time + self
|
|
32
|
+
end unless 1.respond_to?(:years)
|
|
33
|
+
|
|
34
|
+
class Time
|
|
35
|
+
def self.current = now
|
|
36
|
+
end unless Time.respond_to?(:current)
|
|
37
|
+
|
|
38
|
+
class String
|
|
39
|
+
# CamelCase / namespaced -> snake_case (matches ActiveSupport::Inflector#underscore
|
|
40
|
+
# for the simple identifiers pam_dsl applies it to).
|
|
41
|
+
def underscore
|
|
42
|
+
gsub("::", "/")
|
|
43
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
44
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
45
|
+
.tr("-", "_")
|
|
46
|
+
.downcase
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# snake_case / CamelCase -> "Title Case" (e.g. "fraud_detection" -> "Fraud Detection").
|
|
50
|
+
def titleize
|
|
51
|
+
underscore.tr("_", " ").gsub(/\b[a-z]/, &:upcase)
|
|
52
|
+
end
|
|
53
|
+
end unless "".respond_to?(:underscore)
|
|
54
|
+
|
|
55
|
+
# Object#blank?/present? — same truth table as ActiveSupport for the values
|
|
56
|
+
# pam_dsl checks (nil, false, "", [], and "present" objects).
|
|
57
|
+
class Object
|
|
58
|
+
def blank?
|
|
59
|
+
respond_to?(:empty?) ? !!empty? : !self
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def present? = !blank?
|
|
63
|
+
end unless Object.new.respond_to?(:present?)
|
|
64
|
+
|
|
65
|
+
class NilClass
|
|
66
|
+
def blank? = true
|
|
67
|
+
end unless nil.respond_to?(:blank?)
|
data/lib/pam_dsl/field.rb
CHANGED
|
@@ -11,6 +11,15 @@ module PamDsl
|
|
|
11
11
|
:location, :identifier, :credential, :token, :payment_token, :custom
|
|
12
12
|
].freeze
|
|
13
13
|
|
|
14
|
+
# GDPR Article 9 special-category data is determined by the *kind* of data,
|
|
15
|
+
# not by a risk label: health, biometric, genetic, racial/ethnic, political,
|
|
16
|
+
# religious, trade-union, or sex-life/orientation data. Of PAM's PII_TYPES,
|
|
17
|
+
# :health and :biometric are the Article 9 categories; this set is the single
|
|
18
|
+
# place to extend should the taxonomy grow (e.g., a future :genetic type).
|
|
19
|
+
# The :restricted sensitivity *level* is an Article 32 risk tier and is
|
|
20
|
+
# deliberately independent of this set (Definition 1, Condition 5).
|
|
21
|
+
SPECIAL_CATEGORY_TYPES = [:health, :biometric].freeze
|
|
22
|
+
|
|
14
23
|
def initialize(name, type:, sensitivity: :internal)
|
|
15
24
|
@name = name.to_sym
|
|
16
25
|
@type = type.to_sym
|
|
@@ -56,11 +65,18 @@ module PamDsl
|
|
|
56
65
|
[:confidential, :restricted].include?(@sensitivity)
|
|
57
66
|
end
|
|
58
67
|
|
|
59
|
-
# Check if field is highly restricted
|
|
68
|
+
# Check if field is highly restricted (Article 32 risk tier)
|
|
60
69
|
def restricted?
|
|
61
70
|
@sensitivity == :restricted
|
|
62
71
|
end
|
|
63
72
|
|
|
73
|
+
# Check if field holds GDPR Article 9 special-category data (by type, not by
|
|
74
|
+
# sensitivity level). Triggers the Art. 9(2) basis requirement in Definition 1,
|
|
75
|
+
# Condition 5.
|
|
76
|
+
def special_category?
|
|
77
|
+
SPECIAL_CATEGORY_TYPES.include?(@type)
|
|
78
|
+
end
|
|
79
|
+
|
|
64
80
|
private
|
|
65
81
|
|
|
66
82
|
def validate!
|
|
@@ -10,7 +10,7 @@ module PamDsl
|
|
|
10
10
|
# compliance = PamDsl::GDPRCompliance.new(
|
|
11
11
|
# subject_id: user.id,
|
|
12
12
|
# subject_type: 'User',
|
|
13
|
-
#
|
|
13
|
+
# record_reader: ->(sid, stype) {
|
|
14
14
|
# Lyra.config.event_store.read.to_a.select { |e| relates_to_subject?(e, sid, stype) }
|
|
15
15
|
# }
|
|
16
16
|
# )
|
|
@@ -19,7 +19,7 @@ module PamDsl
|
|
|
19
19
|
# @example With RubyEventStore
|
|
20
20
|
# compliance = PamDsl::GDPRCompliance.new(
|
|
21
21
|
# subject_id: user.id,
|
|
22
|
-
#
|
|
22
|
+
# record_reader: ->(sid, stype) {
|
|
23
23
|
# event_store.read.stream("User$#{sid}").to_a
|
|
24
24
|
# },
|
|
25
25
|
# attribute_extractor: ->(e) { e.data[:attributes] || {} },
|
|
@@ -33,7 +33,7 @@ module PamDsl
|
|
|
33
33
|
#
|
|
34
34
|
# @param subject_id [Object] The data subject's identifier
|
|
35
35
|
# @param subject_type [String] The type/class of the subject (default: 'User')
|
|
36
|
-
# @param
|
|
36
|
+
# @param record_reader [Proc] Block that returns records for a subject: ->(subject_id, subject_type) { [...] }
|
|
37
37
|
# @param attribute_extractor [Proc] Block to extract attributes from event: ->(event) { hash }
|
|
38
38
|
# @param timestamp_extractor [Proc] Block to extract timestamp from event: ->(event) { time }
|
|
39
39
|
# @param operation_extractor [Proc] Block to extract operation from event: ->(event) { :created/:updated/:destroyed }
|
|
@@ -42,11 +42,12 @@ module PamDsl
|
|
|
42
42
|
# @param changes_extractor [Proc] Block to extract changes from event: ->(event) { hash }
|
|
43
43
|
# @param retention_policy [Hash] Retention periods by model class
|
|
44
44
|
#
|
|
45
|
-
def initialize(subject_id:, subject_type: 'User',
|
|
45
|
+
def initialize(subject_id:, subject_type: 'User', record_reader:, **options)
|
|
46
46
|
@subject_id = subject_id
|
|
47
47
|
@subject_type = subject_type
|
|
48
|
-
@
|
|
48
|
+
@record_reader = record_reader
|
|
49
49
|
@options = options
|
|
50
|
+
@pam_policy = options[:policy_name] ? PamDsl.registry.get(options[:policy_name]) : nil
|
|
50
51
|
|
|
51
52
|
# Set up extractors with sensible defaults
|
|
52
53
|
@attribute_extractor = options[:attribute_extractor] || method(:default_attribute_extractor)
|
|
@@ -55,6 +56,7 @@ module PamDsl
|
|
|
55
56
|
@model_class_extractor = options[:model_class_extractor] || method(:default_model_class_extractor)
|
|
56
57
|
@model_id_extractor = options[:model_id_extractor] || method(:default_model_id_extractor)
|
|
57
58
|
@changes_extractor = options[:changes_extractor] || method(:default_changes_extractor)
|
|
59
|
+
@source_extractor = options[:source_extractor] || method(:default_source_extractor)
|
|
58
60
|
@retention_policy = options[:retention_policy] || default_retention_policy
|
|
59
61
|
end
|
|
60
62
|
|
|
@@ -64,7 +66,7 @@ module PamDsl
|
|
|
64
66
|
# @return [Hash] Complete data export including events, PII inventory, and lineage
|
|
65
67
|
#
|
|
66
68
|
def data_export
|
|
67
|
-
events =
|
|
69
|
+
events = collect_all_records
|
|
68
70
|
|
|
69
71
|
{
|
|
70
72
|
subject: { id: subject_id, type: subject_type },
|
|
@@ -82,7 +84,7 @@ module PamDsl
|
|
|
82
84
|
# @return [Hash] Report on affected data and recommended deletion strategy
|
|
83
85
|
#
|
|
84
86
|
def right_to_be_forgotten_report
|
|
85
|
-
events =
|
|
87
|
+
events = collect_all_records
|
|
86
88
|
|
|
87
89
|
{
|
|
88
90
|
subject: { id: subject_id, type: subject_type },
|
|
@@ -102,7 +104,7 @@ module PamDsl
|
|
|
102
104
|
# @return [String, Hash] Exported data in requested format
|
|
103
105
|
#
|
|
104
106
|
def portable_export(format: :json)
|
|
105
|
-
events =
|
|
107
|
+
events = collect_all_records
|
|
106
108
|
|
|
107
109
|
data = {
|
|
108
110
|
version: "1.0",
|
|
@@ -129,7 +131,7 @@ module PamDsl
|
|
|
129
131
|
# @return [Array<Hash>] History of all data corrections
|
|
130
132
|
#
|
|
131
133
|
def rectification_history
|
|
132
|
-
events =
|
|
134
|
+
events = collect_all_records
|
|
133
135
|
|
|
134
136
|
corrections = events.select do |event|
|
|
135
137
|
extract_operation(event) == :updated && has_pii?(event)
|
|
@@ -153,17 +155,15 @@ module PamDsl
|
|
|
153
155
|
# @return [Array<Hash>] Processing activities grouped by source
|
|
154
156
|
#
|
|
155
157
|
def processing_activities
|
|
156
|
-
events =
|
|
158
|
+
events = collect_all_records
|
|
157
159
|
|
|
158
|
-
activities = events.group_by
|
|
159
|
-
event.respond_to?(:metadata) ? (event.metadata[:source] || 'unknown') : 'unknown'
|
|
160
|
-
end
|
|
160
|
+
activities = events.group_by { |record| @source_extractor.call(record) }
|
|
161
161
|
|
|
162
162
|
activities.map do |source, source_events|
|
|
163
163
|
{
|
|
164
164
|
source: source,
|
|
165
165
|
purpose: infer_purpose(source, source_events),
|
|
166
|
-
legal_basis: determine_legal_basis(source_events),
|
|
166
|
+
legal_basis: determine_legal_basis(source, source_events),
|
|
167
167
|
data_categories: categorize_data(source_events),
|
|
168
168
|
recipients: identify_recipients(source_events),
|
|
169
169
|
retention_period: determine_retention_period(source_events),
|
|
@@ -178,7 +178,7 @@ module PamDsl
|
|
|
178
178
|
# @return [Array<Hash>] Compliance status per model class
|
|
179
179
|
#
|
|
180
180
|
def retention_compliance_check
|
|
181
|
-
events =
|
|
181
|
+
events = collect_all_records
|
|
182
182
|
|
|
183
183
|
events.group_by { |e| extract_model_class(e) }.map do |model_class, model_events|
|
|
184
184
|
policy = @retention_policy[model_class] || @retention_policy[:default] || { duration: 7.years }
|
|
@@ -204,7 +204,7 @@ module PamDsl
|
|
|
204
204
|
# @return [Hash] Current consents, history, and processing legitimacy
|
|
205
205
|
#
|
|
206
206
|
def consent_audit
|
|
207
|
-
events =
|
|
207
|
+
events = collect_all_records
|
|
208
208
|
|
|
209
209
|
consent_events = events.select do |event|
|
|
210
210
|
data = event.respond_to?(:data) ? event.data : {}
|
|
@@ -251,11 +251,21 @@ module PamDsl
|
|
|
251
251
|
private
|
|
252
252
|
|
|
253
253
|
# Collect all events for the subject
|
|
254
|
-
def
|
|
255
|
-
@
|
|
254
|
+
def collect_all_records
|
|
255
|
+
@record_reader.call(subject_id, subject_type)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Default extractors that work with common record/event structures
|
|
259
|
+
def default_source_extractor(record)
|
|
260
|
+
if record.respond_to?(:metadata)
|
|
261
|
+
record.metadata[:source] || 'unknown'
|
|
262
|
+
elsif record.is_a?(Hash)
|
|
263
|
+
(record[:source] || record["source"] || 'unknown').to_s
|
|
264
|
+
else
|
|
265
|
+
'unknown'
|
|
266
|
+
end
|
|
256
267
|
end
|
|
257
268
|
|
|
258
|
-
# Default extractors that work with common event structures
|
|
259
269
|
def default_attribute_extractor(event)
|
|
260
270
|
return event.attributes if event.respond_to?(:attributes) && !event.attributes.is_a?(Method)
|
|
261
271
|
return event.data[:attributes] || event.data["attributes"] || {} if event.respond_to?(:data)
|
|
@@ -446,8 +456,24 @@ module PamDsl
|
|
|
446
456
|
end
|
|
447
457
|
end
|
|
448
458
|
|
|
449
|
-
|
|
450
|
-
"Consent
|
|
459
|
+
LEGAL_BASIS_LABELS = {
|
|
460
|
+
consent: "Consent (Art. 6(1)(a))",
|
|
461
|
+
contract: "Contract (Art. 6(1)(b))",
|
|
462
|
+
legal_obligation: "Legal obligation (Art. 6(1)(c))",
|
|
463
|
+
vital_interests: "Vital interests (Art. 6(1)(d))",
|
|
464
|
+
public_task: "Public task (Art. 6(1)(e))",
|
|
465
|
+
legitimate_interests: "Legitimate interests (Art. 6(1)(f))"
|
|
466
|
+
}.freeze
|
|
467
|
+
|
|
468
|
+
def determine_legal_basis(source, _events)
|
|
469
|
+
return "Not specified" unless @pam_policy
|
|
470
|
+
|
|
471
|
+
purpose = @pam_policy.purposes.values.find { |p| source_matches_purpose?(source, p) }
|
|
472
|
+
LEGAL_BASIS_LABELS[purpose&.legal_basis] || "Not specified"
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def source_matches_purpose?(source, purpose)
|
|
476
|
+
source.to_s.downcase.include?(purpose.name.to_s.downcase.tr('_', ' ').split.first)
|
|
451
477
|
end
|
|
452
478
|
|
|
453
479
|
def categorize_data(events)
|
data/lib/pam_dsl/pii_masker.rb
CHANGED
|
@@ -34,6 +34,7 @@ module PamDsl
|
|
|
34
34
|
# @return [Hash] New hash with PII fields masked
|
|
35
35
|
#
|
|
36
36
|
def mask(attributes, strategy: :partial)
|
|
37
|
+
raise ArgumentError, "Unknown strategy #{strategy.inspect}. Must be one of: #{STRATEGIES.join(', ')}" unless STRATEGIES.include?(strategy)
|
|
37
38
|
return attributes if attributes.nil? || attributes.empty?
|
|
38
39
|
|
|
39
40
|
masked = attributes.dup
|
data/lib/pam_dsl/policy.rb
CHANGED
|
@@ -53,48 +53,74 @@ module PamDsl
|
|
|
53
53
|
|
|
54
54
|
# Get a purpose by name
|
|
55
55
|
def get_purpose(name)
|
|
56
|
-
@purposes[name.to_sym] || raise(
|
|
56
|
+
@purposes[name.to_sym] || raise(UndeclaredPurposeError, "Purpose '#{name}' not defined in policy '#{@name}'")
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
-
# Check if field is allowed for purpose
|
|
59
|
+
# Check if field is allowed for purpose.
|
|
60
|
+
#
|
|
61
|
+
# The two declaration notations are equivalent (paper §3.2): `allow_for :p` on a field
|
|
62
|
+
# and listing the field under `requires`/`optionally` on the purpose are alternative
|
|
63
|
+
# entry points to the same constraint. When the field carries an explicit allow_for
|
|
64
|
+
# declaration, either side is sufficient. When the field has no allow_for declarations
|
|
65
|
+
# it imposes no restriction and the purpose-side declaration is authoritative.
|
|
60
66
|
def allowed?(field_name, purpose_name)
|
|
61
67
|
field = @fields[field_name.to_sym]
|
|
62
68
|
purpose = @purposes[purpose_name.to_sym]
|
|
63
69
|
|
|
64
70
|
return false unless field && purpose
|
|
65
71
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
true
|
|
72
|
+
if field.purposes.empty?
|
|
73
|
+
purpose.allows_field?(field_name)
|
|
74
|
+
else
|
|
75
|
+
field.purposes.include?(purpose_name.to_sym) || purpose.allows_field?(field_name)
|
|
76
|
+
end
|
|
73
77
|
end
|
|
74
78
|
|
|
75
|
-
# Validate data access
|
|
76
|
-
def validate_access!(field_names, purpose_name,
|
|
79
|
+
# Validate data access for a (fields, purpose, subject) triple — Def. 1.
|
|
80
|
+
def validate_access!(field_names, purpose_name, subject:)
|
|
77
81
|
purpose = get_purpose(purpose_name)
|
|
78
82
|
|
|
79
|
-
#
|
|
83
|
+
# Condition 4 (Def. 1): legal basis satisfied — consent check delegates to CS
|
|
80
84
|
if purpose.requires_consent?
|
|
81
|
-
@consent_policy.validate!(
|
|
82
|
-
purpose_name,
|
|
83
|
-
granted: consent_granted,
|
|
84
|
-
granted_at: consent_granted_at
|
|
85
|
-
)
|
|
85
|
+
@consent_policy.validate!(purpose_name, subject: subject)
|
|
86
86
|
end
|
|
87
87
|
|
|
88
|
-
# Check
|
|
88
|
+
# Check each field: existence first (InvalidFieldError), then membership (PurposeFieldMismatchError)
|
|
89
89
|
field_names.each do |field_name|
|
|
90
|
+
get_field(field_name)
|
|
90
91
|
unless allowed?(field_name, purpose_name)
|
|
91
|
-
raise
|
|
92
|
+
raise PurposeFieldMismatchError, "Field '#{field_name}' not allowed for purpose '#{purpose_name}'"
|
|
92
93
|
end
|
|
93
94
|
end
|
|
94
95
|
|
|
96
|
+
# Condition 5 (Def. 1): special-category fields require an Art. 9(2) basis on
|
|
97
|
+
# the purpose. Special category is determined by the *type* of data (Art. 9 —
|
|
98
|
+
# health, biometric, ...), independently of the :restricted sensitivity *level*
|
|
99
|
+
# (an Art. 32 risk tier). High-risk-but-ordinary data such as tax or bank
|
|
100
|
+
# identifiers may be :restricted without demanding an Art. 9(2) basis.
|
|
101
|
+
has_special_category = field_names.any? do |fn|
|
|
102
|
+
f = @fields[fn.to_sym]
|
|
103
|
+
f&.special_category?
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
if has_special_category && !purpose.art9_basis?
|
|
107
|
+
raise SensitivityViolationError,
|
|
108
|
+
"Purpose '#{purpose_name}' accesses special-category (Article 9) data but declares no Art. 9(2) basis"
|
|
109
|
+
end
|
|
110
|
+
|
|
95
111
|
true
|
|
96
112
|
end
|
|
97
113
|
|
|
114
|
+
# Returns purposes whose legal basis is :legitimate_interests but whose LIA has not
|
|
115
|
+
# been recorded via lia_documented!. These represent compliance gaps: Art. 6(1)(f)
|
|
116
|
+
# requires a balancing test — a human-judgment obligation PAM cannot enforce at runtime
|
|
117
|
+
# but can verify has been documented (paper §3.2, Def. 2).
|
|
118
|
+
def lia_compliance_gaps
|
|
119
|
+
@purposes.values.select do |p|
|
|
120
|
+
p.legal_basis == :legitimate_interests && !p.lia_documented?
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
98
124
|
# Get all sensitive fields
|
|
99
125
|
def sensitive_fields
|
|
100
126
|
@fields.values.select(&:sensitive?)
|
|
@@ -116,7 +116,7 @@ module PamDsl
|
|
|
116
116
|
billing: {
|
|
117
117
|
description: "Processing payments and generating invoices",
|
|
118
118
|
basis: :contract,
|
|
119
|
-
requires: [:email, :address]
|
|
119
|
+
requires: [:email, :address, :identifier, :financial, :credit_card]
|
|
120
120
|
},
|
|
121
121
|
legal_compliance: {
|
|
122
122
|
description: "Compliance with legal and regulatory requirements",
|
|
@@ -465,40 +465,31 @@ module PamDsl
|
|
|
465
465
|
end
|
|
466
466
|
|
|
467
467
|
def generate_purposes_code(detected_fields)
|
|
468
|
-
|
|
469
|
-
|
|
468
|
+
return "" if detected_fields.empty?
|
|
469
|
+
|
|
470
|
+
detected_types = detected_fields.values.map { |c| c[:type] }.uniq
|
|
471
|
+
fields_by_type = detected_fields.each_with_object(Hash.new { |h, k| h[k] = [] }) do |(name, config), h|
|
|
472
|
+
h[config[:type]] << name
|
|
473
|
+
end
|
|
470
474
|
|
|
471
475
|
purposes = []
|
|
472
476
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
|
477
|
+
DEFAULT_PURPOSES.each do |purpose_name, config|
|
|
478
|
+
required_types = config[:requires]
|
|
479
|
+
next unless required_types.empty? || required_types.any? { |t| detected_types.include?(t) }
|
|
483
480
|
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
requires #{([:email, :address, :vat_number] & field_names).map { |f| ":#{f}" }.join(", ")}
|
|
491
|
-
end
|
|
492
|
-
RUBY
|
|
493
|
-
end
|
|
481
|
+
matching_fields = required_types.flat_map { |t| fields_by_type[t] }
|
|
482
|
+
requires_clause = matching_fields.map { |f| ":#{f}" }.join(", ")
|
|
483
|
+
|
|
484
|
+
lia_line = config[:basis] == :legitimate_interests \
|
|
485
|
+
? "# lia_documented! # uncomment after conducting the LIA balancing test\n " \
|
|
486
|
+
: ""
|
|
494
487
|
|
|
495
|
-
# Audit trail if we have IP address
|
|
496
|
-
if field_names.include?(:ip_address)
|
|
497
488
|
purposes << <<~RUBY
|
|
498
|
-
purpose
|
|
499
|
-
describe "
|
|
500
|
-
basis :
|
|
501
|
-
requires
|
|
489
|
+
purpose :#{purpose_name} do
|
|
490
|
+
describe "#{config[:description]}"
|
|
491
|
+
basis :#{config[:basis]}
|
|
492
|
+
#{lia_line}#{"requires #{requires_clause}" unless requires_clause.empty?}
|
|
502
493
|
end
|
|
503
494
|
RUBY
|
|
504
495
|
end
|
data/lib/pam_dsl/purpose.rb
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
1
1
|
module PamDsl
|
|
2
2
|
# Represents a processing purpose
|
|
3
3
|
class Purpose
|
|
4
|
-
attr_reader :name, :description, :legal_basis, :required_fields, :optional_fields, :metadata
|
|
4
|
+
attr_reader :name, :description, :legal_basis, :art9_bases, :required_fields, :optional_fields, :metadata
|
|
5
|
+
|
|
6
|
+
# Art. 9(2) sub-clauses permitting processing of special-category data
|
|
7
|
+
ART9_BASES = [
|
|
8
|
+
:explicit_consent, # 9(2)(a) - explicit consent
|
|
9
|
+
:employment_law, # 9(2)(b) - employment / social security law
|
|
10
|
+
:vital_interests, # 9(2)(c) - vital interests, subject incapable of consenting
|
|
11
|
+
:non_profit, # 9(2)(d) - legitimate non-profit body, members/former members only
|
|
12
|
+
:made_public, # 9(2)(e) - data manifestly made public by subject
|
|
13
|
+
:legal_claims, # 9(2)(f) - legal claims / judicial acts
|
|
14
|
+
:substantial_public_interest, # 9(2)(g) - substantial public interest (Union/Member State law)
|
|
15
|
+
:health_care, # 9(2)(h) - medical diagnosis, health/social care
|
|
16
|
+
:public_health, # 9(2)(i) - public health
|
|
17
|
+
:research_archiving # 9(2)(j) - scientific/historical research, statistics
|
|
18
|
+
].freeze
|
|
5
19
|
|
|
6
20
|
LEGAL_BASES = [
|
|
7
21
|
:consent, # Article 6(1)(a) - Data subject has given consent
|
|
@@ -16,6 +30,8 @@ module PamDsl
|
|
|
16
30
|
@name = name.to_sym
|
|
17
31
|
@description = ""
|
|
18
32
|
@legal_basis = :consent
|
|
33
|
+
@art9_bases = []
|
|
34
|
+
@lia_documented = false
|
|
19
35
|
@required_fields = []
|
|
20
36
|
@optional_fields = []
|
|
21
37
|
@metadata = {}
|
|
@@ -37,6 +53,32 @@ module PamDsl
|
|
|
37
53
|
self
|
|
38
54
|
end
|
|
39
55
|
|
|
56
|
+
# Record that a Legitimate Interests Assessment (LIA) has been conducted and documented
|
|
57
|
+
# for this purpose (Art. 6(1)(f)). This is a human-judgment obligation — PAM cannot
|
|
58
|
+
# automate the balancing test, but it can verify that one has been recorded.
|
|
59
|
+
def lia_documented!(value = true)
|
|
60
|
+
@lia_documented = value
|
|
61
|
+
self
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# True when a LIA has been recorded for this purpose
|
|
65
|
+
def lia_documented?
|
|
66
|
+
@lia_documented
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Declare one or more Art. 9(2) bases for processing special-category (restricted) data.
|
|
70
|
+
# Multiple calls accumulate; passing multiple symbols in one call is also accepted.
|
|
71
|
+
def art9_basis(*bases)
|
|
72
|
+
bases.flatten.each do |b|
|
|
73
|
+
b = b.to_sym
|
|
74
|
+
unless ART9_BASES.include?(b)
|
|
75
|
+
raise Error, "Invalid Art. 9(2) basis: #{b}. Must be one of #{ART9_BASES.join(', ')}"
|
|
76
|
+
end
|
|
77
|
+
@art9_bases << b unless @art9_bases.include?(b)
|
|
78
|
+
end
|
|
79
|
+
self
|
|
80
|
+
end
|
|
81
|
+
|
|
40
82
|
# Define required fields
|
|
41
83
|
def requires(*field_names)
|
|
42
84
|
@required_fields.concat(field_names.map(&:to_sym))
|
|
@@ -55,6 +97,11 @@ module PamDsl
|
|
|
55
97
|
self
|
|
56
98
|
end
|
|
57
99
|
|
|
100
|
+
# True when the purpose carries at least one Art. 9(2) basis (Def. 1, Condition 5)
|
|
101
|
+
def art9_basis?
|
|
102
|
+
@art9_bases.any?
|
|
103
|
+
end
|
|
104
|
+
|
|
58
105
|
# Check if purpose requires consent
|
|
59
106
|
def requires_consent?
|
|
60
107
|
@legal_basis == :consent
|
data/lib/pam_dsl/reporter.rb
CHANGED
data/lib/pam_dsl/version.rb
CHANGED
data/lib/pam_dsl.rb
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
# ActiveSupport provides convenience helpers (Numeric durations, Time.current,
|
|
2
|
+
# String#underscore, Object#present?). It is optional: when it is not installed
|
|
3
|
+
# (e.g. pam_dsl used standalone, outside Rails) a minimal standard-library
|
|
4
|
+
# polyfill is loaded instead. See pam_dsl/core_ext.rb.
|
|
5
|
+
#
|
|
6
|
+
# Set PAM_DSL_FORCE_POLYFILL to load the polyfill even when ActiveSupport is
|
|
7
|
+
# available --- used by the test suite to exercise both code paths.
|
|
8
|
+
if ENV["PAM_DSL_FORCE_POLYFILL"]
|
|
9
|
+
require_relative "pam_dsl/core_ext"
|
|
10
|
+
else
|
|
11
|
+
begin
|
|
12
|
+
require "active_support"
|
|
13
|
+
require "active_support/core_ext"
|
|
14
|
+
rescue LoadError
|
|
15
|
+
require_relative "pam_dsl/core_ext"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
3
18
|
|
|
4
19
|
require_relative "pam_dsl/version"
|
|
5
20
|
require_relative "pam_dsl/pii_detector"
|
|
@@ -22,7 +37,15 @@ module PamDsl
|
|
|
22
37
|
class Error < StandardError; end
|
|
23
38
|
class PolicyNotFoundError < Error; end
|
|
24
39
|
class InvalidFieldError < Error; end
|
|
40
|
+
class UndeclaredPurposeError < Error; end
|
|
41
|
+
class PurposeFieldMismatchError < Error; end
|
|
25
42
|
class ConsentRequiredError < Error; end
|
|
43
|
+
class SensitivityViolationError < Error; end
|
|
44
|
+
|
|
45
|
+
# Class used to recognise duration values in case/when: ActiveSupport::Duration
|
|
46
|
+
# when ActiveSupport is loaded, otherwise plain Numeric (the polyfill represents
|
|
47
|
+
# durations as an integer number of seconds).
|
|
48
|
+
DURATION_CLASS = defined?(ActiveSupport::Duration) ? ActiveSupport::Duration : Numeric
|
|
26
49
|
|
|
27
50
|
class << self
|
|
28
51
|
# Rails configuration (set by Railtie)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: orfeas_pam_dsl
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michail Pantelelis
|
|
@@ -16,7 +16,7 @@ dependencies:
|
|
|
16
16
|
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
18
|
version: '6.0'
|
|
19
|
-
type: :
|
|
19
|
+
type: :development
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
@@ -95,6 +95,7 @@ files:
|
|
|
95
95
|
- Rakefile
|
|
96
96
|
- lib/pam_dsl.rb
|
|
97
97
|
- lib/pam_dsl/consent.rb
|
|
98
|
+
- lib/pam_dsl/core_ext.rb
|
|
98
99
|
- lib/pam_dsl/field.rb
|
|
99
100
|
- lib/pam_dsl/gdpr_compliance.rb
|
|
100
101
|
- lib/pam_dsl/pii_detector.rb
|
|
@@ -130,7 +131,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
130
131
|
- !ruby/object:Gem::Version
|
|
131
132
|
version: '0'
|
|
132
133
|
requirements: []
|
|
133
|
-
rubygems_version: 4.0.
|
|
134
|
+
rubygems_version: 4.0.20
|
|
134
135
|
specification_version: 4
|
|
135
136
|
summary: Privacy Attribute Matrix (PAM) DSL for ORFEAS Framework
|
|
136
137
|
test_files: []
|