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 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}