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