Skip to main content

systemprompt_users/repository/user/
service_account.rs

1//! Service-account rows: the non-human identity a workflow acts as.
2//!
3//! A service account is created active with the plain `user` role and no
4//! interactive credential. Its credentials are API keys and in-process JWTs;
5//! every interactive login path refuses it by `account_kind`.
6//!
7//! Creation is idempotent on the normalised email: ensuring an account that
8//! already exists returns it unchanged, and an email held by a human account
9//! is refused rather than converted.
10//!
11//! Copyright (c) systemprompt.io — Business Source License 1.1.
12//! See <https://systemprompt.io> for licensing details.
13
14use chrono::Utc;
15use systemprompt_identifiers::UserId;
16
17use crate::error::{Result, UserError};
18use crate::models::{AccountKind, User, UserRole, UserRow, UserStatus, normalise_email};
19use crate::repository::UserRepository;
20
21#[derive(Debug, Clone)]
22pub struct NewServiceAccount {
23    pub name: String,
24    pub email: String,
25    pub created_by: UserId,
26}
27
28#[derive(Debug, Clone)]
29pub struct EnsuredServiceAccount {
30    pub user: User,
31    pub created: bool,
32}
33
34impl UserRepository {
35    pub async fn ensure_service_account(
36        &self,
37        account: NewServiceAccount,
38    ) -> Result<EnsuredServiceAccount> {
39        let id = UserId::generate();
40        let email = normalise_email(&account.email);
41        let inserted = sqlx::query_as!(
42            UserRow,
43            r#"
44            INSERT INTO users (
45                id, name, email, display_name, status, email_verified, roles, is_bot,
46                account_kind, created_at, updated_at
47            )
48            VALUES ($1, $2, $3, $2, $4, true, ARRAY[$5]::TEXT[], false, $6, $7, $7)
49            ON CONFLICT (email) DO NOTHING
50            RETURNING id, name, email, full_name, display_name, status, email_verified,
51                      roles, avatar_url, is_bot, is_scanner, created_at, updated_at, account_kind
52            "#,
53            id.as_str(),
54            account.name,
55            email,
56            UserStatus::Active.as_str(),
57            UserRole::User.as_str(),
58            AccountKind::Service.as_str(),
59            Utc::now()
60        )
61        .fetch_optional(&*self.write_pool)
62        .await?;
63        if let Some(row) = inserted {
64            let user = User::try_from(row)?;
65            tracing::info!(
66                user_id = %user.id,
67                created_by = %account.created_by,
68                "service account created"
69            );
70            return Ok(EnsuredServiceAccount {
71                user,
72                created: true,
73            });
74        }
75        let existing = sqlx::query_as!(
76            UserRow,
77            r#"
78            SELECT id, name, email, full_name, display_name, status, email_verified,
79                   roles, avatar_url, is_bot, is_scanner, created_at, updated_at, account_kind
80            FROM users
81            WHERE email = $1
82            "#,
83            email
84        )
85        .fetch_one(&*self.write_pool)
86        .await
87        .map_err(UserError::from)
88        .and_then(User::try_from)?;
89        if !existing.is_service_account() || existing.status == UserStatus::Deleted {
90            return Err(UserError::EmailAlreadyExists(email));
91        }
92        Ok(EnsuredServiceAccount {
93            user: existing,
94            created: false,
95        })
96    }
97
98    pub async fn list_service_accounts(&self) -> Result<Vec<User>> {
99        let rows = sqlx::query_as!(
100            UserRow,
101            r#"
102            SELECT id, name, email, full_name, display_name, status, email_verified,
103                   roles, avatar_url, is_bot, is_scanner, created_at, updated_at, account_kind
104            FROM users
105            WHERE account_kind = $1 AND status != $2
106            ORDER BY created_at
107            "#,
108            AccountKind::Service.as_str(),
109            UserStatus::Deleted.as_str()
110        )
111        .fetch_all(&*self.pool)
112        .await?;
113        rows.into_iter().map(User::try_from).collect()
114    }
115}