Skip to main content

qualia_client_core/wellfair/
consent_store.rs

1//! Persisted owner consent grants evaluated by PolicyDecisionService.
2
3use std::fs::{self, OpenOptions};
4use std::io::{BufRead, BufReader, Write};
5use std::path::{Path, PathBuf};
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use serde::{Deserialize, Serialize};
9
10use super::host_state::ConsentGrantDraft;
11
12pub const CONSENTS_FILE: &str = "wellfair/consents.jsonl";
13pub const MAX_LIST: usize = 64;
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16pub struct ConsentGrantRecord {
17    pub id: String,
18    pub recipient: String,
19    pub purpose: String,
20    pub fields: Vec<String>,
21    pub scope: String,
22    pub granted_at_unix: u32,
23    pub expires_at_unix: Option<u64>,
24    pub revoked: bool,
25}
26
27impl ConsentGrantRecord {
28    pub fn from_draft(draft: &ConsentGrantDraft, scope: &str) -> Self {
29        let granted_at_unix = SystemTime::now()
30            .duration_since(UNIX_EPOCH)
31            .map(|d| d.as_secs() as u32)
32            .unwrap_or(0);
33        Self {
34            id: format!("consent-{granted_at_unix}-{}", &draft.recipient),
35            recipient: draft.recipient.clone(),
36            purpose: draft.purpose.clone(),
37            fields: draft.fields.clone(),
38            scope: scope.to_string(),
39            granted_at_unix,
40            expires_at_unix: draft.expires_at_unix,
41            revoked: false,
42        }
43    }
44
45    pub fn is_active(&self, now_unix: u64) -> bool {
46        if self.revoked {
47            return false;
48        }
49        if let Some(exp) = self.expires_at_unix {
50            if now_unix >= exp {
51                return false;
52            }
53        }
54        true
55    }
56}
57
58pub struct ConsentStore {
59    path: PathBuf,
60}
61
62impl ConsentStore {
63    pub fn open(storage_root: impl AsRef<Path>) -> std::io::Result<Self> {
64        let path = storage_root.as_ref().join(CONSENTS_FILE);
65        if let Some(parent) = path.parent() {
66            fs::create_dir_all(parent)?;
67        }
68        if !path.exists() {
69            OpenOptions::new().create(true).write(true).open(&path)?;
70        }
71        Ok(Self { path })
72    }
73
74    pub fn append(&self, grant: &ConsentGrantRecord) -> std::io::Result<()> {
75        let line =
76            serde_json::to_string(grant).map_err(|e| std::io::Error::other(e.to_string()))?;
77        let mut file = OpenOptions::new().append(true).open(&self.path)?;
78        writeln!(file, "{line}")?;
79        file.sync_all()?;
80        Ok(())
81    }
82
83    pub fn list_all(&self) -> std::io::Result<Vec<ConsentGrantRecord>> {
84        let file = fs::File::open(&self.path)?;
85        let reader = BufReader::new(file);
86        let mut records = Vec::new();
87        for line in reader.lines() {
88            let line = line?;
89            if line.trim().is_empty() {
90                continue;
91            }
92            if let Ok(record) = serde_json::from_str::<ConsentGrantRecord>(&line) {
93                records.push(record);
94            }
95        }
96        Ok(records)
97    }
98
99    pub fn list_active(&self, now_unix: u64) -> std::io::Result<Vec<ConsentGrantRecord>> {
100        let all = self.list_all()?;
101        let mut active: Vec<_> = all.into_iter().filter(|g| g.is_active(now_unix)).collect();
102        let keep = MAX_LIST.min(active.len());
103        if active.len() > keep {
104            active.drain(0..active.len() - keep);
105        }
106        active.reverse();
107        Ok(active)
108    }
109
110    pub fn revoke(&self, grant_id: &str) -> std::io::Result<bool> {
111        let all = self.list_all()?;
112        let mut found = false;
113        let mut rewritten = Vec::with_capacity(all.len());
114        for mut grant in all {
115            if grant.id == grant_id && !grant.revoked {
116                grant.revoked = true;
117                found = true;
118            }
119            rewritten.push(grant);
120        }
121        if !found {
122            return Ok(false);
123        }
124        let tmp = self.path.with_extension("jsonl.tmp");
125        {
126            let mut file = OpenOptions::new()
127                .create(true)
128                .write(true)
129                .truncate(true)
130                .open(&tmp)?;
131            for grant in &rewritten {
132                let line = serde_json::to_string(grant)
133                    .map_err(|e| std::io::Error::other(e.to_string()))?;
134                writeln!(file, "{line}")?;
135            }
136            file.sync_all()?;
137        }
138        fs::rename(&tmp, &self.path)?;
139        Ok(true)
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn consent_grant_round_trip() {
149        let dir = tempfile::tempdir().unwrap();
150        let store = ConsentStore::open(dir.path()).unwrap();
151        let draft = ConsentGrantDraft {
152            recipient: "wellfair-care".into(),
153            purpose: "write_record".into(),
154            fields: vec!["health.observation".into()],
155            expires_at_unix: None,
156        };
157        let grant = ConsentGrantRecord::from_draft(&draft, "write_record");
158        store.append(&grant).unwrap();
159        let active = store.list_active(u64::MAX).unwrap();
160        assert_eq!(active.len(), 1);
161        assert!(store.revoke(&grant.id).unwrap());
162        assert!(store.list_active(u64::MAX).unwrap().is_empty());
163    }
164}