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