@valentinkolb/cloud 0.4.0 → 0.5.1

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 -6
  2. package/scripts/preload.ts +78 -23
  3. package/src/_internal/define-app.ts +53 -46
  4. package/src/api/accounts-entities.ts +4 -0
  5. package/src/api/admin-core-settings.ts +98 -0
  6. package/src/api/announcements.ts +131 -0
  7. package/src/api/auth/schemas.ts +24 -0
  8. package/src/api/auth.ts +116 -13
  9. package/src/api/index.ts +7 -2
  10. package/src/api/me.ts +203 -14
  11. package/src/api/search/schemas.ts +1 -0
  12. package/src/api/search.ts +62 -8
  13. package/src/config/ssr.ts +2 -9
  14. package/src/contracts/announcements.test.ts +37 -0
  15. package/src/contracts/announcements.ts +121 -0
  16. package/src/contracts/app.ts +2 -0
  17. package/src/contracts/index.ts +3 -2
  18. package/src/contracts/registry.ts +2 -0
  19. package/src/contracts/shared.ts +108 -1
  20. package/src/desktop/index.ts +704 -0
  21. package/src/desktop/solid.tsx +938 -0
  22. package/src/server/api/index.ts +1 -1
  23. package/src/server/api/respond.ts +50 -10
  24. package/src/server/index.ts +44 -38
  25. package/src/server/middleware/auth.ts +98 -9
  26. package/src/server/middleware/index.ts +2 -1
  27. package/src/server/middleware/settings.ts +26 -0
  28. package/src/server/services/access.test.ts +197 -0
  29. package/src/server/services/access.ts +254 -6
  30. package/src/server/services/index.ts +14 -11
  31. package/src/server/services/pagination.ts +22 -0
  32. package/src/server/time.ts +45 -0
  33. package/src/services/account-lifecycle/index.ts +142 -18
  34. package/src/services/accounts/app.ts +658 -170
  35. package/src/services/accounts/authz.test.ts +77 -0
  36. package/src/services/accounts/authz.ts +22 -0
  37. package/src/services/accounts/entities.ts +84 -5
  38. package/src/services/accounts/groups.ts +30 -24
  39. package/src/services/accounts/model.test.ts +30 -0
  40. package/src/services/accounts/switching.test.ts +14 -0
  41. package/src/services/accounts/switching.ts +15 -6
  42. package/src/services/accounts/users.ts +75 -52
  43. package/src/services/announcements/index.test.ts +32 -0
  44. package/src/services/announcements/index.ts +224 -0
  45. package/src/services/audit/index.test.ts +84 -0
  46. package/src/services/audit/index.ts +431 -0
  47. package/src/services/auth-flows/index.ts +9 -2
  48. package/src/services/auth-flows/ipa.ts +47 -7
  49. package/src/services/auth-flows/magic-link.ts +92 -20
  50. package/src/services/auth-flows/password-reset.ts +284 -0
  51. package/src/services/auth-flows/proxy-return.test.ts +24 -0
  52. package/src/services/auth-flows/proxy-return.ts +49 -0
  53. package/src/services/gateway.ts +162 -0
  54. package/src/services/index.ts +44 -2
  55. package/src/services/ipa/effective-groups.test.ts +33 -0
  56. package/src/services/ipa/effective-groups.ts +70 -0
  57. package/src/services/ipa/profile.ts +45 -3
  58. package/src/services/ipa/search.ts +3 -5
  59. package/src/services/ipa/service-account.ts +15 -0
  60. package/src/services/ipa/sync-planning.test.ts +32 -0
  61. package/src/services/ipa/sync-planning.ts +22 -0
  62. package/src/services/ipa/sync.ts +110 -38
  63. package/src/services/notifications/index.ts +82 -11
  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 +79 -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 +58 -0
  92. package/src/shared/redirect.ts +56 -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,12 +1,27 @@
1
+ // biome-ignore-all assist/source/organizeImports: Preserve grouped service barrel exports.
1
2
  export { ipa } from "./ipa";
2
3
  export { accounts } from "./accounts";
3
4
  export { accountsAppService } from "./accounts";
4
5
  export { providers } from "./providers";
5
6
  export { authFlows } from "./auth-flows";
6
- export { toPgTextArray, toPgUuidArray, escapeLikePattern } from "./postgres";
7
+ export { toPgTextArray, toPgUuidArray, escapeLikePattern, isUniqueViolation } from "./postgres";
7
8
 
8
9
  export { logger, logging } from "./logging";
9
10
  export type { LogEntry } from "./logging";
11
+ export { audit } from "./audit";
12
+ export type { AuditActionGroup, AuditActor, AuditEvent, AuditListFilter, AuditOutcome, AuditRecordParams, AuditTarget } from "./audit";
13
+
14
+ export {
15
+ GATEWAY_TELEMETRY_TENANT,
16
+ buildGatewayRouteSnapshot,
17
+ gatewayTelemetryTopic,
18
+ latestGatewayRouteSnapshot,
19
+ listGatewayRouteSnapshots,
20
+ publishGatewayRouteSnapshot,
21
+ publishRequestTelemetry,
22
+ removeGatewayRouteSnapshot,
23
+ } from "./gateway";
24
+ export type { GatewayRouteSnapshot, GatewayRouteSnapshotInput, GatewayRouteWarning, GatewayTelemetryEvent } from "./gateway";
10
25
 
11
26
  export { notifications } from "./notifications";
12
27
  export type {
@@ -16,8 +31,25 @@ export type {
16
31
  SendToUserParams,
17
32
  NotificationMessage,
18
33
  } from "./notifications";
34
+ export { announcements } from "./announcements";
35
+ export type { AnnouncementsService } from "./announcements";
19
36
 
20
37
  export { session } from "./session";
38
+ export { serviceAccounts } from "./service-accounts";
39
+ export type { ServiceAccount, ServiceAccountKind, ServiceAccountStatus } from "./service-accounts";
40
+ export { serviceAccountCredentials } from "./service-account-credentials";
41
+ export type {
42
+ AuthenticatedServiceAccountCredential,
43
+ ServiceAccountCredential,
44
+ ServiceAccountCredentialKind,
45
+ ServiceAccountCredentialOverview,
46
+ ServiceAccountCredentialOwner,
47
+ ServiceAccountCredentialStatus,
48
+ } from "./service-account-credentials";
49
+ export { oauthTokens } from "./oauth-tokens";
50
+ export type { AuthenticatedOAuthToken } from "./oauth-tokens";
51
+ export { webauthn } from "./webauthn";
52
+ export type { WebAuthnRp } from "./webauthn";
21
53
 
22
54
  export { accountLifecycle } from "./account-lifecycle";
23
55
  export type { AccountLifecycleService } from "./account-lifecycle";
@@ -32,11 +64,21 @@ export type { SettingDef, SettingKind, SettingOption } from "./settings/defaults
32
64
  export { renderTemplate } from "./settings/templates";
33
65
  export { settingsService } from "./settings/app";
34
66
  export type { SettingsService } from "./settings/app";
67
+ export { decryptSecret, encryptSecret, secrets } from "./secrets";
35
68
 
36
69
  // Typed async API + cache-aside primitives.
37
70
  export { coreSettings, createSettingsAPI } from "./settings/api";
38
71
  export type { SettingsAPI } from "./settings/api";
39
- export { readKey as settingsReadKey, writeKey as settingsWriteKey, deleteKey as settingsDeleteKey, bulkRead as settingsBulkRead, allKnownKeys as settingsAllKnownKeys } from "./settings/store";
72
+ export {
73
+ readKey as settingsReadKey,
74
+ writeKey as settingsWriteKey,
75
+ deleteKey as settingsDeleteKey,
76
+ bulkRead as settingsBulkRead,
77
+ allKnownKeys as settingsAllKnownKeys,
78
+ listLegacyKeys as settingsListLegacyKeys,
79
+ deleteLegacyKeys as settingsDeleteLegacyKeys,
80
+ } from "./settings/store";
81
+ export type { LegacySettingRow } from "./settings/store";
40
82
  export { loadSnapshot as loadSettingsSnapshot } from "./settings/snapshot";
41
83
 
42
84
  export { weatherService } from "./weather";
@@ -0,0 +1,33 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildEffectiveIpaGroupsByUid } from "./effective-groups";
3
+
4
+ describe("buildEffectiveIpaGroupsByUid", () => {
5
+ test("includes direct and inherited parent groups", () => {
6
+ const effective = buildEffectiveIpaGroupsByUid([
7
+ { cn: "base-sync", users: [], groups: ["team"] },
8
+ { cn: "base-realm", users: [], groups: ["base-sync"] },
9
+ { cn: "team", users: ["eva"], groups: [] },
10
+ ]);
11
+
12
+ expect(effective.get("eva")).toEqual(["base-realm", "base-sync", "team"]);
13
+ });
14
+
15
+ test("keeps transit groups available even when callers hide them from display", () => {
16
+ const effective = buildEffectiveIpaGroupsByUid([
17
+ { cn: "base-realm", users: [], groups: ["excluded-transit"] },
18
+ { cn: "excluded-transit", users: [], groups: ["team"] },
19
+ { cn: "team", users: ["eva"], groups: [] },
20
+ ]);
21
+
22
+ expect(effective.get("eva")).toEqual(["base-realm", "excluded-transit", "team"]);
23
+ });
24
+
25
+ test("terminates on cyclic group nesting", () => {
26
+ const effective = buildEffectiveIpaGroupsByUid([
27
+ { cn: "a", users: ["eva"], groups: ["b"] },
28
+ { cn: "b", users: [], groups: ["a"] },
29
+ ]);
30
+
31
+ expect(effective.get("eva")).toEqual(["a", "b"]);
32
+ });
33
+ });
@@ -0,0 +1,70 @@
1
+ export type IpaGroupMembership = {
2
+ cn: string;
3
+ users: string[];
4
+ groups: string[];
5
+ };
6
+
7
+ const sorted = (values: Iterable<string>): string[] => [...values].sort((a, b) => a.localeCompare(b));
8
+
9
+ /**
10
+ * Build effective IPA group membership from authoritative group records.
11
+ * A group contains direct users and child groups; user effective membership is
12
+ * direct groups plus every parent group reached through group nesting.
13
+ */
14
+ export const buildEffectiveIpaGroupsByUid = (groups: IpaGroupMembership[]): Map<string, string[]> => {
15
+ const groupNames = new Set(groups.map((group) => group.cn).filter(Boolean));
16
+ const childToParents = new Map<string, Set<string>>();
17
+ const directGroupsByUid = new Map<string, Set<string>>();
18
+
19
+ for (const group of groups) {
20
+ if (!group.cn) continue;
21
+
22
+ for (const uid of group.users) {
23
+ if (!uid) continue;
24
+ const directGroups = directGroupsByUid.get(uid) ?? new Set<string>();
25
+ directGroups.add(group.cn);
26
+ directGroupsByUid.set(uid, directGroups);
27
+ }
28
+
29
+ for (const child of group.groups) {
30
+ if (!child || !groupNames.has(child)) continue;
31
+ const parents = childToParents.get(child) ?? new Set<string>();
32
+ parents.add(group.cn);
33
+ childToParents.set(child, parents);
34
+ }
35
+ }
36
+
37
+ const memo = new Map<string, Set<string>>();
38
+ const resolveGroupClosure = (groupName: string, visiting = new Set<string>()): Set<string> => {
39
+ const cached = memo.get(groupName);
40
+ if (cached) return cached;
41
+
42
+ const closure = new Set<string>([groupName]);
43
+ if (visiting.has(groupName)) return closure;
44
+
45
+ const nextVisiting = new Set(visiting);
46
+ nextVisiting.add(groupName);
47
+
48
+ for (const parent of childToParents.get(groupName) ?? []) {
49
+ for (const inherited of resolveGroupClosure(parent, nextVisiting)) {
50
+ closure.add(inherited);
51
+ }
52
+ }
53
+
54
+ memo.set(groupName, closure);
55
+ return closure;
56
+ };
57
+
58
+ const effectiveByUid = new Map<string, string[]>();
59
+ for (const [uid, directGroups] of directGroupsByUid) {
60
+ const effective = new Set<string>();
61
+ for (const groupName of directGroups) {
62
+ for (const inherited of resolveGroupClosure(groupName)) {
63
+ effective.add(inherited);
64
+ }
65
+ }
66
+ effectiveByUid.set(uid, sorted(effective));
67
+ }
68
+
69
+ return effectiveByUid;
70
+ };
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Centralized profile calculation for IPA-backed users.
3
3
  *
4
- * The calculation uses the local mirrored IPA group tree only and treats
5
- * `auth.groups.id` as the canonical group identity.
4
+ * Full sync writes `auth.ipa_user_effective_groups` from FreeIPA group_find.
5
+ * Local group mutations keep that projection as source of truth; the local
6
+ * display mirror is only a bootstrap fallback before the first full sync.
6
7
  */
7
8
 
8
9
  import { sql } from "bun";
@@ -37,6 +38,17 @@ export const getAllUserGroups = async (userId: string): Promise<string[]> => {
37
38
  return rows.map((row) => row.name as string);
38
39
  };
39
40
 
41
+ export const getEffectiveUserGroups = async (userId: string): Promise<string[]> => {
42
+ const rows: DbRow[] = await sql`
43
+ SELECT group_name
44
+ FROM auth.ipa_user_effective_groups
45
+ WHERE user_id = ${userId}::uuid
46
+ ORDER BY group_name
47
+ `;
48
+
49
+ return rows.map((row) => row.group_name as string);
50
+ };
51
+
40
52
  /**
41
53
  * Calculate canonical IPA profile from effective group names. Reads
42
54
  * `freeipa.groups.base_ipa_realm` from settings (cache-aside).
@@ -54,11 +66,41 @@ export const calculateIpaProfileFromLocalDb = async (userId: string): Promise<"u
54
66
  return calculateIpaProfile(groups);
55
67
  };
56
68
 
69
+ export const calculateIpaProfileFromEffectiveProjection = async (userId: string): Promise<"user" | "guest"> => {
70
+ const groups = await getEffectiveUserGroups(userId);
71
+ return calculateIpaProfile(groups);
72
+ };
73
+
74
+ const rebuildEffectiveProjectionFromLocalMirror = async (userId: string): Promise<string[]> => {
75
+ const groups = await getAllUserGroups(userId);
76
+ await sql`
77
+ DELETE FROM auth.ipa_user_effective_groups
78
+ WHERE user_id = ${userId}::uuid
79
+ `;
80
+
81
+ for (const group of groups) {
82
+ await sql`
83
+ INSERT INTO auth.ipa_user_effective_groups (user_id, group_name)
84
+ VALUES (${userId}::uuid, ${group})
85
+ ON CONFLICT DO NOTHING
86
+ `;
87
+ }
88
+
89
+ return groups;
90
+ };
91
+
92
+ const getEffectiveUserGroupsWithMirrorFallback = async (userId: string): Promise<string[]> => {
93
+ const projectedGroups = await getEffectiveUserGroups(userId);
94
+ if (projectedGroups.length > 0) return projectedGroups;
95
+ return rebuildEffectiveProjectionFromLocalMirror(userId);
96
+ };
97
+
57
98
  /**
58
99
  * Update one IPA-backed user's canonical profile projection.
59
100
  */
60
101
  export const updateUserIpaProfile = async (userId: string): Promise<void> => {
61
- const profile = await calculateIpaProfileFromLocalDb(userId);
102
+ const groups = await getEffectiveUserGroupsWithMirrorFallback(userId);
103
+ const profile = await calculateIpaProfile(groups);
62
104
  await sql`
63
105
  UPDATE auth.users
64
106
  SET provider = 'ipa',
@@ -63,11 +63,9 @@ export const search = async (query: string, options: SearchOptions): Promise<{ u
63
63
  SELECT u.id, u.uid, u.provider, u.profile, u.given_name, u.sn, u.display_name, u.mail,
64
64
  EXISTS(
65
65
  SELECT 1
66
- FROM auth.user_groups_v2 ug_admin
67
- JOIN auth.groups g_admin ON g_admin.id = ug_admin.group_id
68
- WHERE ug_admin.user_id = u.id
69
- AND g_admin.provider = 'ipa'
70
- AND g_admin.name = ANY(${toPgTextArray(groupsAdmin)}::text[])
66
+ FROM auth.ipa_user_effective_groups eg
67
+ WHERE eg.user_id = u.id
68
+ AND eg.group_name = ANY(${toPgTextArray(groupsAdmin)}::text[])
71
69
  ) AS effective_admin
72
70
  FROM auth.users u
73
71
  WHERE u.provider = 'ipa'
@@ -0,0 +1,15 @@
1
+ import type { MutationResult } from "../../contracts/shared";
2
+ import { providers } from "../providers";
3
+ import { getFreeIpaConfig } from "../freeipa-config";
4
+
5
+ export const getServiceIpaSession = async (): Promise<MutationResult<string>> => {
6
+ if (!(await getFreeIpaConfig()).enabled) {
7
+ return { ok: false, error: "FreeIPA is disabled.", status: 400 };
8
+ }
9
+
10
+ try {
11
+ return { ok: true, data: await providers.ipa.auth.getServiceSession() };
12
+ } catch {
13
+ return { ok: false, error: "Internal FreeIPA service session unavailable.", status: 500 };
14
+ }
15
+ };
@@ -0,0 +1,32 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { selectStaleLocalIpaRows } from "./sync-planning";
3
+
4
+ describe("selectStaleLocalIpaRows", () => {
5
+ test("keeps unchanged active IPA users out of stale transitions", () => {
6
+ const stale = selectStaleLocalIpaRows({
7
+ localRows: [{ uid: "eva", mail: "eva@example.test" }],
8
+ activeRemoteUsers: [{ uid: "eva", mail: "eva@example.test" }],
9
+ });
10
+
11
+ expect(stale).toEqual([]);
12
+ });
13
+
14
+ test("keeps UID-renamed IPA users out of stale transitions when mail still matches", () => {
15
+ const stale = selectStaleLocalIpaRows({
16
+ localRows: [{ uid: "old-eva", mail: "eva@example.test" }],
17
+ activeRemoteUsers: [{ uid: "new-eva", mail: "eva@example.test" }],
18
+ });
19
+
20
+ expect(stale).toEqual([]);
21
+ });
22
+
23
+ test("returns IPA users with no active UID or mail match as stale", () => {
24
+ const local = { uid: "old-eva", mail: "old-eva@example.test" };
25
+ const stale = selectStaleLocalIpaRows({
26
+ localRows: [local],
27
+ activeRemoteUsers: [{ uid: "new-eva", mail: "eva@example.test" }],
28
+ });
29
+
30
+ expect(stale).toEqual([local]);
31
+ });
32
+ });
@@ -0,0 +1,22 @@
1
+ export type IpaIdentity = {
2
+ uid: string;
3
+ mail: string | null;
4
+ };
5
+
6
+ export const selectStaleLocalIpaRows = <T extends IpaIdentity>(params: {
7
+ localRows: T[];
8
+ activeRemoteUsers: IpaIdentity[];
9
+ }): T[] => {
10
+ const activeUids = new Set(params.activeRemoteUsers.map((user) => user.uid));
11
+ const activeMails = new Set(
12
+ params.activeRemoteUsers
13
+ .map((user) => user.mail)
14
+ .filter((mail): mail is string => Boolean(mail)),
15
+ );
16
+
17
+ return params.localRows.filter((row) => {
18
+ if (activeUids.has(row.uid)) return false;
19
+ if (row.mail && activeMails.has(row.mail)) return false;
20
+ return true;
21
+ });
22
+ };
@@ -1,6 +1,7 @@
1
1
  import { sql } from "bun";
2
2
  import { applyIpaAccountTransitionPolicy } from "../accounts/switching";
3
3
  import {
4
+ calculateIpaProfileFromGroupNames,
4
5
  parseIpaAccountTransitionPolicy,
5
6
  parseIpaMatchMode,
6
7
  } from "../account-model";
@@ -10,9 +11,12 @@ import * as settings from "../settings";
10
11
  import { session } from "../session";
11
12
  import { freeipa } from "../../server/services";
12
13
  import { getFreeIpaConfig } from "../freeipa-config";
13
- import { calculateIpaProfile, calculateIpaProfileFromLocalDb } from "./profile";
14
+ import { buildEffectiveIpaGroupsByUid } from "./effective-groups";
15
+ import { calculateIpaProfileFromEffectiveProjection, getEffectiveUserGroups } from "./profile";
16
+ import { selectStaleLocalIpaRows } from "./sync-planning";
14
17
 
15
18
  type DbRow = Record<string, unknown>;
19
+ type LocalIpaRow = DbRow & { uid: string; mail: string | null };
16
20
 
17
21
  const log = logger("auth:ipa:sync");
18
22
 
@@ -148,7 +152,7 @@ const transformSyncUser = (raw: Record<string, unknown>): SyncUser => {
148
152
  * `excludedGroupsSet` is hoisted to the caller so we don't re-read settings
149
153
  * once per group.
150
154
  */
151
- const transformSyncGroup = (raw: Record<string, unknown>, excludedGroupsSet: Set<string>): SyncGroup => ({
155
+ const transformSyncGroup = (raw: Record<string, unknown>, excludedGroupsSet: Set<string> = new Set()): SyncGroup => ({
152
156
  cn: freeipa.util.str(raw.cn),
153
157
  description: freeipa.util.str(raw.description) || null,
154
158
  gidnumber: freeipa.util.num(raw.gidnumber),
@@ -224,15 +228,13 @@ export const syncFromIpa = async (): Promise<void> => {
224
228
  ]);
225
229
 
226
230
  const allRawUsers = readIpaList({ response: usersRes, entity: "users" });
227
-
228
- const users = allRawUsers
229
- .filter((raw) => {
230
- const groups = (raw.memberof_group as string[]) ?? [];
231
- return config.groupsBaseSync.some((g) => groups.includes(g));
232
- })
233
- .map(transformSyncUser);
234
-
231
+ const allUsers = allRawUsers.map(transformSyncUser);
235
232
  const allRawGroups = readIpaList({ response: groupsRes, entity: "groups" });
233
+ const effectiveGroupsByUid = buildEffectiveIpaGroupsByUid(allRawGroups.map((raw) => transformSyncGroup(raw)));
234
+ const users = allUsers.filter((user) => {
235
+ const effectiveGroups = effectiveGroupsByUid.get(user.uid) ?? [];
236
+ return config.groupsBaseSync.some((group) => effectiveGroups.includes(group));
237
+ });
236
238
  const groups = allRawGroups.map((raw) => transformSyncGroup(raw, excludedGroupsSet)).filter((g) => !excludedGroupsSet.has(g.cn));
237
239
 
238
240
  const activeUsers = users.filter((u) => !isExpired(u));
@@ -241,7 +243,7 @@ export const syncFromIpa = async (): Promise<void> => {
241
243
  // stale/transition branch so their local mirror is either demoted or deleted per policy,
242
244
  // and their sessions are revoked. Treating expired users as in-scope would leave a stale
243
245
  // unexpired local row plus live sessions.
244
- const inScopeUids = new Set(activeUsers.map((u) => u.uid));
246
+ const remoteGroupCns = new Set(allRawGroups.map((raw) => freeipa.util.str(raw.cn)).filter(Boolean));
245
247
  const groupCns = new Set(groups.map((g) => g.cn));
246
248
  const matchMode = parseIpaMatchMode(await settings.get<string | null>("freeipa.user_match_mode"));
247
249
  const transitionPolicy = parseIpaAccountTransitionPolicy(
@@ -269,7 +271,7 @@ export const syncFromIpa = async (): Promise<void> => {
269
271
  if (activeUsers.length === 0 && localIpaUsers > 0) {
270
272
  throw new Error(`Refusing IPA sync: remote active users list is empty while local has ${localIpaUsers} IPA users`);
271
273
  }
272
- if (groupCns.size === 0 && localGroups > 0) {
274
+ if (remoteGroupCns.size === 0 && localGroups > 0) {
273
275
  throw new Error(`Refusing IPA sync: remote groups list is empty while local has ${localGroups} groups`);
274
276
  }
275
277
 
@@ -279,20 +281,65 @@ export const syncFromIpa = async (): Promise<void> => {
279
281
  WHERE provider = 'ipa'
280
282
  ORDER BY uid
281
283
  `;
282
- const staleLocalUsers = localIpaRows.filter((row) => !inScopeUids.has(row.uid as string));
284
+ const currentProfileByUid = new Map(
285
+ localIpaRows.map((row) => [row.uid as string, row.profile as "user" | "guest"]),
286
+ );
287
+ let profileDriftCount = 0;
288
+ const profileDriftSamples: string[] = [];
289
+ let profilesPromoted = 0;
290
+ let profilesDemoted = 0;
291
+ for (const user of activeUsers) {
292
+ const effectiveGroups = effectiveGroupsByUid.get(user.uid) ?? [];
293
+ const graphProfile = calculateIpaProfileFromGroupNames(effectiveGroups, config.groupsBaseIpaRealm);
294
+ const userSideProfile = calculateIpaProfileFromGroupNames(user.memberofGroup, config.groupsBaseIpaRealm);
295
+ if (graphProfile !== userSideProfile) {
296
+ profileDriftCount += 1;
297
+ if (profileDriftSamples.length < 10) profileDriftSamples.push(user.uid);
298
+ }
299
+
300
+ const previousProfile = currentProfileByUid.get(user.uid);
301
+ if (previousProfile === "guest" && graphProfile === "user") profilesPromoted += 1;
302
+ if (previousProfile === "user" && graphProfile === "guest") profilesDemoted += 1;
303
+ }
304
+
305
+ const localIpaIdentityRows: LocalIpaRow[] = localIpaRows.map((row) => ({
306
+ ...row,
307
+ uid: row.uid as string,
308
+ mail: (row.mail as string | null) ?? null,
309
+ }));
310
+ const staleLocalUsers = selectStaleLocalIpaRows({
311
+ localRows: localIpaIdentityRows,
312
+ activeRemoteUsers: activeUsers.map((user) => ({ uid: user.uid, mail: user.mail })),
313
+ });
283
314
  const staleLimit = Math.max(10, Math.ceil(Math.max(localIpaUsers, 1) * 0.2));
284
315
  if (staleLocalUsers.length > staleLimit) {
285
316
  throw new Error(
286
317
  `Refusing IPA sync: ${staleLocalUsers.length} local IPA users disappeared from sync scope (limit ${staleLimit})`,
287
318
  );
288
319
  }
320
+ if (profilesDemoted > staleLimit) {
321
+ throw new Error(
322
+ `Refusing IPA sync: ${profilesDemoted} IPA users would be downgraded from user to guest (limit ${staleLimit})`,
323
+ );
324
+ }
325
+ if (profilesDemoted > 0) {
326
+ log.warn("IPA sync will downgrade user profiles", { profilesDemoted, limit: staleLimit });
327
+ }
328
+ if (profileDriftCount > 0) {
329
+ log.warn("IPA user memberOf drift detected; using group graph projection", {
330
+ profileDriftCount,
331
+ sampleUids: profileDriftSamples,
332
+ });
333
+ }
289
334
 
290
335
  const staleDemotedUsers: Array<{ id: string; uid: string }> = [];
336
+ let effectiveGroupsRebuilt = 0;
291
337
  await sql.begin(async (tx) => {
292
338
  // 1. Upsert active IPA users
293
339
  // Match order: mail (existing IPA user, handles UID renames) → mail (guest promotion) → uid (new or unchanged)
294
340
  for (const u of activeUsers) {
295
- const profile = await calculateIpaProfile(u.memberofGroup);
341
+ const effectiveGroups = effectiveGroupsByUid.get(u.uid) ?? [];
342
+ const profile = calculateIpaProfileFromGroupNames(effectiveGroups, config.groupsBaseIpaRealm);
296
343
  const provider = "ipa";
297
344
 
298
345
  if (u.mail) {
@@ -473,6 +520,23 @@ export const syncFromIpa = async (): Promise<void> => {
473
520
  const userIdRows: DbRow[] = await tx`SELECT id, uid FROM auth.users WHERE provider = 'ipa'`;
474
521
  const uidToId = new Map<string, string>(userIdRows.map((r) => [r.uid as string, r.id as string]));
475
522
 
523
+ await tx`
524
+ DELETE FROM auth.ipa_user_effective_groups
525
+ WHERE user_id IN (SELECT id FROM auth.users WHERE provider = 'ipa')
526
+ `;
527
+ const effectiveGroupRows: { user_id: string; group_name: string }[] = [];
528
+ for (const [uid, groupNames] of effectiveGroupsByUid) {
529
+ const userId = uidToId.get(uid);
530
+ if (!userId) continue;
531
+ for (const groupName of groupNames) {
532
+ effectiveGroupRows.push({ user_id: userId, group_name: groupName });
533
+ }
534
+ }
535
+ if (effectiveGroupRows.length > 0) {
536
+ await tx`INSERT INTO auth.ipa_user_effective_groups ${sql(effectiveGroupRows, "user_id", "group_name")} ON CONFLICT DO NOTHING`;
537
+ }
538
+ effectiveGroupsRebuilt = effectiveGroupRows.length;
539
+
476
540
  // Bulk INSERT helper. Bun's `sql(rows)` only generates valid VALUES
477
541
  // syntax when fed an array of OBJECTS (it derives the column list from
478
542
  // object keys). Passing array-of-arrays produces broken SQL like
@@ -541,6 +605,11 @@ export const syncFromIpa = async (): Promise<void> => {
541
605
  skippedLocalMailConflicts,
542
606
  skippedLocalUidConflicts,
543
607
  staleUsersDemoted: staleDemotedUsers.length,
608
+ scopeTransitions: staleDemotedUsers.length,
609
+ profileDriftCount,
610
+ profilesPromoted,
611
+ profilesDemoted,
612
+ effectiveGroupsRebuilt,
544
613
  upsertedUsersByUid,
545
614
  insertedUsersByUid,
546
615
  updatedUsersByUid,
@@ -566,8 +635,9 @@ export type SyncUserOutcome =
566
635
 
567
636
  /**
568
637
  * Reconcile a local IPA mirror row after discovering the remote user is no
569
- * longer in sync scope (expired or removed from base-sync groups). Applies the
570
- * configured account-transition policy and revokes all sessions for the user.
638
+ * longer valid because the IPA account is expired. Full sync also uses this
639
+ * transition policy for graph-derived out-of-scope users; single-user sync does
640
+ * not demote based on FreeIPA user-side memberOf data.
571
641
  */
572
642
  const reconcileOutOfScopeUser = async (params: {
573
643
  userId: string;
@@ -632,12 +702,13 @@ const reconcileOutOfScopeUser = async (params: {
632
702
  * Called on login to ensure time-sensitive data is up-to-date.
633
703
  *
634
704
  * IMPORTANT: Only syncs user attributes (name, mail, expiry, aliases).
635
- * Does NOT sync group memberships - those are only synced via periodic syncFromIpa().
636
- * Realm is calculated from LOCAL DB group memberships (optimistically updated by mutations).
705
+ * Does NOT sync group memberships. Scope/profile come from the last full-sync
706
+ * effective group projection; user-side memberOf drift is logged but never used
707
+ * for destructive transitions here.
637
708
  *
638
709
  * Returns a typed outcome so callers (notably `authFlows.ipa.login`) can decide
639
- * whether to grant a session. Non-`synced` outcomes reconcile local mirror state
640
- * where possible (expired / out_of_scope transition policy + session revocation).
710
+ * whether to grant a session. Expired users are reconciled immediately; group
711
+ * scope changes are handled by full sync.
641
712
  */
642
713
  export const syncUser = async (username: string): Promise<SyncUserOutcome> => {
643
714
  const config = await getFreeIpaConfig();
@@ -697,29 +768,30 @@ export const syncUser = async (username: string): Promise<SyncUserOutcome> => {
697
768
  return { status: "expired", userId: existingUserId };
698
769
  }
699
770
 
700
- const inSyncGroups = config.groupsBaseSync.some((g) => user.memberofGroup.includes(g));
701
- if (!inSyncGroups) {
702
- log.warn("User not in sync groups during single-user sync", { username });
703
- if (existingUserId) {
704
- await reconcileOutOfScopeUser({
705
- userId: existingUserId,
706
- uid: user.uid,
707
- mail: (existingRow?.mail as string | null) ?? user.mail,
708
- displayName: (existingRow?.display_name as string | null) ?? user.displayName,
709
- previousProfile,
710
- reason: "sync_out_of_scope_demoted",
711
- meta: { reason: "missing_from_ipa_sync_scope" },
712
- });
713
- }
714
- return { status: "out_of_scope", userId: existingUserId };
715
- }
716
-
717
771
  if (!existingUserId) {
718
772
  log.warn("User not found in local DB during single-user sync", { username });
719
773
  return { status: "not_found_local" };
720
774
  }
721
775
 
722
- const profile = await calculateIpaProfileFromLocalDb(existingUserId);
776
+ const effectiveGroups = await getEffectiveUserGroups(existingUserId);
777
+ const inSyncGroups = config.groupsBaseSync.some((g) => effectiveGroups.includes(g));
778
+ if (!inSyncGroups) {
779
+ log.warn("User not in projected sync groups during single-user sync", {
780
+ username,
781
+ reason: "missing_from_last_full_sync_projection",
782
+ });
783
+ return { status: "out_of_scope", userId: existingUserId };
784
+ }
785
+
786
+ const profile = await calculateIpaProfileFromEffectiveProjection(existingUserId);
787
+ const userSideProfile = calculateIpaProfileFromGroupNames(user.memberofGroup, config.groupsBaseIpaRealm);
788
+ if (profile !== userSideProfile) {
789
+ log.warn("IPA user memberOf drift detected during single-user sync", {
790
+ username,
791
+ projectedProfile: profile,
792
+ userSideProfile,
793
+ });
794
+ }
723
795
  const provider = "ipa";
724
796
 
725
797
  // Update user attributes only (no group sync!)