Skip to main content

systemprompt_cli/commands/admin/session/
login.rs

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