agentics_persistence/db/sessions/
admin_service_tokens.rs1use chrono::{DateTime, Utc};
2use sqlx::{PgPool, Row};
3
4use agentics_domain::models::ids::{AdminServiceTokenId, HumanId};
5use agentics_error::{ErrorDetail, Result, ServiceError};
6
7use crate::db::ids::{admin_service_token_id_from_row, human_id_from_row};
8
9const ADMIN_SERVICE_TOKEN_ACTIVE_LABEL_INDEX: &str = "idx_admin_service_tokens_owner_active_label";
10
11#[derive(Debug, Clone)]
13pub struct AuthenticatedAdminServiceToken {
14 pub token_id: AdminServiceTokenId,
15 pub label: String,
16 pub created_by_human_id: HumanId,
17 pub expires_at: Option<DateTime<Utc>>,
18}
19
20#[derive(Debug, Clone)]
22pub struct AdminServiceTokenRecord {
23 pub id: AdminServiceTokenId,
24 pub label: String,
25 pub status: String,
26 pub created_by_human_id: HumanId,
27 pub created_at: DateTime<Utc>,
28 pub last_used_at: Option<DateTime<Utc>>,
29 pub expires_at: Option<DateTime<Utc>>,
30 pub revoked_by_human_id: Option<HumanId>,
31 pub revoked_at: Option<DateTime<Utc>>,
32}
33
34#[derive(Debug, Clone)]
36pub struct CreateAdminServiceTokenInput {
37 pub id: AdminServiceTokenId,
38 pub token_hash: String,
39 pub label: String,
40 pub created_by_human_id: HumanId,
41 pub expires_at: Option<DateTime<Utc>>,
42}
43
44pub async fn create_admin_service_token(
46 pool: &PgPool,
47 input: &CreateAdminServiceTokenInput,
48) -> Result<AdminServiceTokenRecord> {
49 let row = sqlx::query(
50 r#"
51 INSERT INTO admin_service_tokens (
52 id,
53 token_hash,
54 label,
55 created_by_human_id,
56 expires_at
57 )
58 VALUES ($1::uuid, $2, $3, $4::uuid, $5)
59 RETURNING
60 id::text AS id,
61 label,
62 status,
63 created_by_human_id::text AS created_by_human_id,
64 created_at,
65 last_used_at,
66 expires_at,
67 revoked_by_human_id::text AS revoked_by_human_id,
68 revoked_at
69 "#,
70 )
71 .bind(input.id.as_str())
72 .bind(&input.token_hash)
73 .bind(input.label.trim())
74 .bind(input.created_by_human_id.as_str())
75 .bind(input.expires_at)
76 .fetch_one(pool)
77 .await
78 .map_err(map_admin_service_token_create_error)?;
79
80 admin_service_token_record_from_row(&row)
81}
82
83pub async fn list_admin_service_tokens(pool: &PgPool) -> Result<Vec<AdminServiceTokenRecord>> {
85 let rows = sqlx::query(
86 r#"
87 SELECT
88 id::text AS id,
89 label,
90 status,
91 created_by_human_id::text AS created_by_human_id,
92 created_at,
93 last_used_at,
94 expires_at,
95 revoked_by_human_id::text AS revoked_by_human_id,
96 revoked_at
97 FROM admin_service_tokens
98 ORDER BY created_at DESC
99 "#,
100 )
101 .fetch_all(pool)
102 .await?;
103
104 rows.iter()
105 .map(admin_service_token_record_from_row)
106 .collect()
107}
108
109pub async fn revoke_admin_service_token(
111 pool: &PgPool,
112 id: &AdminServiceTokenId,
113 revoked_by_human_id: &HumanId,
114) -> Result<AdminServiceTokenRecord> {
115 let row = sqlx::query(
116 r#"
117 UPDATE admin_service_tokens
118 SET status = 'revoked',
119 revoked_at = COALESCE(revoked_at, NOW()),
120 revoked_by_human_id = COALESCE(revoked_by_human_id, $2::uuid)
121 WHERE id = $1::uuid
122 RETURNING
123 id::text AS id,
124 label,
125 status,
126 created_by_human_id::text AS created_by_human_id,
127 created_at,
128 last_used_at,
129 expires_at,
130 revoked_by_human_id::text AS revoked_by_human_id,
131 revoked_at
132 "#,
133 )
134 .bind(id.as_str())
135 .bind(revoked_by_human_id.as_str())
136 .fetch_optional(pool)
137 .await?
138 .ok_or(ServiceError::NotFound)?;
139
140 admin_service_token_record_from_row(&row)
141}
142
143pub async fn authenticate_admin_service_token(
145 pool: &PgPool,
146 token_hash: &str,
147) -> Result<Option<AuthenticatedAdminServiceToken>> {
148 let row = sqlx::query(
149 r#"
150 SELECT
151 t.id::text AS id,
152 t.label,
153 t.created_by_human_id::text AS created_by_human_id,
154 t.expires_at
155 FROM admin_service_tokens t
156 JOIN humans h ON h.id = t.created_by_human_id
157 JOIN human_roles r ON r.human_id = h.id
158 WHERE t.token_hash = $1
159 AND t.status = 'active'
160 AND (t.expires_at IS NULL OR t.expires_at > NOW())
161 AND h.status = 'active'
162 AND r.role = 'admin'
163 AND r.revoked_at IS NULL
164 LIMIT 1
165 "#,
166 )
167 .bind(token_hash)
168 .fetch_optional(pool)
169 .await?;
170
171 let Some(row) = row else {
172 return Ok(None);
173 };
174
175 let token_id = admin_service_token_id_from_row(&row, "id")?;
176 sqlx::query("UPDATE admin_service_tokens SET last_used_at = NOW() WHERE id = $1::uuid")
177 .bind(token_id.as_str())
178 .execute(pool)
179 .await?;
180
181 Ok(Some(AuthenticatedAdminServiceToken {
182 token_id,
183 label: row.try_get("label")?,
184 created_by_human_id: human_id_from_row(&row, "created_by_human_id")?,
185 expires_at: row.try_get("expires_at")?,
186 }))
187}
188
189fn admin_service_token_record_from_row(
190 row: &sqlx::postgres::PgRow,
191) -> Result<AdminServiceTokenRecord> {
192 Ok(AdminServiceTokenRecord {
193 id: admin_service_token_id_from_row(row, "id")?,
194 label: row.try_get("label")?,
195 status: row.try_get("status")?,
196 created_by_human_id: human_id_from_row(row, "created_by_human_id")?,
197 created_at: row.try_get("created_at")?,
198 last_used_at: row.try_get("last_used_at")?,
199 expires_at: row.try_get("expires_at")?,
200 revoked_by_human_id: row
201 .try_get::<Option<String>, _>("revoked_by_human_id")?
202 .map(HumanId::try_new)
203 .transpose()
204 .map_err(|e| {
205 ServiceError::Internal(format!("stored invalid token revoker human id: {e}"))
206 })?,
207 revoked_at: row.try_get("revoked_at")?,
208 })
209}
210
211fn map_admin_service_token_create_error(error: sqlx::Error) -> ServiceError {
212 match error {
213 sqlx::Error::Database(db_err)
214 if db_err.is_unique_violation()
215 && db_err
216 .constraint()
217 .is_some_and(|name| name == ADMIN_SERVICE_TOKEN_ACTIVE_LABEL_INDEX) =>
218 {
219 duplicate_token_label_conflict(
220 "active admin service token label already exists for this admin",
221 "An active admin service token from this admin already uses this label.",
222 )
223 }
224 error => ServiceError::Database(error),
225 }
226}
227
228fn duplicate_token_label_conflict(message: &str, detail_message: &str) -> ServiceError {
229 ServiceError::conflict_with_details(
230 message,
231 [ErrorDetail {
232 field: Some("label".to_string()),
233 message: detail_message.to_string(),
234 }],
235 )
236}