qualia_client_core/wellfair/
consent_store.rs1use 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}