Skip to main content

joy_core/
identity.rs

1// Copyright (c) 2026 Joydev GmbH (joydev.com)
2// SPDX-License-Identifier: MIT
3
4//! Identity resolution for Joy CLI operations.
5//!
6//! Resolves the acting user's identity from:
7//! 1. Active session (if one exists for any member)
8//! 2. `git config user.email` (fallback for projects without auth)
9//!
10//! AI members authenticate via `joy auth --token`, which creates a
11//! session. There is no self-declared identity override.
12
13use std::path::Path;
14
15use crate::error::JoyError;
16use crate::model::project::{is_ai_member, Project};
17use crate::store;
18use crate::vcs::Vcs;
19
20/// The resolved identity of the acting user.
21#[derive(Debug, Clone, PartialEq)]
22pub struct Identity {
23    /// The member ID (email or `ai:tool@joy`).
24    pub member: String,
25    /// If the member is an AI, the human who delegated the action.
26    pub delegated_by: Option<String>,
27    /// Whether this identity was cryptographically authenticated (session or token).
28    pub authenticated: bool,
29}
30
31impl Identity {
32    /// Format for event log entries.
33    /// Returns `"member"` or `"member delegated-by:human"`.
34    pub fn log_user(&self) -> String {
35        match &self.delegated_by {
36            Some(human) => format!("{} delegated-by:{}", self.member, human),
37            None => self.member.clone(),
38        }
39    }
40}
41
42/// Resolve the acting identity for the current operation.
43///
44/// Priority:
45/// 1. JOY_SESSION -- ephemeral-key-bound AI session handle (ADR-033)
46/// 2. Human session by git email
47/// 3. Fallback: git email, unauthenticated
48pub fn resolve_identity(root: &Path) -> Result<Identity, JoyError> {
49    let git_email = crate::vcs::default_vcs().user_email()?;
50    let project = load_project_optional(root);
51    let project_id = crate::auth::session::project_id(root).ok();
52
53    // 1. JOY_SESSION: env var carries the ephemeral private key bound to
54    //    the session (ADR-033). We derive the public key from it and match
55    //    against `session_public_key` stored in the session file. Without
56    //    possession of the env var a sibling terminal cannot reuse a
57    //    session file it can read.
58    if let Some(env_value) = std::env::var("JOY_SESSION").ok().filter(|s| !s.is_empty()) {
59        if let Some((sid, ephemeral_private)) = crate::auth::session::parse_session_env(&env_value)
60        {
61            if let Ok(Some(sess)) = crate::auth::session::load_session_by_id(&sid) {
62                if sess.claims.expires > chrono::Utc::now() && is_ai_member(&sess.claims.member) {
63                    let session_matches_project = project_id
64                        .as_ref()
65                        .map(|pid| sess.claims.project_id == *pid)
66                        .unwrap_or(false);
67                    if session_matches_project {
68                        if let Some(ref project) = project {
69                            if project.members.contains_key(&sess.claims.member)
70                                && ephemeral_public_matches(&sess, &ephemeral_private)
71                            {
72                                return Ok(Identity {
73                                    member: sess.claims.member.clone(),
74                                    delegated_by: crate::vcs::default_vcs().user_email().ok(),
75                                    authenticated: true,
76                                });
77                            }
78                        }
79                    } else if let Some(ref current_pid) = project_id {
80                        // JOY_SESSION is a valid live AI session, but for a
81                        // different project. Silently falling back to the
82                        // git-email identity would confuse the caller when
83                        // the subsequent guard denial names the human
84                        // instead of the AI they thought they were acting
85                        // as. Emit a one-line stderr hint and continue
86                        // with the fallback so read-only commands still
87                        // work.
88                        eprintln!(
89                            "{}",
90                            cross_project_session_warning(
91                                &sess.claims.project_id,
92                                &sess.claims.member,
93                                current_pid,
94                            )
95                        );
96                    }
97                }
98            }
99        }
100    }
101
102    // 2. Human session by git email
103    if let Some(ref pid) = project_id {
104        if let Some(session_identity) = session_identity(root, &git_email, pid, &project) {
105            return Ok(session_identity);
106        }
107    }
108
109    // 3. Fallback: git email, not authenticated
110    Ok(Identity {
111        member: git_email,
112        delegated_by: None,
113        authenticated: false,
114    })
115}
116
117/// Try to build an Identity from an active session for a member.
118fn session_identity(
119    root: &Path,
120    member: &str,
121    project_id: &str,
122    project: &Option<Project>,
123) -> Option<Identity> {
124    if !check_session(root, member, project) {
125        return None;
126    }
127
128    // Read the session to get delegated_by info
129    let delegated_by = crate::auth::session::load_session(project_id, member)
130        .ok()
131        .flatten()
132        .and_then(|_sess| {
133            // AI sessions have delegated_by from the token auth event
134            if is_ai_member(member) {
135                // The delegating human is tracked in the event log,
136                // but for identity resolution we just mark it as delegated
137                crate::vcs::default_vcs().user_email().ok()
138            } else {
139                None
140            }
141        });
142
143    Some(Identity {
144        member: member.to_string(),
145        delegated_by,
146        authenticated: true,
147    })
148}
149
150/// Check whether the project has any AI members.
151pub fn has_ai_members(root: &Path) -> bool {
152    let project = load_project_optional(root);
153    match project {
154        Some(p) => p.members.keys().any(|k| is_ai_member(k)),
155        None => false,
156    }
157}
158
159/// Check if the member has an active, valid session.
160fn check_session(root: &Path, member: &str, project: &Option<Project>) -> bool {
161    let Some(project) = project else {
162        return false;
163    };
164    if !project.members.contains_key(member) {
165        return false;
166    };
167    let Ok(project_id) = crate::auth::session::project_id(root) else {
168        return false;
169    };
170    let Ok(Some(sess)) = crate::auth::session::load_session(&project_id, member) else {
171        return false;
172    };
173
174    // Check expiry and member match
175    if sess.claims.expires <= chrono::Utc::now() || sess.claims.member != member {
176        return false;
177    }
178
179    // For human members: validate session signature against public key + TTY binding
180    if !is_ai_member(member) {
181        let m = project.members.get(member).unwrap();
182        let Some(ref pk_hex) = m.verify_key else {
183            return false;
184        };
185        let Ok(pk) = crate::auth::PublicKey::from_hex(pk_hex) else {
186            return false;
187        };
188        if crate::auth::session::validate_session(&sess, &pk, &project_id).is_err() {
189            return false;
190        }
191        // TTY binding: session must come from the same terminal context.
192        // Both session TTY and current TTY must match (including None == None
193        // for non-interactive contexts like CI, test harnesses, or AI tools).
194        let current_tty = crate::auth::session::current_tty();
195        if sess.claims.tty != current_tty {
196            return false;
197        }
198        return true;
199    }
200
201    // For AI members: under ADR-033 the only valid authentication path is
202    // the JOY_SESSION env var matched to the ephemeral public key. A
203    // session file on its own no longer authenticates anyone.
204    false
205}
206
207/// Build the cross-project JOY_SESSION warning text.
208///
209/// Extracted as a pure helper so it can be asserted directly in unit
210/// tests without touching stderr capture or environment mutation.
211fn cross_project_session_warning(
212    session_project: &str,
213    session_member: &str,
214    current_project: &str,
215) -> String {
216    format!(
217        "Warning: JOY_SESSION belongs to project {session_project} \
218         (member {session_member}), but the current project is {current_project}. \
219         Ask the human for a delegation in this project: \
220         joy auth token add {session_member}"
221    )
222}
223
224/// Verify that the private key bytes from JOY_SESSION derive to the public
225/// key recorded in the session claims. This is the core proof-of-possession
226/// check for AI sessions under ADR-033.
227fn ephemeral_public_matches(
228    sess: &crate::auth::session::SessionToken,
229    ephemeral_private: &[u8; 32],
230) -> bool {
231    let Some(ref stored_pk_hex) = sess.claims.session_public_key else {
232        return false;
233    };
234    let kp = crate::auth::IdentityKeypair::from_seed(ephemeral_private);
235    kp.public_key().to_hex() == *stored_pk_hex
236}
237
238fn load_project_optional(root: &Path) -> Option<Project> {
239    let project_path = store::joy_dir(root).join(store::PROJECT_FILE);
240    store::read_project(&project_path).ok()
241}
242
243#[allow(dead_code)]
244fn validate_member(member: &str, project: &Option<Project>) -> Result<(), JoyError> {
245    let Some(project) = project else {
246        return Ok(());
247    };
248    if project.members.is_empty() {
249        return Ok(());
250    }
251    if !project.members.contains_key(member) {
252        return Err(JoyError::Other(format!(
253            "'{}' is not a registered project member. \
254             Use `joy member add {}` to register.",
255            member, member
256        )));
257    }
258    Ok(())
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn identity_log_user_simple() {
267        let id = Identity {
268            member: "alice@example.com".into(),
269            delegated_by: None,
270            authenticated: false,
271        };
272        assert_eq!(id.log_user(), "alice@example.com");
273    }
274
275    #[test]
276    fn identity_log_user_delegated() {
277        let id = Identity {
278            member: "ai:claude@joy".into(),
279            delegated_by: Some("horst@joydev.com".into()),
280            authenticated: false,
281        };
282        assert_eq!(id.log_user(), "ai:claude@joy delegated-by:horst@joydev.com");
283    }
284
285    #[test]
286    fn cross_project_warning_names_session_and_current_projects() {
287        let msg = cross_project_session_warning("JOY", "ai:claude@joy", "JI");
288        assert!(msg.contains("belongs to project JOY"));
289        assert!(msg.contains("member ai:claude@joy"));
290        assert!(msg.contains("current project is JI"));
291        assert!(msg.contains("joy auth token add ai:claude@joy"));
292    }
293}