Skip to main content

auth/
module.rs

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: None,
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("shield".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("shield".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}