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, 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}