systemprompt_cli/commands/admin/session/
login.rs1use std::path::Path;
7use std::sync::Arc;
8
9use anyhow::{Context, Result};
10use chrono::Duration as ChronoDuration;
11use clap::Args;
12use serde::{Deserialize, Serialize};
13
14use crate::cli_settings::CliConfig;
15use crate::paths::ResolvedPaths;
16use crate::shared::CommandOutput;
17use systemprompt_agent::repository::context::ContextRepository;
18use systemprompt_cloud::SessionKey;
19use systemprompt_config::{ProfileBootstrap, SecretsBootstrap};
20use systemprompt_database::{Database, DbPool};
21use systemprompt_identifiers::{ContextId, SessionId, SessionToken, UserId};
22use systemprompt_logging::CliService;
23use systemprompt_models::auth::{Permission, RateLimitTier, UserType};
24use systemprompt_models::{Profile, Secrets};
25use systemprompt_security::{SessionGenerator, SessionParams};
26use systemprompt_users::{User, UserRole};
27
28use super::login_helpers::{
29 SessionStoreParams, fetch_admin_user, save_session_to_store, try_use_existing_session,
30};
31use crate::session::api::create_local_session_row;
32
33#[derive(Debug, Args)]
34pub struct LoginArgs {
35 #[arg(
36 long,
37 env = "SYSTEMPROMPT_ADMIN_EMAIL",
38 hide = true,
39 help = "Override email from credentials"
40 )]
41 pub email: Option<String>,
42
43 #[arg(long, default_value = "24", help = "Session duration in hours")]
44 pub duration_hours: i64,
45
46 #[arg(long, help = "Only output the token (for scripting)")]
47 pub token_only: bool,
48
49 #[arg(
50 long,
51 help = "Force creation of a new session even if a valid one exists"
52 )]
53 pub force_new: bool,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct LoginOutput {
58 pub status: String,
59 pub user_id: UserId,
60 pub email: String,
61 pub session_id: SessionId,
62 pub expires_in_hours: i64,
63}
64
65pub async fn execute(args: LoginArgs, _config: &CliConfig) -> Result<CommandOutput> {
66 let profile = ProfileBootstrap::get().context("No profile loaded")?;
67 let profile_path = ProfileBootstrap::get_path().context("Profile path not set")?;
68 let secrets = SecretsBootstrap::get().context("Secrets not initialized")?;
69
70 login_for_profile(profile, profile_path, secrets, &args).await
71}
72
73pub async fn login_for_profile(
74 profile: &Profile,
75 profile_path: &str,
76 secrets: &Secrets,
77 args: &LoginArgs,
78) -> Result<CommandOutput> {
79 let sessions_dir = ResolvedPaths::discover().sessions_dir();
80 let session_key = session_key_for_profile(profile);
81
82 let database_url = secrets.effective_database_url(profile.database.external_db_access);
83
84 let db = Database::new_postgres(database_url)
85 .await
86 .context("Failed to connect to database")?;
87 let db_pool = DbPool::from(Arc::new(db));
88
89 if !args.force_new
90 && let Some(output) = try_use_existing_session(
91 &sessions_dir,
92 &session_key,
93 &profile.security.issuer,
94 args,
95 &db_pool,
96 )
97 .await?
98 {
99 return Ok(output);
100 }
101
102 let admin_name = &profile.system_admin.username;
103 progress(args, &format!("Fetching admin user: {admin_name}"));
104 let admin_user = fetch_admin_user(
105 &db_pool,
106 admin_name,
107 profile.target.is_cloud(),
108 args.email.as_deref(),
109 )
110 .await?;
111
112 progress(args, "Creating session...");
113 let session_id = create_local_session_row(
114 &db_pool,
115 &admin_user.id,
116 chrono::Duration::hours(args.duration_hours),
117 )
118 .await?;
119
120 progress(args, "Creating context...");
121 let context_id =
122 create_cli_context(&db_pool, &admin_user.id, &session_id, profile_path).await?;
123
124 progress(args, "Generating token...");
125 let session_token = generate_session_token(profile, args, &admin_user, &session_id)?;
126
127 save_session_to_store(SessionStoreParams {
128 sessions_dir: &sessions_dir,
129 session_key: &session_key,
130 profile_path,
131 session_token: session_token.clone(),
132 issuer: &profile.security.issuer,
133 ttl: chrono::Duration::hours(args.duration_hours),
134 session_id: session_id.clone(),
135 context_id,
136 user_id: admin_user.id.clone(),
137 user_email: &admin_user.email,
138 user_type: UserType::Admin,
139 })?;
140
141 let output = LoginOutput {
142 status: "created".to_owned(),
143 user_id: admin_user.id.clone(),
144 email: admin_user.email.clone(),
145 session_id,
146 expires_in_hours: args.duration_hours,
147 };
148
149 if args.token_only {
150 CliService::output(session_token.as_str());
151 return Ok(CommandOutput::card_value("Admin Session", &output).with_skip_render());
152 }
153
154 CliService::success(&format!(
155 "Session saved to {}/index.json",
156 sessions_dir.display()
157 ));
158 Ok(CommandOutput::card_value("Admin Session", &output))
159}
160
161fn progress(args: &LoginArgs, message: &str) {
162 if !args.token_only {
163 CliService::info(message);
164 }
165}
166
167async fn create_cli_context(
168 db_pool: &DbPool,
169 user_id: &UserId,
170 session_id: &SessionId,
171 profile_path: &str,
172) -> Result<ContextId> {
173 let profile_name = Path::new(profile_path)
174 .parent()
175 .and_then(|d| d.file_name())
176 .and_then(|n| n.to_str())
177 .unwrap_or("unknown");
178
179 let context_repo = ContextRepository::new(db_pool)?;
180 context_repo
181 .get_or_create_cli_context(
182 user_id,
183 session_id,
184 &format!("CLI Session - {}", profile_name),
185 )
186 .await
187 .context("Failed to create CLI context")
188}
189
190fn generate_session_token(
191 profile: &Profile,
192 args: &LoginArgs,
193 admin_user: &User,
194 session_id: &SessionId,
195) -> Result<SessionToken> {
196 let session_generator = SessionGenerator::new(&profile.security.issuer);
197 session_generator
198 .generate(&SessionParams {
199 user_id: &admin_user.id,
200 session_id,
201 email: &admin_user.email,
202 duration: ChronoDuration::hours(args.duration_hours),
203 user_type: UserType::Admin,
204 permissions: vec![Permission::Admin],
205 roles: vec![UserRole::Admin.as_str().to_owned()],
206 attributes: std::collections::BTreeMap::new(),
207 rate_limit_tier: RateLimitTier::Admin,
208 })
209 .context("Failed to generate session token")
210}
211
212fn session_key_for_profile(profile: &Profile) -> SessionKey {
213 if profile.target.is_local() {
214 SessionKey::Local
215 } else {
216 let tenant_id = profile.cloud.as_ref().and_then(|c| c.tenant_id.as_ref());
217 SessionKey::from_tenant_id(tenant_id)
218 }
219}