Skip to main content

systemprompt_cli/session/creation/
helpers.rs

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