@valentinkolb/cloud 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/package.json +18 -8
  2. package/scripts/preload.ts +78 -23
  3. package/src/_internal/define-app.ts +119 -47
  4. package/src/_internal/runtime-context.ts +1 -0
  5. package/src/api/accounts-entities.ts +4 -0
  6. package/src/api/admin-core-settings.ts +98 -0
  7. package/src/api/announcements.ts +131 -0
  8. package/src/api/auth/schemas.ts +24 -0
  9. package/src/api/auth.ts +113 -10
  10. package/src/api/index.ts +15 -25
  11. package/src/api/me.ts +203 -14
  12. package/src/api/search/schemas.ts +1 -0
  13. package/src/api/search.ts +62 -8
  14. package/src/config/ssr.ts +2 -9
  15. package/src/contracts/announcements.test.ts +37 -0
  16. package/src/contracts/announcements.ts +121 -0
  17. package/src/contracts/app.ts +4 -0
  18. package/src/contracts/index.ts +3 -2
  19. package/src/contracts/registry.ts +4 -0
  20. package/src/contracts/shared.ts +108 -1
  21. package/src/desktop/index.ts +704 -0
  22. package/src/desktop/solid.tsx +938 -0
  23. package/src/server/api/index.ts +1 -1
  24. package/src/server/api/respond.ts +50 -10
  25. package/src/server/index.ts +44 -38
  26. package/src/server/middleware/auth.ts +98 -9
  27. package/src/server/middleware/index.ts +2 -1
  28. package/src/server/middleware/settings.ts +26 -0
  29. package/src/server/services/access.test.ts +197 -0
  30. package/src/server/services/access.ts +254 -6
  31. package/src/server/services/index.ts +14 -11
  32. package/src/server/services/pagination.ts +22 -0
  33. package/src/server/time.ts +45 -0
  34. package/src/services/account-lifecycle/index.ts +142 -18
  35. package/src/services/accounts/app.ts +658 -170
  36. package/src/services/accounts/authz.test.ts +77 -0
  37. package/src/services/accounts/authz.ts +22 -0
  38. package/src/services/accounts/entities.ts +84 -5
  39. package/src/services/accounts/groups.ts +30 -24
  40. package/src/services/accounts/model.test.ts +30 -0
  41. package/src/services/accounts/switching.test.ts +14 -0
  42. package/src/services/accounts/switching.ts +15 -6
  43. package/src/services/accounts/users.ts +75 -52
  44. package/src/services/announcements/index.test.ts +32 -0
  45. package/src/services/announcements/index.ts +224 -0
  46. package/src/services/audit/index.test.ts +84 -0
  47. package/src/services/audit/index.ts +431 -0
  48. package/src/services/auth-flows/index.ts +9 -2
  49. package/src/services/auth-flows/ipa.ts +0 -2
  50. package/src/services/auth-flows/magic-link.ts +3 -2
  51. package/src/services/auth-flows/password-reset.ts +284 -0
  52. package/src/services/auth-flows/proxy-return.test.ts +24 -0
  53. package/src/services/auth-flows/proxy-return.ts +49 -0
  54. package/src/services/gateway.ts +162 -0
  55. package/src/services/index.ts +44 -2
  56. package/src/services/ipa/effective-groups.test.ts +33 -0
  57. package/src/services/ipa/effective-groups.ts +70 -0
  58. package/src/services/ipa/profile.ts +45 -3
  59. package/src/services/ipa/search.ts +3 -5
  60. package/src/services/ipa/service-account.ts +15 -0
  61. package/src/services/ipa/sync-planning.test.ts +32 -0
  62. package/src/services/ipa/sync-planning.ts +22 -0
  63. package/src/services/ipa/sync.ts +110 -38
  64. package/src/services/oauth-tokens.ts +104 -0
  65. package/src/services/postgres.ts +21 -6
  66. package/src/services/providers/local/auth.test.ts +22 -0
  67. package/src/services/providers/local/auth.ts +46 -3
  68. package/src/services/secrets.ts +10 -0
  69. package/src/services/service-account-credentials.test.ts +210 -0
  70. package/src/services/service-account-credentials.ts +715 -0
  71. package/src/services/service-accounts.ts +188 -0
  72. package/src/services/session/index.ts +7 -8
  73. package/src/services/settings/app.ts +4 -20
  74. package/src/services/settings/defaults.ts +64 -22
  75. package/src/services/settings/store.ts +47 -0
  76. package/src/services/weather/forecast.ts +40 -7
  77. package/src/services/webauthn.test.ts +36 -0
  78. package/src/services/webauthn.ts +384 -0
  79. package/src/shared/icons.ts +391 -100
  80. package/src/shared/index.ts +7 -0
  81. package/src/shared/markdown/extensions/code.ts +38 -1
  82. package/src/shared/markdown/extensions/images.ts +39 -3
  83. package/src/shared/markdown/extensions/info-blocks.ts +5 -5
  84. package/src/shared/markdown/extensions/mark.ts +48 -0
  85. package/src/shared/markdown/extensions/sub-sup.ts +60 -0
  86. package/src/shared/markdown/extensions/tables.ts +79 -58
  87. package/src/shared/markdown/formula.test.ts +1089 -0
  88. package/src/shared/markdown/formula.ts +1187 -0
  89. package/src/shared/markdown/index.ts +76 -2
  90. package/src/shared/mock-cover.ts +130 -0
  91. package/src/shared/redirect.test.ts +49 -0
  92. package/src/shared/redirect.ts +52 -0
  93. package/src/shared/theme.test.ts +24 -0
  94. package/src/shared/theme.ts +68 -0
  95. package/src/shared/time.ts +13 -0
  96. package/src/ssr/AdminLayout.tsx +7 -3
  97. package/src/ssr/AdminSidebar.tsx +115 -49
  98. package/src/ssr/AppLaunchpad.island.tsx +176 -0
  99. package/src/ssr/Footer.island.tsx +3 -8
  100. package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
  101. package/src/ssr/GlobalSearchDialog.tsx +545 -117
  102. package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
  103. package/src/ssr/Layout.tsx +74 -66
  104. package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
  105. package/src/ssr/LayoutHelp.tsx +266 -0
  106. package/src/ssr/NavMenu.island.tsx +0 -39
  107. package/src/ssr/ThemeToggleRail.island.tsx +3 -3
  108. package/src/ssr/TimezoneCookie.island.tsx +23 -0
  109. package/src/ssr/islands/index.ts +13 -0
  110. package/src/styles/base-popover.css +5 -2
  111. package/src/styles/effects.css +87 -6
  112. package/src/styles/global.css +146 -9
  113. package/src/styles/input.css +3 -1
  114. package/src/styles/utilities-buttons.css +133 -27
  115. package/src/styles/utilities-code-display.css +67 -0
  116. package/src/styles/utilities-completion.css +223 -0
  117. package/src/styles/utilities-detail.css +73 -0
  118. package/src/styles/utilities-feedback.css +16 -15
  119. package/src/styles/utilities-layout.css +42 -2
  120. package/src/styles/utilities-markdown-editor.css +472 -0
  121. package/src/styles/utilities-navigation.css +63 -8
  122. package/src/styles/utilities-script.css +84 -0
  123. package/src/styles/utilities-table-tile.css +229 -0
  124. package/src/types/ambient.d.ts +9 -0
  125. package/src/ui/completion/behaviors.test.ts +95 -0
  126. package/src/ui/completion/behaviors.ts +205 -0
  127. package/src/ui/completion/engine.ts +368 -0
  128. package/src/ui/completion/index.ts +40 -0
  129. package/src/ui/completion/overlay.ts +92 -0
  130. package/src/ui/dialog-core.ts +173 -45
  131. package/src/ui/filter/FilterChip.tsx +42 -40
  132. package/src/ui/index.ts +11 -12
  133. package/src/ui/input/AutocompleteEditor.tsx +656 -0
  134. package/src/ui/input/CheckboxCard.tsx +91 -0
  135. package/src/ui/input/Combobox.tsx +375 -0
  136. package/src/ui/input/DatePicker.tsx +846 -0
  137. package/src/ui/input/DateTimeInput.tsx +29 -4
  138. package/src/ui/input/FileDropzone.tsx +116 -0
  139. package/src/ui/input/IconInput.tsx +116 -0
  140. package/src/ui/input/ImageInput.tsx +19 -2
  141. package/src/ui/input/MultiSelectInput.tsx +448 -0
  142. package/src/ui/input/NumberInput.tsx +417 -61
  143. package/src/ui/input/SegmentedControl.tsx +2 -2
  144. package/src/ui/input/Select.tsx +172 -10
  145. package/src/ui/input/Slider.tsx +3 -4
  146. package/src/ui/input/Switch.tsx +3 -2
  147. package/src/ui/input/TemplateEditor.tsx +212 -0
  148. package/src/ui/input/TextInput.tsx +144 -13
  149. package/src/ui/input/index.ts +53 -8
  150. package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
  151. package/src/ui/input/markdown/Toolbar.tsx +90 -0
  152. package/src/ui/input/markdown/actions.ts +233 -0
  153. package/src/ui/input/markdown/active-formats.ts +94 -0
  154. package/src/ui/input/markdown/behaviors.ts +193 -0
  155. package/src/ui/input/markdown/code-zone.ts +23 -0
  156. package/src/ui/input/markdown/highlight.ts +316 -0
  157. package/src/ui/layout.ts +22 -0
  158. package/src/ui/misc/AppOverview.tsx +105 -0
  159. package/src/ui/misc/AppWorkspace.tsx +607 -0
  160. package/src/ui/misc/Calendar.tsx +1291 -0
  161. package/src/ui/misc/Chart.tsx +162 -0
  162. package/src/ui/misc/CodeDisplay.tsx +54 -0
  163. package/src/ui/misc/ContextMenu.tsx +2 -2
  164. package/src/ui/misc/DataTable.tsx +269 -0
  165. package/src/ui/misc/DockWorkspace.tsx +425 -0
  166. package/src/ui/misc/Docs.tsx +153 -0
  167. package/src/ui/misc/Dropdown.tsx +2 -2
  168. package/src/ui/misc/EntitySearch.tsx +260 -129
  169. package/src/ui/misc/LinkCard.tsx +14 -2
  170. package/src/ui/misc/LogEntriesTable.tsx +34 -31
  171. package/src/ui/misc/Pagination.tsx +31 -12
  172. package/src/ui/misc/PanelDialog.tsx +109 -0
  173. package/src/ui/misc/Panes.tsx +873 -0
  174. package/src/ui/misc/PermissionEditor.tsx +358 -262
  175. package/src/ui/misc/Placeholder.tsx +40 -0
  176. package/src/ui/misc/ProgressBar.tsx +1 -1
  177. package/src/ui/misc/ResourceApiKeys.tsx +260 -0
  178. package/src/ui/misc/SettingsModal.tsx +150 -0
  179. package/src/ui/misc/StatCell.tsx +182 -40
  180. package/src/ui/misc/StatGrid.tsx +149 -0
  181. package/src/ui/misc/StructuredDataPreview.tsx +107 -0
  182. package/src/ui/misc/code-highlight.ts +213 -0
  183. package/src/ui/misc/index.ts +93 -12
  184. package/src/ui/prompts.tsx +362 -312
  185. package/src/ui/toast.ts +384 -0
  186. package/src/ui/widgets/Widget.tsx +12 -4
  187. package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
  188. package/src/ui/ipa/GroupView.tsx +0 -36
  189. package/src/ui/ipa/LoginBtn.tsx +0 -16
  190. package/src/ui/ipa/UserView.tsx +0 -58
  191. package/src/ui/ipa/index.ts +0 -4
  192. package/src/ui/navigation.ts +0 -32
  193. package/src/ui/sidebar.tsx +0 -468
  194. /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
@@ -1,5 +1,6 @@
1
1
  import { sql } from "bun";
2
2
  import { accountLifecycle } from "../account-lifecycle";
3
+ import { audit, type AuditActor, type AuditTarget } from "../audit";
3
4
  import { lifecycleJobs } from "../account-lifecycle/scheduler";
4
5
  import { logger, logging, type LogEntry } from "../logging";
5
6
  import { notifications } from "../notifications";
@@ -11,6 +12,7 @@ import { providers } from "../providers";
11
12
  import * as users from "./users";
12
13
  import * as groups from "./groups";
13
14
  import * as entities from "./entities";
15
+ import { canMutateManagedGroup, hasOnlySelfUpdateFields, isAdminActor, isSelfTarget, type AccountsActor } from "./authz";
14
16
  import type {
15
17
  BaseGroup,
16
18
  BaseUser,
@@ -23,7 +25,17 @@ import type {
23
25
  UserProvider,
24
26
  } from "../../contracts/shared";
25
27
  import { dates } from "../../shared";
26
- import { err, fail, ok, paginate, type PageParams, type Paginated, type Result, type ServiceError } from "../../server/services";
28
+ import {
29
+ err,
30
+ fail,
31
+ ok,
32
+ paginate,
33
+ paginateItems,
34
+ type PageParams,
35
+ type Paginated,
36
+ type Result,
37
+ type ServiceError,
38
+ } from "../../server/services";
27
39
 
28
40
  type CreateUserInput =
29
41
  | {
@@ -49,6 +61,7 @@ type CreateUserInput =
49
61
 
50
62
  type DbRow = Record<string, unknown>;
51
63
  type MutationErrorStatus = Extract<MutationResult, { ok: false }>["status"];
64
+ type CreateUserResult = { id: string; uid: string; accountExpires: string | null; notificationSent: boolean };
52
65
 
53
66
  export type AccountRequestStatus = "pending" | "completed" | "denied";
54
67
  export type AccountRequestScope = "open" | "processed" | "all";
@@ -105,28 +118,6 @@ const ACTIVITY_SOURCES = [
105
118
  "auth:lifecycle:scheduler",
106
119
  ] as const;
107
120
 
108
- const paginateItems = <T>(items: T[], pagination?: PageParams): Paginated<T> => {
109
- if (!pagination) {
110
- return {
111
- items,
112
- page: 1,
113
- perPage: items.length,
114
- total: items.length,
115
- hasNext: false,
116
- };
117
- }
118
-
119
- const { page, perPage, offset } = paginate(pagination);
120
- const pagedItems = items.slice(offset, offset + perPage);
121
- return {
122
- items: pagedItems,
123
- page,
124
- perPage,
125
- total: items.length,
126
- hasNext: page * perPage < items.length,
127
- };
128
- };
129
-
130
121
  const toServiceError = (status: MutationErrorStatus, message: string): ServiceError => {
131
122
  if (status === 400) return err.badInput(message);
132
123
  if (status === 401) return err.unauthenticated(message);
@@ -226,6 +217,91 @@ const mapSummary = (row: DbRow): AccountsDashboardSummary => {
226
217
 
227
218
  const appLog = logger("accounts:app");
228
219
 
220
+ const auditActor = (actor: AccountsActor | null | undefined): AuditActor | null =>
221
+ actor
222
+ ? {
223
+ userId: actor.userId,
224
+ uid: actor.uid,
225
+ provider: actor.provider,
226
+ roles: actor.roles,
227
+ }
228
+ : null;
229
+
230
+ const userTarget = (user: { id?: string | null; uid?: string | null; provider?: string | null } | null | undefined): AuditTarget => ({
231
+ type: "user",
232
+ id: user?.id ?? null,
233
+ label: user?.uid ?? null,
234
+ provider: user?.provider ?? null,
235
+ });
236
+
237
+ const groupTarget = (group: { id?: string | null; name?: string | null; provider?: string | null } | null | undefined): AuditTarget => ({
238
+ type: "group",
239
+ id: group?.id ?? null,
240
+ label: group?.name ?? null,
241
+ provider: group?.provider ?? null,
242
+ });
243
+
244
+ const recordCompletedMutation = <T,>(params: {
245
+ action: string;
246
+ actor?: AuditActor | null;
247
+ target?: AuditTarget | null;
248
+ metadata?: Record<string, unknown> | null;
249
+ result: Result<T>;
250
+ }) => (params.result.ok ? audit.recordResultAfterSideEffect(params) : audit.recordResult(params));
251
+
252
+ const requireAdminActor = async <T,>(params: {
253
+ actor: AccountsActor | null | undefined;
254
+ action: string;
255
+ target?: AuditTarget;
256
+ }): Promise<Result<T> | null> => {
257
+ if (isAdminActor(params.actor)) return null;
258
+ return audit.deny<T>({
259
+ action: params.action,
260
+ actor: auditActor(params.actor),
261
+ target: params.target,
262
+ message: "Admin access required",
263
+ });
264
+ };
265
+
266
+ const requireDifferentActor = async <T,>(params: {
267
+ actor: AccountsActor | null | undefined;
268
+ targetUserId: string;
269
+ action: string;
270
+ message: string;
271
+ target?: AuditTarget;
272
+ }): Promise<Result<T> | null> => {
273
+ if (!isSelfTarget({ actor: params.actor, targetUserId: params.targetUserId })) return null;
274
+ return audit.deny<T>({
275
+ action: params.action,
276
+ actor: auditActor(params.actor),
277
+ target: params.target,
278
+ message: params.message,
279
+ });
280
+ };
281
+
282
+ const authorizeGroupMutation = async <T,>(params: {
283
+ actor: AccountsActor | null | undefined;
284
+ group: BaseGroup;
285
+ action: string;
286
+ }): Promise<Result<T> | null> => {
287
+ if (canMutateManagedGroup({ actor: params.actor, groupId: params.group.id, managedGroupIds: [] })) return null;
288
+ if (!params.actor) {
289
+ return audit.deny<T>({
290
+ action: params.action,
291
+ target: groupTarget(params.group),
292
+ message: "Access denied",
293
+ });
294
+ }
295
+ const managedGroupIds = await users.getManagedGroupIds({ id: params.actor.userId, recursive: true });
296
+ if (canMutateManagedGroup({ actor: params.actor, groupId: params.group.id, managedGroupIds })) return null;
297
+ return audit.deny<T>({
298
+ action: params.action,
299
+ actor: auditActor(params.actor),
300
+ target: groupTarget(params.group),
301
+ message: "Access denied",
302
+ });
303
+ };
304
+
229
305
  const buildAccountRequestWhereClause = (config: {
230
306
  access: { userId: string; isAdmin: boolean };
231
307
  filter?: { status?: AccountRequestStatus; scope?: AccountRequestScope };
@@ -322,20 +398,102 @@ export const accountsAppService = {
322
398
  recursive: config.recursive,
323
399
  }),
324
400
  },
325
- create: async (config: { ipaSession?: string | null; data: CreateUserInput; processedBy: string }) => {
326
- const createResult = fromMutationResult(
327
- await users.create({
328
- ipaSession: config.ipaSession,
329
- data: {
330
- ...config.data,
331
- profile: config.data.provider === "ipa" ? "user" : config.data.profile,
332
- admin: config.data.provider === "local" ? config.data.admin : undefined,
333
- },
334
- }),
335
- );
336
- if (!createResult.ok) return createResult;
401
+ create: async (config: {
402
+ actor: AccountsActor;
403
+ data: CreateUserInput;
404
+ processedBy: string;
405
+ }): Promise<Result<CreateUserResult>> => {
406
+ const adminError = await requireAdminActor<{ id: string; uid: string; accountExpires: string | null; notificationSent: boolean }>({
407
+ actor: config.actor,
408
+ action: "accounts.user.create",
409
+ target: { type: "user", label: config.data.email, provider: config.data.provider },
410
+ });
411
+ if (adminError) return adminError;
412
+ let requestCompletionFailed = false;
413
+ const createFromRequest = async () => {
414
+ const txResult = await sql.begin(async (tx) => {
415
+ const requestRows: DbRow[] = await tx`
416
+ SELECT r.id
417
+ FROM auth.account_requests r
418
+ JOIN auth.users u ON u.id = r.user_id
419
+ WHERE r.id = ${config.data.requestId}
420
+ AND r.status = 'pending'
421
+ AND lower(u.mail) = lower(${config.data.email})
422
+ LIMIT 1
423
+ FOR UPDATE OF r
424
+ `;
425
+ if (requestRows.length === 0) {
426
+ return {
427
+ completed: false,
428
+ result: fail(err.badInput("Account request not found, already processed, or not owned by the target email.")),
429
+ };
430
+ }
431
+
432
+ const result = fromMutationResult(
433
+ await users.create({
434
+ data: {
435
+ ...config.data,
436
+ profile: config.data.provider === "ipa" ? "user" : config.data.profile,
437
+ admin: config.data.provider === "local" ? config.data.admin : undefined,
438
+ },
439
+ }),
440
+ );
441
+ if (!result.ok) return { completed: false, result };
442
+
443
+ const completedRows: DbRow[] = await tx`
444
+ UPDATE auth.account_requests
445
+ SET status = 'completed', processed_at = now(), processed_by = ${config.processedBy}
446
+ WHERE id = ${config.data.requestId}
447
+ AND user_id = ${result.data.user.id}::uuid
448
+ AND status = 'pending'
449
+ RETURNING id
450
+ `;
451
+
452
+ return { completed: completedRows.length > 0, result };
453
+ });
454
+
455
+ if (txResult.result.ok && !txResult.completed) {
456
+ requestCompletionFailed = true;
457
+ appLog.warn("Account request completion did not match", {
458
+ requestId: config.data.requestId,
459
+ createdUserId: txResult.result.data.user.id,
460
+ processedBy: config.processedBy,
461
+ });
462
+ await audit.recordResultAfterSideEffect({
463
+ action: "accounts.request.complete",
464
+ actor: auditActor(config.actor),
465
+ target: { type: "account_request", id: config.data.requestId },
466
+ metadata: { createdUserId: txResult.result.data.user.id, provider: config.data.provider },
467
+ result: fail(err.badInput("Account request not found, already processed, or not owned by the created user")),
468
+ });
469
+ }
470
+
471
+ return txResult.result;
472
+ };
473
+
474
+ const createResult = config.data.requestId
475
+ ? await createFromRequest()
476
+ : fromMutationResult(
477
+ await users.create({
478
+ data: {
479
+ ...config.data,
480
+ profile: config.data.provider === "ipa" ? "user" : config.data.profile,
481
+ admin: config.data.provider === "local" ? config.data.admin : undefined,
482
+ },
483
+ }),
484
+ );
485
+ if (!createResult.ok) {
486
+ return audit.recordResult({
487
+ action: "accounts.user.create",
488
+ actor: auditActor(config.actor),
489
+ target: { type: "user", label: config.data.email, provider: config.data.provider },
490
+ metadata: { provider: config.data.provider, requestId: config.data.requestId ?? null },
491
+ result: createResult,
492
+ });
493
+ }
337
494
 
338
495
  const created = createResult.data;
496
+
339
497
  const autoSend = config.data.autoSendNotification ?? true;
340
498
  if (autoSend && created.user.mail) {
341
499
  if (config.data.provider === "ipa" && created.temporaryPassword) {
@@ -366,54 +524,212 @@ export const accountsAppService = {
366
524
  }
367
525
  }
368
526
 
369
- if (config.data.requestId) {
370
- // Require the pending request to belong to the user we just created.
371
- // Without the user_id match an admin could pass any request ID and
372
- // silently "complete" an unrelated request. Fail loudly on zero-match
373
- // instead of ignoring it the caller asked us to close this request.
374
- const matched = await sql`
375
- UPDATE auth.account_requests
376
- SET status = 'completed', processed_at = now(), processed_by = ${config.processedBy}
377
- WHERE id = ${config.data.requestId}
378
- AND user_id = ${created.user.id}::uuid
379
- AND status = 'pending'
380
- RETURNING id
381
- `;
382
- if (matched.length === 0) {
383
- appLog.warn("Account request completion did not match", {
384
- requestId: config.data.requestId,
385
- createdUserId: created.user.id,
386
- processedBy: config.processedBy,
387
- });
388
- return fail(err.badInput("Account request not found, already processed, or not owned by the created user"));
389
- }
527
+ return audit.recordResultAfterSideEffect({
528
+ action: "accounts.user.create",
529
+ actor: auditActor(config.actor),
530
+ target: userTarget(created.user),
531
+ metadata: { provider: config.data.provider, requestId: config.data.requestId ?? null, notificationSent: autoSend, requestCompletionFailed },
532
+ result: ok({
533
+ id: created.user.id,
534
+ uid: created.user.uid,
535
+ accountExpires: created.user.accountExpires,
536
+ notificationSent: autoSend,
537
+ }),
538
+ });
539
+ },
540
+ update: async (config: { actor: AccountsActor; id: string; data: Parameters<typeof users.update>[0]["data"] }) => {
541
+ const target = await users.getMinimal({ id: config.id });
542
+ const targetInfo = userTarget(target);
543
+ const selfService = config.actor.userId === config.id;
544
+ if (selfService && !hasOnlySelfUpdateFields(config.data as Record<string, unknown>)) {
545
+ return audit.recordResult({
546
+ action: "accounts.user.update",
547
+ actor: auditActor(config.actor),
548
+ target: targetInfo,
549
+ metadata: { changedFields: Object.keys(config.data), selfService },
550
+ result: fail(err.forbidden("Only admins can update account management fields.")),
551
+ });
390
552
  }
391
-
392
- return ok({
393
- id: created.user.id,
394
- uid: created.user.uid,
395
- accountExpires: created.user.accountExpires,
396
- notificationSent: autoSend,
553
+ if (!selfService) {
554
+ const adminError = await requireAdminActor<void>({ actor: config.actor, action: "accounts.user.update", target: targetInfo });
555
+ if (adminError) return adminError;
556
+ }
557
+ const result = fromMutationResult(await users.update(config));
558
+ return recordCompletedMutation({
559
+ action: "accounts.user.update",
560
+ actor: auditActor(config.actor),
561
+ target: targetInfo,
562
+ metadata: { changedFields: Object.keys(config.data), selfService },
563
+ result,
564
+ });
565
+ },
566
+ resetPassword: async (config: { actor: AccountsActor; id: string }) => {
567
+ const target = await users.getMinimal({ id: config.id });
568
+ const targetInfo = userTarget(target);
569
+ const adminError = await requireAdminActor<{ password: string }>({ actor: config.actor, action: "accounts.user.password_reset", target: targetInfo });
570
+ if (adminError) return adminError;
571
+ const selfError = await requireDifferentActor<{ password: string }>({
572
+ actor: config.actor,
573
+ targetUserId: config.id,
574
+ action: "accounts.user.password_reset",
575
+ target: targetInfo,
576
+ message: "You cannot reset your own password from the admin users API.",
577
+ });
578
+ if (selfError) return selfError;
579
+ const result = fromMutationResult(await users.resetPassword(config));
580
+ return recordCompletedMutation({
581
+ action: "accounts.user.password_reset",
582
+ actor: auditActor(config.actor),
583
+ target: targetInfo,
584
+ result: result.ok ? ok({ password: "[REDACTED]" }) : result,
585
+ }).then(() => result);
586
+ },
587
+ setExpiry: async (config: { actor: AccountsActor; id: string; expiryDate: string | null }) => {
588
+ const target = await users.getMinimal({ id: config.id });
589
+ const targetInfo = userTarget(target);
590
+ const adminError = await requireAdminActor<void>({ actor: config.actor, action: "accounts.user.set_expiry", target: targetInfo });
591
+ if (adminError) return adminError;
592
+ const result = fromMutationResult(await users.setExpiry(config));
593
+ return recordCompletedMutation({
594
+ action: "accounts.user.set_expiry",
595
+ actor: auditActor(config.actor),
596
+ target: targetInfo,
597
+ metadata: { expiryDate: config.expiryDate },
598
+ result,
599
+ });
600
+ },
601
+ setProfile: async (config: { actor: AccountsActor; id: string; profile: UserProfile }) => {
602
+ const target = await users.getMinimal({ id: config.id });
603
+ const targetInfo = userTarget(target);
604
+ const adminError = await requireAdminActor<void>({ actor: config.actor, action: "accounts.user.set_profile", target: targetInfo });
605
+ if (adminError) return adminError;
606
+ const selfError = config.profile === "guest"
607
+ ? await requireDifferentActor<void>({
608
+ actor: config.actor,
609
+ targetUserId: config.id,
610
+ action: "accounts.user.set_profile",
611
+ target: targetInfo,
612
+ message: "You cannot demote your own account to guest.",
613
+ })
614
+ : null;
615
+ if (selfError) return selfError;
616
+ const result = fromMutationResult(await users.setProfile(config));
617
+ return recordCompletedMutation({
618
+ action: "accounts.user.set_profile",
619
+ actor: auditActor(config.actor),
620
+ target: targetInfo,
621
+ metadata: { profile: config.profile },
622
+ result,
623
+ });
624
+ },
625
+ setAdmin: async (config: { actor: AccountsActor; id: string; admin: boolean }) => {
626
+ const target = await users.getMinimal({ id: config.id });
627
+ const targetInfo = userTarget(target);
628
+ const adminError = await requireAdminActor<void>({ actor: config.actor, action: "accounts.user.set_admin", target: targetInfo });
629
+ if (adminError) return adminError;
630
+ const result = fromMutationResult(await users.setAdmin(config));
631
+ return recordCompletedMutation({
632
+ action: "accounts.user.set_admin",
633
+ actor: auditActor(config.actor),
634
+ target: targetInfo,
635
+ metadata: { admin: config.admin },
636
+ result,
637
+ });
638
+ },
639
+ switchProvider: async (config: { actor: AccountsActor; id: string; provider: UserProvider }) => {
640
+ const target = await users.getMinimal({ id: config.id });
641
+ const targetInfo = userTarget(target);
642
+ const adminError = await requireAdminActor<void>({ actor: config.actor, action: "accounts.user.switch_provider", target: targetInfo });
643
+ if (adminError) return adminError;
644
+ const selfError = await requireDifferentActor<void>({
645
+ actor: config.actor,
646
+ targetUserId: config.id,
647
+ action: "accounts.user.switch_provider",
648
+ target: targetInfo,
649
+ message: "You cannot switch your own account provider.",
650
+ });
651
+ if (selfError) return selfError;
652
+ const result = fromMutationResult(await users.switchProvider(config));
653
+ return recordCompletedMutation({
654
+ action: "accounts.user.switch_provider",
655
+ actor: auditActor(config.actor),
656
+ target: targetInfo,
657
+ metadata: { provider: config.provider },
658
+ result,
659
+ });
660
+ },
661
+ demoteToGuest: async (config: { actor: AccountsActor; id: string }) => {
662
+ const target = await users.getMinimal({ id: config.id });
663
+ const targetInfo = userTarget(target);
664
+ const adminError = await requireAdminActor<void>({ actor: config.actor, action: "accounts.user.demote_to_guest", target: targetInfo });
665
+ if (adminError) return adminError;
666
+ const selfError = await requireDifferentActor<void>({
667
+ actor: config.actor,
668
+ targetUserId: config.id,
669
+ action: "accounts.user.demote_to_guest",
670
+ target: targetInfo,
671
+ message: "You cannot demote your own account.",
672
+ });
673
+ if (selfError) return selfError;
674
+ const result = fromMutationResult(await users.demoteToGuest(config));
675
+ return recordCompletedMutation({
676
+ action: "accounts.user.demote_to_guest",
677
+ actor: auditActor(config.actor),
678
+ target: targetInfo,
679
+ result,
680
+ });
681
+ },
682
+ sendLoginLink: async (config: { actor: AccountsActor; id: string }) => {
683
+ const target = await users.getMinimal({ id: config.id });
684
+ const adminError = await requireAdminActor<void>({ actor: config.actor, action: "accounts.user.send_login_link", target: userTarget(target) });
685
+ if (adminError) return adminError;
686
+ const result = fromMutationResult(await users.sendLoginLink(config));
687
+ return recordCompletedMutation({
688
+ action: "accounts.user.send_login_link",
689
+ actor: auditActor(config.actor),
690
+ target: userTarget(target),
691
+ result,
692
+ });
693
+ },
694
+ createLoginToken: async (config: { actor: AccountsActor; id: string }) => {
695
+ const target = await users.getMinimal({ id: config.id });
696
+ const targetInfo = userTarget(target);
697
+ const adminError = await requireAdminActor<{ token: string; magicLink: string; expiresInSeconds: number }>({
698
+ actor: config.actor,
699
+ action: "accounts.user.create_login_token",
700
+ target: targetInfo,
701
+ });
702
+ if (adminError) return adminError;
703
+ const result = fromMutationResult(await users.createLoginToken(config));
704
+ await recordCompletedMutation({
705
+ action: "accounts.user.create_login_token",
706
+ actor: auditActor(config.actor),
707
+ target: targetInfo,
708
+ result: result.ok ? ok({ token: "[REDACTED]", magicLink: "[REDACTED]", expiresInSeconds: result.data.expiresInSeconds }) : result,
709
+ });
710
+ return result;
711
+ },
712
+ remove: async (config: { actor: AccountsActor; id: string }) => {
713
+ const target = await users.getMinimal({ id: config.id });
714
+ const targetInfo = userTarget(target);
715
+ const adminError = await requireAdminActor<void>({ actor: config.actor, action: "accounts.user.remove", target: targetInfo });
716
+ if (adminError) return adminError;
717
+ const selfError = await requireDifferentActor<void>({
718
+ actor: config.actor,
719
+ targetUserId: config.id,
720
+ action: "accounts.user.remove",
721
+ target: targetInfo,
722
+ message: "You cannot delete your own account.",
723
+ });
724
+ if (selfError) return selfError;
725
+ const result = fromMutationResult(await users.remove(config));
726
+ return recordCompletedMutation({
727
+ action: "accounts.user.remove",
728
+ actor: auditActor(config.actor),
729
+ target: targetInfo,
730
+ result,
397
731
  });
398
732
  },
399
- update: async (config: { ipaSession?: string | null; id: string; data: Parameters<typeof users.update>[0]["data"] }) =>
400
- fromMutationResult(await users.update(config)),
401
- resetPassword: async (config: { ipaSession: string; id: string }) =>
402
- fromMutationResult(await users.resetPassword(config)),
403
- setExpiry: async (config: { ipaSession?: string | null; id: string; expiryDate: string | null }) =>
404
- fromMutationResult(await users.setExpiry(config)),
405
- setProfile: async (config: { id: string; profile: UserProfile }) =>
406
- fromMutationResult(await users.setProfile(config)),
407
- setAdmin: async (config: { id: string; admin: boolean }) =>
408
- fromMutationResult(await users.setAdmin(config)),
409
- switchProvider: async (config: { ipaSession: string; id: string; provider: UserProvider }) =>
410
- fromMutationResult(await users.switchProvider(config)),
411
- demoteToGuest: async (config: { ipaSession: string; id: string; actor: { userId: string; uid: string } }) =>
412
- fromMutationResult(await users.demoteToGuest(config)),
413
- sendLoginLink: async (config: { id: string }) => fromMutationResult(await users.sendLoginLink(config)),
414
- createLoginToken: async (config: { id: string }) => fromMutationResult(await users.createLoginToken(config)),
415
- remove: async (config: { ipaSession?: string | null; id: string; actor: { userId: string; uid: string } }) =>
416
- fromMutationResult(await users.remove(config)),
417
733
 
418
734
  /**
419
735
  * Change an IPA user's own password. Verifies the current password via
@@ -422,19 +738,21 @@ export const accountsAppService = {
422
738
  * admin app is UI only and must not dispatch on provider or speak to
423
739
  * FreeIPA directly.
424
740
  */
425
- changeOwnPassword: async (config: {
426
- user: User;
427
- currentPassword: string;
428
- newPassword: string;
429
- }): Promise<Result<void>> => {
430
- if (!(await getFreeIpaConfig()).enabled) return fail(err.badInput("FreeIPA is disabled."));
741
+ changeOwnPassword: async (config: { user: User; currentPassword: string; newPassword: string }): Promise<Result<void>> => {
742
+ const actor = { userId: config.user.id, uid: config.user.uid, roles: config.user.roles, provider: config.user.provider };
743
+ if (!(await getFreeIpaConfig()).enabled) {
744
+ const result = fail(err.badInput("FreeIPA is disabled."));
745
+ return audit.recordResult({ action: "accounts.user.change_own_password", actor: auditActor(actor), target: userTarget(config.user), result });
746
+ }
431
747
  if (config.user.provider !== "ipa") {
432
- return fail(err.badInput("Password change is only available for IPA accounts."));
748
+ const result = fail(err.badInput("Password change is only available for IPA accounts."));
749
+ return audit.recordResult({ action: "accounts.user.change_own_password", actor: auditActor(actor), target: userTarget(config.user), result });
433
750
  }
434
751
 
435
752
  const verify = await providers.ipa.auth.login(config.user.uid, config.currentPassword);
436
753
  if (verify.status !== "success") {
437
- return fail(err.unauthenticated("Current password is incorrect."));
754
+ const result = fail(err.unauthenticated("Current password is incorrect."));
755
+ return audit.recordResult({ action: "accounts.user.change_own_password", actor: auditActor(actor), target: userTarget(config.user), result });
438
756
  }
439
757
 
440
758
  const result = await providers.ipa.auth.changePassword({
@@ -442,8 +760,8 @@ export const accountsAppService = {
442
760
  uid: config.user.uid,
443
761
  newPassword: config.newPassword,
444
762
  });
445
- if (!result.ok) return fail(toServiceError(result.status, result.error));
446
- return ok(undefined);
763
+ const serviceResult = result.ok ? ok(undefined) : fail(toServiceError(result.status, result.error));
764
+ return recordCompletedMutation({ action: "accounts.user.change_own_password", actor: auditActor(actor), target: userTarget(config.user), result: serviceResult });
447
765
  },
448
766
 
449
767
  /**
@@ -451,15 +769,18 @@ export const accountsAppService = {
451
769
  * callers must enforce that before calling. Dispatches to the correct
452
770
  * provider internally — callers should not branch on provider themselves.
453
771
  */
454
- removeSelf: async (config: { user: User; ipaSession: string | null }): Promise<Result<void>> => {
455
- const actor = { userId: config.user.id, uid: config.user.uid };
772
+ removeSelf: async (config: { user: User }): Promise<Result<void>> => {
773
+ const actor = { userId: config.user.id, uid: config.user.uid, roles: config.user.roles, provider: config.user.provider };
774
+ if (config.user.profile !== "guest") {
775
+ const result = fail(err.forbidden("Only guest accounts can be self-deleted."));
776
+ return audit.recordResult({ action: "accounts.user.remove_self", actor: auditActor(actor), target: userTarget(config.user), result });
777
+ }
456
778
  if (config.user.provider === "ipa") {
457
- if (!config.ipaSession) return fail(err.unauthenticated("IPA session required."));
458
- return fromMutationResult(
459
- await providers.ipa.users.remove({ ipaSession: config.ipaSession, id: config.user.id, actor }),
460
- );
779
+ const result = fromMutationResult(await users.remove({ id: config.user.id, actor }));
780
+ return recordCompletedMutation({ action: "accounts.user.remove_self", actor: auditActor(actor), target: userTarget(config.user), result });
461
781
  }
462
- return fromMutationResult(await providers.local.users.remove({ id: config.user.id, actor }));
782
+ const result = fromMutationResult(await providers.local.users.remove({ id: config.user.id, actor }));
783
+ return recordCompletedMutation({ action: "accounts.user.remove_self", actor: auditActor(actor), target: userTarget(config.user), result });
463
784
  },
464
785
  },
465
786
 
@@ -511,26 +832,64 @@ export const accountsAppService = {
511
832
  });
512
833
  return paginateItems(filtered, config.pagination);
513
834
  },
514
- add: async (config: { ipaSession?: string | null; id: string; provider?: UserProvider; userId?: string; groupId?: string }) =>
515
- fromMutationResult(
835
+ add: async (config: { actor: AccountsActor; id: string; provider?: UserProvider; userId?: string; groupId?: string }) => {
836
+ const group = await groups.get({ id: config.id });
837
+ if (!group) {
838
+ const result = fail(err.notFound("Group not found"));
839
+ return audit.recordResult({
840
+ action: "accounts.group.member.add",
841
+ actor: auditActor(config.actor),
842
+ target: { type: "group", id: config.id },
843
+ result,
844
+ });
845
+ }
846
+ const accessError = await authorizeGroupMutation<void>({ actor: config.actor, group, action: "accounts.group.member.add" });
847
+ if (accessError) return accessError;
848
+ const result = fromMutationResult(
516
849
  await groups.addMember({
517
- ipaSession: config.ipaSession,
518
850
  id: config.id,
519
851
  provider: config.provider,
520
852
  user: config.userId,
521
853
  group: config.groupId,
522
854
  }),
523
- ),
524
- remove: async (config: { ipaSession?: string | null; id: string; provider?: UserProvider; userId?: string; groupId?: string }) =>
525
- fromMutationResult(
855
+ );
856
+ return recordCompletedMutation({
857
+ action: "accounts.group.member.add",
858
+ actor: auditActor(config.actor),
859
+ target: groupTarget(group),
860
+ metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null },
861
+ result,
862
+ });
863
+ },
864
+ remove: async (config: { actor: AccountsActor; id: string; provider?: UserProvider; userId?: string; groupId?: string }) => {
865
+ const group = await groups.get({ id: config.id });
866
+ if (!group) {
867
+ const result = fail(err.notFound("Group not found"));
868
+ return audit.recordResult({
869
+ action: "accounts.group.member.remove",
870
+ actor: auditActor(config.actor),
871
+ target: { type: "group", id: config.id },
872
+ result,
873
+ });
874
+ }
875
+ const accessError = await authorizeGroupMutation<void>({ actor: config.actor, group, action: "accounts.group.member.remove" });
876
+ if (accessError) return accessError;
877
+ const result = fromMutationResult(
526
878
  await groups.removeMember({
527
- ipaSession: config.ipaSession,
528
879
  id: config.id,
529
880
  provider: config.provider,
530
881
  user: config.userId,
531
882
  group: config.groupId,
532
883
  }),
533
- ),
884
+ );
885
+ return recordCompletedMutation({
886
+ action: "accounts.group.member.remove",
887
+ actor: auditActor(config.actor),
888
+ target: groupTarget(group),
889
+ metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null },
890
+ result,
891
+ });
892
+ },
534
893
  },
535
894
  manager: {
536
895
  list: async (config: {
@@ -550,26 +909,64 @@ export const accountsAppService = {
550
909
  });
551
910
  return paginateItems(filtered, config.pagination);
552
911
  },
553
- add: async (config: { ipaSession?: string | null; id: string; provider?: UserProvider; userId?: string; groupId?: string }) =>
554
- fromMutationResult(
912
+ add: async (config: { actor: AccountsActor; id: string; provider?: UserProvider; userId?: string; groupId?: string }) => {
913
+ const group = await groups.get({ id: config.id });
914
+ if (!group) {
915
+ const result = fail(err.notFound("Group not found"));
916
+ return audit.recordResult({
917
+ action: "accounts.group.manager.add",
918
+ actor: auditActor(config.actor),
919
+ target: { type: "group", id: config.id },
920
+ result,
921
+ });
922
+ }
923
+ const accessError = await requireAdminActor<void>({ actor: config.actor, action: "accounts.group.manager.add", target: groupTarget(group) });
924
+ if (accessError) return accessError;
925
+ const result = fromMutationResult(
555
926
  await groups.addManager({
556
- ipaSession: config.ipaSession,
557
927
  id: config.id,
558
928
  provider: config.provider,
559
929
  user: config.userId,
560
930
  group: config.groupId,
561
931
  }),
562
- ),
563
- remove: async (config: { ipaSession?: string | null; id: string; provider?: UserProvider; userId?: string; groupId?: string }) =>
564
- fromMutationResult(
932
+ );
933
+ return recordCompletedMutation({
934
+ action: "accounts.group.manager.add",
935
+ actor: auditActor(config.actor),
936
+ target: groupTarget(group),
937
+ metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null },
938
+ result,
939
+ });
940
+ },
941
+ remove: async (config: { actor: AccountsActor; id: string; provider?: UserProvider; userId?: string; groupId?: string }) => {
942
+ const group = await groups.get({ id: config.id });
943
+ if (!group) {
944
+ const result = fail(err.notFound("Group not found"));
945
+ return audit.recordResult({
946
+ action: "accounts.group.manager.remove",
947
+ actor: auditActor(config.actor),
948
+ target: { type: "group", id: config.id },
949
+ result,
950
+ });
951
+ }
952
+ const accessError = await requireAdminActor<void>({ actor: config.actor, action: "accounts.group.manager.remove", target: groupTarget(group) });
953
+ if (accessError) return accessError;
954
+ const result = fromMutationResult(
565
955
  await groups.removeManager({
566
- ipaSession: config.ipaSession,
567
956
  id: config.id,
568
957
  provider: config.provider,
569
958
  user: config.userId,
570
959
  group: config.groupId,
571
960
  }),
572
- ),
961
+ );
962
+ return recordCompletedMutation({
963
+ action: "accounts.group.manager.remove",
964
+ actor: auditActor(config.actor),
965
+ target: groupTarget(group),
966
+ metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null },
967
+ result,
968
+ });
969
+ },
573
970
  },
574
971
  parent: {
575
972
  list: async (config: {
@@ -595,14 +992,62 @@ export const accountsAppService = {
595
992
  return paginateItems(filtered, config.pagination);
596
993
  },
597
994
  },
598
- create: async (config: { ipaSession?: string | null; provider: UserProvider; name: string; description?: string; posix?: boolean }) =>
599
- fromMutationResult(await groups.create(config)),
600
- update: async (config: { ipaSession?: string | null; id: string; provider?: UserProvider; description: string }) =>
601
- fromMutationResult(await groups.update(config)),
602
- remove: async (config: { ipaSession?: string | null; id: string; provider?: UserProvider }) =>
603
- fromMutationResult(await groups.remove(config)),
604
- makePosix: async (config: { ipaSession?: string | null; id: string; provider?: UserProvider }) =>
605
- fromMutationResult(await groups.makePosix(config)),
995
+ create: async (config: { actor: AccountsActor; provider: UserProvider; name: string; description?: string; posix?: boolean }) => {
996
+ const adminError = await requireAdminActor<BaseGroup>({
997
+ actor: config.actor,
998
+ action: "accounts.group.create",
999
+ target: { type: "group", label: config.name, provider: config.provider },
1000
+ });
1001
+ if (adminError) return adminError;
1002
+ const result = fromMutationResult(await groups.create(config));
1003
+ return recordCompletedMutation({
1004
+ action: "accounts.group.create",
1005
+ actor: auditActor(config.actor),
1006
+ target: result.ok ? groupTarget(result.data) : { type: "group", label: config.name, provider: config.provider },
1007
+ metadata: { posix: config.posix ?? false },
1008
+ result,
1009
+ });
1010
+ },
1011
+ update: async (config: { actor: AccountsActor; id: string; provider?: UserProvider; description: string }) => {
1012
+ const group = await groups.get({ id: config.id });
1013
+ const target = groupTarget(group ?? { id: config.id, name: null, provider: config.provider ?? null });
1014
+ const adminError = await requireAdminActor<void>({ actor: config.actor, action: "accounts.group.update", target });
1015
+ if (adminError) return adminError;
1016
+ const result = fromMutationResult(await groups.update(config));
1017
+ return recordCompletedMutation({
1018
+ action: "accounts.group.update",
1019
+ actor: auditActor(config.actor),
1020
+ target,
1021
+ metadata: { changedFields: ["description"] },
1022
+ result,
1023
+ });
1024
+ },
1025
+ remove: async (config: { actor: AccountsActor; id: string; provider?: UserProvider }) => {
1026
+ const group = await groups.get({ id: config.id });
1027
+ const target = groupTarget(group ?? { id: config.id, name: null, provider: config.provider ?? null });
1028
+ const adminError = await requireAdminActor<void>({ actor: config.actor, action: "accounts.group.remove", target });
1029
+ if (adminError) return adminError;
1030
+ const result = fromMutationResult(await groups.remove(config));
1031
+ return recordCompletedMutation({
1032
+ action: "accounts.group.remove",
1033
+ actor: auditActor(config.actor),
1034
+ target,
1035
+ result,
1036
+ });
1037
+ },
1038
+ makePosix: async (config: { actor: AccountsActor; id: string; provider?: UserProvider }) => {
1039
+ const group = await groups.get({ id: config.id });
1040
+ const target = groupTarget(group ?? { id: config.id, name: null, provider: config.provider ?? null });
1041
+ const adminError = await requireAdminActor<{ gidnumber: number | null }>({ actor: config.actor, action: "accounts.group.make_posix", target });
1042
+ if (adminError) return adminError;
1043
+ const result = fromMutationResult(await groups.makePosix(config));
1044
+ return recordCompletedMutation({
1045
+ action: "accounts.group.make_posix",
1046
+ actor: auditActor(config.actor),
1047
+ target,
1048
+ result,
1049
+ });
1050
+ },
606
1051
  },
607
1052
  entity: {
608
1053
  list: async (config: {
@@ -613,6 +1058,7 @@ export const accountsAppService = {
613
1058
  profile?: UserProfile;
614
1059
  excludeUserIds?: string[];
615
1060
  excludeGroupIds?: string[];
1061
+ excludeServiceAccountIds?: string[];
616
1062
  userMemberOfGroupIds?: string[];
617
1063
  memberOfGroupId?: string;
618
1064
  managerOfGroupId?: string;
@@ -628,6 +1074,7 @@ export const accountsAppService = {
628
1074
  profile: config.profile,
629
1075
  excludeUserIds: config.excludeUserIds,
630
1076
  excludeGroupIds: config.excludeGroupIds,
1077
+ excludeServiceAccountIds: config.excludeServiceAccountIds,
631
1078
  userMemberOfGroupIds: config.userMemberOfGroupIds,
632
1079
  memberOfGroupId: config.memberOfGroupId,
633
1080
  managerOfGroupId: config.managerOfGroupId,
@@ -714,15 +1161,22 @@ export const accountsAppService = {
714
1161
  createdAt: rows[0]!.created_at as Date,
715
1162
  };
716
1163
  },
717
- create: async (config: { user: Pick<User, "id" | "mail" | "provider">; data: { phone?: string; comment?: string; acceptedAgb: true } }) => {
1164
+ create: async (config: {
1165
+ user: Pick<User, "id" | "uid" | "mail" | "provider" | "roles">;
1166
+ data: { phone?: string; comment?: string; acceptedAgb: true };
1167
+ }) => {
1168
+ const actor = { userId: config.user.id, uid: config.user.uid, roles: config.user.roles, provider: config.user.provider };
718
1169
  if (!(await getFreeIpaConfig()).enabled) {
719
- return fail(err.badInput("FreeIPA is disabled"));
1170
+ const result = fail(err.badInput("FreeIPA is disabled"));
1171
+ return audit.recordResult({ action: "accounts.request.create", actor: auditActor(actor), target: { type: "account_request", label: config.user.mail }, result });
720
1172
  }
721
1173
  if (config.user.provider !== "local") {
722
- return fail(err.forbidden("Only local accounts can request IPA-backed access"));
1174
+ const result = fail(err.forbidden("Only local accounts can request IPA-backed access"));
1175
+ return audit.recordResult({ action: "accounts.request.create", actor: auditActor(actor), target: { type: "account_request", label: config.user.mail }, result });
723
1176
  }
724
1177
  if (!config.user.mail) {
725
- return fail(err.badInput("Your account has no email address"));
1178
+ const result = fail(err.badInput("Your account has no email address"));
1179
+ return audit.recordResult({ action: "accounts.request.create", actor: auditActor(actor), target: { type: "account_request", id: config.user.id }, result });
726
1180
  }
727
1181
 
728
1182
  const existingRows: DbRow[] = await sql`
@@ -730,11 +1184,12 @@ export const accountsAppService = {
730
1184
  WHERE user_id = ${config.user.id} AND status = 'pending'
731
1185
  `;
732
1186
  if (existingRows.length > 0) {
733
- return fail({
1187
+ const result = fail({
734
1188
  code: "CONFLICT",
735
1189
  message: "You already have a pending account request",
736
1190
  status: 409,
737
1191
  });
1192
+ return audit.recordResult({ action: "accounts.request.create", actor: auditActor(actor), target: { type: "account_request", label: config.user.mail }, result });
738
1193
  }
739
1194
 
740
1195
  try {
@@ -744,55 +1199,100 @@ export const accountsAppService = {
744
1199
  RETURNING id
745
1200
  `;
746
1201
 
747
- return ok({
748
- id: rows[0]!.id as string,
1202
+ const requestId = rows[0]!.id as string;
1203
+ const result = ok({
1204
+ id: requestId,
749
1205
  message: "FreeIPA account request submitted",
750
1206
  });
1207
+ return audit.recordResultAfterSideEffect({
1208
+ action: "accounts.request.create",
1209
+ actor: auditActor(actor),
1210
+ target: { type: "account_request", id: requestId, label: config.user.mail },
1211
+ metadata: { hasPhone: !!config.data.phone, hasComment: !!config.data.comment },
1212
+ result,
1213
+ });
751
1214
  } catch (error) {
752
1215
  // Belt-and-suspenders: the partial unique index
753
1216
  // uq_account_requests_one_pending_per_user closes the race between the
754
1217
  // check above and the insert under concurrent submissions.
755
1218
  if (isUniqueViolation(error, "uq_account_requests_one_pending_per_user")) {
756
- return fail({
1219
+ const result = fail({
757
1220
  code: "CONFLICT",
758
1221
  message: "You already have a pending account request",
759
1222
  status: 409,
760
1223
  });
1224
+ return audit.recordResult({ action: "accounts.request.create", actor: auditActor(actor), target: { type: "account_request", label: config.user.mail }, result });
761
1225
  }
762
1226
  throw error;
763
1227
  }
764
1228
  },
765
- withdraw: async (config: { id: string; userId: string }) => {
1229
+ withdraw: async (config: { id: string; actor: AccountsActor }) => {
1230
+ const deletedRows: DbRow[] = await sql`
1231
+ DELETE FROM auth.account_requests
1232
+ WHERE id = ${config.id}
1233
+ AND user_id = ${config.actor.userId}::uuid
1234
+ AND status = 'pending'
1235
+ RETURNING id
1236
+ `;
1237
+
1238
+ if (deletedRows.length > 0) {
1239
+ return audit.recordResultAfterSideEffect({
1240
+ action: "accounts.request.withdraw",
1241
+ actor: auditActor(config.actor),
1242
+ target: { type: "account_request", id: config.id },
1243
+ result: ok(),
1244
+ });
1245
+ }
1246
+
766
1247
  const rows: DbRow[] = await sql`
767
1248
  SELECT id, user_id, status FROM auth.account_requests WHERE id = ${config.id}
768
1249
  `;
769
-
770
- if (rows.length === 0) return fail(err.notFound("Request"));
1250
+ if (rows.length === 0) {
1251
+ const result = fail(err.notFound("Request"));
1252
+ return audit.recordResult({ action: "accounts.request.withdraw", actor: auditActor(config.actor), target: { type: "account_request", id: config.id }, result });
1253
+ }
771
1254
  const request = rows[0]!;
772
- if (request.user_id !== config.userId) return fail(err.forbidden("Access denied"));
773
- if (request.status !== "pending") return fail(err.forbidden("Only pending requests can be withdrawn"));
774
-
775
- await sql`DELETE FROM auth.account_requests WHERE id = ${config.id}`;
776
- return ok();
1255
+ if (request.user_id !== config.actor.userId) {
1256
+ const result = fail(err.forbidden("Access denied"));
1257
+ return audit.recordResult({ action: "accounts.request.withdraw", actor: auditActor(config.actor), target: { type: "account_request", id: config.id }, result });
1258
+ }
1259
+ if (request.status !== "pending") {
1260
+ const result = fail(err.forbidden("Only pending requests can be withdrawn"));
1261
+ return audit.recordResult({ action: "accounts.request.withdraw", actor: auditActor(config.actor), target: { type: "account_request", id: config.id }, result });
1262
+ }
1263
+ const result = fail(err.conflict("Account request could not be withdrawn. Please retry."));
1264
+ return audit.recordResult({ action: "accounts.request.withdraw", actor: auditActor(config.actor), target: { type: "account_request", id: config.id }, result });
777
1265
  },
778
- deny: async (config: { id: string; reason?: string; processedBy: string }) => {
1266
+ deny: async (config: { id: string; reason?: string; actor: AccountsActor }) => {
1267
+ const adminError = await requireAdminActor<void>({
1268
+ actor: config.actor,
1269
+ action: "accounts.request.deny",
1270
+ target: { type: "account_request", id: config.id },
1271
+ });
1272
+ if (adminError) return adminError;
779
1273
  const rows: DbRow[] = await sql`
780
- SELECT r.id, r.user_id, r.status, u.mail AS email, u.given_name AS first_name
781
- FROM auth.account_requests r
782
- JOIN auth.users u ON u.id = r.user_id
1274
+ UPDATE auth.account_requests r
1275
+ SET status = 'denied', denied_reason = ${config.reason ?? null}, processed_at = now(), processed_by = ${config.actor.userId}
1276
+ FROM auth.users u
783
1277
  WHERE r.id = ${config.id}
1278
+ AND r.status = 'pending'
1279
+ AND u.id = r.user_id
1280
+ RETURNING r.id, r.user_id, r.status, u.mail AS email, u.given_name AS first_name
784
1281
  `;
785
1282
 
786
- if (rows.length === 0) return fail(err.notFound("Request"));
1283
+ if (rows.length === 0) {
1284
+ const currentRows: DbRow[] = await sql`
1285
+ SELECT id, status FROM auth.account_requests WHERE id = ${config.id}
1286
+ `;
1287
+ if (currentRows.length === 0) {
1288
+ const result = fail(err.notFound("Request"));
1289
+ return audit.recordResult({ action: "accounts.request.deny", actor: auditActor(config.actor), target: { type: "account_request", id: config.id }, result });
1290
+ }
1291
+ const result = fail(err.badInput("Only pending requests can be denied"));
1292
+ return audit.recordResult({ action: "accounts.request.deny", actor: auditActor(config.actor), target: { type: "account_request", id: config.id }, result });
1293
+ }
787
1294
 
788
1295
  const request = rows[0]!;
789
- if (request.status !== "pending") return fail(err.badInput("Only pending requests can be denied"));
790
-
791
- await sql`
792
- UPDATE auth.account_requests
793
- SET status = 'denied', denied_reason = ${config.reason ?? null}, processed_at = now(), processed_by = ${config.processedBy}
794
- WHERE id = ${config.id}
795
- `;
796
1296
 
797
1297
  if (config.reason) {
798
1298
  const template = await settings.get<string>("mail.account_request_denial");
@@ -810,11 +1310,17 @@ export const accountsAppService = {
810
1310
  APP_NAME: appName,
811
1311
  }),
812
1312
  autoSend: true,
813
- sentBy: config.processedBy,
1313
+ sentBy: config.actor.userId,
814
1314
  });
815
1315
  }
816
1316
 
817
- return ok();
1317
+ return audit.recordResultAfterSideEffect({
1318
+ action: "accounts.request.deny",
1319
+ actor: auditActor(config.actor),
1320
+ target: { type: "account_request", id: config.id, label: request.email as string },
1321
+ metadata: { hasReason: !!config.reason },
1322
+ result: ok(),
1323
+ });
818
1324
  },
819
1325
  },
820
1326
 
@@ -924,10 +1430,7 @@ export const accountsAppService = {
924
1430
  return mapSummary(rows[0] ?? {});
925
1431
  },
926
1432
  activity: async (): Promise<LogEntry[]> => {
927
- const result = await logging.list(
928
- { page: 1, perPage: 15, offset: 0 },
929
- { sources: [...ACTIVITY_SOURCES] },
930
- );
1433
+ const result = await logging.list({ page: 1, perPage: 15, offset: 0 }, { sources: [...ACTIVITY_SOURCES] });
931
1434
  return result.entries;
932
1435
  },
933
1436
  },
@@ -944,21 +1447,6 @@ export const accountsAppService = {
944
1447
  runGuestBackfill: async (): Promise<string> => lifecycleJobs.submitGuestBackfill(),
945
1448
  runReminders: async (): Promise<string> => lifecycleJobs.submitReminderRun(),
946
1449
  },
947
-
948
- /**
949
- * Obtain a privileged FreeIPA service session for operations performed on
950
- * behalf of the system (e.g. a local group manager mutating an IPA group
951
- * they don't personally own). Exposed through the facade so admin UIs
952
- * never import `providers.*` directly.
953
- */
954
- getServiceIpaSession: async (): Promise<Result<string>> => {
955
- if (!(await getFreeIpaConfig()).enabled) return fail(err.badInput("FreeIPA is disabled."));
956
- try {
957
- return ok(await providers.ipa.auth.getServiceSession());
958
- } catch {
959
- return fail(err.internal("Internal FreeIPA session unavailable."));
960
- }
961
- },
962
1450
  } as const;
963
1451
 
964
1452
  export type AccountsAppService = typeof accountsAppService;