Skip to main content

auth/
module.rs

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