Skip to main content

systemprompt_users/models/
mod.rs

1//! Data types for the users domain.
2//!
3//! Defines the persisted [`User`] record and its projections
4//! ([`UserActivity`], [`UserWithSessions`], [`UserStats`],
5//! [`UserCountBreakdown`], [`UserExport`]), session rows
6//! ([`UserSession`]), and the credential records
7//! [`UserApiKey`] / [`NewApiKey`] and [`UserDeviceCert`]. Role and status
8//! enums are re-exported from `systemprompt_models::auth`.
9//!
10//! An API key's `purpose` is the caller purpose bound by the key's issuer and
11//! forwarded to the credential broker as `x-systemprompt-caller`; the bearer
12//! cannot set it.
13//!
14//! Copyright (c) systemprompt.io — Business Source License 1.1.
15//! See <https://systemprompt.io> for licensing details.
16
17use chrono::{DateTime, Utc};
18use serde::{Deserialize, Serialize};
19use sqlx::FromRow;
20use systemprompt_identifiers::{ApiKeyId, DeviceCertId, SessionId, UserId};
21use systemprompt_models::attribution::ScopeBinding;
22
23pub use systemprompt_models::auth::{AccountKind, UserRole, UserStatus};
24
25mod rows;
26pub(crate) use rows::{UserActivityRow, UserApiKeyRow, UserRow, UserWithSessionsRow};
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct User {
30    pub id: UserId,
31    pub name: String,
32    pub email: String,
33    pub full_name: Option<String>,
34    pub display_name: Option<String>,
35    pub status: UserStatus,
36    pub email_verified: bool,
37    pub roles: Vec<String>,
38    pub avatar_url: Option<String>,
39    pub is_bot: bool,
40    pub is_scanner: bool,
41    pub created_at: DateTime<Utc>,
42    pub updated_at: DateTime<Utc>,
43    #[serde(default)]
44    pub account_kind: AccountKind,
45}
46
47#[must_use]
48pub fn normalise_email(email: &str) -> String {
49    email.trim().to_lowercase()
50}
51
52impl User {
53    pub const fn is_active(&self) -> bool {
54        self.status.is_active()
55    }
56
57    pub const fn is_service_account(&self) -> bool {
58        self.account_kind.is_service()
59    }
60
61    pub fn is_admin(&self) -> bool {
62        self.has_role(UserRole::Admin)
63    }
64
65    pub fn has_role(&self, role: UserRole) -> bool {
66        self.roles.iter().any(|held| held == role.as_str())
67    }
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
71pub struct UserActivity {
72    #[sqlx(try_from = "String")]
73    pub user_id: UserId,
74    pub last_active: Option<DateTime<Utc>>,
75    pub session_count: i64,
76    pub task_count: i64,
77    pub message_count: i64,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct UserWithSessions {
82    pub id: UserId,
83    pub name: String,
84    pub email: String,
85    pub full_name: Option<String>,
86    pub status: UserStatus,
87    pub roles: Vec<String>,
88    pub created_at: DateTime<Utc>,
89    pub active_sessions: i64,
90    pub last_session_at: Option<DateTime<Utc>>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
94pub struct UserSession {
95    pub session_id: SessionId,
96    pub user_id: Option<UserId>,
97    pub ip_address: Option<String>,
98    pub user_agent: Option<String>,
99    pub device_type: Option<String>,
100    pub started_at: Option<DateTime<Utc>>,
101    pub last_activity_at: Option<DateTime<Utc>>,
102    pub ended_at: Option<DateTime<Utc>>,
103}
104
105#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
106pub struct UserStats {
107    pub total: i64,
108    pub created_24h: i64,
109    pub created_7d: i64,
110    pub created_30d: i64,
111    pub active: i64,
112    pub suspended: i64,
113    pub admins: i64,
114    pub anonymous: i64,
115    pub bots: i64,
116    pub oldest_user: Option<DateTime<Utc>>,
117    pub newest_user: Option<DateTime<Utc>>,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct UserCountBreakdown {
122    pub total: i64,
123    pub by_status: std::collections::HashMap<String, i64>,
124    pub by_role: std::collections::HashMap<String, i64>,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct UserExport {
129    pub id: UserId,
130    pub name: String,
131    pub email: String,
132    pub full_name: Option<String>,
133    pub display_name: Option<String>,
134    pub status: UserStatus,
135    pub email_verified: bool,
136    pub roles: Vec<String>,
137    pub is_bot: bool,
138    pub is_scanner: bool,
139    pub created_at: DateTime<Utc>,
140    pub updated_at: DateTime<Utc>,
141}
142
143/// The limits an API key carries: an optional model allowlist, and a spend
144/// budget and request ceiling counted over `request_window_seconds`.
145#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
146pub struct ApiKeyLimits {
147    pub model_allowlist: Option<Vec<String>>,
148    pub budget_microdollars: Option<i64>,
149    pub max_requests: Option<i32>,
150    pub request_window_seconds: Option<i32>,
151}
152
153impl ApiKeyLimits {
154    #[must_use]
155    pub fn allows_model(&self, model: &str) -> bool {
156        self.model_allowlist
157            .as_ref()
158            .is_none_or(|allowed| allowed.iter().any(|m| m == model))
159    }
160}
161
162/// What minted an API key: a person or a tool (`user`), or a bridge device
163/// enrolment (`device`), the PAT of the interactive host a person installed.
164#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(rename_all = "snake_case")]
166pub enum ApiKeyKind {
167    #[default]
168    User,
169    Device,
170}
171
172impl ApiKeyKind {
173    #[must_use]
174    pub const fn as_str(self) -> &'static str {
175        match self {
176            Self::User => "user",
177            Self::Device => "device",
178        }
179    }
180
181    #[must_use]
182    pub fn from_stored(value: &str) -> Self {
183        if value == "device" {
184            Self::Device
185        } else {
186            Self::User
187        }
188    }
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct UserApiKey {
193    pub id: ApiKeyId,
194    pub user_id: UserId,
195    pub name: String,
196    pub key_prefix: String,
197    pub key_hash: String,
198    pub created_at: Option<DateTime<Utc>>,
199    pub last_used_at: Option<DateTime<Utc>>,
200    pub expires_at: Option<DateTime<Utc>>,
201    pub revoked_at: Option<DateTime<Utc>>,
202    pub limits: ApiKeyLimits,
203    pub scopes: Vec<ScopeBinding>,
204    pub purpose: Option<String>,
205    #[serde(default)]
206    pub kind: ApiKeyKind,
207}
208
209impl UserApiKey {
210    #[must_use]
211    pub const fn is_interactive_host(&self) -> bool {
212        matches!(self.kind, ApiKeyKind::Device) && self.purpose.is_none()
213    }
214
215    pub fn is_active(&self, now: DateTime<Utc>) -> bool {
216        if self.revoked_at.is_some() {
217            return false;
218        }
219        if let Some(expires_at) = self.expires_at
220            && now >= expires_at
221        {
222            return false;
223        }
224        true
225    }
226}
227
228#[derive(Debug, Clone)]
229pub struct NewApiKey {
230    pub record: UserApiKey,
231    pub secret: String,
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
235pub struct UserDeviceCert {
236    #[sqlx(try_from = "String")]
237    pub id: DeviceCertId,
238    #[sqlx(try_from = "String")]
239    pub user_id: UserId,
240    pub fingerprint: String,
241    pub label: String,
242    pub enrolled_at: Option<DateTime<Utc>>,
243    pub revoked_at: Option<DateTime<Utc>>,
244}
245
246impl UserDeviceCert {
247    pub const fn is_active(&self) -> bool {
248        self.revoked_at.is_none()
249    }
250}
251
252impl From<User> for UserExport {
253    fn from(user: User) -> Self {
254        Self {
255            id: user.id,
256            name: user.name,
257            email: user.email,
258            full_name: user.full_name,
259            display_name: user.display_name,
260            status: user.status,
261            email_verified: user.email_verified,
262            roles: user.roles,
263            is_bot: user.is_bot,
264            is_scanner: user.is_scanner,
265            created_at: user.created_at,
266            updated_at: user.updated_at,
267        }
268    }
269}