Skip to main content

systemprompt_cli/commands/admin/session/
login.rs

1use 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            attributes: std::collections::BTreeMap::new(),
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}