Skip to main content

joy_core/
privacy.rs

1// Copyright (c) 2026 Joydev GmbH (joydev.com)
2// SPDX-License-Identifier: MIT
3
4//! Privacy-mode migration (ADR-042).
5//!
6//! Switches a project's *working* `.joy/` files between `open` (cleartext
7//! e-mail) and `anonymous` (opaque ids + encrypted `members.yaml`). The switch
8//! is one atomic, deliberate operation: it rekeys the member map, writes the
9//! verifier and the encrypted members file, and rewrites every item and log so
10//! no member e-mail remains in any working file. Switching back restores them.
11//!
12//! Git *commit history* is deliberately out of scope: old commits keep their
13//! e-mails, which only a history rewrite could change. The guarantee here is
14//! about the working tree.
15//!
16//! The migration requires the operator's unlocked identity seed (auth) and the
17//! manage capability; both are enforced by the caller (`joy project set`).
18
19use std::collections::BTreeMap;
20use std::path::Path;
21
22use joy_crypt::identity::{Keypair, PublicKey};
23
24use crate::crypt::{self, ZoneKey};
25use crate::error::JoyError;
26use crate::member_id::{email_match, opaque_member_id};
27use crate::members_file::{self, MemberInfo, MembersFile, MEMBERS_ZONE};
28use crate::model::project::{Member, PrivacyMode};
29use crate::model::Project;
30use crate::store;
31
32/// A human member is one whose map key is an e-mail (not an `ai:` synthetic id).
33/// Only human members carry PII and get anonymized; AI members keep their
34/// readable synthetic id.
35fn is_human_key(key: &str) -> bool {
36    !key.starts_with("ai:")
37}
38
39/// Resolve the member-map key for a git e-mail, honoring the privacy mode. In
40/// `open` mode the key is the e-mail itself; in `anonymous` mode it is the
41/// opaque id whose stored `email_match` verifies against the e-mail. Returns
42/// `None` when the e-mail is not a member.
43pub fn member_key_for_email(project: &Project, email: &str) -> Option<String> {
44    if project.privacy_mode() != PrivacyMode::Anonymous {
45        return project
46            .members
47            .contains_key(email)
48            .then(|| email.to_string());
49    }
50    for (id, member) in &project.members {
51        if let (Some(verifier), Some(nonce)) = (&member.email_match, &member.kdf_nonce) {
52            if email_match(email, nonce).ok().as_deref() == Some(verifier.as_str()) {
53                return Some(id.clone());
54            }
55        }
56    }
57    None
58}
59
60/// The single source of a member's e-mail (the concept's `email_for`).
61///
62/// Open mode: the member-map key *is* the e-mail, returned as-is. Anonymous
63/// mode: the e-mail lives only in the decrypted `members.yaml`, looked up by
64/// the opaque id. Every consumer that needs a member's e-mail (attestation
65/// verification, display, account matching) goes through here and is otherwise
66/// oblivious to the privacy mode. Returns `None` when the member is unknown or,
67/// in anonymous mode, when `members` is not available (locked).
68pub fn email_for(
69    project: &Project,
70    member: &str,
71    members: Option<&crate::members_file::MembersFile>,
72) -> Option<String> {
73    if project.privacy_mode() != PrivacyMode::Anonymous {
74        return project
75            .members
76            .contains_key(member)
77            .then(|| member.to_string());
78    }
79    members.and_then(|m| m.email_for(member).map(str::to_string))
80}
81
82fn io_err(ctx: &str, e: std::io::Error) -> JoyError {
83    JoyError::Other(format!("{ctx}: {e}"))
84}
85
86/// GDPR Art. 17 erasure: remove a member's e-mail and name from the encrypted
87/// `members.yaml` and re-encrypt, severing the id -> PII resolution. The opaque
88/// id, the `email_match` verifier and the whole audit trail in the versioned
89/// files are deliberately left intact (Art. 17(3): the audit trail rests on a
90/// legitimate interest). After this, no Joy output can resolve that id to a
91/// person. Anonymous mode only; needs an operator seed with members.yaml access.
92/// Returns whether an entry was actually removed.
93pub fn erase_member(
94    root: &Path,
95    project: &Project,
96    operator_seed: &[u8; 32],
97    target_id: &str,
98) -> Result<bool, JoyError> {
99    if project.privacy_mode() != PrivacyMode::Anonymous {
100        return Err(JoyError::Other(
101            "erasure applies only to anonymous projects".into(),
102        ));
103    }
104    let operator_vk = Keypair::from_seed(operator_seed).public_key().to_hex();
105    let wrap = project
106        .members
107        .values()
108        .find(|m| m.verify_key.as_deref() == Some(operator_vk.as_str()))
109        .and_then(|m| m.members_wrap.clone())
110        .ok_or_else(|| JoyError::Other("operator has no members.yaml access wrap".into()))?;
111    let zone_key = crypt::unwrap_for_member(&wrap, MEMBERS_ZONE, operator_seed)?;
112    let mut mf = members_file::read(root, &zone_key)?;
113    let removed = mf.members.remove(target_id).is_some();
114    if removed {
115        members_file::write(root, &zone_key, &mf)?;
116    }
117    Ok(removed)
118}
119
120/// Replace each `from -> to` in a single text file, if present.
121fn rewrite_file(path: &Path, replacements: &[(String, String)]) -> Result<(), JoyError> {
122    if !path.exists() {
123        return Ok(());
124    }
125    let mut content = std::fs::read_to_string(path).map_err(|e| io_err("read", e))?;
126    let mut changed = false;
127    for (from, to) in replacements {
128        if !from.is_empty() && content.contains(from.as_str()) {
129            content = content.replace(from.as_str(), to);
130            changed = true;
131        }
132    }
133    if changed {
134        std::fs::write(path, content).map_err(|e| io_err("write", e))?;
135    }
136    Ok(())
137}
138
139/// Replace each `from -> to` across every `*.<ext>` file in `dir`.
140fn rewrite_dir(dir: &Path, ext: &str, replacements: &[(String, String)]) -> Result<(), JoyError> {
141    if !dir.exists() {
142        return Ok(());
143    }
144    for entry in std::fs::read_dir(dir).map_err(|e| io_err("read_dir", e))? {
145        let path = entry.map_err(|e| io_err("read_dir entry", e))?.path();
146        if path.extension().and_then(|e| e.to_str()) == Some(ext) {
147            rewrite_file(&path, replacements)?;
148        }
149    }
150    Ok(())
151}
152
153/// Rewrite project.yaml, all items, and all logs with the given substitutions.
154/// Used to scrub residual e-mails (attestation `attester` / `signed_fields`,
155/// item `created_by` / `assignees` / comment authors, log actors) on switch in,
156/// and to restore them on switch out.
157fn rewrite_working_tree(root: &Path, replacements: &[(String, String)]) -> Result<(), JoyError> {
158    let joy = store::joy_dir(root);
159    rewrite_file(&joy.join(store::PROJECT_FILE), replacements)?;
160    rewrite_dir(&joy.join(store::ITEMS_DIR), "yaml", replacements)?;
161    rewrite_dir(&joy.join(store::LOG_DIR), "log", replacements)?;
162    Ok(())
163}
164
165/// Remove a top-level key from project.yaml. Needed because
166/// `write_yaml_preserve` keeps keys present in the original file but absent from
167/// the serialized struct (so a `privacy` field cleared to `None` would otherwise
168/// linger as the stale `anonymous` value).
169fn prune_yaml_key(path: &Path, key: &str) -> Result<(), JoyError> {
170    use serde_yaml_ng::Value;
171    let raw = std::fs::read_to_string(path).map_err(|e| io_err("read", e))?;
172    let mut value: Value = serde_yaml_ng::from_str(&raw)?;
173    if let Some(map) = value.as_mapping_mut() {
174        map.remove(Value::String(key.to_string()));
175    }
176    let yaml = serde_yaml_ng::to_string(&value)?;
177    std::fs::write(path, yaml).map_err(|e| io_err("write", e))?;
178    Ok(())
179}
180
181/// Switch a project from `open` to `anonymous`.
182///
183/// `operator_seed` is the unlocked identity seed of the manage member running
184/// the switch; it grants every member access to the members.yaml zone key.
185/// Returns the `(email, opaque_id)` pairs that were anonymized.
186pub fn switch_to_anonymous(
187    root: &Path,
188    project: &mut Project,
189    operator_seed: &[u8; 32],
190) -> Result<Vec<(String, String)>, JoyError> {
191    if project.privacy_mode() == PrivacyMode::Anonymous {
192        return Err(JoyError::Other("project is already anonymous".into()));
193    }
194
195    let operator_pk = Keypair::from_seed(operator_seed).public_key();
196    let zone_key = ZoneKey::generate();
197
198    let mut renamed: Vec<(String, String)> = Vec::new();
199    let mut new_members: BTreeMap<String, Member> = BTreeMap::new();
200    let mut mf = MembersFile::default();
201
202    for (key, mut member) in std::mem::take(&mut project.members) {
203        if !is_human_key(&key) {
204            // AI member: keep synthetic id and entry as-is.
205            new_members.insert(key, member);
206            continue;
207        }
208        let email = key;
209        let verify_key = member.verify_key.clone().ok_or_else(|| {
210            JoyError::Other(format!(
211                "member {email} has no verify_key; run joy auth init first"
212            ))
213        })?;
214        let kdf_nonce = member
215            .kdf_nonce
216            .clone()
217            .ok_or_else(|| JoyError::Other(format!("member {email} has no kdf_nonce")))?;
218
219        let id = opaque_member_id(&verify_key)
220            .map_err(|e| JoyError::Other(format!("bad verify_key for {email}: {e}")))?;
221        let verifier = email_match(&email, &kdf_nonce)
222            .map_err(|e| JoyError::Other(format!("bad kdf_nonce for {email}: {e}")))?;
223
224        let recipient_pk = PublicKey::from_hex(&verify_key)?;
225        let wrap = crypt::wrap_for_member(
226            &zone_key,
227            MEMBERS_ZONE,
228            operator_seed,
229            &operator_pk,
230            &recipient_pk,
231        );
232
233        member.email_match = Some(verifier);
234        member.members_wrap = Some(wrap);
235
236        mf.members.insert(
237            id.clone(),
238            MemberInfo {
239                email: email.clone(),
240                name: None,
241            },
242        );
243        renamed.push((email, id.clone()));
244        new_members.insert(id, member);
245    }
246
247    project.members = new_members;
248    project.privacy = Some(PrivacyMode::Anonymous);
249
250    // Persist the structural changes, then scrub residual e-mails (attestation
251    // fields in project.yaml, item bodies, logs) by textual substitution.
252    let project_path = store::joy_dir(root).join(store::PROJECT_FILE);
253    store::write_yaml_preserve(&project_path, project)?;
254    members_file::write(root, &zone_key, &mf)?;
255    rewrite_working_tree(root, &renamed)?;
256
257    Ok(renamed)
258}
259
260/// Switch a project from `anonymous` back to `open`.
261///
262/// `operator_seed` must belong to a member with a members.yaml wrap so the zone
263/// key can be unwrapped and the e-mails recovered.
264pub fn switch_to_open(
265    root: &Path,
266    project: &mut Project,
267    operator_seed: &[u8; 32],
268) -> Result<Vec<(String, String)>, JoyError> {
269    if project.privacy_mode() != PrivacyMode::Anonymous {
270        return Err(JoyError::Other("project is not anonymous".into()));
271    }
272
273    // Find the operator's own entry (its verify_key matches the seed) to get the
274    // members.yaml wrap, then unwrap the zone key with the operator's seed.
275    let operator_vk = Keypair::from_seed(operator_seed).public_key().to_hex();
276    let wrap = project
277        .members
278        .values()
279        .find(|m| m.verify_key.as_deref() == Some(operator_vk.as_str()))
280        .and_then(|m| m.members_wrap.clone())
281        .ok_or_else(|| JoyError::Other("operator has no members.yaml access wrap".into()))?;
282    let zone_key = crypt::unwrap_for_member(&wrap, MEMBERS_ZONE, operator_seed)?;
283    let mf = members_file::read(root, &zone_key)?;
284
285    let mut renamed: Vec<(String, String)> = Vec::new();
286    let mut new_members: BTreeMap<String, Member> = BTreeMap::new();
287
288    for (key, mut member) in std::mem::take(&mut project.members) {
289        if !is_human_key(&key) && !mf.members.contains_key(&key) {
290            new_members.insert(key, member);
291            continue;
292        }
293        match mf.email_for(&key) {
294            Some(email) => {
295                member.email_match = None;
296                member.members_wrap = None;
297                renamed.push((key.clone(), email.to_string()));
298                new_members.insert(email.to_string(), member);
299            }
300            None => {
301                // Not in members.yaml (e.g. an AI member): keep as-is.
302                new_members.insert(key, member);
303            }
304        }
305    }
306
307    project.members = new_members;
308    project.privacy = None;
309
310    let project_path = store::joy_dir(root).join(store::PROJECT_FILE);
311    store::write_yaml_preserve(&project_path, project)?;
312    prune_yaml_key(&project_path, "privacy")?;
313    // Remove the encrypted members file and restore e-mails in the working tree.
314    let mp = members_file::members_path(root);
315    if mp.exists() {
316        std::fs::remove_file(&mp).map_err(|e| io_err("remove members.yaml", e))?;
317    }
318    rewrite_working_tree(root, &renamed)?;
319
320    Ok(renamed)
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use crate::model::project::MemberCapabilities;
327
328    const EMAIL: &str = "test@example.com";
329    const NONCE: &str = "8c1f00000000000000000000000000000000000000000000000000000000e4ab";
330
331    fn setup(root: &Path, seed: &[u8; 32]) -> Project {
332        let joy = store::joy_dir(root);
333        std::fs::create_dir_all(joy.join(store::ITEMS_DIR)).unwrap();
334        std::fs::create_dir_all(joy.join(store::LOG_DIR)).unwrap();
335
336        let vk = Keypair::from_seed(seed).public_key().to_hex();
337        let mut member = Member::new(MemberCapabilities::All);
338        member.verify_key = Some(vk);
339        member.kdf_nonce = Some(NONCE.to_string());
340
341        let mut project = Project::new("Test".into(), Some("T".into()));
342        project.members.insert(EMAIL.to_string(), member);
343        store::write_yaml_preserve(&joy.join(store::PROJECT_FILE), &project).unwrap();
344
345        // An item assigned to the human, and a log line naming them.
346        std::fs::write(
347            joy.join(store::ITEMS_DIR).join("T-0001-x.yaml"),
348            format!("id: T-0001\ntitle: x\nassignees:\n- member: {EMAIL}\ncreated_by: {EMAIL}\n"),
349        )
350        .unwrap();
351        std::fs::write(
352            joy.join(store::LOG_DIR).join("2026-06-11.log"),
353            format!("2026-06-11T09:00:00Z T-0001 item.created [{EMAIL}]\n"),
354        )
355        .unwrap();
356
357        project
358    }
359
360    fn no_email_anywhere(root: &Path) -> bool {
361        let joy = store::joy_dir(root);
362        for sub in [store::PROJECT_FILE] {
363            if std::fs::read_to_string(joy.join(sub))
364                .unwrap()
365                .contains(EMAIL)
366            {
367                return false;
368            }
369        }
370        for dir in [store::ITEMS_DIR, store::LOG_DIR] {
371            for entry in std::fs::read_dir(joy.join(dir)).unwrap() {
372                let p = entry.unwrap().path();
373                if std::fs::read(&p)
374                    .unwrap()
375                    .windows(EMAIL.len())
376                    .any(|w| w == EMAIL.as_bytes())
377                {
378                    return false;
379                }
380            }
381        }
382        true
383    }
384
385    #[test]
386    fn switch_round_trip_scrubs_then_restores_emails() {
387        let dir = tempfile::tempdir().unwrap();
388        let root = dir.path();
389        let seed = [7u8; 32];
390        let mut project = setup(root, &seed);
391
392        // Before: the e-mail is present.
393        assert!(!no_email_anywhere(root));
394
395        // Switch to anonymous: no e-mail anywhere, members.yaml is an encrypted blob.
396        let renamed = switch_to_anonymous(root, &mut project, &seed).unwrap();
397        assert_eq!(renamed.len(), 1);
398        let id = renamed[0].1.clone();
399        assert!(id.starts_with("m-"));
400        assert!(
401            no_email_anywhere(root),
402            "no e-mail must remain after switch"
403        );
404        assert!(members_file::exists(root));
405        let raw = std::fs::read(members_file::members_path(root)).unwrap();
406        assert!(crypt::looks_like_blob(&raw));
407        // project.yaml now keyed by opaque id, carries email_match.
408        let pj: Project =
409            store::read_yaml(&store::joy_dir(root).join(store::PROJECT_FILE)).unwrap();
410        assert!(pj.members.contains_key(&id));
411        assert_eq!(pj.privacy, Some(PrivacyMode::Anonymous));
412        assert!(pj.members[&id].email_match.is_some());
413
414        // Switch back: e-mails restored, members.yaml gone.
415        switch_to_open(root, &mut project, &seed).unwrap();
416        assert!(!no_email_anywhere(root), "e-mail must be restored");
417        assert!(!members_file::exists(root));
418        let pj2: Project =
419            store::read_yaml(&store::joy_dir(root).join(store::PROJECT_FILE)).unwrap();
420        assert!(pj2.members.contains_key(EMAIL));
421        assert_eq!(pj2.privacy, None);
422        assert!(pj2.members[EMAIL].email_match.is_none());
423    }
424}