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}