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