@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
@@ -0,0 +1,77 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { canMutateManagedGroup, hasOnlySelfUpdateFields, isAdminActor, isSelfTarget, type AccountsActor } from "./authz";
3
+
4
+ const actor = (overrides: Partial<AccountsActor> = {}): AccountsActor => ({
5
+ userId: "user-1",
6
+ uid: "eva",
7
+ roles: ["user", "local/user"],
8
+ provider: "local",
9
+ ...overrides,
10
+ });
11
+
12
+ describe("accounts service authorization helpers", () => {
13
+ test("recognizes admin actors from roles", () => {
14
+ expect(isAdminActor(actor({ roles: ["user", "admin"] }))).toBe(true);
15
+ expect(isAdminActor(actor({ roles: ["user", "group-manager"] }))).toBe(false);
16
+ expect(isAdminActor(null)).toBe(false);
17
+ });
18
+
19
+ test("recognizes self-service targets by user id", () => {
20
+ expect(isSelfTarget({ actor: actor({ userId: "user-1" }), targetUserId: "user-1" })).toBe(true);
21
+ expect(isSelfTarget({ actor: actor({ userId: "user-1" }), targetUserId: "user-2" })).toBe(false);
22
+ expect(isSelfTarget({ actor: null, targetUserId: "user-1" })).toBe(false);
23
+ });
24
+
25
+ test("allows managed group mutations for admins or recursive managers only", () => {
26
+ expect(
27
+ canMutateManagedGroup({
28
+ actor: actor({ roles: ["admin"] }),
29
+ groupId: "group-1",
30
+ managedGroupIds: [],
31
+ }),
32
+ ).toBe(true);
33
+
34
+ expect(
35
+ canMutateManagedGroup({
36
+ actor: actor({ roles: ["user", "group-manager"] }),
37
+ groupId: "group-1",
38
+ managedGroupIds: ["group-1", "child-group"],
39
+ }),
40
+ ).toBe(true);
41
+
42
+ expect(
43
+ canMutateManagedGroup({
44
+ actor: actor({ roles: ["user", "group-manager"] }),
45
+ groupId: "group-1",
46
+ managedGroupIds: ["other-group"],
47
+ }),
48
+ ).toBe(false);
49
+
50
+ expect(canMutateManagedGroup({ actor: null, groupId: "group-1", managedGroupIds: ["group-1"] })).toBe(false);
51
+ });
52
+
53
+ test("rejects managed group mutations for stale or guest manager relations", () => {
54
+ expect(
55
+ canMutateManagedGroup({
56
+ actor: actor({ roles: ["guest", "local/guest"] }),
57
+ groupId: "group-1",
58
+ managedGroupIds: ["group-1"],
59
+ }),
60
+ ).toBe(false);
61
+
62
+ expect(
63
+ canMutateManagedGroup({
64
+ actor: actor({ roles: ["user", "local/user"] }),
65
+ groupId: "group-1",
66
+ managedGroupIds: ["group-1"],
67
+ }),
68
+ ).toBe(false);
69
+ });
70
+
71
+ test("allows only self-service profile fields for self updates", () => {
72
+ expect(hasOnlySelfUpdateFields({ givenname: "Eva", sn: "Becker", displayName: "Eva Becker" })).toBe(true);
73
+ expect(hasOnlySelfUpdateFields({ ipa: { phone: "+49" } })).toBe(true);
74
+ expect(hasOnlySelfUpdateFields({ mail: "eva@example.com" })).toBe(false);
75
+ expect(hasOnlySelfUpdateFields({ givenname: "Eva", mail: "eva@example.com" })).toBe(false);
76
+ });
77
+ });
@@ -1,5 +1,9 @@
1
1
  import type { Role, UserProfile, UserProvider } from "../../contracts/shared";
2
2
 
3
+ export type AccountsActor = { userId: string; uid: string; roles: string[]; provider?: string | null };
4
+
5
+ const SELF_UPDATE_FIELDS = new Set(["givenname", "sn", "displayName", "ipa"]);
6
+
3
7
  export const buildRoles = (params: {
4
8
  provider: UserProvider;
5
9
  profile: UserProfile;
@@ -20,3 +24,21 @@ export const buildRoles = (params: {
20
24
  if (manages.length > 0) roles.add("group-manager");
21
25
  return [...roles];
22
26
  };
27
+
28
+ export const isAdminActor = (actor: AccountsActor | null | undefined): boolean => !!actor?.roles.includes("admin");
29
+
30
+ export const isSelfTarget = (params: { actor: AccountsActor | null | undefined; targetUserId: string }): boolean =>
31
+ params.actor?.userId === params.targetUserId;
32
+
33
+ export const canMutateManagedGroup = (params: {
34
+ actor: AccountsActor | null | undefined;
35
+ groupId: string;
36
+ managedGroupIds: string[];
37
+ }): boolean => {
38
+ if (isAdminActor(params.actor)) return true;
39
+ if (!params.actor?.roles.includes("user") || !params.actor.roles.includes("group-manager")) return false;
40
+ return params.managedGroupIds.includes(params.groupId);
41
+ };
42
+
43
+ export const hasOnlySelfUpdateFields = (data: Record<string, unknown>): boolean =>
44
+ Object.keys(data).every((field) => SELF_UPDATE_FIELDS.has(field));
@@ -21,6 +21,7 @@ export type EntityListParams = {
21
21
  profile?: UserProfile;
22
22
  excludeUserIds?: string[];
23
23
  excludeGroupIds?: string[];
24
+ excludeServiceAccountIds?: string[];
24
25
  userMemberOfGroupIds?: string[];
25
26
  memberOfGroupId?: string;
26
27
  managerOfGroupId?: string;
@@ -383,6 +384,25 @@ const mapEntityRow = (row: DbRow): EntityListItem => {
383
384
  };
384
385
  }
385
386
 
387
+ if (row.kind === "service_account") {
388
+ return {
389
+ kind: "service_account",
390
+ serviceAccount: {
391
+ id: String(row.id),
392
+ name: String(row.name ?? ""),
393
+ kind: row.service_account_kind === "resource_bound" ? "resource_bound" : "user_delegated",
394
+ status: row.status === "disabled" ? "disabled" : "active",
395
+ delegatedUserId: typeof row.delegated_user_id === "string" ? row.delegated_user_id : null,
396
+ appId: typeof row.app_id === "string" ? row.app_id : null,
397
+ resourceType: typeof row.resource_type === "string" ? row.resource_type : null,
398
+ resourceId: typeof row.resource_id === "string" ? row.resource_id : null,
399
+ createdBy: typeof row.created_by === "string" ? row.created_by : null,
400
+ createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at),
401
+ },
402
+ relation: direct === undefined ? undefined : { direct },
403
+ };
404
+ }
405
+
386
406
  return {
387
407
  kind: "group",
388
408
  group: buildBaseGroup(row),
@@ -412,6 +432,10 @@ export const list = async (params: EntityListParams): Promise<{
412
432
  (params.excludeGroupIds?.length ?? 0) === 0
413
433
  ? sql`TRUE`
414
434
  : sql`(kind <> 'group' OR id <> ALL(${toPgUuidArray(params.excludeGroupIds ?? [])}::uuid[]))`;
435
+ const excludeServiceAccountCondition =
436
+ (params.excludeServiceAccountIds?.length ?? 0) === 0
437
+ ? sql`TRUE`
438
+ : sql`(kind <> 'service_account' OR id <> ALL(${toPgUuidArray(params.excludeServiceAccountIds ?? [])}::uuid[]))`;
415
439
  const userMemberOfGroupCondition =
416
440
  (params.userMemberOfGroupIds?.length ?? 0) === 0
417
441
  ? sql`TRUE`
@@ -428,6 +452,7 @@ export const list = async (params: EntityListParams): Promise<{
428
452
  AND (${params.profile ?? null}::text IS NULL OR kind = 'group' OR profile = ${params.profile ?? null})
429
453
  AND ${excludeUserCondition}
430
454
  AND ${excludeGroupCondition}
455
+ AND ${excludeServiceAccountCondition}
431
456
  AND ${userMemberOfGroupCondition}
432
457
  AND (
433
458
  ${pattern}::text IS NULL
@@ -446,6 +471,14 @@ export const list = async (params: EntityListParams): Promise<{
446
471
  OR LOWER(COALESCE(description, '')) LIKE ${pattern} ESCAPE '\\'
447
472
  )
448
473
  )
474
+ OR (
475
+ kind = 'service_account' AND (
476
+ LOWER(name) LIKE ${pattern} ESCAPE '\\'
477
+ OR LOWER(COALESCE(app_id, '')) LIKE ${pattern} ESCAPE '\\'
478
+ OR LOWER(COALESCE(resource_type, '')) LIKE ${pattern} ESCAPE '\\'
479
+ OR LOWER(COALESCE(resource_id, '')) LIKE ${pattern} ESCAPE '\\'
480
+ )
481
+ )
449
482
  )
450
483
  `;
451
484
 
@@ -471,13 +504,19 @@ export const list = async (params: EntityListParams): Promise<{
471
504
  WHEN u.provider = 'local' THEN u.admin
472
505
  ELSE EXISTS(
473
506
  SELECT 1
474
- FROM auth.user_groups_v2 ug_admin
475
- JOIN auth.groups g_admin ON g_admin.id = ug_admin.group_id
476
- WHERE ug_admin.user_id = u.id
477
- AND g_admin.provider = 'ipa'
478
- AND g_admin.name = ANY(${groupsAdminLiteral}::text[])
507
+ FROM auth.ipa_user_effective_groups eg
508
+ WHERE eg.user_id = u.id
509
+ AND eg.group_name = ANY(${groupsAdminLiteral}::text[])
479
510
  )
480
511
  END AS effective_admin,
512
+ NULL::text AS service_account_kind,
513
+ NULL::text AS status,
514
+ NULL::uuid AS delegated_user_id,
515
+ NULL::text AS app_id,
516
+ NULL::text AS resource_type,
517
+ NULL::text AS resource_id,
518
+ NULL::uuid AS created_by,
519
+ NULL::timestamptz AS created_at,
481
520
  LOWER(COALESCE(NULLIF(u.display_name, ''), NULLIF(u.mail, ''), u.uid)) AS sort_label
482
521
  ${spec.userFrom}
483
522
  WHERE ${spec.userWhere}
@@ -498,14 +537,54 @@ export const list = async (params: EntityListParams): Promise<{
498
537
  g.description,
499
538
  g.gid_number,
500
539
  NULL::boolean AS effective_admin,
540
+ NULL::text AS service_account_kind,
541
+ NULL::text AS status,
542
+ NULL::uuid AS delegated_user_id,
543
+ NULL::text AS app_id,
544
+ NULL::text AS resource_type,
545
+ NULL::text AS resource_id,
546
+ NULL::uuid AS created_by,
547
+ NULL::timestamptz AS created_at,
501
548
  LOWER(g.name) AS sort_label
502
549
  ${spec.groupFrom}
503
550
  WHERE ${spec.groupWhere}
504
551
  ),
552
+ service_account_rows AS (
553
+ SELECT
554
+ 'service_account'::text AS kind,
555
+ NULL::boolean AS direct,
556
+ sa.id,
557
+ NULL::text AS provider,
558
+ NULL::text AS profile,
559
+ NULL::text AS uid,
560
+ NULL::text AS given_name,
561
+ NULL::text AS sn,
562
+ NULL::text AS display_name,
563
+ NULL::text AS mail,
564
+ sa.name,
565
+ CASE
566
+ WHEN sa.kind = 'user_delegated' THEN 'Personal automation keys'
567
+ ELSE CONCAT_WS(' · ', sa.app_id, sa.resource_type, sa.resource_id)
568
+ END AS description,
569
+ NULL::int AS gid_number,
570
+ NULL::boolean AS effective_admin,
571
+ sa.kind AS service_account_kind,
572
+ sa.status,
573
+ sa.delegated_user_id,
574
+ sa.app_id,
575
+ sa.resource_type,
576
+ sa.resource_id,
577
+ sa.created_by,
578
+ sa.created_at,
579
+ LOWER(sa.name) AS sort_label
580
+ FROM auth.service_accounts sa
581
+ ),
505
582
  entity_rows AS (
506
583
  SELECT * FROM user_rows
507
584
  UNION ALL
508
585
  SELECT * FROM group_rows
586
+ UNION ALL
587
+ SELECT * FROM service_account_rows
509
588
  )
510
589
  SELECT *, COUNT(*) OVER() AS total
511
590
  FROM entity_rows
@@ -2,6 +2,7 @@ import { sql } from "bun";
2
2
  import type { BaseGroup, GroupMember, MutationResult, UserProvider } from "../../contracts/shared";
3
3
  import * as localGroups from "./local-groups";
4
4
  import { providers } from "../providers";
5
+ import { getServiceIpaSession } from "../ipa/service-account";
5
6
  import { freeipa } from "../../server/services";
6
7
  import { toPgUuidArray } from "../postgres";
7
8
  import { buildBaseGroup } from "./base-group";
@@ -138,7 +139,6 @@ export const getManagedGroups = async (params: { id: string; provider?: UserProv
138
139
  };
139
140
 
140
141
  export const create = async (params: {
141
- ipaSession?: string | null;
142
142
  provider: UserProvider;
143
143
  name: string;
144
144
  description?: string;
@@ -148,9 +148,10 @@ export const create = async (params: {
148
148
  if (params.posix) return { ok: false, error: "Local groups do not support POSIX mode", status: 400 };
149
149
  return localGroups.create({ name: params.name, description: params.description });
150
150
  }
151
- if (!params.ipaSession) return { ok: false, error: "IPA session required to create IPA groups", status: 401 };
151
+ const serviceSession = await getServiceIpaSession();
152
+ if (!serviceSession.ok) return serviceSession;
152
153
  return providers.ipa.groups.add({
153
- ipaSession: params.ipaSession,
154
+ ipaSession: serviceSession.data,
154
155
  cn: params.name,
155
156
  description: params.description,
156
157
  posix: params.posix,
@@ -158,87 +159,92 @@ export const create = async (params: {
158
159
  };
159
160
 
160
161
  export const update = async (params: {
161
- ipaSession?: string | null;
162
162
  id: string;
163
163
  provider?: UserProvider;
164
164
  description: string;
165
165
  }): Promise<MutationResult<void>> => {
166
166
  const provider = params.provider ?? (await getGroup(params.id))?.provider;
167
167
  if (provider === "local") return localGroups.update({ id: params.id, description: params.description });
168
- if (!params.ipaSession) return { ok: false, error: "IPA session required to update IPA groups", status: 401 };
168
+ const serviceSession = await getServiceIpaSession();
169
+ if (!serviceSession.ok) return serviceSession;
169
170
  return providers.ipa.groups.update({
170
- ipaSession: params.ipaSession,
171
+ ipaSession: serviceSession.data,
171
172
  id: params.id,
172
173
  description: params.description,
173
174
  });
174
175
  };
175
176
 
176
- export const remove = async (params: { ipaSession?: string | null; id: string; provider?: UserProvider }): Promise<MutationResult<void>> => {
177
+ export const remove = async (params: { id: string; provider?: UserProvider }): Promise<MutationResult<void>> => {
177
178
  const provider = params.provider ?? (await getGroup(params.id))?.provider;
178
179
  if (provider === "local") return localGroups.remove({ id: params.id });
179
- if (!params.ipaSession) return { ok: false, error: "IPA session required to delete IPA groups", status: 401 };
180
+ const serviceSession = await getServiceIpaSession();
181
+ if (!serviceSession.ok) return serviceSession;
180
182
  return providers.ipa.groups.remove({
181
- ipaSession: params.ipaSession,
183
+ ipaSession: serviceSession.data,
182
184
  id: params.id,
183
185
  });
184
186
  };
185
187
 
186
188
  export const makePosix = async (params: {
187
- ipaSession?: string | null;
188
189
  id: string;
189
190
  provider?: UserProvider;
190
191
  }): Promise<MutationResult<{ gidnumber: number | null }>> => {
191
192
  const provider = params.provider ?? (await getGroup(params.id))?.provider;
192
193
  if (provider === "local") return { ok: false, error: "Local groups do not support POSIX mode", status: 400 };
193
- if (!params.ipaSession) return { ok: false, error: "IPA session required to change IPA groups", status: 401 };
194
+ const serviceSession = await getServiceIpaSession();
195
+ if (!serviceSession.ok) return serviceSession;
194
196
  return providers.ipa.groups.makePosix({
195
- ipaSession: params.ipaSession,
197
+ ipaSession: serviceSession.data,
196
198
  id: params.id,
197
199
  });
198
200
  };
199
201
 
200
- export const addMember = async (params: { ipaSession?: string | null; id: string; provider?: UserProvider; user?: string; group?: string }): Promise<MutationResult<void>> => {
202
+ export const addMember = async (params: { id: string; provider?: UserProvider; user?: string; group?: string }): Promise<MutationResult<void>> => {
201
203
  const provider = params.provider ?? (await getGroup(params.id))?.provider;
202
204
  if (provider === "local") return localGroups.addMember({ id: params.id, user: params.user, group: params.group });
203
- if (!params.ipaSession) return { ok: false, error: "IPA session required to update IPA groups", status: 401 };
205
+ const serviceSession = await getServiceIpaSession();
206
+ if (!serviceSession.ok) return serviceSession;
204
207
  return providers.ipa.groups.addMember({
205
- ipaSession: params.ipaSession,
208
+ ipaSession: serviceSession.data,
206
209
  id: params.id,
207
210
  user: params.user,
208
211
  group: params.group,
209
212
  });
210
213
  };
211
214
 
212
- export const removeMember = async (params: { ipaSession?: string | null; id: string; provider?: UserProvider; user?: string; group?: string }): Promise<MutationResult<void>> => {
215
+ export const removeMember = async (params: { id: string; provider?: UserProvider; user?: string; group?: string }): Promise<MutationResult<void>> => {
213
216
  const provider = params.provider ?? (await getGroup(params.id))?.provider;
214
217
  if (provider === "local") return localGroups.removeMember({ id: params.id, user: params.user, group: params.group });
215
- if (!params.ipaSession) return { ok: false, error: "IPA session required to update IPA groups", status: 401 };
218
+ const serviceSession = await getServiceIpaSession();
219
+ if (!serviceSession.ok) return serviceSession;
216
220
  return providers.ipa.groups.removeMember({
217
- ipaSession: params.ipaSession,
221
+ ipaSession: serviceSession.data,
218
222
  id: params.id,
219
223
  user: params.user,
220
224
  group: params.group,
221
225
  });
222
226
  };
223
227
 
224
- export const addManager = async (params: { ipaSession?: string | null; id: string; provider?: UserProvider; user?: string; group?: string }): Promise<MutationResult<void>> => {
228
+ export const addManager = async (params: { id: string; provider?: UserProvider; user?: string; group?: string }): Promise<MutationResult<void>> => {
225
229
  const provider = params.provider ?? (await getGroup(params.id))?.provider;
226
230
  if (provider === "local") return localGroups.addManager({ id: params.id, user: params.user, group: params.group });
227
- if (!params.ipaSession) return { ok: false, error: "IPA session required to update IPA groups", status: 401 };
231
+ const serviceSession = await getServiceIpaSession();
232
+ if (!serviceSession.ok) return serviceSession;
228
233
  return providers.ipa.groups.addManager({
229
- ipaSession: params.ipaSession,
234
+ ipaSession: serviceSession.data,
230
235
  id: params.id,
231
236
  user: params.user,
232
237
  group: params.group,
233
238
  });
234
239
  };
235
240
 
236
- export const removeManager = async (params: { ipaSession?: string | null; id: string; provider?: UserProvider; user?: string; group?: string }): Promise<MutationResult<void>> => {
241
+ export const removeManager = async (params: { id: string; provider?: UserProvider; user?: string; group?: string }): Promise<MutationResult<void>> => {
237
242
  const provider = params.provider ?? (await getGroup(params.id))?.provider;
238
243
  if (provider === "local") return localGroups.removeManager({ id: params.id, user: params.user, group: params.group });
239
- if (!params.ipaSession) return { ok: false, error: "IPA session required to update IPA groups", status: 401 };
244
+ const serviceSession = await getServiceIpaSession();
245
+ if (!serviceSession.ok) return serviceSession;
240
246
  return providers.ipa.groups.removeManager({
241
- ipaSession: params.ipaSession,
247
+ ipaSession: serviceSession.data,
242
248
  id: params.id,
243
249
  user: params.user,
244
250
  group: params.group,
@@ -0,0 +1,30 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ calculateIpaProfileFromGroupNames,
4
+ deriveIpaAdminFromGroupNames,
5
+ parseIpaAccountTransitionPolicy,
6
+ } from "./model";
7
+
8
+ describe("IPA account model helpers", () => {
9
+ test("classifies full IPA users from effective base realm groups", () => {
10
+ expect(calculateIpaProfileFromGroupNames(["base-sync", "base-realm"], ["base-realm"])).toBe("user");
11
+ });
12
+
13
+ test("classifies in-scope IPA users without base realm as guests", () => {
14
+ expect(calculateIpaProfileFromGroupNames(["base-sync"], ["base-realm"])).toBe("guest");
15
+ });
16
+
17
+ test("derives IPA admin from effective groups", () => {
18
+ expect(deriveIpaAdminFromGroupNames(["hidden-admin-transit", "admins"], ["admins"])).toBe(true);
19
+ expect(deriveIpaAdminFromGroupNames(["base-sync"], ["admins"])).toBe(false);
20
+ });
21
+
22
+ test("parses all transition policy settings with safe guest demotion fallback", () => {
23
+ expect(parseIpaAccountTransitionPolicy("delete")).toBe("delete");
24
+ expect(parseIpaAccountTransitionPolicy("demote_to_local")).toBe("demote_to_local");
25
+ expect(parseIpaAccountTransitionPolicy("demote_to_local_user")).toBe("demote_to_local_user");
26
+ expect(parseIpaAccountTransitionPolicy("demote_to_local_guest")).toBe("demote_to_local_guest");
27
+ expect(parseIpaAccountTransitionPolicy("unexpected")).toBe("demote_to_local_guest");
28
+ expect(parseIpaAccountTransitionPolicy(null)).toBe("demote_to_local_guest");
29
+ });
30
+ });
@@ -0,0 +1,14 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { resolveIpaTransitionProfile } from "./switching";
3
+
4
+ describe("resolveIpaTransitionProfile", () => {
5
+ test("keeps the current profile for demote_to_local", () => {
6
+ expect(resolveIpaTransitionProfile({ currentProfile: "user", policy: "demote_to_local" })).toBe("user");
7
+ expect(resolveIpaTransitionProfile({ currentProfile: "guest", policy: "demote_to_local" })).toBe("guest");
8
+ });
9
+
10
+ test("supports explicit local user and guest transition policies", () => {
11
+ expect(resolveIpaTransitionProfile({ currentProfile: "guest", policy: "demote_to_local_user" })).toBe("user");
12
+ expect(resolveIpaTransitionProfile({ currentProfile: "user", policy: "demote_to_local_guest" })).toBe("guest");
13
+ });
14
+ });
@@ -53,12 +53,7 @@ export const resolveIpaTransitionTarget = async (params: {
53
53
  currentProfile: UserProfile;
54
54
  policy: Exclude<IpaAccountTransitionPolicy, "delete">;
55
55
  }): Promise<{ targetProfile: UserProfile; accountExpires: Date | null }> => {
56
- const targetProfile =
57
- params.policy === "demote_to_local"
58
- ? params.currentProfile
59
- : params.policy === "demote_to_local_user"
60
- ? "user"
61
- : "guest";
56
+ const targetProfile = resolveIpaTransitionProfile(params);
62
57
 
63
58
  return {
64
59
  targetProfile,
@@ -66,6 +61,15 @@ export const resolveIpaTransitionTarget = async (params: {
66
61
  };
67
62
  };
68
63
 
64
+ export const resolveIpaTransitionProfile = (params: {
65
+ currentProfile: UserProfile;
66
+ policy: Exclude<IpaAccountTransitionPolicy, "delete">;
67
+ }): UserProfile => {
68
+ if (params.policy === "demote_to_local") return params.currentProfile;
69
+ if (params.policy === "demote_to_local_user") return "user";
70
+ return "guest";
71
+ };
72
+
69
73
  export const transitionIpaUserToLocal = async (params: {
70
74
  userId: string;
71
75
  targetProfile: UserProfile;
@@ -88,6 +92,11 @@ export const transitionIpaUserToLocal = async (params: {
88
92
  WHERE user_id = ${params.userId}::uuid
89
93
  `;
90
94
 
95
+ await db`
96
+ DELETE FROM auth.ipa_user_effective_groups
97
+ WHERE user_id = ${params.userId}::uuid
98
+ `;
99
+
91
100
  await clearUserRelationsForProvider({
92
101
  userId: params.userId,
93
102
  provider: "ipa",