Skip to main content

agentics_persistence/db/sessions/
admin_service_tokens.rs

1use 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/// Persisted admin service token resolved from a bearer token.
12#[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/// Persisted admin service-token metadata returned to admins.
21#[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/// Input for creating an admin service token.
35#[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
44/// Create an admin service token.
45pub 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
83/// List admin service tokens.
84pub 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
109/// Revoke an admin service token.
110pub 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
143/// Authenticate an admin service token by hashed bearer token.
144pub 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}