Skip to main content

auth/
console_api.rs

1use crate::models::AuthUserId;
2use crate::repositories::{AuthUserRepository, PostgresAuthUserRepository};
3use axum::Json;
4use axum::extract::{Path, Query, State};
5use axum::http::HeaderMap;
6use chrono::{DateTime, Utc};
7use platform_core::{AppContext, AppError, ErrorCode};
8use platform_http::responses::json;
9use platform_http::{
10    ApiErrorResponse, ApiOpenApiRouter, ErrorResponse, HttpRequestContext, JsonBody, OpenApiRouter,
11    routes,
12};
13use serde::{Deserialize, Serialize};
14use utoipa::ToSchema;
15
16pub const AUTH_CONTRACT_DIGEST: &str =
17    "sha256:b57f7626fb6eac67b0595c17894671f08782ec4ca7d8c69990769048570999ed";
18pub const LIST_USERS_OPERATION: &str = "auth/http/GET:/users";
19pub const LIST_SESSIONS_OPERATION: &str = "auth/http/GET:/sessions";
20pub const DISABLE_USER_OPERATION: &str = "auth/http/POST:/users/{id}/disable";
21pub const ENABLE_USER_OPERATION: &str = "auth/http/POST:/users/{id}/enable";
22pub const REVOKE_SESSION_OPERATION: &str = "auth/http/POST:/sessions/{id}/revoke";
23
24const DEFAULT_PAGE_LIMIT: i64 = 100;
25const MAX_PAGE_LIMIT: i64 = 200;
26
27#[derive(Debug, Deserialize)]
28pub struct AuthConsoleListQuery {
29    #[serde(default)]
30    pub limit: Option<i64>,
31    #[serde(default)]
32    pub cursor: Option<String>,
33}
34
35#[derive(Debug, Serialize, ToSchema)]
36pub struct AuthConsoleUser {
37    pub id: String,
38    pub is_anonymous: bool,
39    pub created_at: DateTime<Utc>,
40    pub disabled_at: Option<DateTime<Utc>>,
41    pub disabled_reason: Option<String>,
42    pub disabled_until: Option<DateTime<Utc>>,
43}
44
45#[derive(Debug, Serialize, ToSchema)]
46pub struct AuthConsoleSession {
47    pub id: String,
48    pub user_id: String,
49    pub device_id: Option<String>,
50    pub client_ip: Option<String>,
51    pub user_agent: Option<String>,
52    pub created_at: DateTime<Utc>,
53    pub expires_at: DateTime<Utc>,
54    pub revoked_at: Option<DateTime<Utc>>,
55}
56
57#[derive(Debug, Serialize, ToSchema)]
58pub struct AuthConsoleUserPage {
59    pub records: Vec<AuthConsoleUser>,
60    pub next_cursor: Option<String>,
61}
62
63#[derive(Debug, Serialize, ToSchema)]
64pub struct AuthConsoleSessionPage {
65    pub records: Vec<AuthConsoleSession>,
66    pub next_cursor: Option<String>,
67}
68
69#[derive(Debug, Deserialize, ToSchema)]
70pub struct DisableAuthUserRequest {
71    #[serde(default)]
72    pub reason: Option<String>,
73    #[serde(default)]
74    pub disabled_until: Option<DateTime<Utc>>,
75}
76
77#[derive(Debug, Serialize, ToSchema)]
78pub struct AuthUserMutationResponse {
79    pub user_id: String,
80    pub changed: bool,
81}
82
83#[derive(Debug, Serialize, ToSchema)]
84pub struct AuthSessionMutationResponse {
85    pub session_id: String,
86    pub revoked: bool,
87}
88
89pub fn router() -> ApiOpenApiRouter {
90    OpenApiRouter::new()
91        .routes(routes!(list_users))
92        .routes(routes!(list_sessions))
93        .routes(routes!(disable_user))
94        .routes(routes!(enable_user))
95        .routes(routes!(revoke_session))
96}
97
98#[utoipa::path(
99    get,
100    path = "/v1/auth/console/users",
101    operation_id = "auth_console_list_users",
102    tag = "auth-console",
103    params(
104        ("limit" = Option<i64>, Query, minimum = 1, maximum = 200),
105        ("cursor" = Option<String>, Query)
106    ),
107    responses(
108        (status = 200, body = AuthConsoleUserPage, content_type = "application/json"),
109        (status = 400, body = ErrorResponse, content_type = "application/problem+json"),
110        (status = 403, body = ErrorResponse, content_type = "application/problem+json"),
111        (status = 500, body = ErrorResponse, content_type = "application/problem+json")
112    )
113)]
114async fn list_users(
115    State(ctx): State<AppContext>,
116    HttpRequestContext(request_ctx): HttpRequestContext,
117    headers: HeaderMap,
118    Query(query): Query<AuthConsoleListQuery>,
119) -> Result<Json<AuthConsoleUserPage>, ApiErrorResponse> {
120    validate_surface_request(
121        &headers,
122        AUTH_CONTRACT_DIGEST,
123        LIST_USERS_OPERATION,
124        crate::module::AUTH_USERS_READ,
125        &ctx,
126        &request_ctx,
127    )?;
128    let (limit, cursor) = list_input(query, &request_ctx)?;
129    let rows = PostgresAuthUserRepository::from_context(&ctx)
130        .list(limit + 1, cursor.as_deref())
131        .await
132        .map_err(|error| ApiErrorResponse::with_context(error, &request_ctx))?;
133    let has_more = i64::try_from(rows.len()).is_ok_and(|count| count > limit);
134    let mut records = rows
135        .into_iter()
136        .take(usize::try_from(limit).unwrap_or_default())
137        .map(|user| AuthConsoleUser {
138            id: user.id.0,
139            is_anonymous: user.is_anonymous,
140            created_at: user.created_at,
141            disabled_at: user.disabled_at,
142            disabled_reason: user.disabled_reason,
143            disabled_until: user.disabled_until,
144        })
145        .collect::<Vec<_>>();
146    let next_cursor = has_more
147        .then(|| records.last().map(|user| user.id.clone()))
148        .flatten();
149    records.shrink_to_fit();
150    Ok(json(AuthConsoleUserPage {
151        records,
152        next_cursor,
153    }))
154}
155
156#[utoipa::path(
157    get,
158    path = "/v1/auth/console/sessions",
159    operation_id = "auth_console_list_sessions",
160    tag = "auth-console",
161    params(
162        ("limit" = Option<i64>, Query, minimum = 1, maximum = 200),
163        ("cursor" = Option<String>, Query)
164    ),
165    responses(
166        (status = 200, body = AuthConsoleSessionPage, content_type = "application/json"),
167        (status = 400, body = ErrorResponse, content_type = "application/problem+json"),
168        (status = 403, body = ErrorResponse, content_type = "application/problem+json"),
169        (status = 500, body = ErrorResponse, content_type = "application/problem+json")
170    )
171)]
172async fn list_sessions(
173    State(ctx): State<AppContext>,
174    HttpRequestContext(request_ctx): HttpRequestContext,
175    headers: HeaderMap,
176    Query(query): Query<AuthConsoleListQuery>,
177) -> Result<Json<AuthConsoleSessionPage>, ApiErrorResponse> {
178    validate_surface_request(
179        &headers,
180        AUTH_CONTRACT_DIGEST,
181        LIST_SESSIONS_OPERATION,
182        crate::module::AUTH_SESSIONS_READ,
183        &ctx,
184        &request_ctx,
185    )?;
186    let (limit, cursor) = list_input(query, &request_ctx)?;
187    let rows = PostgresAuthUserRepository::from_context(&ctx)
188        .list_sessions(limit + 1, cursor.as_deref())
189        .await
190        .map_err(|error| ApiErrorResponse::with_context(error, &request_ctx))?;
191    let has_more = i64::try_from(rows.len()).is_ok_and(|count| count > limit);
192    let records = rows
193        .into_iter()
194        .take(usize::try_from(limit).unwrap_or_default())
195        .map(|session| AuthConsoleSession {
196            id: session.id,
197            user_id: session.user_id.0,
198            device_id: session.device_id,
199            client_ip: session.client_ip,
200            user_agent: session.user_agent,
201            created_at: session.created_at,
202            expires_at: session.expires_at,
203            revoked_at: session.revoked_at,
204        })
205        .collect::<Vec<_>>();
206    let next_cursor = has_more
207        .then(|| records.last().map(|session| session.id.clone()))
208        .flatten();
209    Ok(json(AuthConsoleSessionPage {
210        records,
211        next_cursor,
212    }))
213}
214
215#[utoipa::path(
216    post,
217    path = "/v1/auth/console/users/{user_id}/disable",
218    operation_id = "auth_console_disable_user",
219    tag = "auth-console",
220    params(("user_id" = String, Path)),
221    request_body = DisableAuthUserRequest,
222    responses(
223        (status = 200, body = AuthUserMutationResponse, content_type = "application/json"),
224        (status = 400, body = ErrorResponse, content_type = "application/problem+json"),
225        (status = 403, body = ErrorResponse, content_type = "application/problem+json"),
226        (status = 404, body = ErrorResponse, content_type = "application/problem+json"),
227        (status = 500, body = ErrorResponse, content_type = "application/problem+json")
228    )
229)]
230async fn disable_user(
231    State(ctx): State<AppContext>,
232    HttpRequestContext(request_ctx): HttpRequestContext,
233    headers: HeaderMap,
234    Path(user_id): Path<String>,
235    JsonBody(input): JsonBody<DisableAuthUserRequest>,
236) -> Result<Json<AuthUserMutationResponse>, ApiErrorResponse> {
237    validate_surface_request(
238        &headers,
239        AUTH_CONTRACT_DIGEST,
240        DISABLE_USER_OPERATION,
241        crate::module::AUTH_USERS_MANAGE,
242        &ctx,
243        &request_ctx,
244    )?;
245    require_resource_id(&user_id, "user_id", &request_ctx)?;
246    if input
247        .disabled_until
248        .is_some_and(|until| until <= ctx.clock.now())
249    {
250        return Err(validation_error(
251            "disabled_until must be in the future",
252            &request_ctx,
253        ));
254    }
255    let reason = input
256        .reason
257        .as_deref()
258        .map(str::trim)
259        .filter(|value| !value.is_empty());
260    let changed = PostgresAuthUserRepository::from_context(&ctx)
261        .set_user_disabled_at(
262            &AuthUserId(user_id.clone()),
263            Some(ctx.clock.now()),
264            reason,
265            input.disabled_until,
266        )
267        .await
268        .map_err(|error| ApiErrorResponse::with_context(error, &request_ctx))?;
269    require_changed(changed, "Auth user was not found", &request_ctx)?;
270    Ok(json(AuthUserMutationResponse { user_id, changed }))
271}
272
273#[utoipa::path(
274    post,
275    path = "/v1/auth/console/users/{user_id}/enable",
276    operation_id = "auth_console_enable_user",
277    tag = "auth-console",
278    params(("user_id" = String, Path)),
279    responses(
280        (status = 200, body = AuthUserMutationResponse, content_type = "application/json"),
281        (status = 403, body = ErrorResponse, content_type = "application/problem+json"),
282        (status = 404, body = ErrorResponse, content_type = "application/problem+json"),
283        (status = 500, body = ErrorResponse, content_type = "application/problem+json")
284    )
285)]
286async fn enable_user(
287    State(ctx): State<AppContext>,
288    HttpRequestContext(request_ctx): HttpRequestContext,
289    headers: HeaderMap,
290    Path(user_id): Path<String>,
291) -> Result<Json<AuthUserMutationResponse>, ApiErrorResponse> {
292    validate_surface_request(
293        &headers,
294        AUTH_CONTRACT_DIGEST,
295        ENABLE_USER_OPERATION,
296        crate::module::AUTH_USERS_MANAGE,
297        &ctx,
298        &request_ctx,
299    )?;
300    require_resource_id(&user_id, "user_id", &request_ctx)?;
301    let changed = PostgresAuthUserRepository::from_context(&ctx)
302        .set_user_disabled_at(&AuthUserId(user_id.clone()), None, None, None)
303        .await
304        .map_err(|error| ApiErrorResponse::with_context(error, &request_ctx))?;
305    require_changed(changed, "Auth user was not found", &request_ctx)?;
306    Ok(json(AuthUserMutationResponse { user_id, changed }))
307}
308
309#[utoipa::path(
310    post,
311    path = "/v1/auth/console/sessions/{session_id}/revoke",
312    operation_id = "auth_console_revoke_session",
313    tag = "auth-console",
314    params(("session_id" = String, Path)),
315    responses(
316        (status = 200, body = AuthSessionMutationResponse, content_type = "application/json"),
317        (status = 403, body = ErrorResponse, content_type = "application/problem+json"),
318        (status = 500, body = ErrorResponse, content_type = "application/problem+json")
319    )
320)]
321async fn revoke_session(
322    State(ctx): State<AppContext>,
323    HttpRequestContext(request_ctx): HttpRequestContext,
324    headers: HeaderMap,
325    Path(session_id): Path<String>,
326) -> Result<Json<AuthSessionMutationResponse>, ApiErrorResponse> {
327    validate_surface_request(
328        &headers,
329        AUTH_CONTRACT_DIGEST,
330        REVOKE_SESSION_OPERATION,
331        crate::module::AUTH_SESSIONS_REVOKE,
332        &ctx,
333        &request_ctx,
334    )?;
335    require_resource_id(&session_id, "session_id", &request_ctx)?;
336    let revoked = PostgresAuthUserRepository::from_context(&ctx)
337        .revoke_session_by_id(&session_id, ctx.clock.now())
338        .await
339        .map_err(|error| ApiErrorResponse::with_context(error, &request_ctx))?;
340    Ok(json(AuthSessionMutationResponse {
341        session_id,
342        revoked,
343    }))
344}
345
346fn list_input(
347    query: AuthConsoleListQuery,
348    request_ctx: &platform_core::RequestContext,
349) -> Result<(i64, Option<String>), ApiErrorResponse> {
350    let limit = query.limit.unwrap_or(DEFAULT_PAGE_LIMIT);
351    if !(1..=MAX_PAGE_LIMIT).contains(&limit) {
352        return Err(validation_error(
353            "limit must be between 1 and 200",
354            request_ctx,
355        ));
356    }
357    if query.cursor.as_deref().is_some_and(str::is_empty) {
358        return Err(validation_error("cursor must be non-empty", request_ctx));
359    }
360    Ok((limit, query.cursor))
361}
362
363pub fn validate_surface_request(
364    headers: &HeaderMap,
365    expected_contract_digest: &str,
366    expected_operation: &str,
367    expected_capability: &str,
368    ctx: &AppContext,
369    request_ctx: &platform_core::RequestContext,
370) -> Result<(), ApiErrorResponse> {
371    let authority = header(headers, "x-lenso-console-delegated-authority");
372    let deadline =
373        header(headers, "x-lenso-deadline-unix-ms").and_then(|value| value.parse::<i64>().ok());
374    let valid = header(headers, "x-lenso-console-contract-digest")
375        == Some(expected_contract_digest)
376        && header(headers, "x-lenso-console-operation-id") == Some(expected_operation)
377        && header(headers, "x-lenso-console-capability") == Some(expected_capability)
378        && header(headers, "x-lenso-console-delegated-actor").is_some_and(non_empty)
379        && header(headers, "x-lenso-console-service-id").is_some_and(non_empty)
380        && authority.is_some_and(valid_digest)
381        && deadline.is_some_and(|value| value > ctx.clock.now().timestamp_millis());
382    if !valid {
383        return Err(ApiErrorResponse::with_context(
384            AppError::new(
385                ErrorCode::Forbidden,
386                "Auth Business API request is not bound to an accepted Console Surface operation",
387            ),
388            request_ctx,
389        ));
390    }
391    Ok(())
392}
393
394fn require_resource_id(
395    value: &str,
396    field: &str,
397    request_ctx: &platform_core::RequestContext,
398) -> Result<(), ApiErrorResponse> {
399    if value.is_empty()
400        || !value
401            .bytes()
402            .all(|byte| byte.is_ascii_alphanumeric() || b"._~-".contains(&byte))
403    {
404        return Err(validation_error(
405            format!("{field} contains an unsafe path character"),
406            request_ctx,
407        ));
408    }
409    Ok(())
410}
411
412fn require_changed(
413    changed: bool,
414    message: &str,
415    request_ctx: &platform_core::RequestContext,
416) -> Result<(), ApiErrorResponse> {
417    if !changed {
418        return Err(ApiErrorResponse::with_context(
419            AppError::new(ErrorCode::NotFound, message),
420            request_ctx,
421        ));
422    }
423    Ok(())
424}
425
426fn validation_error(
427    message: impl Into<String>,
428    request_ctx: &platform_core::RequestContext,
429) -> ApiErrorResponse {
430    ApiErrorResponse::with_context(
431        AppError::new(ErrorCode::Validation, message.into()),
432        request_ctx,
433    )
434}
435
436fn header<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
437    headers.get(name).and_then(|value| value.to_str().ok())
438}
439
440fn non_empty(value: &str) -> bool {
441    !value.trim().is_empty()
442}
443
444fn valid_digest(value: &str) -> bool {
445    value.strip_prefix("sha256:").is_some_and(|digest| {
446        digest.len() == 64
447            && digest
448                .bytes()
449                .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
450    })
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456    use sha2::{Digest, Sha256};
457
458    #[test]
459    fn committed_contract_digest_matches_the_surface_client() {
460        let contract =
461            include_bytes!("../../../packages/auth-console/src/auth-business-api.v1.json");
462        let digest = Sha256::digest(contract)
463            .iter()
464            .map(|byte| format!("{byte:02x}"))
465            .collect::<String>();
466        let actual = format!("sha256:{digest}");
467        assert_eq!(actual, AUTH_CONTRACT_DIGEST);
468    }
469
470    #[test]
471    fn resource_ids_are_path_segment_safe() {
472        assert!(
473            "usr_1.test-2~ok"
474                .bytes()
475                .all(|byte| { byte.is_ascii_alphanumeric() || b"._~-".contains(&byte) })
476        );
477        assert!(
478            !"../usr_1"
479                .bytes()
480                .all(|byte| { byte.is_ascii_alphanumeric() || b"._~-".contains(&byte) })
481        );
482    }
483}