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::{
27    UserActivityRow, UserApiKeyRow, UserDeviceCertRow, UserRow, UserWithSessionsRow,
28};
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct User {
32    pub id: UserId,
33    pub name: String,
34    pub email: String,
35    pub full_name: Option<String>,
36    pub display_name: Option<String>,
37    pub status: UserStatus,
38    pub email_verified: bool,
39    pub roles: Vec<String>,
40    pub avatar_url: Option<String>,
41    pub is_bot: bool,
42    pub is_scanner: bool,
43    pub created_at: DateTime<Utc>,
44    pub updated_at: DateTime<Utc>,
45    #[serde(default)]
46    pub account_kind: AccountKind,
47}
48
49#[must_use]
50pub fn normalise_email(email: &str) -> String {
51    email.trim().to_lowercase()
52}
53
54impl User {
55    pub const fn is_active(&self) -> bool {
56        self.status.is_active()
57    }
58
59    pub const fn is_service_account(&self) -> bool {
60        self.account_kind.is_service()
61    }
62
63    pub fn is_admin(&self) -> bool {
64        self.has_role(UserRole::Admin)
65    }
66
67    pub fn has_role(&self, role: UserRole) -> bool {
68        self.roles.iter().any(|held| held == role.as_str())
69    }
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
73pub struct UserActivity {
74    #[sqlx(try_from = "String")]
75    pub user_id: UserId,
76    pub last_active: Option<DateTime<Utc>>,
77    pub session_count: i64,
78    pub task_count: i64,
79    pub message_count: i64,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct UserWithSessions {
84    pub id: UserId,
85    pub name: String,
86    pub email: String,
87    pub full_name: Option<String>,
88    pub status: UserStatus,
89    pub roles: Vec<String>,
90    pub created_at: DateTime<Utc>,
91    pub active_sessions: i64,
92    pub last_session_at: Option<DateTime<Utc>>,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
96pub struct UserSession {
97    pub session_id: SessionId,
98    pub user_id: Option<UserId>,
99    pub ip_address: Option<String>,
100    pub user_agent: Option<String>,
101    pub device_type: Option<String>,
102    pub started_at: Option<DateTime<Utc>>,
103    pub last_activity_at: Option<DateTime<Utc>>,
104    pub ended_at: Option<DateTime<Utc>>,
105}
106
107#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
108pub struct UserStats {
109    pub total: i64,
110    pub created_24h: i64,
111    pub created_7d: i64,
112    pub created_30d: i64,
113    pub active: i64,
114    pub suspended: i64,
115    pub admins: i64,
116    pub anonymous: i64,
117    pub bots: i64,
118    pub oldest_user: Option<DateTime<Utc>>,
119    pub newest_user: Option<DateTime<Utc>>,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct UserCountBreakdown {
124    pub total: i64,
125    pub by_status: std::collections::HashMap<String, i64>,
126    pub by_role: std::collections::HashMap<String, i64>,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct UserExport {
131    pub id: UserId,
132    pub name: String,
133    pub email: String,
134    pub full_name: Option<String>,
135    pub display_name: Option<String>,
136    pub status: UserStatus,
137    pub email_verified: bool,
138    pub roles: Vec<String>,
139    pub is_bot: bool,
140    pub is_scanner: bool,
141    pub created_at: DateTime<Utc>,
142    pub updated_at: DateTime<Utc>,
143}
144
145/// The limits an API key carries: an optional model allowlist, and a spend
146/// budget and request ceiling counted over `request_window_seconds`.
147#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
148pub struct ApiKeyLimits {
149    pub model_allowlist: Option<Vec<String>>,
150    pub budget_microdollars: Option<i64>,
151    pub max_requests: Option<i32>,
152    pub request_window_seconds: Option<i32>,
153}
154
155impl ApiKeyLimits {
156    #[must_use]
157    pub fn allows_model(&self, model: &str) -> bool {
158        self.model_allowlist
159            .as_ref()
160            .is_none_or(|allowed| allowed.iter().any(|m| m == model))
161    }
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct UserApiKey {
166    pub id: ApiKeyId,
167    pub user_id: UserId,
168    pub name: String,
169    pub key_prefix: String,
170    pub key_hash: String,
171    pub created_at: Option<DateTime<Utc>>,
172    pub last_used_at: Option<DateTime<Utc>>,
173    pub expires_at: Option<DateTime<Utc>>,
174    pub revoked_at: Option<DateTime<Utc>>,
175    pub limits: ApiKeyLimits,
176    pub scopes: Vec<ScopeBinding>,
177    pub purpose: Option<String>,
178}
179
180impl UserApiKey {
181    pub fn is_active(&self, now: DateTime<Utc>) -> bool {
182        if self.revoked_at.is_some() {
183            return false;
184        }
185        if let Some(expires_at) = self.expires_at
186            && now >= expires_at
187        {
188            return false;
189        }
190        true
191    }
192}
193
194#[derive(Debug, Clone)]
195pub struct NewApiKey {
196    pub record: UserApiKey,
197    pub secret: String,
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
201pub struct UserDeviceCert {
202    #[sqlx(try_from = "String")]
203    pub id: DeviceCertId,
204    #[sqlx(try_from = "String")]
205    pub user_id: UserId,
206    pub fingerprint: String,
207    pub label: String,
208    pub enrolled_at: Option<DateTime<Utc>>,
209    pub revoked_at: Option<DateTime<Utc>>,
210}
211
212impl UserDeviceCert {
213    pub const fn is_active(&self) -> bool {
214        self.revoked_at.is_none()
215    }
216}
217
218impl From<User> for UserExport {
219    fn from(user: User) -> Self {
220        Self {
221            id: user.id,
222            name: user.name,
223            email: user.email,
224            full_name: user.full_name,
225            display_name: user.display_name,
226            status: user.status,
227            email_verified: user.email_verified,
228            roles: user.roles,
229            is_bot: user.is_bot,
230            is_scanner: user.is_scanner,
231            created_at: user.created_at,
232            updated_at: user.updated_at,
233        }
234    }
235}