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    #[serde(default = "Utc::now")]
81    pub created_at: DateTime<Utc>,
82    pub expires_at: DateTime<Utc>,
83    #[serde(default = "Utc::now")]
84    pub last_used: DateTime<Utc>,
85}
86
87#[derive(Debug)]
88pub struct CliSessionBuilder {
89    tenant_key: Option<TenantId>,
90    profile_name: ProfileName,
91    profile_path: Option<PathBuf>,
92    session_token: SessionToken,
93    issuer: String,
94    session_id: SessionId,
95    context_id: ContextId,
96    user_id: UserId,
97    user_email: Email,
98    user_type: UserType,
99    ttl: Duration,
100}
101
102impl CliSessionBuilder {
103    pub fn new(
104        binding: SessionBinding,
105        session_token: SessionToken,
106        session_id: SessionId,
107        context_id: ContextId,
108        identity: SessionIdentity,
109    ) -> Self {
110        Self {
111            tenant_key: None,
112            profile_name: binding.profile_name,
113            profile_path: None,
114            session_token,
115            issuer: binding.issuer,
116            ttl: Duration::hours(SESSION_DURATION_HOURS),
117            session_id,
118            context_id,
119            user_id: identity.user_id,
120            user_email: identity.email,
121            user_type: identity.user_type,
122        }
123    }
124
125    #[must_use]
126    pub fn with_tenant_key(mut self, tenant_key: TenantId) -> Self {
127        self.tenant_key = Some(tenant_key);
128        self
129    }
130
131    #[must_use]
132    pub fn with_session_key(mut self, key: &SessionKey) -> Self {
133        self.tenant_key = match key {
134            SessionKey::Local => Some(TenantId::new(LOCAL_SESSION_KEY)),
135            SessionKey::Tenant(id) => Some(id.clone()),
136        };
137        self
138    }
139
140    #[must_use]
141    pub fn with_profile_path(mut self, profile_path: impl Into<PathBuf>) -> Self {
142        self.profile_path = Some(profile_path.into());
143        self
144    }
145
146    #[must_use]
147    pub const fn with_ttl(mut self, ttl: Duration) -> Self {
148        self.ttl = ttl;
149        self
150    }
151
152    #[must_use]
153    pub fn build(self) -> CliSession {
154        let now = Utc::now();
155        let expires_at = now + self.ttl;
156        CliSession {
157            version: CURRENT_VERSION,
158            tenant_key: self.tenant_key,
159            profile_name: self.profile_name,
160            profile_path: self.profile_path,
161            session_token: self.session_token,
162            issuer: self.issuer,
163            session_id: self.session_id,
164            context_id: self.context_id,
165            user_id: self.user_id,
166            user_email: self.user_email,
167            user_type: self.user_type,
168            created_at: now,
169            expires_at,
170            last_used: now,
171        }
172    }
173}
174
175impl CliSession {
176    pub fn builder(
177        binding: SessionBinding,
178        session_token: SessionToken,
179        session_id: SessionId,
180        context_id: ContextId,
181        identity: SessionIdentity,
182    ) -> CliSessionBuilder {
183        CliSessionBuilder::new(binding, session_token, session_id, context_id, identity)
184    }
185
186    #[must_use]
187    pub fn matches_issuer(&self, issuer: &str) -> bool {
188        self.issuer == issuer
189    }
190
191    pub const fn context_id(&self) -> &ContextId {
192        &self.context_id
193    }
194
195    pub fn touch(&mut self) {
196        self.last_used = Utc::now();
197    }
198
199    pub fn set_context_id(&mut self, context_id: ContextId) {
200        self.context_id = context_id;
201        self.last_used = Utc::now();
202    }
203
204    pub fn update_profile_path(&mut self, profile_path: PathBuf) {
205        self.profile_path = Some(profile_path);
206        self.last_used = Utc::now();
207    }
208
209    #[must_use]
210    pub fn is_expired(&self) -> bool {
211        Utc::now() >= self.expires_at
212    }
213
214    #[must_use]
215    pub fn is_valid_for_profile(&self, profile_name: &str) -> bool {
216        self.profile_name.as_str() == profile_name && !self.is_expired()
217    }
218
219    #[must_use]
220    pub fn has_valid_credentials(&self) -> bool {
221        !self.session_token.as_str().is_empty()
222    }
223
224    #[must_use]
225    pub fn is_valid_for_tenant(&self, key: &SessionKey) -> bool {
226        if self.is_expired() || !self.has_valid_credentials() {
227            return false;
228        }
229
230        match (key, &self.tenant_key) {
231            (SessionKey::Local, None) => true,
232            (SessionKey::Local, Some(k)) => k.as_str() == LOCAL_SESSION_KEY,
233            (SessionKey::Tenant(id), Some(k)) => k == id,
234            (SessionKey::Tenant(_), None) => false,
235        }
236    }
237
238    #[must_use]
239    pub fn session_key(&self) -> SessionKey {
240        match &self.tenant_key {
241            None => SessionKey::Local,
242            Some(k) if k.as_str() == LOCAL_SESSION_KEY => SessionKey::Local,
243            Some(k) => SessionKey::Tenant(k.clone()),
244        }
245    }
246
247    pub fn load_from_path(path: &Path) -> CloudResult<Self> {
248        if !path.exists() {
249            return Err(CloudError::NotAuthenticated);
250        }
251
252        let content = fs::read_to_string(path)?;
253
254        let mut session: Self = serde_json::from_str(&content)
255            .map_err(|e| CloudError::CredentialsCorrupted { source: e })?;
256
257        if session.version < MIN_SUPPORTED_VERSION || session.version > CURRENT_VERSION {
258            return Err(CloudError::SessionVersionMismatch {
259                min: MIN_SUPPORTED_VERSION,
260                max: CURRENT_VERSION,
261                actual: session.version,
262                path: path.display().to_string(),
263            });
264        }
265
266        session.version = CURRENT_VERSION;
267        Ok(session)
268    }
269
270    pub fn save_to_path(&self, path: &Path) -> CloudResult<()> {
271        if let Some(dir) = path.parent() {
272            fs::create_dir_all(dir)?;
273
274            let gitignore_path = dir.join(".gitignore");
275            if !gitignore_path.exists() {
276                fs::write(&gitignore_path, "*\n")?;
277            }
278        }
279
280        let content = serde_json::to_string_pretty(self)?;
281        fs::write(path, content)?;
282
283        #[cfg(unix)]
284        {
285            use std::os::unix::fs::PermissionsExt;
286            let mut perms = fs::metadata(path)?.permissions();
287            perms.set_mode(0o600);
288            fs::set_permissions(path, perms)?;
289        }
290
291        Ok(())
292    }
293
294    pub fn delete_from_path(path: &Path) -> CloudResult<()> {
295        if path.exists() {
296            fs::remove_file(path)?;
297        }
298        Ok(())
299    }
300}