systemprompt_cloud/cli_session/
session.rs1use 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#[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}