Skip to main content

systemprompt_cli/session/creation/
helpers.rs

1//! User resolution for CLI session creation.
2//!
3//! Local administrator lookup uses the configured username. Credentialed
4//! paths resolve email addresses from session hints or cloud credentials.
5//!
6//! Copyright (c) systemprompt.io — Business Source License 1.1.
7//! See <https://systemprompt.io> for licensing details.
8
9use std::sync::Arc;
10
11use anyhow::{Context, Result};
12use chrono::Duration as ChronoDuration;
13use systemprompt_agent::repository::context::ContextRepository;
14use systemprompt_cloud::{
15    CliSession, CloudCredentials, CredentialsBootstrap, SessionBinding, SessionIdentity, SessionKey,
16};
17use systemprompt_config::SecretsBootstrap;
18use systemprompt_database::{Database, DbPool, PoolConfig};
19use systemprompt_identifiers::{ContextId, Email, ProfileName, SessionId, SessionToken};
20use systemprompt_models::auth::{Permission, RateLimitTier, UserType};
21use systemprompt_security::{SessionGenerator, SessionParams};
22use systemprompt_users::{UserRepository, UserService};
23
24use crate::session::resolution::ProfileContext;
25
26pub(super) struct ResolvedSecrets {
27    pub database_url: String,
28    pub database_write_url: Option<String>,
29}
30
31pub(super) fn load_secrets() -> Result<ResolvedSecrets> {
32    let secrets = SecretsBootstrap::get().map_err(|e| {
33        anyhow::anyhow!(
34            "Secrets not initialized: {}\n\nEnsure your profile has a valid secrets \
35             configuration.\nCheck that secrets.json exists or environment variables are set.",
36            e
37        )
38    })?;
39
40    Ok(ResolvedSecrets {
41        database_url: secrets.database_url.clone(),
42        database_write_url: secrets.database_write_url.clone(),
43    })
44}
45
46pub(super) async fn connect_database(secrets: &ResolvedSecrets) -> Result<DbPool> {
47    let db = Database::connect(
48        &secrets.database_url,
49        secrets.database_write_url.as_deref(),
50        &PoolConfig::default(),
51    )
52    .await
53    .context("Failed to connect to database")?;
54    Ok(DbPool::from(Arc::new(db)))
55}
56
57pub async fn get_or_create_admin(
58    db_pool: &DbPool,
59    email: &str,
60    context_type: &str,
61) -> Result<systemprompt_users::User> {
62    let email = Email::try_new(email).map_err(|e| {
63        anyhow::anyhow!("refusing to provision an admin for an invalid address: {e}")
64    })?;
65    let email = email.as_str();
66
67    let user_service = UserService::new(Arc::new(UserRepository::new(db_pool)));
68
69    if let Some(user) = user_service
70        .find_by_email(email)
71        .await
72        .context("Failed to query user by email")?
73    {
74        if user.is_service_account() {
75            anyhow::bail!("{email} is a service account; it cannot open a CLI session");
76        }
77        if user.is_admin() {
78            return Ok(user);
79        }
80
81        tracing::info!(email = %email, context = %context_type, "Promoting existing user to admin");
82
83        return user_service
84            .assign_roles(&user.id, &["admin".to_owned()])
85            .await
86            .context("Failed to assign admin role to existing user");
87    }
88
89    let name = email.split('@').next().unwrap_or("admin").to_owned();
90
91    tracing::info!(email = %email, name = %name, context = %context_type, "Auto-provisioning user");
92
93    let user = match user_service
94        .create_if_absent(&name, email, None, None)
95        .await
96        .with_context(|| format!("Failed to create user in {context_type} database"))?
97    {
98        Some(user) => user,
99        None => user_service
100            .find_by_email(email)
101            .await
102            .context("Failed to query user by email")?
103            .with_context(|| format!("User {email} vanished between provisioning and lookup"))?,
104    };
105
106    user_service
107        .assign_roles(&user.id, &["admin".to_owned()])
108        .await
109        .context("Failed to assign admin role to new user")
110}
111
112pub fn generate_admin_token(
113    issuer: &str,
114    user: &systemprompt_users::User,
115    session_id: &SessionId,
116) -> Result<SessionToken> {
117    let generator = SessionGenerator::new(issuer);
118    generator
119        .generate(&SessionParams {
120            user_id: &user.id,
121            session_id,
122            email: &user.email,
123            duration: ChronoDuration::hours(crate::session::api::DEFAULT_CLI_SESSION_HOURS),
124            user_type: UserType::Admin,
125            permissions: vec![Permission::Admin],
126            roles: vec!["admin".to_owned()],
127            attributes: std::collections::BTreeMap::new(),
128            rate_limit_tier: RateLimitTier::Admin,
129        })
130        .context("Failed to generate session token")
131}
132
133pub async fn create_cli_context(
134    db_pool: DbPool,
135    user: &systemprompt_users::User,
136    session_id: &SessionId,
137    profile_name: &ProfileName,
138) -> Result<ContextId> {
139    let context_repo = ContextRepository::new(&db_pool);
140    context_repo
141        .get_or_create_cli_context(
142            &user.id,
143            session_id,
144            &format!("CLI Session - {}", profile_name),
145        )
146        .await
147        .context("Failed to create CLI context")
148}
149
150pub(super) struct SessionComponents {
151    pub session_token: SessionToken,
152    pub session_id: SessionId,
153    pub context_id: ContextId,
154}
155
156pub(super) fn build_cli_session(
157    profile_ctx: &ProfileContext<'_>,
158    session_key: &SessionKey,
159    components: SessionComponents,
160    admin_user: &systemprompt_users::User,
161    issuer: &str,
162) -> Result<CliSession> {
163    let email =
164        Email::try_new(&admin_user.email).map_err(|e| anyhow::anyhow!("Invalid email: {}", e))?;
165
166    Ok(CliSession::builder(
167        SessionBinding::new(profile_ctx.name.clone(), issuer.to_owned()),
168        components.session_token,
169        components.session_id,
170        components.context_id,
171        SessionIdentity::new(admin_user.id.clone(), email, UserType::Admin),
172    )
173    .with_session_key(session_key)
174    .with_profile_path(profile_ctx.path.clone())
175    .build())
176}
177
178pub async fn resolve_local_admin(
179    db_pool: &DbPool,
180    admin_name: &str,
181) -> Result<systemprompt_users::User> {
182    let user_service = UserService::new(Arc::new(UserRepository::new(db_pool)));
183
184    let user = user_service
185        .find_by_name(admin_name)
186        .await
187        .context("Failed to query the local admin user by name")?
188        .with_context(|| {
189            format!(
190                "Local admin user '{admin_name}' not found.\n\nRun 'systemprompt admin bootstrap \
191                 --email <your email>' to create it with a real address."
192            )
193        })?;
194
195    if !user.is_active() {
196        anyhow::bail!("Local admin user '{admin_name}' exists but is not active.");
197    }
198    if user.is_service_account() {
199        anyhow::bail!("User '{admin_name}' is a service account; it cannot open a CLI session.");
200    }
201    if !user.is_admin() {
202        anyhow::bail!(
203            "User '{admin_name}' exists but does not hold the admin role. Run 'systemprompt admin \
204             bootstrap' to repair it."
205        );
206    }
207
208    Ok(user)
209}
210
211#[doc(hidden)]
212pub async fn resolve_credentialed_user_email(session_email_hint: Option<&str>) -> Result<Email> {
213    if let Some(email) = session_email_hint {
214        return Email::try_new(email).context("session email hint is not a valid email address");
215    }
216
217    CredentialsBootstrap::try_init()
218        .await
219        .context("Failed to initialize credentials. Run 'systemprompt cloud auth login'.")?;
220
221    let creds = CredentialsBootstrap::require().map_err(|_e| {
222        anyhow::anyhow!(
223            "Cloud authentication required for new sessions.\n\nRun 'systemprompt cloud auth \
224             login' to authenticate."
225        )
226    })?;
227    Ok(creds.user_email.clone())
228}
229
230#[doc(hidden)]
231pub async fn resolve_admin_with_fallback(
232    db_pool: &DbPool,
233    user_email: &str,
234    session_email_hint: Option<&str>,
235    context_type: &str,
236) -> Result<systemprompt_users::User> {
237    match get_or_create_admin(db_pool, user_email, context_type).await {
238        Ok(user) => Ok(user),
239        Err(e) if session_email_hint.is_some() => {
240            tracing::warn!(
241                email = %user_email,
242                error = %e,
243                "Session user lookup failed, falling back to cloud credentials"
244            );
245            if let Err(init_err) = CredentialsBootstrap::try_init().await {
246                tracing::debug!(error = %init_err, "Credentials init failed during fallback");
247            }
248            if let Ok(creds) = CredentialsBootstrap::require()
249                && creds.user_email.as_str() != user_email
250            {
251                return get_or_create_admin(db_pool, creds.user_email.as_str(), context_type).await;
252            }
253            Err(e)
254        },
255        Err(e) => Err(e),
256    }
257}
258
259pub async fn resolve_tenant_admin_with_fallback(
260    db_pool: &DbPool,
261    creds: &CloudCredentials,
262    user_email: &str,
263    session_email_hint: Option<&str>,
264) -> Result<systemprompt_users::User> {
265    match get_or_create_admin(db_pool, user_email, "tenant").await {
266        Ok(user) => Ok(user),
267        Err(e) if session_email_hint.is_some() && creds.user_email.as_str() != user_email => {
268            tracing::warn!(
269                email = %user_email,
270                error = %e,
271                "Session user lookup failed, falling back to cloud credentials"
272            );
273            get_or_create_admin(db_pool, creds.user_email.as_str(), "tenant").await
274        },
275        Err(e) => Err(e),
276    }
277}