1use crate::admin::AuthAdminData;
2use crate::repositories::PostgresAuthUserRepository;
3use contracts::{ServiceOperationIdempotency, ServiceOperationMetadata};
4use platform_core::AppContext;
5use platform_http::ApiOpenApiRouter;
6use platform_module::{
7 AdminAction, AdminActionDangerLevel, AdminActionInputField, AdminActionInputSchema,
8 AdminDeclarativeComponent, AdminDeclarativePage, AdminDeclarativeSection,
9 AdminDeclarativeSurface, AdminSchema, ConsoleContributionKind, ConsoleNavigation,
10 ConsoleNavigationGroup, ConsoleSlot, ConsoleSlotContext, ConsoleSlotContextField,
11 ConsoleSlotContextFieldType, ConsoleSurface, ConsoleSurfacePresentation, ConsoleWorkspaceRef,
12 EntitySchema, FieldSchema, FieldType, LinkedBinding, LinkedHttpContribution, Module,
13 ModuleHttpMethod, ModuleHttpRoute, ModuleManifest,
14};
15use std::sync::Arc;
16
17pub const MODULE_NAME: &str = "auth";
18pub const AUTH_USERS_READ: &str = "auth.users.read";
19pub const AUTH_USERS_MANAGE: &str = "auth.users.manage";
20pub const AUTH_SESSIONS_READ: &str = "auth.sessions.read";
21pub const AUTH_SESSIONS_REVOKE: &str = "auth.sessions.revoke";
22pub const AUTH_USERS_DETAIL_ACTIONS_SLOT: &str = "auth.users.detail.actions";
23pub const AUTH_USERS_DETAIL_ACTIONS_SLOT_VERSION: u32 = 1;
24
25pub fn http_routes() -> Vec<ModuleHttpRoute> {
26 vec![
27 ModuleHttpRoute {
28 method: ModuleHttpMethod::Get,
29 path: crate::console_artifact::AUTH_CONSOLE_ARTIFACT_PATH.to_owned(),
30 capability: None,
31 operation: None,
32 display_name: Some("Download Auth Console Artifact".to_owned()),
33 story_title: Some("Auth Console Artifact Downloaded".to_owned()),
34 },
35 ModuleHttpRoute {
36 method: ModuleHttpMethod::Get,
37 path: crate::console_artifact::AUTH_CONSOLE_RELEASE_PATH.to_owned(),
38 capability: None,
39 operation: None,
40 display_name: Some("Read Auth Console Release".to_owned()),
41 story_title: Some("Auth Console Release Read".to_owned()),
42 },
43 ModuleHttpRoute {
44 method: ModuleHttpMethod::Post,
45 path: "/v1/auth/dev/sessions".to_owned(),
46 capability: None,
47 operation: None,
48 display_name: Some("Create Development Session".to_owned()),
49 story_title: Some("Development Auth Session".to_owned()),
50 },
51 ModuleHttpRoute {
52 method: ModuleHttpMethod::Post,
53 path: "/v1/auth/sessions/revoke".to_owned(),
54 capability: None,
55 operation: None,
56 display_name: Some("Revoke Session".to_owned()),
57 story_title: Some("Auth Session Revoked".to_owned()),
58 },
59 business_route(
60 ModuleHttpMethod::Get,
61 "/v1/auth/console/users",
62 AUTH_USERS_READ,
63 crate::console_api::LIST_USERS_OPERATION,
64 "List Auth Users",
65 ),
66 business_route(
67 ModuleHttpMethod::Get,
68 "/v1/auth/console/sessions",
69 AUTH_SESSIONS_READ,
70 crate::console_api::LIST_SESSIONS_OPERATION,
71 "List Auth Sessions",
72 ),
73 business_route(
74 ModuleHttpMethod::Post,
75 "/v1/auth/console/users/{user_id}/disable",
76 AUTH_USERS_MANAGE,
77 crate::console_api::DISABLE_USER_OPERATION,
78 "Disable Auth User",
79 ),
80 business_route(
81 ModuleHttpMethod::Post,
82 "/v1/auth/console/users/{user_id}/enable",
83 AUTH_USERS_MANAGE,
84 crate::console_api::ENABLE_USER_OPERATION,
85 "Enable Auth User",
86 ),
87 business_route(
88 ModuleHttpMethod::Post,
89 "/v1/auth/console/sessions/{session_id}/revoke",
90 AUTH_SESSIONS_REVOKE,
91 crate::console_api::REVOKE_SESSION_OPERATION,
92 "Revoke Auth Session From Console",
93 ),
94 ]
95}
96
97fn business_route(
98 method: ModuleHttpMethod,
99 path: &str,
100 capability: &str,
101 operation_id: &str,
102 display_name: &str,
103) -> ModuleHttpRoute {
104 ModuleHttpRoute {
105 method,
106 path: path.to_owned(),
107 capability: Some(capability.to_owned()),
108 display_name: Some(display_name.to_owned()),
109 story_title: Some(display_name.to_owned()),
110 operation: Some(ServiceOperationMetadata {
111 operation_id: Some(operation_id.to_owned()),
112 summary: Some(display_name.to_owned()),
113 idempotency: Some(ServiceOperationIdempotency::Idempotent),
114 timeout_ms: Some(10_000),
115 ..ServiceOperationMetadata::default()
116 }),
117 }
118}
119
120pub fn user_schema() -> AdminSchema {
121 AdminSchema {
122 entities: vec![
123 EntitySchema {
124 name: "users".to_owned(),
125 label: "Users".to_owned(),
126 read_capability: AUTH_USERS_READ.to_owned(),
127 fields: vec![
128 FieldSchema {
129 name: "id".to_owned(),
130 label: "ID".to_owned(),
131 field_type: FieldType::String,
132 nullable: false,
133 },
134 FieldSchema {
135 name: "is_anonymous".to_owned(),
136 label: "Anonymous".to_owned(),
137 field_type: FieldType::Boolean,
138 nullable: false,
139 },
140 FieldSchema {
141 name: "device_id".to_owned(),
142 label: "Device".to_owned(),
143 field_type: FieldType::String,
144 nullable: true,
145 },
146 FieldSchema {
147 name: "created_at".to_owned(),
148 label: "Created".to_owned(),
149 field_type: FieldType::Timestamp,
150 nullable: false,
151 },
152 FieldSchema {
153 name: "disabled_at".to_owned(),
154 label: "Disabled".to_owned(),
155 field_type: FieldType::Timestamp,
156 nullable: true,
157 },
158 FieldSchema {
159 name: "disabled_reason".to_owned(),
160 label: "Reason".to_owned(),
161 field_type: FieldType::String,
162 nullable: true,
163 },
164 FieldSchema {
165 name: "disabled_until".to_owned(),
166 label: "Until".to_owned(),
167 field_type: FieldType::Timestamp,
168 nullable: true,
169 },
170 ],
171 },
172 EntitySchema {
173 name: "sessions".to_owned(),
174 label: "Sessions".to_owned(),
175 read_capability: AUTH_SESSIONS_READ.to_owned(),
176 fields: vec![
177 FieldSchema {
178 name: "id".to_owned(),
179 label: "ID".to_owned(),
180 field_type: FieldType::String,
181 nullable: false,
182 },
183 FieldSchema {
184 name: "user_id".to_owned(),
185 label: "User".to_owned(),
186 field_type: FieldType::String,
187 nullable: false,
188 },
189 FieldSchema {
190 name: "device_id".to_owned(),
191 label: "Device".to_owned(),
192 field_type: FieldType::String,
193 nullable: true,
194 },
195 FieldSchema {
196 name: "client_ip".to_owned(),
197 label: "IP".to_owned(),
198 field_type: FieldType::String,
199 nullable: true,
200 },
201 FieldSchema {
202 name: "user_agent".to_owned(),
203 label: "User agent".to_owned(),
204 field_type: FieldType::String,
205 nullable: true,
206 },
207 FieldSchema {
208 name: "created_at".to_owned(),
209 label: "Created".to_owned(),
210 field_type: FieldType::Timestamp,
211 nullable: false,
212 },
213 FieldSchema {
214 name: "expires_at".to_owned(),
215 label: "Expires".to_owned(),
216 field_type: FieldType::Timestamp,
217 nullable: false,
218 },
219 FieldSchema {
220 name: "revoked_at".to_owned(),
221 label: "Revoked".to_owned(),
222 field_type: FieldType::Timestamp,
223 nullable: true,
224 },
225 ],
226 },
227 ],
228 }
229}
230
231pub fn admin_surface() -> AdminDeclarativeSurface {
232 AdminDeclarativeSurface {
233 pages: vec![AdminDeclarativePage {
234 name: "sessions".to_owned(),
235 label: "Sessions".to_owned(),
236 sections: vec![AdminDeclarativeSection {
237 name: "sessions".to_owned(),
238 label: "Sessions".to_owned(),
239 component: AdminDeclarativeComponent::EntityTable {
240 entity: "sessions".to_owned(),
241 },
242 }],
243 }],
244 actions: vec![
245 action_with_string_input(
246 "revoke_session",
247 "Revoke session",
248 "session_id",
249 "Session",
250 AUTH_SESSIONS_REVOKE,
251 AdminActionDangerLevel::Medium,
252 ),
253 disable_user_action(),
254 action_with_string_input(
255 "enable_user",
256 "Enable user",
257 "user_id",
258 "User",
259 AUTH_USERS_MANAGE,
260 AdminActionDangerLevel::Low,
261 ),
262 ],
263 fallback_schema: Some(user_schema()),
264 }
265}
266
267fn action_with_string_input(
268 name: &str,
269 label: &str,
270 input_name: &str,
271 input_label: &str,
272 capability: &str,
273 danger_level: AdminActionDangerLevel,
274) -> AdminAction {
275 AdminAction {
276 name: name.to_owned(),
277 label: label.to_owned(),
278 capability: capability.to_owned(),
279 input_schema: Some(AdminActionInputSchema {
280 fields: vec![AdminActionInputField {
281 name: input_name.to_owned(),
282 label: input_label.to_owned(),
283 field_type: FieldType::String,
284 required: true,
285 description: None,
286 }],
287 }),
288 confirmation: None,
289 operation: None,
290 danger_level,
291 }
292}
293
294fn disable_user_action() -> AdminAction {
295 AdminAction {
296 name: "disable_user".to_owned(),
297 label: "Disable user".to_owned(),
298 capability: AUTH_USERS_MANAGE.to_owned(),
299 input_schema: Some(AdminActionInputSchema {
300 fields: vec![
301 AdminActionInputField {
302 name: "user_id".to_owned(),
303 label: "User".to_owned(),
304 field_type: FieldType::String,
305 required: true,
306 description: None,
307 },
308 AdminActionInputField {
309 name: "reason".to_owned(),
310 label: "Reason".to_owned(),
311 field_type: FieldType::String,
312 required: false,
313 description: None,
314 },
315 AdminActionInputField {
316 name: "disabled_until".to_owned(),
317 label: "Until".to_owned(),
318 field_type: FieldType::Timestamp,
319 required: false,
320 description: Some("RFC3339 timestamp; omit for permanent".to_owned()),
321 },
322 ],
323 }),
324 confirmation: None,
325 operation: None,
326 danger_level: AdminActionDangerLevel::Medium,
327 }
328}
329
330fn auth_workspace() -> ConsoleWorkspaceRef {
331 ConsoleWorkspaceRef {
332 id: "auth".to_owned(),
333 label: "Auth".to_owned(),
334 icon: Some("shield".to_owned()),
335 }
336}
337
338fn auth_directory_group() -> ConsoleNavigationGroup {
339 ConsoleNavigationGroup {
340 id: "directory".to_owned(),
341 label: "Directory".to_owned(),
342 icon: Some("users".to_owned()),
343 order: Some(10),
344 }
345}
346
347pub fn console_surfaces() -> Vec<ConsoleSurface> {
348 vec![
349 ConsoleSurface {
350 name: "users".to_owned(),
351 label: "Users".to_owned(),
352 route: "/auth/users".to_owned(),
353 presentation: ConsoleSurfacePresentation::Esm {
354 entry: "users".to_owned(),
355 },
356 icon: Some("users".to_owned()),
357 required_capabilities: vec![AUTH_USERS_READ.to_owned()],
358 navigation: Some(ConsoleNavigation {
359 workspace: auth_workspace(),
360 group: Some(auth_directory_group()),
361 order: Some(50),
362 }),
363 },
364 ConsoleSurface {
365 name: "sessions".to_owned(),
366 label: "Sessions".to_owned(),
367 route: "/auth/sessions".to_owned(),
368 presentation: ConsoleSurfacePresentation::Esm {
369 entry: "sessions".to_owned(),
370 },
371 icon: Some("activity".to_owned()),
372 required_capabilities: vec![AUTH_SESSIONS_READ.to_owned()],
373 navigation: Some(ConsoleNavigation {
374 workspace: auth_workspace(),
375 group: Some(auth_directory_group()),
376 order: Some(60),
377 }),
378 },
379 ]
380}
381
382pub fn console_slots() -> Vec<ConsoleSlot> {
383 vec![ConsoleSlot {
384 id: AUTH_USERS_DETAIL_ACTIONS_SLOT.to_owned(),
385 version: AUTH_USERS_DETAIL_ACTIONS_SLOT_VERSION,
386 label: "User detail actions".to_owned(),
387 accepts: vec![ConsoleContributionKind::AdminAction],
388 context: vec![ConsoleSlotContext {
389 name: "selected_user".to_owned(),
390 fields: vec![ConsoleSlotContextField {
391 name: "id".to_owned(),
392 field_type: ConsoleSlotContextFieldType::String,
393 required: true,
394 }],
395 }],
396 }]
397}
398
399pub fn manifest() -> ModuleManifest {
400 ModuleManifest::builder(MODULE_NAME)
401 .capabilities(vec![
402 AUTH_USERS_READ.to_owned(),
403 AUTH_USERS_MANAGE.to_owned(),
404 AUTH_SESSIONS_READ.to_owned(),
405 AUTH_SESSIONS_REVOKE.to_owned(),
406 ])
407 .http_routes(http_routes())
408 .declarative_admin(admin_surface())
409 .console(console_surfaces())
410 .console_slots(console_slots())
411 .build()
412}
413
414pub fn merge_http(base: ApiOpenApiRouter) -> ApiOpenApiRouter {
415 base.merge(crate::routes::router())
416 .merge(crate::console_api::router())
417 .merge(crate::console_artifact::router())
418}
419
420pub fn binding() -> LinkedBinding {
421 LinkedBinding::builder()
422 .http(LinkedHttpContribution {
423 public_prefixes: &["/v1/auth/console/", "/v1/auth/dev/", "/v1/auth/sessions/"],
424 merge: merge_http,
425 })
426 .build()
427}
428
429pub fn module(ctx: &AppContext) -> Module {
430 let repository = Arc::new(PostgresAuthUserRepository::from_context(ctx));
431 let admin = Arc::new(AuthAdminData::new(repository));
432 Module::linked(manifest(), binding())
433 .with_runtime_config(crate::config::RUNTIME_CONFIG.as_slice())
434 .with_admin_data(admin.clone())
435 .with_admin_actions(admin)
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441 use platform_module::{ModuleManifestLintSeverity, lint_module_manifest};
442
443 #[test]
444 fn manifest_declares_auth_user_anchor() {
445 let manifest = manifest();
446
447 assert_eq!(manifest.module_id, format!("lenso/{MODULE_NAME}"));
448 assert_eq!(
449 manifest.capabilities,
450 vec![
451 "auth.sessions.read",
452 "auth.sessions.revoke",
453 "auth.users.manage",
454 "auth.users.read"
455 ]
456 );
457 assert_eq!(manifest.http_routes, http_routes());
458 assert_eq!(
459 manifest.admin,
460 Some(platform_module::AdminSurface::DeclarativeCustom(
461 admin_surface()
462 ))
463 );
464 assert_eq!(manifest.console, console_surfaces());
465 assert_eq!(manifest.console_slots, console_slots());
466
467 let lints = lint_module_manifest(&manifest);
468 assert!(
469 lints
470 .iter()
471 .all(|lint| lint.severity == ModuleManifestLintSeverity::Ok),
472 "auth manifest should not have warning/error lints: {lints:?}"
473 );
474 }
475
476 #[test]
477 fn admin_actions_require_narrow_mutation_capabilities() {
478 let actions = admin_surface().actions;
479
480 assert_eq!(
481 actions
482 .iter()
483 .find(|action| action.name == "revoke_session")
484 .expect("revoke action")
485 .capability,
486 "auth.sessions.revoke"
487 );
488 assert_eq!(
489 actions
490 .iter()
491 .find(|action| action.name == "disable_user")
492 .expect("disable action")
493 .capability,
494 "auth.users.manage"
495 );
496 assert_eq!(
497 actions
498 .iter()
499 .find(|action| action.name == "enable_user")
500 .expect("enable action")
501 .capability,
502 "auth.users.manage"
503 );
504 }
505
506 #[test]
507 fn generated_console_manifest_matches_checked_in_artifact_manifest() {
508 let generated =
509 serde_json::to_value(manifest().console_module_manifest("^2.1.0", "^2.0.0"))
510 .expect("console module manifest should serialize");
511 let checked_in: serde_json::Value =
512 serde_json::from_str(include_str!("../console-module.json"))
513 .expect("console module manifest fixture should be valid JSON");
514
515 assert_eq!(generated, checked_in);
516 }
517}