Skip to main content

systemprompt_cloud/cli_session/
session.rs

1//! The [`CliSession`] record, its [`CliSessionBuilder`], and on-disk load/save.
2//!
3//! A session bundles the authenticated [`SessionIdentity`] with its token,
4//! profile binding, and expiry. Persistence writes a `0600` file under a
5//! `.gitignore`-protected directory and rejects on-disk versions outside the
6//! supported range.
7//!
8//! Copyright (c) systemprompt.io — Business Source License 1.1.
9//! See <https://systemprompt.io> for licensing details.
10
11use std::fs;
12use std::path::{Path, PathBuf};
13
14use chrono::{DateTime, Duration, Utc};
15use serde::{Deserialize, Serialize};
16use systemprompt_identifiers::{
17    ContextId, Email, ProfileName, SessionId, SessionToken, TenantId, UserId,
18};
19use systemprompt_models::auth::UserType;
20
21use super::{LOCAL_SESSION_KEY, SessionKey};
22use crate::error::{CloudError, CloudResult};
23
24const CURRENT_VERSION: u32 = 6;
25const MIN_SUPPORTED_VERSION: u32 = 6;
26const SESSION_DURATION_HOURS: i64 = 24;
27
28/// The profile a session belongs to, paired with the issuer its token was
29/// minted under. The two always travel together — a session is only reusable
30/// when both still match the loaded profile.
31#[derive(Debug, Clone)]
32pub struct SessionBinding {
33    pub profile_name: ProfileName,
34    pub issuer: String,
35}
36
37impl SessionBinding {
38    #[must_use]
39    pub const fn new(profile_name: ProfileName, issuer: String) -> Self {
40        Self {
41            profile_name,
42            issuer,
43        }
44    }
45}
46
47#[derive(Debug, Clone)]
48pub struct SessionIdentity {
49    pub user_id: UserId,
50    pub email: Email,
51    pub user_type: UserType,
52}
53
54impl SessionIdentity {
55    #[must_use]
56    pub const fn new(user_id: UserId, email: Email, user_type: UserType) -> Self {
57        Self {
58            user_id,
59            email,
60            user_type,
61        }
62    }
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct CliSession {
67    pub version: u32,
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub tenant_key: Option<TenantId>,
70    pub profile_name: ProfileName,
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub profile_path: Option<PathBuf>,
73    pub session_token: SessionToken,
74    pub issuer: String,
75    pub session_id: SessionId,
76    pub context_id: ContextId,
77    pub user_id: UserId,
78    pub user_email: Email,
79    pub user_type: UserType,
80    pub created_at: DateTime<Utc>,
81    pub expires_at: DateTime<Utc>,
82    pub last_used: DateTime<Utc>,
83}
84
85#[derive(Debug)]
86pub struct CliSessionBuilder {
87    tenant_key: Option<TenantId>,
88    profile_name: ProfileName,
89    profile_path: Option<PathBuf>,
90    session_token: SessionToken,
91    issuer: String,
92    session_id: SessionId,
93    context_id: ContextId,
94    user_id: UserId,
95    user_email: Email,
96    user_type: UserType,
97    ttl: Duration,
98}
99
100impl CliSessionBuilder {
101    pub fn new(
102        binding: SessionBinding,
103        session_token: SessionToken,
104        session_id: SessionId,
105        context_id: ContextId,
106        identity: SessionIdentity,
107    ) -> Self {
108        Self {
109            tenant_key: None,
110            profile_name: binding.profile_name,
111            profile_path: None,
112            session_token,
113            issuer: binding.issuer,
114            ttl: Duration::hours(SESSION_DURATION_HOURS),
115            session_id,
116            context_id,
117            user_id: identity.user_id,
118            user_email: identity.email,
119            user_type: identity.user_type,
120        }
121    }
122
123    #[must_use]
124    pub fn with_tenant_key(mut self, tenant_key: TenantId) -> Self {
125        self.tenant_key = Some(tenant_key);
126        self
127    }
128
129    #[must_use]
130    pub fn with_session_key(mut self, key: &SessionKey) -> Self {
131        self.tenant_key = match key {
132            SessionKey::Local => Some(TenantId::new(LOCAL_SESSION_KEY)),
133            SessionKey::Tenant(id) => Some(id.clone()),
134        };
135        self
136    }
137
138    #[must_use]
139    pub fn with_profile_path(mut self, profile_path: impl Into<PathBuf>) -> Self {
140        self.profile_path = Some(profile_path.into());
141        self
142    }
143
144    #[must_use]
145    pub const fn with_ttl(mut self, ttl: Duration) -> Self {
146        self.ttl = ttl;
147        self
148    }
149
150    #[must_use]
151    pub fn build(self) -> CliSession {
152        let now = Utc::now();
153        let expires_at = now + self.ttl;
154        CliSession {
155            version: CURRENT_VERSION,
156            tenant_key: self.tenant_key,
157            profile_name: self.profile_name,
158            profile_path: self.profile_path,
159            session_token: self.session_token,
160            issuer: self.issuer,
161            session_id: self.session_id,
162            context_id: self.context_id,
163            user_id: self.user_id,
164            user_email: self.user_email,
165            user_type: self.user_type,
166            created_at: now,
167            expires_at,
168            last_used: now,
169        }
170    }
171}
172
173impl CliSession {
174    pub fn builder(
175        binding: SessionBinding,
176        session_token: SessionToken,
177        session_id: SessionId,
178        context_id: ContextId,
179        identity: SessionIdentity,
180    ) -> CliSessionBuilder {
181        CliSessionBuilder::new(binding, session_token, session_id, context_id, identity)
182    }
183
184    #[must_use]
185    pub fn matches_issuer(&self, issuer: &str) -> bool {
186        self.issuer == issuer
187    }
188
189    pub const fn context_id(&self) -> &ContextId {
190        &self.context_id
191    }
192
193    pub fn touch(&mut self) {
194        self.last_used = Utc::now();
195    }
196
197    pub fn set_context_id(&mut self, context_id: ContextId) {
198        self.context_id = context_id;
199        self.last_used = Utc::now();
200    }
201
202    pub fn update_profile_path(&mut self, profile_path: PathBuf) {
203        self.profile_path = Some(profile_path);
204        self.last_used = Utc::now();
205    }
206
207    #[must_use]
208    pub fn is_expired(&self) -> bool {
209        Utc::now() >= self.expires_at
210    }
211
212    #[must_use]
213    pub fn is_valid_for_profile(&self, profile_name: &str) -> bool {
214        self.profile_name.as_str() == profile_name && !self.is_expired()
215    }
216
217    #[must_use]
218    pub fn has_valid_credentials(&self) -> bool {
219        !self.session_token.as_str().is_empty()
220    }
221
222    #[must_use]
223    pub fn is_valid_for_tenant(&self, key: &SessionKey) -> bool {
224        if self.is_expired() || !self.has_valid_credentials() {
225            return false;
226        }
227
228        match (key, &self.tenant_key) {
229            (SessionKey::Local, None) => true,
230            (SessionKey::Local, Some(k)) => k.as_str() == LOCAL_SESSION_KEY,
231            (SessionKey::Tenant(id), Some(k)) => k == id,
232            (SessionKey::Tenant(_), None) => false,
233        }
234    }
235
236    #[must_use]
237    pub fn session_key(&self) -> SessionKey {
238        match &self.tenant_key {
239            None => SessionKey::Local,
240            Some(k) if k.as_str() == LOCAL_SESSION_KEY => SessionKey::Local,
241            Some(k) => SessionKey::Tenant(k.clone()),
242        }
243    }
244
245    pub fn load_from_path(path: &Path) -> CloudResult<Self> {
246        if !path.exists() {
247            return Err(CloudError::NotAuthenticated);
248        }
249
250        let content = fs::read_to_string(path)?;
251
252        let mut session: Self = serde_json::from_str(&content)
253            .map_err(|e| CloudError::CredentialsCorrupted { source: e })?;
254
255        if session.version < MIN_SUPPORTED_VERSION || session.version > CURRENT_VERSION {
256            return Err(CloudError::SessionVersionMismatch {
257                min: MIN_SUPPORTED_VERSION,
258                max: CURRENT_VERSION,
259                actual: session.version,
260                path: path.display().to_string(),
261            });
262        }
263
264        session.version = CURRENT_VERSION;
265        Ok(session)
266    }
267
268    pub fn save_to_path(&self, path: &Path) -> CloudResult<()> {
269        if let Some(dir) = path.parent() {
270            fs::create_dir_all(dir)?;
271
272            let gitignore_path = dir.join(".gitignore");
273            if !gitignore_path.exists() {
274                fs::write(&gitignore_path, "*\n")?;
275            }
276        }
277
278        let content = serde_json::to_string_pretty(self)?;
279        fs::write(path, content)?;
280
281        #[cfg(unix)]
282        {
283            use std::os::unix::fs::PermissionsExt;
284            let mut perms = fs::metadata(path)?.permissions();
285            perms.set_mode(0o600);
286            fs::set_permissions(path, perms)?;
287        }
288
289        Ok(())
290    }
291
292    pub fn delete_from_path(path: &Path) -> CloudResult<()> {
293        if path.exists() {
294            fs::remove_file(path)?;
295        }
296        Ok(())
297    }
298}