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}