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