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#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct UserApiKey {
164    pub id: ApiKeyId,
165    pub user_id: UserId,
166    pub name: String,
167    pub key_prefix: String,
168    pub key_hash: String,
169    pub created_at: Option<DateTime<Utc>>,
170    pub last_used_at: Option<DateTime<Utc>>,
171    pub expires_at: Option<DateTime<Utc>>,
172    pub revoked_at: Option<DateTime<Utc>>,
173    pub limits: ApiKeyLimits,
174    pub scopes: Vec<ScopeBinding>,
175    pub purpose: Option<String>,
176}
177
178impl UserApiKey {
179    pub fn is_active(&self, now: DateTime<Utc>) -> bool {
180        if self.revoked_at.is_some() {
181            return false;
182        }
183        if let Some(expires_at) = self.expires_at
184            && now >= expires_at
185        {
186            return false;
187        }
188        true
189    }
190}
191
192#[derive(Debug, Clone)]
193pub struct NewApiKey {
194    pub record: UserApiKey,
195    pub secret: String,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
199pub struct UserDeviceCert {
200    #[sqlx(try_from = "String")]
201    pub id: DeviceCertId,
202    #[sqlx(try_from = "String")]
203    pub user_id: UserId,
204    pub fingerprint: String,
205    pub label: String,
206    pub enrolled_at: Option<DateTime<Utc>>,
207    pub revoked_at: Option<DateTime<Utc>>,
208}
209
210impl UserDeviceCert {
211    pub const fn is_active(&self) -> bool {
212        self.revoked_at.is_none()
213    }
214}
215
216impl From<User> for UserExport {
217    fn from(user: User) -> Self {
218        Self {
219            id: user.id,
220            name: user.name,
221            email: user.email,
222            full_name: user.full_name,
223            display_name: user.display_name,
224            status: user.status,
225            email_verified: user.email_verified,
226            roles: user.roles,
227            is_bot: user.is_bot,
228            is_scanner: user.is_scanner,
229            created_at: user.created_at,
230            updated_at: user.updated_at,
231        }
232    }
233}