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}