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