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_manifest::{Profile, Secrets};
24use systemprompt_models::auth::{Permission, RateLimitTier, UserType};
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(long, hide = true, help = "Override email from credentials")]
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
68fn ensure_interactive_admin(admin_user: &User) -> Result<()> {
69    if admin_user.is_service_account() {
70        anyhow::bail!(
71            "User '{}' is a service account; service accounts cannot open an interactive session",
72            admin_user.name
73        );
74    }
75    Ok(())
76}
77
78pub async fn login_for_profile(
79    profile: &Profile,
80    profile_path: &str,
81    secrets: &Secrets,
82    args: &LoginArgs,
83) -> Result<CommandOutput> {
84    let sessions_dir = ResolvedPaths::discover().sessions_dir();
85    let session_key = session_key_for_profile(profile);
86
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        && let Some(output) = try_use_existing_session(
96            &sessions_dir,
97            &session_key,
98            &profile.security.issuer,
99            args,
100            &db_pool,
101        )
102        .await?
103    {
104        return Ok(output);
105    }
106
107    let admin_name = &profile.system_admin.username;
108    progress(args, &format!("Fetching admin user: {admin_name}"));
109    let admin_user = fetch_admin_user(
110        &db_pool,
111        admin_name,
112        profile.target.is_cloud(),
113        args.email.as_deref(),
114    )
115    .await?;
116    ensure_interactive_admin(&admin_user)?;
117
118    progress(args, "Creating session...");
119    let session_id = create_local_session_row(
120        &db_pool,
121        &admin_user.id,
122        chrono::Duration::hours(args.duration_hours),
123    )
124    .await?;
125
126    progress(args, "Creating context...");
127    let context_id =
128        create_cli_context(&db_pool, &admin_user.id, &session_id, profile_path).await?;
129
130    progress(args, "Generating token...");
131    let session_token = generate_session_token(profile, args, &admin_user, &session_id)?;
132
133    save_session_to_store(SessionStoreParams {
134        sessions_dir: &sessions_dir,
135        session_key: &session_key,
136        profile_path,
137        session_token: session_token.clone(),
138        issuer: &profile.security.issuer,
139        ttl: chrono::Duration::hours(args.duration_hours),
140        session_id: session_id.clone(),
141        context_id,
142        user_id: admin_user.id.clone(),
143        user_email: &admin_user.email,
144        user_type: UserType::Admin,
145    })?;
146
147    let output = LoginOutput {
148        status: "created".to_owned(),
149        user_id: admin_user.id.clone(),
150        email: admin_user.email.clone(),
151        session_id,
152        expires_in_hours: args.duration_hours,
153    };
154
155    if args.token_only {
156        return Ok(CommandOutput::text(session_token.as_str()));
157    }
158
159    CliService::success(&format!(
160        "Session saved to {}/index.json",
161        sessions_dir.display()
162    ));
163    Ok(CommandOutput::card_value("Admin Session", &output))
164}
165
166fn progress(args: &LoginArgs, message: &str) {
167    if !args.token_only {
168        CliService::info(message);
169    }
170}
171
172async fn create_cli_context(
173    db_pool: &DbPool,
174    user_id: &UserId,
175    session_id: &SessionId,
176    profile_path: &str,
177) -> Result<ContextId> {
178    let profile_name = Path::new(profile_path)
179        .parent()
180        .and_then(|d| d.file_name())
181        .and_then(|n| n.to_str())
182        .unwrap_or("unknown");
183
184    let context_repo = ContextRepository::new(db_pool);
185    context_repo
186        .get_or_create_cli_context(
187            user_id,
188            session_id,
189            &format!("CLI Session - {}", profile_name),
190        )
191        .await
192        .context("Failed to create CLI context")
193}
194
195fn generate_session_token(
196    profile: &Profile,
197    args: &LoginArgs,
198    admin_user: &User,
199    session_id: &SessionId,
200) -> Result<SessionToken> {
201    let session_generator = SessionGenerator::new(&profile.security.issuer);
202    session_generator
203        .generate(&SessionParams {
204            user_id: &admin_user.id,
205            session_id,
206            email: &admin_user.email,
207            duration: ChronoDuration::hours(args.duration_hours),
208            user_type: UserType::Admin,
209            permissions: vec![Permission::Admin],
210            roles: vec![UserRole::Admin.as_str().to_owned()],
211            attributes: std::collections::BTreeMap::new(),
212            rate_limit_tier: RateLimitTier::Admin,
213        })
214        .context("Failed to generate session token")
215}
216
217fn session_key_for_profile(profile: &Profile) -> SessionKey {
218    if profile.target.is_local() {
219        SessionKey::Local
220    } else {
221        let tenant_id = profile.cloud.as_ref().and_then(|c| c.tenant_id.as_ref());
222        SessionKey::from_tenant_id(tenant_id)
223    }
224}