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                    }
80                }
81            }
82        }
83    }
84
85    // 2. Human session by git email
86    if let Some(ref pid) = project_id {
87        if let Some(session_identity) = session_identity(root, &git_email, pid, &project) {
88            return Ok(session_identity);
89        }
90    }
91
92    // 3. Fallback: git email, not authenticated
93    Ok(Identity {
94        member: git_email,
95        delegated_by: None,
96        authenticated: false,
97    })
98}
99
100/// Try to build an Identity from an active session for a member.
101fn session_identity(
102    root: &Path,
103    member: &str,
104    project_id: &str,
105    project: &Option<Project>,
106) -> Option<Identity> {
107    if !check_session(root, member, project) {
108        return None;
109    }
110
111    // Read the session to get delegated_by info
112    let delegated_by = crate::auth::session::load_session(project_id, member)
113        .ok()
114        .flatten()
115        .and_then(|_sess| {
116            // AI sessions have delegated_by from the token auth event
117            if is_ai_member(member) {
118                // The delegating human is tracked in the event log,
119                // but for identity resolution we just mark it as delegated
120                crate::vcs::default_vcs().user_email().ok()
121            } else {
122                None
123            }
124        });
125
126    Some(Identity {
127        member: member.to_string(),
128        delegated_by,
129        authenticated: true,
130    })
131}
132
133/// Check whether the project has any AI members.
134pub fn has_ai_members(root: &Path) -> bool {
135    let project = load_project_optional(root);
136    match project {
137        Some(p) => p.members.keys().any(|k| is_ai_member(k)),
138        None => false,
139    }
140}
141
142/// Check if the member has an active, valid session.
143fn check_session(root: &Path, member: &str, project: &Option<Project>) -> bool {
144    let Some(project) = project else {
145        return false;
146    };
147    if !project.members.contains_key(member) {
148        return false;
149    };
150    let Ok(project_id) = crate::auth::session::project_id(root) else {
151        return false;
152    };
153    let Ok(Some(sess)) = crate::auth::session::load_session(&project_id, member) else {
154        return false;
155    };
156
157    // Check expiry and member match
158    if sess.claims.expires <= chrono::Utc::now() || sess.claims.member != member {
159        return false;
160    }
161
162    // For human members: validate session signature against public key + TTY binding
163    if !is_ai_member(member) {
164        let m = project.members.get(member).unwrap();
165        let Some(ref pk_hex) = m.public_key else {
166            return false;
167        };
168        let Ok(pk) = crate::auth::sign::PublicKey::from_hex(pk_hex) else {
169            return false;
170        };
171        if crate::auth::session::validate_session(&sess, &pk, &project_id).is_err() {
172            return false;
173        }
174        // TTY binding: session must come from the same terminal context.
175        // Both session TTY and current TTY must match (including None == None
176        // for non-interactive contexts like CI, test harnesses, or AI tools).
177        let current_tty = crate::auth::session::current_tty();
178        if sess.claims.tty != current_tty {
179            return false;
180        }
181        return true;
182    }
183
184    // For AI members: under ADR-033 the only valid authentication path is
185    // the JOY_SESSION env var matched to the ephemeral public key. A
186    // session file on its own no longer authenticates anyone.
187    false
188}
189
190/// Verify that the private key bytes from JOY_SESSION derive to the public
191/// key recorded in the session claims. This is the core proof-of-possession
192/// check for AI sessions under ADR-033.
193fn ephemeral_public_matches(
194    sess: &crate::auth::session::SessionToken,
195    ephemeral_private: &[u8; 32],
196) -> bool {
197    let Some(ref stored_pk_hex) = sess.claims.session_public_key else {
198        return false;
199    };
200    let kp = crate::auth::sign::IdentityKeypair::from_seed(ephemeral_private);
201    kp.public_key().to_hex() == *stored_pk_hex
202}
203
204fn load_project_optional(root: &Path) -> Option<Project> {
205    let project_path = store::joy_dir(root).join(store::PROJECT_FILE);
206    store::read_yaml(&project_path).ok()
207}
208
209#[allow(dead_code)]
210fn validate_member(member: &str, project: &Option<Project>) -> Result<(), JoyError> {
211    let Some(project) = project else {
212        return Ok(());
213    };
214    if project.members.is_empty() {
215        return Ok(());
216    }
217    if !project.members.contains_key(member) {
218        return Err(JoyError::Other(format!(
219            "'{}' is not a registered project member. \
220             Use `joy member add {}` to register.",
221            member, member
222        )));
223    }
224    Ok(())
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn identity_log_user_simple() {
233        let id = Identity {
234            member: "alice@example.com".into(),
235            delegated_by: None,
236            authenticated: false,
237        };
238        assert_eq!(id.log_user(), "alice@example.com");
239    }
240
241    #[test]
242    fn identity_log_user_delegated() {
243        let id = Identity {
244            member: "ai:claude@joy".into(),
245            delegated_by: Some("horst@joydev.com".into()),
246            authenticated: false,
247        };
248        assert_eq!(id.log_user(), "ai:claude@joy delegated-by:horst@joydev.com");
249    }
250}