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::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}