@valentinkolb/cloud 0.1.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 (196) hide show
  1. package/package.json +69 -0
  2. package/public/logo.svg +1 -0
  3. package/scripts/build.ts +113 -0
  4. package/scripts/preload.ts +73 -0
  5. package/src/_internal/define-app.ts +399 -0
  6. package/src/_internal/heartbeat.ts +33 -0
  7. package/src/_internal/registry.ts +100 -0
  8. package/src/_internal/runtime-context.ts +38 -0
  9. package/src/api/accounts-entities.ts +134 -0
  10. package/src/api/admin-lifecycle.ts +210 -0
  11. package/src/api/auth/schemas.ts +28 -0
  12. package/src/api/auth.ts +230 -0
  13. package/src/api/index.ts +66 -0
  14. package/src/api/me.ts +206 -0
  15. package/src/api/search/schemas.ts +43 -0
  16. package/src/api/search.ts +130 -0
  17. package/src/clients/core.ts +19 -0
  18. package/src/config/env.ts +23 -0
  19. package/src/config/index.ts +6 -0
  20. package/src/config/ssr.ts +58 -0
  21. package/src/contracts/app.ts +140 -0
  22. package/src/contracts/index.ts +5 -0
  23. package/src/contracts/profile.ts +67 -0
  24. package/src/contracts/registry.ts +50 -0
  25. package/src/contracts/settings-types.ts +84 -0
  26. package/src/contracts/shared.ts +258 -0
  27. package/src/contracts/widgets.ts +121 -0
  28. package/src/index.ts +6 -0
  29. package/src/server/api/index.ts +1 -0
  30. package/src/server/api/respond.ts +55 -0
  31. package/src/server/api-client.ts +54 -0
  32. package/src/server/app-context.ts +39 -0
  33. package/src/server/index.ts +62 -0
  34. package/src/server/middleware/auth.ts +168 -0
  35. package/src/server/middleware/index.ts +7 -0
  36. package/src/server/middleware/middleware.ts +47 -0
  37. package/src/server/middleware/openapi.ts +126 -0
  38. package/src/server/middleware/rate-limit.ts +126 -0
  39. package/src/server/middleware/request-logger.ts +41 -0
  40. package/src/server/middleware/validator.ts +35 -0
  41. package/src/server/services/access.ts +294 -0
  42. package/src/server/services/freeipa/client.ts +100 -0
  43. package/src/server/services/freeipa/index.ts +9 -0
  44. package/src/server/services/freeipa/session.ts +78 -0
  45. package/src/server/services/freeipa/tls.ts +48 -0
  46. package/src/server/services/freeipa/util.ts +60 -0
  47. package/src/server/services/geo.ts +154 -0
  48. package/src/server/services/index.ts +28 -0
  49. package/src/server/services/services.ts +13 -0
  50. package/src/services/account-lifecycle/audit.ts +41 -0
  51. package/src/services/account-lifecycle/index.ts +907 -0
  52. package/src/services/account-lifecycle/scheduler.ts +347 -0
  53. package/src/services/account-model.ts +21 -0
  54. package/src/services/accounts/app.ts +966 -0
  55. package/src/services/accounts/authz.ts +22 -0
  56. package/src/services/accounts/base-group.ts +11 -0
  57. package/src/services/accounts/base-user.ts +45 -0
  58. package/src/services/accounts/entities.ts +529 -0
  59. package/src/services/accounts/group-sql.ts +106 -0
  60. package/src/services/accounts/groups.ts +246 -0
  61. package/src/services/accounts/index.ts +14 -0
  62. package/src/services/accounts/ipa-data.ts +64 -0
  63. package/src/services/accounts/lifecycle.ts +2 -0
  64. package/src/services/accounts/local-groups.ts +491 -0
  65. package/src/services/accounts/model.ts +135 -0
  66. package/src/services/accounts/switching.ts +117 -0
  67. package/src/services/accounts/users.ts +714 -0
  68. package/src/services/auth-flows/index.ts +6 -0
  69. package/src/services/auth-flows/ipa.ts +128 -0
  70. package/src/services/auth-flows/magic-link.ts +119 -0
  71. package/src/services/freeipa-config.ts +89 -0
  72. package/src/services/index.ts +46 -0
  73. package/src/services/ipa/auth.ts +122 -0
  74. package/src/services/ipa/groups.ts +684 -0
  75. package/src/services/ipa/guard.ts +17 -0
  76. package/src/services/ipa/index.ts +17 -0
  77. package/src/services/ipa/profile.ts +90 -0
  78. package/src/services/ipa/search.ts +154 -0
  79. package/src/services/ipa/sync.ts +740 -0
  80. package/src/services/ipa/users.ts +794 -0
  81. package/src/services/logging/index.ts +294 -0
  82. package/src/services/notifications/email.ts +123 -0
  83. package/src/services/notifications/index.ts +413 -0
  84. package/src/services/postgres.ts +51 -0
  85. package/src/services/providers/index.ts +27 -0
  86. package/src/services/providers/local/auth.ts +13 -0
  87. package/src/services/providers/local/index.ts +4 -0
  88. package/src/services/providers/local/users.ts +255 -0
  89. package/src/services/session/index.ts +137 -0
  90. package/src/services/settings/api.ts +61 -0
  91. package/src/services/settings/app.ts +101 -0
  92. package/src/services/settings/crypto.ts +69 -0
  93. package/src/services/settings/defaults.ts +824 -0
  94. package/src/services/settings/index.ts +203 -0
  95. package/src/services/settings/namespace.ts +9 -0
  96. package/src/services/settings/snapshot.ts +49 -0
  97. package/src/services/settings/store.ts +179 -0
  98. package/src/services/settings/templates.ts +10 -0
  99. package/src/services/weather/forecast.ts +287 -0
  100. package/src/services/weather/geo.ts +110 -0
  101. package/src/services/weather/index.ts +99 -0
  102. package/src/services/weather/location.ts +24 -0
  103. package/src/services/weather/locations.ts +125 -0
  104. package/src/services/weather/migrate.ts +22 -0
  105. package/src/services/weather/types.ts +61 -0
  106. package/src/services/weather/ui.ts +50 -0
  107. package/src/shared/account-display.ts +17 -0
  108. package/src/shared/account-session.ts +15 -0
  109. package/src/shared/icons.ts +109 -0
  110. package/src/shared/index.ts +10 -0
  111. package/src/shared/markdown/client.ts +130 -0
  112. package/src/shared/markdown/extensions/code.ts +58 -0
  113. package/src/shared/markdown/extensions/images.ts +43 -0
  114. package/src/shared/markdown/extensions/info-blocks.ts +93 -0
  115. package/src/shared/markdown/extensions/katex.ts +120 -0
  116. package/src/shared/markdown/extensions/links.ts +34 -0
  117. package/src/shared/markdown/extensions/tables.ts +88 -0
  118. package/src/shared/markdown/extensions/task-list.ts +53 -0
  119. package/src/shared/markdown/index.ts +97 -0
  120. package/src/shared/markdown/shared.ts +36 -0
  121. package/src/ssr/AdminLayout.tsx +42 -0
  122. package/src/ssr/AdminSidebar.tsx +95 -0
  123. package/src/ssr/Footer.island.tsx +62 -0
  124. package/src/ssr/GlobalSearchDialog.tsx +389 -0
  125. package/src/ssr/GlobalSearchHelpDialog.tsx +106 -0
  126. package/src/ssr/GlobalSearchTrigger.island.tsx +42 -0
  127. package/src/ssr/HotkeysHelpRail.island.tsx +99 -0
  128. package/src/ssr/Layout.tsx +326 -0
  129. package/src/ssr/MoreAppsDropdown.island.tsx +61 -0
  130. package/src/ssr/NavMenu.island.tsx +108 -0
  131. package/src/ssr/ThemeToggleRail.island.tsx +27 -0
  132. package/src/ssr/index.ts +5 -0
  133. package/src/ssr/islands/SearchBar.island.tsx +77 -0
  134. package/src/ssr/islands/index.ts +1 -0
  135. package/src/ssr/runtime.ts +22 -0
  136. package/src/styles/base-popover.css +28 -0
  137. package/src/styles/effects.css +65 -0
  138. package/src/styles/global.css +133 -0
  139. package/src/styles/input.css +54 -0
  140. package/src/styles/tokens.css +35 -0
  141. package/src/styles/utilities-buttons.css +125 -0
  142. package/src/styles/utilities-feedback.css +65 -0
  143. package/src/styles/utilities-layout.css +122 -0
  144. package/src/styles/utilities-navigation.css +196 -0
  145. package/src/types/ambient.d.ts +8 -0
  146. package/src/ui/admin-settings.tsx +148 -0
  147. package/src/ui/dialog-core.ts +146 -0
  148. package/src/ui/filter/FilterChip.tsx +196 -0
  149. package/src/ui/filter/index.ts +2 -0
  150. package/src/ui/index.ts +19 -0
  151. package/src/ui/input/Checkbox.tsx +55 -0
  152. package/src/ui/input/ColorInput.tsx +122 -0
  153. package/src/ui/input/DateTimeInput.tsx +86 -0
  154. package/src/ui/input/ImageInput.tsx +170 -0
  155. package/src/ui/input/NumberInput.tsx +113 -0
  156. package/src/ui/input/PinInput.tsx +169 -0
  157. package/src/ui/input/SegmentedControl.tsx +99 -0
  158. package/src/ui/input/Select.tsx +288 -0
  159. package/src/ui/input/SelectChip.tsx +61 -0
  160. package/src/ui/input/Slider.tsx +118 -0
  161. package/src/ui/input/Switch.tsx +62 -0
  162. package/src/ui/input/TagsInput.tsx +115 -0
  163. package/src/ui/input/TextInput.tsx +160 -0
  164. package/src/ui/input/index.ts +13 -0
  165. package/src/ui/input/types.ts +42 -0
  166. package/src/ui/input/util.tsx +105 -0
  167. package/src/ui/ipa/Avatar.tsx +28 -0
  168. package/src/ui/ipa/GroupView.tsx +36 -0
  169. package/src/ui/ipa/LoginBtn.tsx +16 -0
  170. package/src/ui/ipa/UserView.tsx +58 -0
  171. package/src/ui/ipa/index.ts +4 -0
  172. package/src/ui/misc/ContextMenu.tsx +211 -0
  173. package/src/ui/misc/CopyButton.tsx +28 -0
  174. package/src/ui/misc/Dropdown.tsx +194 -0
  175. package/src/ui/misc/EntitySearch.tsx +213 -0
  176. package/src/ui/misc/Lightbox.tsx +194 -0
  177. package/src/ui/misc/LinkCard.tsx +34 -0
  178. package/src/ui/misc/LogEntriesTable.tsx +61 -0
  179. package/src/ui/misc/MarkdownView.tsx +65 -0
  180. package/src/ui/misc/Pagination.tsx +51 -0
  181. package/src/ui/misc/PermissionEditor.tsx +379 -0
  182. package/src/ui/misc/ProgressBar.tsx +47 -0
  183. package/src/ui/misc/RemoveBtn.tsx +27 -0
  184. package/src/ui/misc/StatCell.tsx +90 -0
  185. package/src/ui/misc/index.ts +18 -0
  186. package/src/ui/navigation.ts +32 -0
  187. package/src/ui/prompts.tsx +854 -0
  188. package/src/ui/sidebar.tsx +468 -0
  189. package/src/ui/widgets/Widget.tsx +62 -0
  190. package/src/ui/widgets/WidgetCard.tsx +19 -0
  191. package/src/ui/widgets/WidgetHero.tsx +39 -0
  192. package/src/ui/widgets/WidgetList.tsx +84 -0
  193. package/src/ui/widgets/WidgetPills.tsx +68 -0
  194. package/src/ui/widgets/WidgetStat.tsx +67 -0
  195. package/src/ui/widgets/WidgetStatus.tsx +62 -0
  196. package/src/ui/widgets/index.ts +9 -0
@@ -0,0 +1,740 @@
1
+ import { sql } from "bun";
2
+ import { applyIpaAccountTransitionPolicy } from "../accounts/switching";
3
+ import {
4
+ parseIpaAccountTransitionPolicy,
5
+ parseIpaMatchMode,
6
+ } from "../account-model";
7
+ import { writeDeletedAccountAudit } from "../account-lifecycle/audit";
8
+ import { logger } from "../logging";
9
+ import * as settings from "../settings";
10
+ import { session } from "../session";
11
+ import { freeipa } from "../../server/services";
12
+ import { getFreeIpaConfig } from "../freeipa-config";
13
+ import { calculateIpaProfile, calculateIpaProfileFromLocalDb } from "./profile";
14
+
15
+ type DbRow = Record<string, unknown>;
16
+
17
+ const log = logger("auth:ipa:sync");
18
+
19
+ const upsertUserIpaData = async (
20
+ db: typeof sql,
21
+ params: {
22
+ userId: string;
23
+ uidNumber: number | null;
24
+ phone: string | null;
25
+ ipaPasswordExpires: Date | null;
26
+ lastLoginIpa: Date | null;
27
+ employeeType: string | null;
28
+ addrStreet: string | null;
29
+ addrPostalCode: string | null;
30
+ addrCity: string | null;
31
+ addrState: string | null;
32
+ mobile: string | null;
33
+ sshPublicKeys: string[];
34
+ sshFingerprints: string[];
35
+ },
36
+ ) =>
37
+ db`
38
+ INSERT INTO auth.user_ipa_data (
39
+ user_id, uid_number, phone, employee_type, mobile, addr_street, addr_postal_code,
40
+ addr_city, addr_state, ipa_password_expires, last_login_ipa, synced_at, ssh_public_keys, ssh_fingerprints
41
+ )
42
+ VALUES (
43
+ ${params.userId},
44
+ ${params.uidNumber},
45
+ ${params.phone},
46
+ ${params.employeeType},
47
+ ${params.mobile},
48
+ ${params.addrStreet},
49
+ ${params.addrPostalCode},
50
+ ${params.addrCity},
51
+ ${params.addrState},
52
+ ${params.ipaPasswordExpires},
53
+ ${params.lastLoginIpa},
54
+ now(),
55
+ ${freeipa.util.toPgTextArray(params.sshPublicKeys)}::text[],
56
+ ${freeipa.util.toPgTextArray(params.sshFingerprints)}::text[]
57
+ )
58
+ ON CONFLICT (user_id) DO UPDATE SET
59
+ uid_number = EXCLUDED.uid_number,
60
+ phone = EXCLUDED.phone,
61
+ employee_type = EXCLUDED.employee_type,
62
+ mobile = EXCLUDED.mobile,
63
+ addr_street = EXCLUDED.addr_street,
64
+ addr_postal_code = EXCLUDED.addr_postal_code,
65
+ addr_city = EXCLUDED.addr_city,
66
+ addr_state = EXCLUDED.addr_state,
67
+ ipa_password_expires = EXCLUDED.ipa_password_expires,
68
+ last_login_ipa = EXCLUDED.last_login_ipa,
69
+ synced_at = EXCLUDED.synced_at,
70
+ ssh_public_keys = EXCLUDED.ssh_public_keys,
71
+ ssh_fingerprints = EXCLUDED.ssh_fingerprints
72
+ `;
73
+
74
+ // ==========================
75
+ // Sync Types
76
+ // ==========================
77
+
78
+ type SyncUser = {
79
+ uid: string;
80
+ uidNumber: number | null;
81
+ givenname: string;
82
+ sn: string;
83
+ displayName: string;
84
+ mail: string | null;
85
+ phone: string | null;
86
+ ipaAccountExpires: Date | null;
87
+ ipaPasswordExpires: Date | null;
88
+ lastLoginIpa: Date | null;
89
+ memberofGroup: string[];
90
+ employeeType: string | null;
91
+ addrStreet: string | null;
92
+ addrPostalCode: string | null;
93
+ addrCity: string | null;
94
+ addrState: string | null;
95
+ mobile: string | null;
96
+ sshPublicKeys: string[];
97
+ sshFingerprints: string[];
98
+ };
99
+
100
+ type SyncGroup = {
101
+ cn: string;
102
+ description: string | null;
103
+ gidnumber: number | null;
104
+ users: string[];
105
+ groups: string[];
106
+ parentGroups: string[];
107
+ managerUsers: string[];
108
+ managerGroups: string[];
109
+ };
110
+
111
+ type IpaCallResponse = Awaited<ReturnType<typeof freeipa.client.call>>;
112
+
113
+ // ==========================
114
+ // Transform helpers
115
+ // ==========================
116
+
117
+ /**
118
+ * Normalizes one raw IPA user record into the sync user model.
119
+ */
120
+ const transformSyncUser = (raw: Record<string, unknown>): SyncUser => {
121
+ const directGroups = (raw.memberof_group as string[]) ?? [];
122
+ const indirectGroups = (raw.memberofindirect_group as string[]) ?? [];
123
+ return {
124
+ uid: freeipa.util.str(raw.uid),
125
+ uidNumber: freeipa.util.num(raw.uidnumber),
126
+ givenname: freeipa.util.str(raw.givenname),
127
+ sn: freeipa.util.str(raw.sn),
128
+ displayName: freeipa.util.str(raw.displayname) || [freeipa.util.str(raw.givenname), freeipa.util.str(raw.sn)].filter(Boolean).join(" ") || freeipa.util.str(raw.uid),
129
+ mail: freeipa.util.str(raw.mail) || null,
130
+ phone: freeipa.util.str(raw.telephonenumber) || null,
131
+ ipaAccountExpires: freeipa.util.parseGeneralizedTime(raw.krbprincipalexpiration),
132
+ ipaPasswordExpires: freeipa.util.parseGeneralizedTime(raw.krbpasswordexpiration),
133
+ lastLoginIpa: freeipa.util.parseGeneralizedTime(raw.krblastsuccessfulauth),
134
+ memberofGroup: [...directGroups, ...indirectGroups],
135
+ employeeType: freeipa.util.str(raw.employeetype) || null,
136
+ addrStreet: freeipa.util.str(raw.street) || null,
137
+ addrPostalCode: freeipa.util.str(raw.postalcode) || null,
138
+ addrCity: freeipa.util.str(raw.l) || null,
139
+ addrState: freeipa.util.str(raw.st) || null,
140
+ mobile: freeipa.util.str(raw.mobile) || null,
141
+ sshPublicKeys: Array.isArray(raw.ipasshpubkey) ? raw.ipasshpubkey : [],
142
+ sshFingerprints: Array.isArray(raw.sshpubkeyfp) ? raw.sshpubkeyfp : [],
143
+ };
144
+ };
145
+
146
+ /**
147
+ * Normalizes one raw IPA group record into the sync group model.
148
+ * `excludedGroupsSet` is hoisted to the caller so we don't re-read settings
149
+ * once per group.
150
+ */
151
+ const transformSyncGroup = (raw: Record<string, unknown>, excludedGroupsSet: Set<string>): SyncGroup => ({
152
+ cn: freeipa.util.str(raw.cn),
153
+ description: freeipa.util.str(raw.description) || null,
154
+ gidnumber: freeipa.util.num(raw.gidnumber),
155
+ users: (raw.member_user as string[]) ?? [],
156
+ groups: ((raw.member_group as string[]) ?? []).filter((g) => !excludedGroupsSet.has(g)),
157
+ parentGroups: ((raw.memberof_group as string[]) ?? []).filter((g) => !excludedGroupsSet.has(g)),
158
+ managerUsers: (raw.membermanager_user as string[]) ?? [],
159
+ managerGroups: ((raw.membermanager_group as string[]) ?? []).filter((g) => !excludedGroupsSet.has(g)),
160
+ });
161
+
162
+ /**
163
+ * Evaluates whether an IPA account is already expired based on account metadata.
164
+ */
165
+ const isExpired = (u: SyncUser): boolean => {
166
+ if (!u.ipaAccountExpires) return false;
167
+ return u.ipaAccountExpires < new Date();
168
+ };
169
+
170
+ /**
171
+ * Reads one IPA list response and throws for transport/protocol failures.
172
+ * Sync must fail hard on invalid payloads to avoid destructive partial updates.
173
+ */
174
+ const readIpaList = (config: { response: IpaCallResponse; entity: string }): Record<string, unknown>[] => {
175
+ if (config.response.error) {
176
+ throw new Error(`IPA ${config.entity} fetch failed: ${config.response.error.message}`);
177
+ }
178
+
179
+ const records = config.response.result?.result;
180
+ if (!Array.isArray(records)) {
181
+ throw new Error(`IPA ${config.entity} fetch returned invalid list payload`);
182
+ }
183
+
184
+ return records as Record<string, unknown>[];
185
+ };
186
+
187
+ // ==========================
188
+ // Sync
189
+ // ==========================
190
+
191
+ /**
192
+ * Runs a full IPA-to-local sync pass for users, groups, and memberships.
193
+ */
194
+ export const syncFromIpa = async (): Promise<void> => {
195
+ const startedAt = Date.now();
196
+ const config = await getFreeIpaConfig();
197
+ if (!config.enabled) {
198
+ log.info("Sync skipped", { reason: "freeipa_disabled" });
199
+ return;
200
+ }
201
+ if (!config.configured) {
202
+ throw new Error("FreeIPA is enabled but not fully configured.");
203
+ }
204
+ const excludedGroupsSet = freeipa.util.toExcludedGroupsSet(config.groupsExcluded);
205
+ const ipaSession = await freeipa.session.getServiceSession({
206
+ url: config.url,
207
+ serviceUser: config.serviceUser,
208
+ servicePassword: config.servicePassword,
209
+ });
210
+
211
+ const [usersRes, groupsRes] = await Promise.all([
212
+ freeipa.client.call({ url: config.url, ipaSession, method: "user_find", args: [], options: { sizelimit: 0, all: true } }),
213
+ freeipa.client.call({
214
+ url: config.url,
215
+ ipaSession,
216
+ method: "group_find",
217
+ args: [],
218
+ options: {
219
+ sizelimit: 0,
220
+ no_members: false,
221
+ all: true,
222
+ },
223
+ }),
224
+ ]);
225
+
226
+ 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
+
235
+ const allRawGroups = readIpaList({ response: groupsRes, entity: "groups" });
236
+ const groups = allRawGroups.map((raw) => transformSyncGroup(raw, excludedGroupsSet)).filter((g) => !excludedGroupsSet.has(g.cn));
237
+
238
+ const activeUsers = users.filter((u) => !isExpired(u));
239
+ const expiredUsers = users.length - activeUsers.length;
240
+ // Only ACTIVE remote users are considered in scope. Expired ones fall through to the
241
+ // stale/transition branch so their local mirror is either demoted or deleted per policy,
242
+ // and their sessions are revoked. Treating expired users as in-scope would leave a stale
243
+ // unexpired local row plus live sessions.
244
+ const inScopeUids = new Set(activeUsers.map((u) => u.uid));
245
+ const groupCns = new Set(groups.map((g) => g.cn));
246
+ const matchMode = parseIpaMatchMode(await settings.get<string | null>("freeipa.user_match_mode"));
247
+ const transitionPolicy = parseIpaAccountTransitionPolicy(
248
+ await settings.get<string | null>("freeipa.account_transition_policy"),
249
+ );
250
+
251
+ let matchedExistingUsersByMail = 0;
252
+ let migratedLocalUsers = 0;
253
+ let skippedLocalMailConflicts = 0;
254
+ let skippedLocalUidConflicts = 0;
255
+ let upsertedUsersByUid = 0;
256
+ let insertedUsersByUid = 0;
257
+ let updatedUsersByUid = 0;
258
+ let deletedGroups = 0;
259
+
260
+ const [localCountsRow] = await sql<DbRow[]>`
261
+ SELECT
262
+ (SELECT COUNT(*)::int FROM auth.users WHERE provider = 'ipa') AS ipa_users,
263
+ (SELECT COUNT(*)::int FROM auth.groups WHERE provider = 'ipa') AS groups
264
+ `;
265
+
266
+ const localIpaUsers = Number(localCountsRow?.ipa_users ?? 0);
267
+ const localGroups = Number(localCountsRow?.groups ?? 0);
268
+
269
+ if (activeUsers.length === 0 && localIpaUsers > 0) {
270
+ throw new Error(`Refusing IPA sync: remote active users list is empty while local has ${localIpaUsers} IPA users`);
271
+ }
272
+ if (groupCns.size === 0 && localGroups > 0) {
273
+ throw new Error(`Refusing IPA sync: remote groups list is empty while local has ${localGroups} groups`);
274
+ }
275
+
276
+ const localIpaRows = await sql<DbRow[]>`
277
+ SELECT id, uid, mail, display_name, profile
278
+ FROM auth.users
279
+ WHERE provider = 'ipa'
280
+ ORDER BY uid
281
+ `;
282
+ const staleLocalUsers = localIpaRows.filter((row) => !inScopeUids.has(row.uid as string));
283
+ const staleLimit = Math.max(10, Math.ceil(Math.max(localIpaUsers, 1) * 0.2));
284
+ if (staleLocalUsers.length > staleLimit) {
285
+ throw new Error(
286
+ `Refusing IPA sync: ${staleLocalUsers.length} local IPA users disappeared from sync scope (limit ${staleLimit})`,
287
+ );
288
+ }
289
+
290
+ const staleDemotedUsers: Array<{ id: string; uid: string }> = [];
291
+ await sql.begin(async (tx) => {
292
+ // 1. Upsert active IPA users
293
+ // Match order: mail (existing IPA user, handles UID renames) → mail (guest promotion) → uid (new or unchanged)
294
+ for (const u of activeUsers) {
295
+ const profile = await calculateIpaProfile(u.memberofGroup);
296
+ const provider = "ipa";
297
+
298
+ if (u.mail) {
299
+ // First: match existing IPA user by mail (handles UID renames)
300
+ const updated = await tx`
301
+ UPDATE auth.users SET
302
+ uid = ${u.uid}, provider = ${provider}, profile = ${profile}, admin = false,
303
+ given_name = ${u.givenname}, sn = ${u.sn},
304
+ display_name = ${u.displayName}, mail = ${u.mail},
305
+ account_expires = ${u.ipaAccountExpires}
306
+ WHERE mail = ${u.mail} AND provider = 'ipa'
307
+ RETURNING id`;
308
+ if (updated.length > 0) {
309
+ await upsertUserIpaData(tx, { userId: updated[0]!.id as string, ...u });
310
+ matchedExistingUsersByMail += 1;
311
+ continue;
312
+ }
313
+
314
+ // Second: optionally migrate a unique local account to IPA.
315
+ if (matchMode === "migrate") {
316
+ const localMatches = await tx<DbRow[]>`
317
+ SELECT id
318
+ FROM auth.users
319
+ WHERE mail = ${u.mail} AND provider = 'local'
320
+ ORDER BY profile = 'user' DESC, created_at ASC
321
+ LIMIT 2
322
+ `;
323
+
324
+ if (localMatches.length === 1) {
325
+ const migrated = await tx`
326
+ UPDATE auth.users SET
327
+ uid = ${u.uid}, provider = ${provider}, profile = ${profile}, admin = false,
328
+ given_name = ${u.givenname}, sn = ${u.sn},
329
+ display_name = ${u.displayName}, mail = ${u.mail},
330
+ account_expires = ${u.ipaAccountExpires}
331
+ WHERE id = ${localMatches[0]!.id as string}::uuid
332
+ RETURNING id`;
333
+ if (migrated.length > 0) {
334
+ await upsertUserIpaData(tx, { userId: migrated[0]!.id as string, ...u });
335
+ migratedLocalUsers += 1;
336
+ continue;
337
+ }
338
+ } else if (localMatches.length > 1) {
339
+ skippedLocalMailConflicts += 1;
340
+ log.warn("Skipping IPA provider migration because multiple local accounts matched by mail", {
341
+ mail: u.mail,
342
+ uid: u.uid,
343
+ });
344
+ continue;
345
+ }
346
+ }
347
+ }
348
+
349
+ const uidConflictRows = await tx<DbRow[]>`
350
+ SELECT id
351
+ FROM auth.users
352
+ WHERE uid = ${u.uid} AND provider = 'local'
353
+ LIMIT 1
354
+ `;
355
+ if (uidConflictRows.length > 0) {
356
+ skippedLocalUidConflicts += 1;
357
+ log.warn("Skipping IPA sync user upsert because a local account already uses the UID", {
358
+ uid: u.uid,
359
+ mail: u.mail,
360
+ });
361
+ continue;
362
+ }
363
+
364
+ // Third: insert new or update existing by uid
365
+ const upserted = await tx<DbRow[]>`
366
+ INSERT INTO auth.users (uid, provider, profile, admin, given_name, sn, display_name, mail, account_expires)
367
+ VALUES (${u.uid}, ${provider}, ${profile}, false, ${u.givenname}, ${u.sn}, ${
368
+ u.displayName
369
+ }, ${u.mail}, ${u.ipaAccountExpires})
370
+ ON CONFLICT (uid) DO UPDATE SET
371
+ provider = ${provider},
372
+ profile = ${profile},
373
+ admin = false,
374
+ given_name = EXCLUDED.given_name, sn = EXCLUDED.sn,
375
+ display_name = EXCLUDED.display_name, mail = EXCLUDED.mail,
376
+ account_expires = EXCLUDED.account_expires
377
+ RETURNING id, (xmax = 0) AS inserted`;
378
+ await upsertUserIpaData(tx, { userId: upserted[0]!.id as string, ...u });
379
+ upsertedUsersByUid += 1;
380
+ if (Boolean(upserted[0]?.inserted)) insertedUsersByUid += 1;
381
+ else updatedUsersByUid += 1;
382
+ }
383
+
384
+ for (const stale of staleLocalUsers) {
385
+ const userId = stale.id as string;
386
+ const uid = stale.uid as string;
387
+ const previousProfile = (stale.profile as "user" | "guest" | null) ?? "guest";
388
+
389
+ if (transitionPolicy === "delete") {
390
+ await writeDeletedAccountAudit({
391
+ db: tx,
392
+ userId,
393
+ uid,
394
+ mail: (stale.mail as string) ?? null,
395
+ displayName: (stale.display_name as string) ?? null,
396
+ previousProvider: "ipa",
397
+ previousProfile,
398
+ reason: "sync_out_of_scope_deleted",
399
+ meta: {
400
+ reason: "missing_from_ipa_sync_scope",
401
+ },
402
+ });
403
+ await tx`DELETE FROM auth.users WHERE id = ${userId}::uuid`;
404
+ staleDemotedUsers.push({ id: userId, uid });
405
+ continue;
406
+ }
407
+
408
+ const target = await applyIpaAccountTransitionPolicy({
409
+ userId,
410
+ currentProfile: previousProfile,
411
+ policy: transitionPolicy,
412
+ db: tx,
413
+ });
414
+ await writeDeletedAccountAudit({
415
+ db: tx,
416
+ userId,
417
+ uid,
418
+ mail: (stale.mail as string) ?? null,
419
+ displayName: (stale.display_name as string) ?? null,
420
+ previousProvider: "ipa",
421
+ previousProfile,
422
+ reason: "sync_out_of_scope_demoted",
423
+ meta: {
424
+ accountExpiresAt: target.accountExpires?.toISOString() ?? null,
425
+ targetProfile: target.targetProfile,
426
+ reason: "missing_from_ipa_sync_scope",
427
+ },
428
+ });
429
+ staleDemotedUsers.push({ id: userId, uid });
430
+ }
431
+
432
+ // 2. Upsert groups + delete stale
433
+ for (const g of groups) {
434
+ await tx`
435
+ INSERT INTO auth.groups (id, cn, name, provider, description, gid_number, synced_at)
436
+ VALUES (gen_random_uuid(), ${g.cn}, ${g.cn}, 'ipa', ${g.description}, ${g.gidnumber}, now())
437
+ ON CONFLICT (provider, name) DO UPDATE SET
438
+ cn = EXCLUDED.cn,
439
+ description = EXCLUDED.description,
440
+ gid_number = EXCLUDED.gid_number,
441
+ synced_at = now()`;
442
+ }
443
+ const groupCnArray = [...groupCns];
444
+ if (groupCnArray.length > 0) {
445
+ const deleted = await tx<DbRow[]>`
446
+ DELETE FROM auth.groups
447
+ WHERE provider = 'ipa'
448
+ AND name <> ALL(${freeipa.util.toPgTextArray(groupCnArray)}::text[])
449
+ RETURNING name
450
+ `;
451
+ deletedGroups = deleted.length;
452
+ } else {
453
+ log.warn("Skipping stale group deletion because resolved group list is empty");
454
+ }
455
+
456
+ const groupRows = await tx<DbRow[]>`SELECT id, name FROM auth.groups WHERE provider = 'ipa'`;
457
+ const groupNameToId = new Map<string, string>(groupRows.map((row) => [row.name as string, row.id as string]));
458
+
459
+ // 3. Rebuild junction tables
460
+ await tx`DELETE FROM auth.user_groups_v2 WHERE group_id IN (SELECT id FROM auth.groups WHERE provider = 'ipa')`;
461
+ await tx`
462
+ DELETE FROM auth.group_groups_v2
463
+ WHERE parent_group_id IN (SELECT id FROM auth.groups WHERE provider = 'ipa')
464
+ OR child_group_id IN (SELECT id FROM auth.groups WHERE provider = 'ipa')
465
+ `;
466
+ await tx`DELETE FROM auth.group_manager_users_v2 WHERE group_id IN (SELECT id FROM auth.groups WHERE provider = 'ipa')`;
467
+ await tx`
468
+ DELETE FROM auth.group_manager_groups_v2
469
+ WHERE group_id IN (SELECT id FROM auth.groups WHERE provider = 'ipa')
470
+ OR manager_group_id IN (SELECT id FROM auth.groups WHERE provider = 'ipa')
471
+ `;
472
+
473
+ const userIdRows: DbRow[] = await tx`SELECT id, uid FROM auth.users WHERE provider = 'ipa'`;
474
+ const uidToId = new Map<string, string>(userIdRows.map((r) => [r.uid as string, r.id as string]));
475
+
476
+ // Bulk INSERT helper. Bun's `sql(rows)` only generates valid VALUES
477
+ // syntax when fed an array of OBJECTS (it derives the column list from
478
+ // object keys). Passing array-of-arrays produces broken SQL like
479
+ // `VALUES ("0", "1") VALUES($1, $2),...` — see Bun postgres bug. So we
480
+ // build typed objects and use the column-form `sql(rows, "col_a", "col_b")`
481
+ // to control column ordering explicitly.
482
+
483
+ // user_groups — built from group's member_user (authoritative source)
484
+ const userGroupRows: { user_id: string; group_id: string }[] = [];
485
+ for (const g of groups) {
486
+ const groupId = groupNameToId.get(g.cn);
487
+ if (!groupId) continue;
488
+ for (const uid of g.users) {
489
+ const userId = uidToId.get(uid);
490
+ if (userId) userGroupRows.push({ user_id: userId, group_id: groupId });
491
+ }
492
+ }
493
+ if (userGroupRows.length > 0) {
494
+ await tx`INSERT INTO auth.user_groups_v2 ${sql(userGroupRows, "user_id", "group_id")} ON CONFLICT DO NOTHING`;
495
+ }
496
+
497
+ // group_groups + manager junctions
498
+ const groupGroupRows: { parent_group_id: string; child_group_id: string }[] = [];
499
+ const managerUserRows: { group_id: string; user_id: string }[] = [];
500
+ const managerGroupRows: { group_id: string; manager_group_id: string }[] = [];
501
+ for (const g of groups) {
502
+ const groupId = groupNameToId.get(g.cn);
503
+ if (!groupId) continue;
504
+ for (const child of g.groups) {
505
+ const childGroupId = groupNameToId.get(child);
506
+ if (childGroupId) groupGroupRows.push({ parent_group_id: groupId, child_group_id: childGroupId });
507
+ }
508
+ for (const uid of g.managerUsers) {
509
+ const userId = uidToId.get(uid);
510
+ if (userId) managerUserRows.push({ group_id: groupId, user_id: userId });
511
+ }
512
+ for (const mgr of g.managerGroups) {
513
+ const managerGroupId = groupNameToId.get(mgr);
514
+ if (managerGroupId) managerGroupRows.push({ group_id: groupId, manager_group_id: managerGroupId });
515
+ }
516
+ }
517
+ if (groupGroupRows.length > 0) {
518
+ await tx`INSERT INTO auth.group_groups_v2 ${sql(groupGroupRows, "parent_group_id", "child_group_id")} ON CONFLICT DO NOTHING`;
519
+ }
520
+ if (managerUserRows.length > 0) {
521
+ await tx`INSERT INTO auth.group_manager_users_v2 ${sql(managerUserRows, "group_id", "user_id")} ON CONFLICT DO NOTHING`;
522
+ }
523
+ if (managerGroupRows.length > 0) {
524
+ await tx`INSERT INTO auth.group_manager_groups_v2 ${sql(managerGroupRows, "group_id", "manager_group_id")} ON CONFLICT DO NOTHING`;
525
+ }
526
+
527
+ });
528
+
529
+ for (const staleUser of staleDemotedUsers) {
530
+ await session.revokeAllForUser(staleUser.id);
531
+ }
532
+
533
+ log.info("Sync complete", {
534
+ durationMs: Date.now() - startedAt,
535
+ remoteUsersFetched: allRawUsers.length,
536
+ remoteUsersInScope: users.length,
537
+ remoteExpiredUsers: expiredUsers,
538
+ activeUsersSynced: activeUsers.length,
539
+ matchedExistingUsersByMail,
540
+ migratedLocalUsers,
541
+ skippedLocalMailConflicts,
542
+ skippedLocalUidConflicts,
543
+ staleUsersDemoted: staleDemotedUsers.length,
544
+ upsertedUsersByUid,
545
+ insertedUsersByUid,
546
+ updatedUsersByUid,
547
+ groupsSynced: groups.length,
548
+ deletedGroups,
549
+ localIpaUsersBefore: localIpaUsers,
550
+ localGroupsBefore: localGroups,
551
+ });
552
+ };
553
+
554
+ /**
555
+ * Outcome of a single-user sync attempt. The login flow must reject any outcome
556
+ * other than `synced`; stale mirror state must never grant a fresh session when
557
+ * FreeIPA says the user is expired, missing, or out of sync scope.
558
+ */
559
+ export type SyncUserOutcome =
560
+ | { status: "synced"; userId: string }
561
+ | { status: "skipped_disabled" }
562
+ | { status: "fetch_failed"; error: string }
563
+ | { status: "expired"; userId: string | null }
564
+ | { status: "out_of_scope"; userId: string | null }
565
+ | { status: "not_found_local" };
566
+
567
+ /**
568
+ * 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.
571
+ */
572
+ const reconcileOutOfScopeUser = async (params: {
573
+ userId: string;
574
+ uid: string;
575
+ mail: string | null;
576
+ displayName: string | null;
577
+ previousProfile: "user" | "guest";
578
+ reason: "ipa_expired_demoted" | "ipa_expired_deleted" | "sync_out_of_scope_demoted" | "sync_out_of_scope_deleted";
579
+ meta?: Record<string, unknown>;
580
+ }): Promise<void> => {
581
+ const transitionPolicy = parseIpaAccountTransitionPolicy(
582
+ await settings.get<string | null>("freeipa.account_transition_policy"),
583
+ );
584
+
585
+ await sql.begin(async (tx) => {
586
+ if (transitionPolicy === "delete") {
587
+ await writeDeletedAccountAudit({
588
+ db: tx,
589
+ userId: params.userId,
590
+ uid: params.uid,
591
+ mail: params.mail,
592
+ displayName: params.displayName,
593
+ previousProvider: "ipa",
594
+ previousProfile: params.previousProfile,
595
+ reason: params.reason.endsWith("_demoted")
596
+ ? (params.reason.replace("_demoted", "_deleted") as typeof params.reason)
597
+ : params.reason,
598
+ meta: params.meta ?? {},
599
+ });
600
+ await tx`DELETE FROM auth.users WHERE id = ${params.userId}::uuid`;
601
+ return;
602
+ }
603
+
604
+ const target = await applyIpaAccountTransitionPolicy({
605
+ userId: params.userId,
606
+ currentProfile: params.previousProfile,
607
+ policy: transitionPolicy,
608
+ db: tx,
609
+ });
610
+ await writeDeletedAccountAudit({
611
+ db: tx,
612
+ userId: params.userId,
613
+ uid: params.uid,
614
+ mail: params.mail,
615
+ displayName: params.displayName,
616
+ previousProvider: "ipa",
617
+ previousProfile: params.previousProfile,
618
+ reason: params.reason,
619
+ meta: {
620
+ ...(params.meta ?? {}),
621
+ accountExpiresAt: target.accountExpires?.toISOString() ?? null,
622
+ targetProfile: target.targetProfile,
623
+ },
624
+ });
625
+ });
626
+
627
+ await session.revokeAllForUser(params.userId);
628
+ };
629
+
630
+ /**
631
+ * Sync a single user's attributes from FreeIPA.
632
+ * Called on login to ensure time-sensitive data is up-to-date.
633
+ *
634
+ * 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).
637
+ *
638
+ * 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).
641
+ */
642
+ export const syncUser = async (username: string): Promise<SyncUserOutcome> => {
643
+ const config = await getFreeIpaConfig();
644
+ if (!config.enabled) {
645
+ log.info("Single-user sync skipped", { reason: "freeipa_disabled", username });
646
+ return { status: "skipped_disabled" };
647
+ }
648
+ if (!config.configured) {
649
+ throw new Error("FreeIPA is enabled but not fully configured.");
650
+ }
651
+ const ipaSession = await freeipa.session.getServiceSession({
652
+ url: config.url,
653
+ serviceUser: config.serviceUser,
654
+ servicePassword: config.servicePassword,
655
+ });
656
+
657
+ // Fetch user from FreeIPA
658
+ const userRes = await freeipa.client.call({
659
+ url: config.url,
660
+ ipaSession,
661
+ method: "user_show",
662
+ args: [username],
663
+ options: { all: true },
664
+ });
665
+ if (userRes.error || !userRes.result?.result) {
666
+ const error = userRes.error?.message ?? "user_show returned empty result";
667
+ log.warn("Could not fetch user", { username, error });
668
+ return { status: "fetch_failed", error };
669
+ }
670
+
671
+ const raw = userRes.result.result as Record<string, unknown>;
672
+ const user = transformSyncUser(raw);
673
+
674
+ const existingRow: DbRow | undefined = (
675
+ await sql<DbRow[]>`
676
+ SELECT id, mail, display_name, profile
677
+ FROM auth.users
678
+ WHERE uid = ${user.uid} AND provider = 'ipa'
679
+ `
680
+ )[0];
681
+ const existingUserId = (existingRow?.id as string | undefined) ?? null;
682
+ const previousProfile: "user" | "guest" = (existingRow?.profile as "user" | "guest" | undefined) ?? "guest";
683
+
684
+ if (isExpired(user)) {
685
+ log.warn("User expired during single-user sync", { username });
686
+ if (existingUserId) {
687
+ await reconcileOutOfScopeUser({
688
+ userId: existingUserId,
689
+ uid: user.uid,
690
+ mail: (existingRow?.mail as string | null) ?? user.mail,
691
+ displayName: (existingRow?.display_name as string | null) ?? user.displayName,
692
+ previousProfile,
693
+ reason: "ipa_expired_demoted",
694
+ meta: { accountExpiresAt: user.ipaAccountExpires?.toISOString() ?? null },
695
+ });
696
+ }
697
+ return { status: "expired", userId: existingUserId };
698
+ }
699
+
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
+ if (!existingUserId) {
718
+ log.warn("User not found in local DB during single-user sync", { username });
719
+ return { status: "not_found_local" };
720
+ }
721
+
722
+ const profile = await calculateIpaProfileFromLocalDb(existingUserId);
723
+ const provider = "ipa";
724
+
725
+ // Update user attributes only (no group sync!)
726
+ await sql`
727
+ UPDATE auth.users SET
728
+ provider = ${provider},
729
+ profile = ${profile},
730
+ admin = false,
731
+ given_name = ${user.givenname},
732
+ sn = ${user.sn},
733
+ display_name = ${user.displayName},
734
+ mail = ${user.mail},
735
+ account_expires = ${user.ipaAccountExpires}
736
+ WHERE uid = ${user.uid}
737
+ `;
738
+ await upsertUserIpaData(sql, { userId: existingUserId, ...user });
739
+ return { status: "synced", userId: existingUserId };
740
+ };