@valentinkolb/cloud 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/package.json +18 -8
  2. package/scripts/preload.ts +78 -23
  3. package/src/_internal/define-app.ts +119 -47
  4. package/src/_internal/runtime-context.ts +1 -0
  5. package/src/api/accounts-entities.ts +4 -0
  6. package/src/api/admin-core-settings.ts +98 -0
  7. package/src/api/announcements.ts +131 -0
  8. package/src/api/auth/schemas.ts +24 -0
  9. package/src/api/auth.ts +113 -10
  10. package/src/api/index.ts +15 -25
  11. package/src/api/me.ts +203 -14
  12. package/src/api/search/schemas.ts +1 -0
  13. package/src/api/search.ts +62 -8
  14. package/src/config/ssr.ts +2 -9
  15. package/src/contracts/announcements.test.ts +37 -0
  16. package/src/contracts/announcements.ts +121 -0
  17. package/src/contracts/app.ts +4 -0
  18. package/src/contracts/index.ts +3 -2
  19. package/src/contracts/registry.ts +4 -0
  20. package/src/contracts/shared.ts +108 -1
  21. package/src/desktop/index.ts +704 -0
  22. package/src/desktop/solid.tsx +938 -0
  23. package/src/server/api/index.ts +1 -1
  24. package/src/server/api/respond.ts +50 -10
  25. package/src/server/index.ts +44 -38
  26. package/src/server/middleware/auth.ts +98 -9
  27. package/src/server/middleware/index.ts +2 -1
  28. package/src/server/middleware/settings.ts +26 -0
  29. package/src/server/services/access.test.ts +197 -0
  30. package/src/server/services/access.ts +254 -6
  31. package/src/server/services/index.ts +14 -11
  32. package/src/server/services/pagination.ts +22 -0
  33. package/src/server/time.ts +45 -0
  34. package/src/services/account-lifecycle/index.ts +142 -18
  35. package/src/services/accounts/app.ts +658 -170
  36. package/src/services/accounts/authz.test.ts +77 -0
  37. package/src/services/accounts/authz.ts +22 -0
  38. package/src/services/accounts/entities.ts +84 -5
  39. package/src/services/accounts/groups.ts +30 -24
  40. package/src/services/accounts/model.test.ts +30 -0
  41. package/src/services/accounts/switching.test.ts +14 -0
  42. package/src/services/accounts/switching.ts +15 -6
  43. package/src/services/accounts/users.ts +75 -52
  44. package/src/services/announcements/index.test.ts +32 -0
  45. package/src/services/announcements/index.ts +224 -0
  46. package/src/services/audit/index.test.ts +84 -0
  47. package/src/services/audit/index.ts +431 -0
  48. package/src/services/auth-flows/index.ts +9 -2
  49. package/src/services/auth-flows/ipa.ts +0 -2
  50. package/src/services/auth-flows/magic-link.ts +3 -2
  51. package/src/services/auth-flows/password-reset.ts +284 -0
  52. package/src/services/auth-flows/proxy-return.test.ts +24 -0
  53. package/src/services/auth-flows/proxy-return.ts +49 -0
  54. package/src/services/gateway.ts +162 -0
  55. package/src/services/index.ts +44 -2
  56. package/src/services/ipa/effective-groups.test.ts +33 -0
  57. package/src/services/ipa/effective-groups.ts +70 -0
  58. package/src/services/ipa/profile.ts +45 -3
  59. package/src/services/ipa/search.ts +3 -5
  60. package/src/services/ipa/service-account.ts +15 -0
  61. package/src/services/ipa/sync-planning.test.ts +32 -0
  62. package/src/services/ipa/sync-planning.ts +22 -0
  63. package/src/services/ipa/sync.ts +110 -38
  64. package/src/services/oauth-tokens.ts +104 -0
  65. package/src/services/postgres.ts +21 -6
  66. package/src/services/providers/local/auth.test.ts +22 -0
  67. package/src/services/providers/local/auth.ts +46 -3
  68. package/src/services/secrets.ts +10 -0
  69. package/src/services/service-account-credentials.test.ts +210 -0
  70. package/src/services/service-account-credentials.ts +715 -0
  71. package/src/services/service-accounts.ts +188 -0
  72. package/src/services/session/index.ts +7 -8
  73. package/src/services/settings/app.ts +4 -20
  74. package/src/services/settings/defaults.ts +64 -22
  75. package/src/services/settings/store.ts +47 -0
  76. package/src/services/weather/forecast.ts +40 -7
  77. package/src/services/webauthn.test.ts +36 -0
  78. package/src/services/webauthn.ts +384 -0
  79. package/src/shared/icons.ts +391 -100
  80. package/src/shared/index.ts +7 -0
  81. package/src/shared/markdown/extensions/code.ts +38 -1
  82. package/src/shared/markdown/extensions/images.ts +39 -3
  83. package/src/shared/markdown/extensions/info-blocks.ts +5 -5
  84. package/src/shared/markdown/extensions/mark.ts +48 -0
  85. package/src/shared/markdown/extensions/sub-sup.ts +60 -0
  86. package/src/shared/markdown/extensions/tables.ts +79 -58
  87. package/src/shared/markdown/formula.test.ts +1089 -0
  88. package/src/shared/markdown/formula.ts +1187 -0
  89. package/src/shared/markdown/index.ts +76 -2
  90. package/src/shared/mock-cover.ts +130 -0
  91. package/src/shared/redirect.test.ts +49 -0
  92. package/src/shared/redirect.ts +52 -0
  93. package/src/shared/theme.test.ts +24 -0
  94. package/src/shared/theme.ts +68 -0
  95. package/src/shared/time.ts +13 -0
  96. package/src/ssr/AdminLayout.tsx +7 -3
  97. package/src/ssr/AdminSidebar.tsx +115 -49
  98. package/src/ssr/AppLaunchpad.island.tsx +176 -0
  99. package/src/ssr/Footer.island.tsx +3 -8
  100. package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
  101. package/src/ssr/GlobalSearchDialog.tsx +545 -117
  102. package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
  103. package/src/ssr/Layout.tsx +74 -66
  104. package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
  105. package/src/ssr/LayoutHelp.tsx +266 -0
  106. package/src/ssr/NavMenu.island.tsx +0 -39
  107. package/src/ssr/ThemeToggleRail.island.tsx +3 -3
  108. package/src/ssr/TimezoneCookie.island.tsx +23 -0
  109. package/src/ssr/islands/index.ts +13 -0
  110. package/src/styles/base-popover.css +5 -2
  111. package/src/styles/effects.css +87 -6
  112. package/src/styles/global.css +146 -9
  113. package/src/styles/input.css +3 -1
  114. package/src/styles/utilities-buttons.css +133 -27
  115. package/src/styles/utilities-code-display.css +67 -0
  116. package/src/styles/utilities-completion.css +223 -0
  117. package/src/styles/utilities-detail.css +73 -0
  118. package/src/styles/utilities-feedback.css +16 -15
  119. package/src/styles/utilities-layout.css +42 -2
  120. package/src/styles/utilities-markdown-editor.css +472 -0
  121. package/src/styles/utilities-navigation.css +63 -8
  122. package/src/styles/utilities-script.css +84 -0
  123. package/src/styles/utilities-table-tile.css +229 -0
  124. package/src/types/ambient.d.ts +9 -0
  125. package/src/ui/completion/behaviors.test.ts +95 -0
  126. package/src/ui/completion/behaviors.ts +205 -0
  127. package/src/ui/completion/engine.ts +368 -0
  128. package/src/ui/completion/index.ts +40 -0
  129. package/src/ui/completion/overlay.ts +92 -0
  130. package/src/ui/dialog-core.ts +173 -45
  131. package/src/ui/filter/FilterChip.tsx +42 -40
  132. package/src/ui/index.ts +11 -12
  133. package/src/ui/input/AutocompleteEditor.tsx +656 -0
  134. package/src/ui/input/CheckboxCard.tsx +91 -0
  135. package/src/ui/input/Combobox.tsx +375 -0
  136. package/src/ui/input/DatePicker.tsx +846 -0
  137. package/src/ui/input/DateTimeInput.tsx +29 -4
  138. package/src/ui/input/FileDropzone.tsx +116 -0
  139. package/src/ui/input/IconInput.tsx +116 -0
  140. package/src/ui/input/ImageInput.tsx +19 -2
  141. package/src/ui/input/MultiSelectInput.tsx +448 -0
  142. package/src/ui/input/NumberInput.tsx +417 -61
  143. package/src/ui/input/SegmentedControl.tsx +2 -2
  144. package/src/ui/input/Select.tsx +172 -10
  145. package/src/ui/input/Slider.tsx +3 -4
  146. package/src/ui/input/Switch.tsx +3 -2
  147. package/src/ui/input/TemplateEditor.tsx +212 -0
  148. package/src/ui/input/TextInput.tsx +144 -13
  149. package/src/ui/input/index.ts +53 -8
  150. package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
  151. package/src/ui/input/markdown/Toolbar.tsx +90 -0
  152. package/src/ui/input/markdown/actions.ts +233 -0
  153. package/src/ui/input/markdown/active-formats.ts +94 -0
  154. package/src/ui/input/markdown/behaviors.ts +193 -0
  155. package/src/ui/input/markdown/code-zone.ts +23 -0
  156. package/src/ui/input/markdown/highlight.ts +316 -0
  157. package/src/ui/layout.ts +22 -0
  158. package/src/ui/misc/AppOverview.tsx +105 -0
  159. package/src/ui/misc/AppWorkspace.tsx +607 -0
  160. package/src/ui/misc/Calendar.tsx +1291 -0
  161. package/src/ui/misc/Chart.tsx +162 -0
  162. package/src/ui/misc/CodeDisplay.tsx +54 -0
  163. package/src/ui/misc/ContextMenu.tsx +2 -2
  164. package/src/ui/misc/DataTable.tsx +269 -0
  165. package/src/ui/misc/DockWorkspace.tsx +425 -0
  166. package/src/ui/misc/Docs.tsx +153 -0
  167. package/src/ui/misc/Dropdown.tsx +2 -2
  168. package/src/ui/misc/EntitySearch.tsx +260 -129
  169. package/src/ui/misc/LinkCard.tsx +14 -2
  170. package/src/ui/misc/LogEntriesTable.tsx +34 -31
  171. package/src/ui/misc/Pagination.tsx +31 -12
  172. package/src/ui/misc/PanelDialog.tsx +109 -0
  173. package/src/ui/misc/Panes.tsx +873 -0
  174. package/src/ui/misc/PermissionEditor.tsx +358 -262
  175. package/src/ui/misc/Placeholder.tsx +40 -0
  176. package/src/ui/misc/ProgressBar.tsx +1 -1
  177. package/src/ui/misc/ResourceApiKeys.tsx +260 -0
  178. package/src/ui/misc/SettingsModal.tsx +150 -0
  179. package/src/ui/misc/StatCell.tsx +182 -40
  180. package/src/ui/misc/StatGrid.tsx +149 -0
  181. package/src/ui/misc/StructuredDataPreview.tsx +107 -0
  182. package/src/ui/misc/code-highlight.ts +213 -0
  183. package/src/ui/misc/index.ts +93 -12
  184. package/src/ui/prompts.tsx +362 -312
  185. package/src/ui/toast.ts +384 -0
  186. package/src/ui/widgets/Widget.tsx +12 -4
  187. package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
  188. package/src/ui/ipa/GroupView.tsx +0 -36
  189. package/src/ui/ipa/LoginBtn.tsx +0 -16
  190. package/src/ui/ipa/UserView.tsx +0 -58
  191. package/src/ui/ipa/index.ts +0 -4
  192. package/src/ui/navigation.ts +0 -32
  193. package/src/ui/sidebar.tsx +0 -468
  194. /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
@@ -1,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!)
@@ -0,0 +1,104 @@
1
+ import { sql } from "bun";
2
+ import * as jose from "jose";
3
+ import { accounts } from "./accounts";
4
+ import * as settings from "./settings";
5
+ import { serviceAccounts, type ServiceAccount } from "./service-accounts";
6
+ import type { User } from "../contracts/shared";
7
+
8
+ type DbKey = {
9
+ public_key: string;
10
+ kid: string;
11
+ };
12
+
13
+ const parseScopeClaim = (payload: jose.JWTPayload): string[] => {
14
+ const value = payload.scope;
15
+ if (typeof value !== "string") return [];
16
+ return value
17
+ .split(/\s+/)
18
+ .map((scope) => scope.trim())
19
+ .filter(Boolean);
20
+ };
21
+
22
+ export type AuthenticatedOAuthToken =
23
+ | {
24
+ kind: "user";
25
+ payload: jose.JWTPayload;
26
+ user: User;
27
+ }
28
+ | {
29
+ kind: "service_account";
30
+ payload: jose.JWTPayload;
31
+ serviceAccount: ServiceAccount;
32
+ delegatedUser: User | null;
33
+ scopes: string[];
34
+ };
35
+
36
+ const getIssuer = async (): Promise<string> => {
37
+ const appUrl = await settings.get<string>("app.url");
38
+ return appUrl.startsWith("http") ? appUrl : `https://${appUrl}`;
39
+ };
40
+
41
+ const getCurrentPublicKey = async (): Promise<CryptoKey | null> => {
42
+ const [row] = await sql<DbKey[]>`
43
+ SELECT public_key, kid
44
+ FROM oauth.keys
45
+ WHERE id = 'current'
46
+ `;
47
+ if (!row) return null;
48
+ return jose.importSPKI(row.public_key, "RS256");
49
+ };
50
+
51
+ const getStringClaim = (payload: jose.JWTPayload, key: string): string | null => {
52
+ const value = payload[key];
53
+ return typeof value === "string" && value.length > 0 ? value : null;
54
+ };
55
+
56
+ export const verifyAccessToken = async (token: string): Promise<AuthenticatedOAuthToken | null> => {
57
+ const publicKey = await getCurrentPublicKey();
58
+ if (!publicKey) return null;
59
+
60
+ let payload: jose.JWTPayload;
61
+ try {
62
+ const result = await jose.jwtVerify(token, publicKey, {
63
+ issuer: await getIssuer(),
64
+ audience: "cloud",
65
+ });
66
+ payload = result.payload;
67
+ } catch {
68
+ return null;
69
+ }
70
+
71
+ if (payload.token_use !== "access") return null;
72
+
73
+ const serviceAccountId = getStringClaim(payload, "service_account_id");
74
+ if (serviceAccountId) {
75
+ const serviceAccount = await serviceAccounts.get({ id: serviceAccountId });
76
+ if (!serviceAccount || serviceAccount.status !== "active") return null;
77
+
78
+ const delegatedUser = serviceAccount.delegatedUserId ? await accounts.users.get({ id: serviceAccount.delegatedUserId }) : null;
79
+ if (serviceAccount.kind === "user_delegated" && !delegatedUser) return null;
80
+
81
+ return {
82
+ kind: "service_account",
83
+ payload,
84
+ serviceAccount,
85
+ delegatedUser,
86
+ scopes: parseScopeClaim(payload),
87
+ };
88
+ }
89
+
90
+ const userId = getStringClaim(payload, "id");
91
+ const uid = getStringClaim(payload, "uid") ?? (typeof payload.sub === "string" ? payload.sub : null);
92
+ const user = userId ? await accounts.users.get({ id: userId }) : uid ? await accounts.users.get({ uid }) : null;
93
+ if (!user) return null;
94
+
95
+ return {
96
+ kind: "user",
97
+ payload,
98
+ user,
99
+ };
100
+ };
101
+
102
+ export const oauthTokens = {
103
+ verifyAccessToken,
104
+ };
@@ -35,17 +35,32 @@ export const parsePgJsonRecord = (value: unknown): Record<string, unknown> | nul
35
35
  };
36
36
 
37
37
  /**
38
- * Classify a thrown Postgres error. Bun's sql driver surfaces the canonical
39
- * SQLSTATE on `.code`. Use this at service boundaries to turn
40
- * unique-constraint violations into typed 409 results instead of bubbling up
41
- * raw DB errors to API clients.
38
+ * Classify a thrown Postgres error. Use at service boundaries to turn
39
+ * unique-constraint violations into typed 409 results instead of bubbling
40
+ * up raw DB errors to API clients.
41
+ *
42
+ * Two driver shapes coexist in this repo:
43
+ * - postgres.js: `e.code = "23505"` (the SQLSTATE directly)
44
+ * - bun.sql: `e.code = "ERR_POSTGRES_SERVER_ERROR"`, SQLSTATE on `e.errno`
45
+ *
46
+ * Checking only `e.code` silently fails on Bun (the Wave-1.1 migration
47
+ * idempotence bug had the same root cause). Treat either field carrying
48
+ * "23505" as a unique violation so the helper works regardless of which
49
+ * driver instantiated the error.
42
50
  */
43
- export type PgError = { code?: string; constraint_name?: string; detail?: string; message?: string };
51
+ export type PgError = {
52
+ code?: string;
53
+ errno?: string;
54
+ constraint_name?: string;
55
+ detail?: string;
56
+ message?: string;
57
+ };
44
58
 
45
59
  export const isUniqueViolation = (error: unknown, constraintName?: string): boolean => {
46
60
  if (!error || typeof error !== "object") return false;
47
61
  const e = error as PgError;
48
- if (e.code !== "23505") return false;
62
+ const sqlstate = e.code === "23505" || e.errno === "23505";
63
+ if (!sqlstate) return false;
49
64
  if (!constraintName) return true;
50
65
  return e.constraint_name === constraintName;
51
66
  };
@@ -0,0 +1,22 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ consumePasswordResetToken,
4
+ createPasswordResetToken,
5
+ } from "./auth";
6
+
7
+ describe("local auth password reset tokens", () => {
8
+ test("consumes password reset tokens only once", async () => {
9
+ const payload = {
10
+ userId: crypto.randomUUID(),
11
+ uid: `reset-${crypto.randomUUID()}`,
12
+ email: `reset-${crypto.randomUUID()}@example.test`,
13
+ };
14
+ const token = await createPasswordResetToken({
15
+ ...payload,
16
+ ttlSeconds: 30,
17
+ });
18
+
19
+ expect(await consumePasswordResetToken(token)).toEqual(payload);
20
+ expect(await consumePasswordResetToken(token)).toBeNull();
21
+ });
22
+ });
@@ -1,13 +1,56 @@
1
1
  import { redis } from "bun";
2
2
 
3
- export const createMagicLinkToken = async (params: { email: string; ttlSeconds?: number }): Promise<string> => {
3
+ export const createMagicLinkToken = async (params: {
4
+ email: string;
5
+ ttlSeconds?: number;
6
+ }): Promise<string> => {
4
7
  const token = crypto.randomUUID();
5
- await redis.set(`email-login:${token}`, JSON.stringify({ email: params.email }), "EX", params.ttlSeconds ?? 300);
8
+ await redis.set(
9
+ `email-login:${token}`,
10
+ JSON.stringify({ email: params.email }),
11
+ "EX",
12
+ params.ttlSeconds ?? 300
13
+ );
6
14
  return token;
7
15
  };
8
16
 
9
- export const consumeMagicLinkToken = async (token: string): Promise<{ email: string } | null> => {
17
+ export const consumeMagicLinkToken = async (
18
+ token: string
19
+ ): Promise<{ email: string } | null> => {
10
20
  const raw = await redis.getdel(`email-login:${token}`);
11
21
  if (!raw) return null;
12
22
  return JSON.parse(raw) as { email: string };
13
23
  };
24
+
25
+ type PasswordResetPayload = {
26
+ userId: string;
27
+ uid: string;
28
+ email: string;
29
+ };
30
+
31
+ const passwordResetTokenKey = (token: string) => `password-reset:${token}`;
32
+
33
+ export const createPasswordResetToken = async (
34
+ params: PasswordResetPayload & { ttlSeconds?: number }
35
+ ): Promise<string> => {
36
+ const token = crypto.randomUUID();
37
+ await redis.set(
38
+ passwordResetTokenKey(token),
39
+ JSON.stringify({
40
+ userId: params.userId,
41
+ uid: params.uid,
42
+ email: params.email,
43
+ }),
44
+ "EX",
45
+ params.ttlSeconds ?? 900
46
+ );
47
+ return token;
48
+ };
49
+
50
+ export const consumePasswordResetToken = async (
51
+ token: string
52
+ ): Promise<PasswordResetPayload | null> => {
53
+ const raw = await redis.getdel(passwordResetTokenKey(token));
54
+ if (!raw) return null;
55
+ return JSON.parse(raw) as PasswordResetPayload;
56
+ };
@@ -0,0 +1,10 @@
1
+ import { decryptValue, encryptValue } from "./settings/crypto";
2
+
3
+ export const encryptSecret = async (value: unknown): Promise<string> => encryptValue(value);
4
+
5
+ export const decryptSecret = async <T = unknown>(value: string): Promise<T> => (await decryptValue(value)) as T;
6
+
7
+ export const secrets = {
8
+ encrypt: encryptSecret,
9
+ decrypt: decryptSecret,
10
+ };