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 -- direct AI session handle (SSH-agent pattern)
46/// 2. JOY_TOKEN   -- AI delegation token (backwards compatibility)
47/// 3. Human session by git email
48/// 4. Fallback: git email, unauthenticated
49pub fn resolve_identity(root: &Path) -> Result<Identity, JoyError> {
50    let git_email = crate::vcs::default_vcs().user_email()?;
51    let project = load_project_optional(root);
52    let project_id = crate::auth::session::project_id(root).ok();
53
54    // 1. JOY_SESSION: direct session lookup by opaque ID.
55    //    `joy auth --token` outputs `export JOY_SESSION=<id>` for eval.
56    //    The ID maps directly to a session file -- no ambiguity.
57    if let Some(sid) = std::env::var("JOY_SESSION").ok().filter(|s| !s.is_empty()) {
58        if let Ok(Some(sess)) = crate::auth::session::load_session_by_id(&sid) {
59            if sess.claims.expires > chrono::Utc::now() && is_ai_member(&sess.claims.member) {
60                // Verify the session belongs to this project (not a different one)
61                let session_matches_project = project_id
62                    .as_ref()
63                    .map(|pid| sess.claims.project_id == *pid)
64                    .unwrap_or(false);
65                if session_matches_project {
66                    if let Some(ref project) = project {
67                        if project.members.contains_key(&sess.claims.member) {
68                            return Ok(Identity {
69                                member: sess.claims.member.clone(),
70                                delegated_by: crate::vcs::default_vcs().user_email().ok(),
71                                authenticated: true,
72                            });
73                        }
74                    }
75                }
76            }
77        }
78    }
79
80    // 2. JOY_TOKEN: decode the delegation token to find the AI member.
81    //    Backwards compatibility for tools not yet using eval pattern.
82    if let Some(token_str) = std::env::var("JOY_TOKEN").ok().filter(|s| !s.is_empty()) {
83        if let Ok(token) = crate::auth::token::decode_token(&token_str) {
84            let ai_member = &token.claims.ai_member;
85            if let Some(ref pid) = project_id {
86                if let Some(id) = session_identity(root, ai_member, pid, &project) {
87                    return Ok(id);
88                }
89            }
90        }
91    }
92
93    // 3. Human session by git email
94    if let Some(ref pid) = project_id {
95        if let Some(session_identity) = session_identity(root, &git_email, pid, &project) {
96            return Ok(session_identity);
97        }
98    }
99
100    // 4. Fallback: git email, not authenticated
101    Ok(Identity {
102        member: git_email,
103        delegated_by: None,
104        authenticated: false,
105    })
106}
107
108/// Try to build an Identity from an active session for a member.
109fn session_identity(
110    root: &Path,
111    member: &str,
112    project_id: &str,
113    project: &Option<Project>,
114) -> Option<Identity> {
115    if !check_session(root, member, project) {
116        return None;
117    }
118
119    // Read the session to get delegated_by info
120    let delegated_by = crate::auth::session::load_session(project_id, member)
121        .ok()
122        .flatten()
123        .and_then(|_sess| {
124            // AI sessions have delegated_by from the token auth event
125            if is_ai_member(member) {
126                // The delegating human is tracked in the event log,
127                // but for identity resolution we just mark it as delegated
128                crate::vcs::default_vcs().user_email().ok()
129            } else {
130                None
131            }
132        });
133
134    Some(Identity {
135        member: member.to_string(),
136        delegated_by,
137        authenticated: true,
138    })
139}
140
141/// Check whether the project has any AI members.
142pub fn has_ai_members(root: &Path) -> bool {
143    let project = load_project_optional(root);
144    match project {
145        Some(p) => p.members.keys().any(|k| is_ai_member(k)),
146        None => false,
147    }
148}
149
150/// Check if the member has an active, valid session.
151fn check_session(root: &Path, member: &str, project: &Option<Project>) -> bool {
152    let Some(project) = project else {
153        return false;
154    };
155    if !project.members.contains_key(member) {
156        return false;
157    };
158    let Ok(project_id) = crate::auth::session::project_id(root) else {
159        return false;
160    };
161    let Ok(Some(sess)) = crate::auth::session::load_session(&project_id, member) else {
162        return false;
163    };
164
165    // Check expiry and member match
166    if sess.claims.expires <= chrono::Utc::now() || sess.claims.member != member {
167        return false;
168    }
169
170    // For human members: validate session signature against public key + TTY binding
171    if !is_ai_member(member) {
172        let m = project.members.get(member).unwrap();
173        let Some(ref pk_hex) = m.public_key else {
174            return false;
175        };
176        let Ok(pk) = crate::auth::sign::PublicKey::from_hex(pk_hex) else {
177            return false;
178        };
179        if crate::auth::session::validate_session(&sess, &pk, &project_id).is_err() {
180            return false;
181        }
182        // TTY binding: session must come from the same terminal context.
183        // Both session TTY and current TTY must match (including None == None
184        // for non-interactive contexts like CI, test harnesses, or AI tools).
185        let current_tty = crate::auth::session::current_tty();
186        if sess.claims.tty != current_tty {
187            return false;
188        }
189        return true;
190    }
191
192    // For AI members: session existence + not expired is sufficient
193    // (token was validated at joy auth --token time)
194    true
195}
196
197fn load_project_optional(root: &Path) -> Option<Project> {
198    let project_path = store::joy_dir(root).join(store::PROJECT_FILE);
199    store::read_yaml(&project_path).ok()
200}
201
202#[allow(dead_code)]
203fn validate_member(member: &str, project: &Option<Project>) -> Result<(), JoyError> {
204    let Some(project) = project else {
205        return Ok(());
206    };
207    if project.members.is_empty() {
208        return Ok(());
209    }
210    if !project.members.contains_key(member) {
211        return Err(JoyError::Other(format!(
212            "'{}' is not a registered project member. \
213             Use `joy member add {}` to register.",
214            member, member
215        )));
216    }
217    Ok(())
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn identity_log_user_simple() {
226        let id = Identity {
227            member: "alice@example.com".into(),
228            delegated_by: None,
229            authenticated: false,
230        };
231        assert_eq!(id.log_user(), "alice@example.com");
232    }
233
234    #[test]
235    fn identity_log_user_delegated() {
236        let id = Identity {
237            member: "ai:claude@joy".into(),
238            delegated_by: Some("horst@joydev.com".into()),
239            authenticated: false,
240        };
241        assert_eq!(id.log_user(), "ai:claude@joy delegated-by:horst@joydev.com");
242    }
243}