baseid_pctf/
consent.rs

1//! Consent record management per PCTF Notice & Consent component.
2//!
3//! Manages the lifecycle of consent: creation, validation, expiry, and revocation.
4//! Each credential presentation should have an associated consent record.
5
6use serde::{Deserialize, Serialize};
7
8/// Status of a consent record.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10pub enum ConsentStatus {
11    /// Consent is active and valid.
12    Active,
13    /// Consent has expired (past the `expires` timestamp).
14    Expired,
15    /// Consent was explicitly revoked by the subject.
16    Revoked,
17}
18
19/// A consent record tracking what data was shared, with whom, and when.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct ConsentRecord {
22    /// Unique identifier for this consent record.
23    pub id: String,
24    /// Who gave consent (holder DID).
25    pub subject: String,
26    /// Who received the data (verifier DID or identifier).
27    pub recipient: String,
28    /// What data elements were shared.
29    pub data_elements: Vec<String>,
30    /// Purpose of sharing (PCTF requires explicit purpose limitation).
31    pub purpose: String,
32    /// When consent was given (RFC 3339).
33    pub timestamp: String,
34    /// When consent expires (RFC 3339), if limited.
35    pub expires: Option<String>,
36    /// Current status of this consent.
37    pub status: ConsentStatus,
38}
39
40impl ConsentRecord {
41    /// Create a new active consent record.
42    pub fn new(
43        id: impl Into<String>,
44        subject: impl Into<String>,
45        recipient: impl Into<String>,
46        data_elements: Vec<String>,
47        purpose: impl Into<String>,
48        timestamp: impl Into<String>,
49        expires: Option<String>,
50    ) -> Self {
51        Self {
52            id: id.into(),
53            subject: subject.into(),
54            recipient: recipient.into(),
55            data_elements,
56            purpose: purpose.into(),
57            timestamp: timestamp.into(),
58            expires,
59            status: ConsentStatus::Active,
60        }
61    }
62
63    /// Check if the consent is currently valid (active and not expired).
64    ///
65    /// `now` should be an RFC 3339 timestamp for the current time.
66    pub fn is_valid(&self, now: &str) -> bool {
67        if self.status != ConsentStatus::Active {
68            return false;
69        }
70        if let Some(ref expires) = self.expires {
71            // Simple lexicographic comparison works for RFC 3339 timestamps
72            // because they sort chronologically.
73            return now <= expires.as_str();
74        }
75        true
76    }
77
78    /// Revoke this consent record.
79    pub fn revoke(&mut self) {
80        self.status = ConsentStatus::Revoked;
81    }
82
83    /// Check expiry and update status if expired.
84    pub fn check_expiry(&mut self, now: &str) {
85        if self.status == ConsentStatus::Active {
86            if let Some(ref expires) = self.expires {
87                if now > expires.as_str() {
88                    self.status = ConsentStatus::Expired;
89                }
90            }
91        }
92    }
93
94    /// Check if the consent covers the given data elements for the stated purpose.
95    pub fn covers(&self, data_elements: &[&str], purpose: &str) -> bool {
96        if self.purpose != purpose {
97            return false;
98        }
99        data_elements
100            .iter()
101            .all(|elem| self.data_elements.iter().any(|d| d == elem))
102    }
103}
104
105/// Manages consent records for a subject.
106pub struct ConsentManager {
107    records: Vec<ConsentRecord>,
108}
109
110impl ConsentManager {
111    /// Create a new empty consent manager.
112    pub fn new() -> Self {
113        Self {
114            records: Vec::new(),
115        }
116    }
117
118    /// Record a new consent.
119    pub fn record_consent(&mut self, consent: ConsentRecord) {
120        self.records.push(consent);
121    }
122
123    /// Revoke consent by ID.
124    pub fn revoke_consent(&mut self, id: &str) -> bool {
125        if let Some(record) = self.records.iter_mut().find(|r| r.id == id) {
126            record.revoke();
127            true
128        } else {
129            false
130        }
131    }
132
133    /// Get a consent record by ID.
134    pub fn get_consent(&self, id: &str) -> Option<&ConsentRecord> {
135        self.records.iter().find(|r| r.id == id)
136    }
137
138    /// Find all valid consents for a given recipient and purpose.
139    pub fn find_valid_consents(
140        &self,
141        recipient: &str,
142        purpose: &str,
143        now: &str,
144    ) -> Vec<&ConsentRecord> {
145        self.records
146            .iter()
147            .filter(|r| r.recipient == recipient && r.purpose == purpose && r.is_valid(now))
148            .collect()
149    }
150
151    /// Check expiry on all records and update statuses.
152    pub fn check_all_expiry(&mut self, now: &str) {
153        for record in &mut self.records {
154            record.check_expiry(now);
155        }
156    }
157
158    /// Get all consent records for a subject.
159    pub fn consents_for_subject(&self, subject: &str) -> Vec<&ConsentRecord> {
160        self.records
161            .iter()
162            .filter(|r| r.subject == subject)
163            .collect()
164    }
165
166    /// Count of all records.
167    pub fn len(&self) -> usize {
168        self.records.len()
169    }
170
171    /// Whether the manager has no records.
172    pub fn is_empty(&self) -> bool {
173        self.records.is_empty()
174    }
175}
176
177impl Default for ConsentManager {
178    fn default() -> Self {
179        Self::new()
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    fn sample_consent() -> ConsentRecord {
188        ConsentRecord::new(
189            "consent-1",
190            "did:key:z6MkHolder",
191            "did:key:z6MkVerifier",
192            vec![
193                "givenName".into(),
194                "familyName".into(),
195                "dateOfBirth".into(),
196            ],
197            "age-verification",
198            "2026-03-01T00:00:00Z",
199            Some("2026-06-01T00:00:00Z".into()),
200        )
201    }
202
203    #[test]
204    fn new_consent_is_active() {
205        let c = sample_consent();
206        assert_eq!(c.status, ConsentStatus::Active);
207    }
208
209    #[test]
210    fn consent_valid_before_expiry() {
211        let c = sample_consent();
212        assert!(c.is_valid("2026-04-01T00:00:00Z"));
213    }
214
215    #[test]
216    fn consent_invalid_after_expiry() {
217        let c = sample_consent();
218        assert!(!c.is_valid("2026-07-01T00:00:00Z"));
219    }
220
221    #[test]
222    fn consent_invalid_when_revoked() {
223        let mut c = sample_consent();
224        c.revoke();
225        assert_eq!(c.status, ConsentStatus::Revoked);
226        assert!(!c.is_valid("2026-04-01T00:00:00Z"));
227    }
228
229    #[test]
230    fn consent_no_expiry_always_valid() {
231        let c = ConsentRecord::new(
232            "consent-2",
233            "did:key:z6MkHolder",
234            "did:key:z6MkVerifier",
235            vec!["email".into()],
236            "contact",
237            "2026-01-01T00:00:00Z",
238            None,
239        );
240        assert!(c.is_valid("2099-12-31T23:59:59Z"));
241    }
242
243    #[test]
244    fn consent_covers_matching_elements_and_purpose() {
245        let c = sample_consent();
246        assert!(c.covers(&["givenName", "familyName"], "age-verification"));
247    }
248
249    #[test]
250    fn consent_does_not_cover_wrong_purpose() {
251        let c = sample_consent();
252        assert!(!c.covers(&["givenName"], "employment-check"));
253    }
254
255    #[test]
256    fn consent_does_not_cover_missing_elements() {
257        let c = sample_consent();
258        assert!(!c.covers(&["givenName", "socialInsuranceNumber"], "age-verification"));
259    }
260
261    #[test]
262    fn check_expiry_updates_status() {
263        let mut c = sample_consent();
264        c.check_expiry("2026-07-01T00:00:00Z");
265        assert_eq!(c.status, ConsentStatus::Expired);
266    }
267
268    #[test]
269    fn manager_record_and_retrieve() {
270        let mut mgr = ConsentManager::new();
271        assert!(mgr.is_empty());
272
273        mgr.record_consent(sample_consent());
274        assert_eq!(mgr.len(), 1);
275
276        let c = mgr.get_consent("consent-1").unwrap();
277        assert_eq!(c.subject, "did:key:z6MkHolder");
278    }
279
280    #[test]
281    fn manager_revoke_consent() {
282        let mut mgr = ConsentManager::new();
283        mgr.record_consent(sample_consent());
284
285        assert!(mgr.revoke_consent("consent-1"));
286        assert!(!mgr.revoke_consent("nonexistent"));
287
288        let c = mgr.get_consent("consent-1").unwrap();
289        assert_eq!(c.status, ConsentStatus::Revoked);
290    }
291
292    #[test]
293    fn manager_find_valid_consents() {
294        let mut mgr = ConsentManager::new();
295        mgr.record_consent(sample_consent());
296        mgr.record_consent(ConsentRecord::new(
297            "consent-2",
298            "did:key:z6MkHolder",
299            "did:key:z6MkVerifier",
300            vec!["email".into()],
301            "contact",
302            "2026-01-01T00:00:00Z",
303            None,
304        ));
305
306        let valid = mgr.find_valid_consents(
307            "did:key:z6MkVerifier",
308            "age-verification",
309            "2026-04-01T00:00:00Z",
310        );
311        assert_eq!(valid.len(), 1);
312        assert_eq!(valid[0].id, "consent-1");
313    }
314
315    #[test]
316    fn consent_serde_roundtrip() {
317        let c = sample_consent();
318        let json = serde_json::to_string(&c).unwrap();
319        let back: ConsentRecord = serde_json::from_str(&json).unwrap();
320        assert_eq!(back.id, "consent-1");
321        assert_eq!(back.status, ConsentStatus::Active);
322    }
323}