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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 905dfe93a7bf586dde5d0e90b43d37195ff892ad2ca51f30e13e8338bd1ea12d
4
- data.tar.gz: 254859b323f8dcd16e15864571feeffc7d7f62f1a69934a824be3713a189b25f
3
+ metadata.gz: cbc7589ac5d89e024056bc8ba7c7cd24344accf0774d110841d7df67c063d12d
4
+ data.tar.gz: 513b8a259f2f1b98802152f0358bfc4d0b615aa6175e83780fc92a42fd426bbe
5
5
  SHA512:
6
- metadata.gz: 0503caca59c63a12aa0beb51b2fd52947e80e78f3d7049729e300884d55920e9b6ae1056209f2948f0373597d0da82580d19497c8bc338852c46881073fe5956
7
- data.tar.gz: e27154dd58c9f6a0a31f1449e28cd211e25a6d0589a80b406b45ecedbbd93bf85a5d5d599778c62df10ca210c79fdf582b572f2e954115121b73bcea44cb3c2a
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
- Define consent requirements for purposes:
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, "2024-01-15"
248
- meta :next_review_date, "2025-01-15"
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) documentation
304
- meta :lia_conducted, true
305
- meta :lia_date, "2024-01-15"
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
- # Validate data access
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
- event_reader: ->(subject_id, subject_type) {
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
- event_reader: ->(sid, stype) {
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, consent_granted:, consent_granted_at:)` - Validate access
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
- ### Consent
1209
+ ### ConsentPolicy
1111
1210
 
1112
- - `for_purpose(purpose, &block)` - Define consent requirement
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:, event_reader:, **extractors)` - Create compliance handler
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
- | Sensitivity | Typical Legal Bases | GDPR Articles |
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` | Explicit Consent + Art. 9(2) exception | Art. 9(2)(a)-(j) |
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
- # Restricted - special category data
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
@@ -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
- # Define consent requirement for a purpose
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
- # Get consent requirement for a purpose
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 a purpose
95
- def validate!(purpose, granted_at: nil, granted: false)
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
- unless granted
100
- raise ConsentRequiredError, "Consent required for purpose: #{purpose}"
101
- end
102
-
103
- if granted_at && requirement.expired?(granted_at)
104
- raise ConsentRequiredError, "Consent expired for purpose: #{purpose}"
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
- # event_reader: ->(sid, stype) {
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
- # event_reader: ->(sid, stype) {
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 event_reader [Proc] Block that returns events for a subject: ->(subject_id, subject_type) { [...] }
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', event_reader:, **options)
45
+ def initialize(subject_id:, subject_type: 'User', record_reader:, **options)
46
46
  @subject_id = subject_id
47
47
  @subject_type = subject_type
48
- @event_reader = event_reader
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 = collect_all_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 = collect_all_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 = collect_all_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 = collect_all_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 = collect_all_events
158
+ events = collect_all_records
157
159
 
158
- activities = events.group_by do |event|
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 = collect_all_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 = collect_all_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 collect_all_events
255
- @event_reader.call(subject_id, subject_type)
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
- def determine_legal_basis(events)
450
- "Consent" # Could be: Consent, Contract, Legal obligation, Vital interests, Public task, Legitimate interests
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)
@@ -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
@@ -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(Error, "Purpose '#{name}' not defined in policy '#{@name}'")
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
- # 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
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, consent_granted: false, consent_granted_at: nil)
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
- # Check consent if required
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 if all fields are allowed for this purpose
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 Error, "Field '#{field_name}' not allowed for purpose '#{purpose_name}'"
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
- # Determine which purposes make sense based on detected fields
469
- field_names = detected_fields.keys
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
- # 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
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
- # 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
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 :audit_trail do
499
- describe "Maintaining security and audit logs"
500
- basis :legal_obligation
501
- requires :ip_address
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
@@ -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
@@ -665,7 +665,7 @@ module PamDsl
665
665
  return "N/A" unless duration
666
666
 
667
667
  case duration
668
- when ActiveSupport::Duration
668
+ when DURATION_CLASS
669
669
  if duration >= 1.year
670
670
  "#{(duration / 1.year).to_i} years"
671
671
  elsif duration >= 1.month
@@ -1,3 +1,3 @@
1
1
  module PamDsl
2
- VERSION = "0.6.0"
2
+ VERSION = "0.8.0"
3
3
  end
data/lib/pam_dsl.rb CHANGED
@@ -1,5 +1,20 @@
1
- require "active_support"
2
- require "active_support/core_ext"
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.6.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: :runtime
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.3
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: []