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, create_session, fetch_admin_user, save_session_to_store,
24 try_use_existing_session,
25};
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_session(
111 &profile.server.api_external_url,
112 &admin_user.id,
113 &admin_user.email,
114 )
115 .await?;
116
117 if !args.token_only {
118 CliService::info("Creating context...");
119 }
120 let profile_name = Path::new(profile_path)
121 .parent()
122 .and_then(|d| d.file_name())
123 .and_then(|n| n.to_str())
124 .unwrap_or("unknown");
125 let context_repo = ContextRepository::new(&db_pool)?;
126 let context_id = context_repo
127 .create_context(
128 &admin_user.id,
129 Some(&session_id),
130 &format!("CLI Session - {}", profile_name),
131 )
132 .await
133 .context("Failed to create CLI context")?;
134
135 if !args.token_only {
136 CliService::info("Generating token...");
137 }
138 let session_generator = SessionGenerator::new(&secrets.jwt_secret, &profile.security.issuer);
139 let duration = ChronoDuration::hours(args.duration_hours);
140 let session_token = session_generator
141 .generate(&SessionParams {
142 user_id: &admin_user.id,
143 session_id: &session_id,
144 email: &admin_user.email,
145 duration,
146 user_type: UserType::Admin,
147 permissions: vec![Permission::Admin],
148 roles: vec!["admin".to_string()],
149 department: None,
150 rate_limit_tier: RateLimitTier::Admin,
151 })
152 .context("Failed to generate session token")?;
153
154 save_session_to_store(SessionStoreParams {
155 sessions_dir: &sessions_dir,
156 session_key: &session_key,
157 profile_path,
158 session_token: session_token.clone(),
159 session_id: session_id.clone(),
160 context_id,
161 user_id: admin_user.id.clone(),
162 user_email: &admin_user.email,
163 })?;
164
165 let output = LoginOutput {
166 status: "created".to_string(),
167 user_id: admin_user.id.clone(),
168 email: admin_user.email.clone(),
169 session_id,
170 expires_in_hours: args.duration_hours,
171 };
172
173 if args.token_only {
174 CliService::output(session_token.as_str());
175 return Ok(CommandResult::text(output).with_skip_render());
176 }
177
178 CliService::success(&format!(
179 "Session saved to {}/index.json",
180 sessions_dir.display()
181 ));
182 Ok(CommandResult::card(output).with_title("Admin Session"))
183}
184
185fn session_key_for_profile(profile: &Profile) -> SessionKey {
186 if profile.target.is_local() {
187 SessionKey::Local
188 } else {
189 let tenant_id = profile.cloud.as_ref().and_then(|c| c.tenant_id.as_deref());
190 SessionKey::from_tenant_id(tenant_id)
191 }
192}
193
194async fn resolve_email() -> Result<String> {
195 CredentialsBootstrap::try_init()
196 .await
197 .context("Failed to initialize credentials")?;
198
199 let creds = CredentialsBootstrap::require().map_err(|_| {
200 anyhow::anyhow!(
201 "No credentials found. Run 'systemprompt cloud auth login' first to authenticate."
202 )
203 })?;
204 Ok(creds.user_email.clone())
205}