1use 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#[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, 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}