@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,491 @@
1
+ import { sql } from "bun";
2
+ import type { BaseGroup, GroupMember, MutationResult, UserProvider } from "../../contracts/shared";
3
+ import { escapeLikePattern, isUniqueViolation } from "../postgres";
4
+
5
+ type DbRow = Record<string, unknown>;
6
+
7
+ type LocalGroupRow = {
8
+ id: string;
9
+ provider: "local";
10
+ name: string;
11
+ description: string | null;
12
+ gidNumber: number | null;
13
+ };
14
+
15
+ const toBaseGroup = (row: LocalGroupRow): BaseGroup => ({
16
+ id: row.id,
17
+ provider: row.provider,
18
+ name: row.name,
19
+ description: row.description,
20
+ gidnumber: row.gidNumber,
21
+ });
22
+
23
+ const getLocalGroupById = async (id: string): Promise<LocalGroupRow | null> => {
24
+ const [row] = await sql<DbRow[]>`
25
+ SELECT id, provider, name, description, gid_number
26
+ FROM auth.groups
27
+ WHERE id = ${id}::uuid AND provider = 'local'
28
+ `;
29
+ if (!row) return null;
30
+ return {
31
+ id: row.id as string,
32
+ provider: row.provider as "local",
33
+ name: row.name as string,
34
+ description: row.description as string | null,
35
+ gidNumber: row.gid_number as number | null,
36
+ };
37
+ };
38
+
39
+ const getUserProvider = async (userId: string): Promise<UserProvider | null> => {
40
+ const [row] = await sql<DbRow[]>`SELECT provider FROM auth.users WHERE id = ${userId}`;
41
+ return (row?.provider as UserProvider | undefined) ?? null;
42
+ };
43
+
44
+ const ensureLocalGroupTreeMember = async (groupId: string): Promise<boolean> => {
45
+ const [row] = await sql<DbRow[]>`SELECT 1 FROM auth.groups WHERE id = ${groupId} AND provider = 'local'`;
46
+ return Boolean(row);
47
+ };
48
+
49
+ const wouldCreateLocalGroupCycle = async (params: { parentGroupId: string; childGroupId: string }): Promise<boolean> => {
50
+ if (params.parentGroupId === params.childGroupId) return true;
51
+
52
+ const [row] = await sql<DbRow[]>`
53
+ WITH RECURSIVE descendants AS (
54
+ SELECT gg.child_group_id
55
+ FROM auth.group_groups_v2 gg
56
+ JOIN auth.groups g ON g.id = gg.child_group_id
57
+ WHERE gg.parent_group_id = ${params.childGroupId}::uuid
58
+ AND g.provider = 'local'
59
+ UNION
60
+ SELECT gg.child_group_id
61
+ FROM auth.group_groups_v2 gg
62
+ JOIN auth.groups g ON g.id = gg.child_group_id
63
+ JOIN descendants d ON d.child_group_id = gg.parent_group_id
64
+ WHERE g.provider = 'local'
65
+ )
66
+ SELECT 1
67
+ FROM descendants
68
+ WHERE child_group_id = ${params.parentGroupId}::uuid
69
+ LIMIT 1
70
+ `;
71
+
72
+ return Boolean(row);
73
+ };
74
+
75
+ export const get = async (params: { id: string }): Promise<BaseGroup | null> => {
76
+ const row = await getLocalGroupById(params.id);
77
+ return row ? toBaseGroup(row) : null;
78
+ };
79
+
80
+ export const create = async (params: {
81
+ name: string;
82
+ description?: string;
83
+ }): Promise<MutationResult<BaseGroup>> => {
84
+ const storedCn = `local:${params.name}`;
85
+ try {
86
+ const rows = await sql<DbRow[]>`
87
+ INSERT INTO auth.groups (id, cn, provider, name, description, synced_at)
88
+ VALUES (gen_random_uuid(), ${storedCn}, 'local', ${params.name}, ${params.description ?? null}, now())
89
+ RETURNING id, provider, name, description, gid_number
90
+ `;
91
+
92
+ return {
93
+ ok: true,
94
+ data: toBaseGroup({
95
+ id: rows[0]!.id as string,
96
+ provider: "local",
97
+ name: rows[0]!.name as string,
98
+ description: rows[0]!.description as string | null,
99
+ gidNumber: rows[0]!.gid_number as number | null,
100
+ }),
101
+ };
102
+ } catch (error) {
103
+ if (isUniqueViolation(error, "groups_cn_key") || isUniqueViolation(error, "groups_provider_name_unique")) {
104
+ return { ok: false, error: "A local group with this name already exists.", status: 409 };
105
+ }
106
+ throw error;
107
+ }
108
+ };
109
+
110
+ export const list = async (params: { page?: number; perPage?: number; search?: string }) => {
111
+ const page = params.page ?? 1;
112
+ const perPage = params.perPage ?? 100;
113
+ const offset = (page - 1) * perPage;
114
+ const search = params.search?.trim().toLowerCase();
115
+ const pattern = search ? `%${escapeLikePattern(search)}%` : null;
116
+
117
+ const [countRow] = await sql<DbRow[]>`
118
+ SELECT COUNT(*)::int AS count
119
+ FROM auth.groups g
120
+ WHERE g.provider = 'local'
121
+ AND (${pattern}::text IS NULL OR LOWER(g.name) LIKE ${pattern} ESCAPE '\\' OR LOWER(g.description) LIKE ${pattern} ESCAPE '\\')
122
+ `;
123
+ const total = Number(countRow?.count ?? 0);
124
+ const rows = await sql<DbRow[]>`
125
+ SELECT id, provider, name, description, gid_number
126
+ FROM auth.groups g
127
+ WHERE g.provider = 'local'
128
+ AND (${pattern}::text IS NULL OR LOWER(g.name) LIKE ${pattern} ESCAPE '\\' OR LOWER(g.description) LIKE ${pattern} ESCAPE '\\')
129
+ ORDER BY g.name
130
+ LIMIT ${perPage} OFFSET ${offset}
131
+ `;
132
+
133
+ return {
134
+ groups: rows.map((row) => toBaseGroup({
135
+ id: row.id as string,
136
+ provider: row.provider as "local",
137
+ name: row.name as string,
138
+ description: row.description as string | null,
139
+ gidNumber: row.gid_number as number | null,
140
+ })),
141
+ total,
142
+ pagination: {
143
+ page,
144
+ perPage,
145
+ totalPages: Math.ceil(total / perPage),
146
+ hasNext: page * perPage < total,
147
+ },
148
+ };
149
+ };
150
+
151
+ export const update = async (params: { id: string; description: string }): Promise<MutationResult<void>> => {
152
+ const group = await getLocalGroupById(params.id);
153
+ if (!group) return { ok: false, error: "Group not found", status: 404 };
154
+
155
+ await sql`
156
+ UPDATE auth.groups
157
+ SET description = ${params.description}
158
+ WHERE id = ${params.id}::uuid
159
+ AND provider = 'local'
160
+ `;
161
+ return { ok: true, data: undefined };
162
+ };
163
+
164
+ export const remove = async (params: { id: string }): Promise<MutationResult<void>> => {
165
+ const group = await getLocalGroupById(params.id);
166
+ if (!group) return { ok: false, error: "Group not found", status: 404 };
167
+
168
+ await sql`DELETE FROM auth.groups WHERE id = ${params.id}::uuid AND provider = 'local'`;
169
+ return { ok: true, data: undefined };
170
+ };
171
+
172
+ export const getMembers = async (params: { id: string; type?: "user" | "group"; recursive?: boolean }): Promise<GroupMember[]> => {
173
+ const group = await getLocalGroupById(params.id);
174
+ if (!group) return [];
175
+ const members: GroupMember[] = [];
176
+
177
+ if (!params.type || params.type === "user") {
178
+ const userRows = params.recursive
179
+ ? await sql<DbRow[]>`
180
+ WITH RECURSIVE local_group_tree AS (
181
+ SELECT ${group.id}::uuid AS group_id
182
+ UNION
183
+ SELECT gg.child_group_id
184
+ FROM auth.group_groups_v2 gg
185
+ JOIN auth.groups g ON g.id = gg.child_group_id
186
+ JOIN local_group_tree tree ON tree.group_id = gg.parent_group_id
187
+ WHERE g.provider = 'local'
188
+ )
189
+ SELECT DISTINCT u.id, u.uid, u.display_name
190
+ FROM local_group_tree tree
191
+ JOIN auth.user_groups_v2 ug ON ug.group_id = tree.group_id
192
+ JOIN auth.users u ON u.id = ug.user_id
193
+ ORDER BY u.uid
194
+ `
195
+ : await sql<DbRow[]>`
196
+ SELECT u.id, u.uid, u.display_name
197
+ FROM auth.user_groups_v2 ug
198
+ JOIN auth.users u ON u.id = ug.user_id
199
+ WHERE ug.group_id = ${group.id}
200
+ ORDER BY u.uid
201
+ `;
202
+ for (const row of userRows) {
203
+ members.push({ type: "user", id: row.id as string, displayName: (row.display_name as string | null) ?? (row.uid as string) });
204
+ }
205
+ }
206
+
207
+ if (!params.type || params.type === "group") {
208
+ const groupRows = params.recursive
209
+ ? await sql<DbRow[]>`
210
+ WITH RECURSIVE local_group_tree AS (
211
+ SELECT gg.child_group_id
212
+ FROM auth.group_groups_v2 gg
213
+ JOIN auth.groups g ON g.id = gg.child_group_id
214
+ WHERE gg.parent_group_id = ${group.id}::uuid
215
+ AND g.provider = 'local'
216
+ UNION
217
+ SELECT gg.child_group_id
218
+ FROM auth.group_groups_v2 gg
219
+ JOIN auth.groups g ON g.id = gg.child_group_id
220
+ JOIN local_group_tree tree ON tree.child_group_id = gg.parent_group_id
221
+ WHERE g.provider = 'local'
222
+ )
223
+ SELECT DISTINCT g.id, g.name
224
+ FROM local_group_tree tree
225
+ JOIN auth.groups g ON g.id = tree.child_group_id
226
+ ORDER BY g.name
227
+ `
228
+ : await sql<DbRow[]>`
229
+ SELECT g.id, g.name
230
+ FROM auth.group_groups_v2 gg
231
+ JOIN auth.groups g ON g.id = gg.child_group_id
232
+ WHERE gg.parent_group_id = ${group.id} AND g.provider = 'local'
233
+ ORDER BY g.name
234
+ `;
235
+ for (const row of groupRows) {
236
+ members.push({ type: "group", id: row.id as string, displayName: row.name as string });
237
+ }
238
+ }
239
+
240
+ return members;
241
+ };
242
+
243
+ export const getManagers = async (params: { id: string; type?: "user" | "group"; recursive?: boolean }): Promise<GroupMember[]> => {
244
+ const group = await getLocalGroupById(params.id);
245
+ if (!group) return [];
246
+
247
+ const managers: GroupMember[] = [];
248
+
249
+ if (!params.type || params.type === "user") {
250
+ // Match IPA semantics (see ipa/groups.ts getManagers): start from the
251
+ // target's *direct manager groups*, then walk UPWARDS via parent
252
+ // relations, then collect members of every group in that set plus direct
253
+ // user managers of the target. The previous implementation walked DOWN
254
+ // the target's *child* tree and treated their managers as managers of the
255
+ // parent, which over-broadens authorization (a manager of a sub-team
256
+ // would inherit rights on the parent team).
257
+ const userRows = params.recursive
258
+ ? await sql<DbRow[]>`
259
+ WITH RECURSIVE manager_groups AS (
260
+ SELECT gmg.manager_group_id AS group_id
261
+ FROM auth.group_manager_groups_v2 gmg
262
+ JOIN auth.groups g_manager ON g_manager.id = gmg.manager_group_id
263
+ WHERE gmg.group_id = ${group.id} AND g_manager.provider = 'local'
264
+ UNION
265
+ SELECT gg.parent_group_id AS group_id
266
+ FROM auth.group_groups_v2 gg
267
+ JOIN auth.groups g_parent ON g_parent.id = gg.parent_group_id
268
+ JOIN manager_groups mg ON gg.child_group_id = mg.group_id
269
+ WHERE g_parent.provider = 'local'
270
+ )
271
+ SELECT DISTINCT u.id, u.uid, u.display_name
272
+ FROM (
273
+ SELECT gmu.user_id
274
+ FROM auth.group_manager_users_v2 gmu
275
+ WHERE gmu.group_id = ${group.id}
276
+ UNION
277
+ SELECT ug.user_id
278
+ FROM auth.user_groups_v2 ug
279
+ JOIN manager_groups mg ON ug.group_id = mg.group_id
280
+ ) all_managers
281
+ JOIN auth.users u ON u.id = all_managers.user_id
282
+ ORDER BY u.uid
283
+ `
284
+ : await sql<DbRow[]>`
285
+ SELECT u.id, u.uid, u.display_name
286
+ FROM auth.group_manager_users_v2 gmu
287
+ JOIN auth.users u ON u.id = gmu.user_id
288
+ WHERE gmu.group_id = ${group.id}
289
+ ORDER BY u.uid
290
+ `;
291
+ for (const row of userRows) {
292
+ managers.push({ type: "user", id: row.id as string, displayName: (row.display_name as string | null) ?? (row.uid as string) });
293
+ }
294
+ }
295
+
296
+ if (!params.type || params.type === "group") {
297
+ // Direct manager groups only. Parents of manager groups are managers-by-transitivity
298
+ // at the user level (handled above), but we don't conflate them into "group managers"
299
+ // of the target — that would misrepresent the configured relations.
300
+ const groupRows = await sql<DbRow[]>`
301
+ SELECT g.id, g.name
302
+ FROM auth.group_manager_groups_v2 gmg
303
+ JOIN auth.groups g ON g.id = gmg.manager_group_id
304
+ WHERE gmg.group_id = ${group.id} AND g.provider = 'local'
305
+ ORDER BY g.name
306
+ `;
307
+ for (const row of groupRows) {
308
+ managers.push({ type: "group", id: row.id as string, displayName: row.name as string });
309
+ }
310
+ }
311
+
312
+ return managers;
313
+ };
314
+
315
+ export const getParents = async (params: { id: string; recursive?: boolean }): Promise<string[]> => {
316
+ const group = await getLocalGroupById(params.id);
317
+ if (!group) return [];
318
+
319
+ const rows = params.recursive
320
+ ? await sql<DbRow[]>`
321
+ WITH RECURSIVE local_parent_tree AS (
322
+ SELECT gg.parent_group_id
323
+ FROM auth.group_groups_v2 gg
324
+ JOIN auth.groups g ON g.id = gg.parent_group_id
325
+ WHERE gg.child_group_id = ${group.id}::uuid
326
+ AND g.provider = 'local'
327
+ UNION
328
+ SELECT gg.parent_group_id
329
+ FROM auth.group_groups_v2 gg
330
+ JOIN auth.groups g ON g.id = gg.parent_group_id
331
+ JOIN local_parent_tree tree ON tree.parent_group_id = gg.child_group_id
332
+ WHERE g.provider = 'local'
333
+ )
334
+ SELECT DISTINCT parent_group_id
335
+ FROM local_parent_tree
336
+ `
337
+ : await sql<DbRow[]>`
338
+ SELECT gg.parent_group_id
339
+ FROM auth.group_groups_v2 gg
340
+ JOIN auth.groups g ON g.id = gg.parent_group_id
341
+ WHERE gg.child_group_id = ${group.id}
342
+ AND g.provider = 'local'
343
+ ORDER BY g.name
344
+ `;
345
+
346
+ return rows.map((row) => row.parent_group_id as string);
347
+ };
348
+
349
+ export const getManagedGroups = async (params: { id: string; recursive?: boolean }): Promise<string[]> => {
350
+ const group = await getLocalGroupById(params.id);
351
+ if (!group) return [];
352
+
353
+ const rows = params.recursive
354
+ ? await sql<DbRow[]>`
355
+ WITH RECURSIVE local_manager_tree AS (
356
+ SELECT ${group.id}::uuid AS manager_group_id
357
+ UNION
358
+ SELECT gg.child_group_id
359
+ FROM auth.group_groups_v2 gg
360
+ JOIN auth.groups g ON g.id = gg.child_group_id
361
+ JOIN local_manager_tree tree ON tree.manager_group_id = gg.parent_group_id
362
+ WHERE g.provider = 'local'
363
+ )
364
+ SELECT DISTINCT gmg.group_id
365
+ FROM local_manager_tree tree
366
+ JOIN auth.group_manager_groups_v2 gmg ON gmg.manager_group_id = tree.manager_group_id
367
+ JOIN auth.groups g ON g.id = gmg.group_id
368
+ WHERE g.provider = 'local'
369
+ `
370
+ : await sql<DbRow[]>`
371
+ SELECT gmg.group_id
372
+ FROM auth.group_manager_groups_v2 gmg
373
+ JOIN auth.groups g ON g.id = gmg.group_id
374
+ WHERE gmg.manager_group_id = ${group.id}
375
+ AND g.provider = 'local'
376
+ ORDER BY g.name
377
+ `;
378
+
379
+ return rows.map((row) => row.group_id as string);
380
+ };
381
+
382
+ export const addMember = async (params: { id: string; user?: string; group?: string }): Promise<MutationResult<void>> => {
383
+ const group = await getLocalGroupById(params.id);
384
+ if (!group) return { ok: false, error: "Group not found", status: 404 };
385
+
386
+ if (params.user) {
387
+ const provider = await getUserProvider(params.user);
388
+ if (!provider) return { ok: false, error: "User not found", status: 404 };
389
+ const [existing] = await sql<DbRow[]>`
390
+ SELECT 1
391
+ FROM auth.user_groups_v2
392
+ WHERE user_id = ${params.user}::uuid
393
+ AND group_id = ${group.id}::uuid
394
+ LIMIT 1
395
+ `;
396
+ if (existing) return { ok: false, error: "User is already a direct member of this group", status: 409 };
397
+ await sql`INSERT INTO auth.user_groups_v2 (user_id, group_id) VALUES (${params.user}, ${group.id}) ON CONFLICT DO NOTHING`;
398
+ return { ok: true, data: undefined };
399
+ }
400
+
401
+ if (params.group) {
402
+ const isLocal = await ensureLocalGroupTreeMember(params.group);
403
+ if (!isLocal) return { ok: false, error: "Only local groups can be nested into local groups", status: 400 };
404
+ const [existing] = await sql<DbRow[]>`
405
+ SELECT 1
406
+ FROM auth.group_groups_v2
407
+ WHERE parent_group_id = ${group.id}::uuid
408
+ AND child_group_id = ${params.group}::uuid
409
+ LIMIT 1
410
+ `;
411
+ if (existing) return { ok: false, error: "Group is already a direct member of this group", status: 409 };
412
+ if (await wouldCreateLocalGroupCycle({ parentGroupId: group.id, childGroupId: params.group })) {
413
+ return { ok: false, error: "Local group nesting cannot create cycles", status: 400 };
414
+ }
415
+ await sql`INSERT INTO auth.group_groups_v2 (parent_group_id, child_group_id) VALUES (${group.id}, ${params.group}) ON CONFLICT DO NOTHING`;
416
+ return { ok: true, data: undefined };
417
+ }
418
+
419
+ return { ok: false, error: "Missing member", status: 400 };
420
+ };
421
+
422
+ export const removeMember = async (params: { id: string; user?: string; group?: string }): Promise<MutationResult<void>> => {
423
+ const group = await getLocalGroupById(params.id);
424
+ if (!group) return { ok: false, error: "Group not found", status: 404 };
425
+
426
+ if (params.user) {
427
+ await sql`DELETE FROM auth.user_groups_v2 WHERE user_id = ${params.user}::uuid AND group_id = ${group.id}::uuid`;
428
+ return { ok: true, data: undefined };
429
+ }
430
+
431
+ if (params.group) {
432
+ await sql`DELETE FROM auth.group_groups_v2 WHERE parent_group_id = ${group.id}::uuid AND child_group_id = ${params.group}::uuid`;
433
+ return { ok: true, data: undefined };
434
+ }
435
+
436
+ return { ok: false, error: "Missing member", status: 400 };
437
+ };
438
+
439
+ export const addManager = async (params: { id: string; user?: string; group?: string }): Promise<MutationResult<void>> => {
440
+ const group = await getLocalGroupById(params.id);
441
+ if (!group) return { ok: false, error: "Group not found", status: 404 };
442
+
443
+ if (params.user) {
444
+ const provider = await getUserProvider(params.user);
445
+ if (!provider) return { ok: false, error: "User not found", status: 404 };
446
+ const [existing] = await sql<DbRow[]>`
447
+ SELECT 1
448
+ FROM auth.group_manager_users_v2
449
+ WHERE group_id = ${group.id}::uuid
450
+ AND user_id = ${params.user}::uuid
451
+ LIMIT 1
452
+ `;
453
+ if (existing) return { ok: false, error: "User is already a direct manager of this group", status: 409 };
454
+ await sql`INSERT INTO auth.group_manager_users_v2 (group_id, user_id) VALUES (${group.id}, ${params.user}) ON CONFLICT DO NOTHING`;
455
+ return { ok: true, data: undefined };
456
+ }
457
+
458
+ if (params.group) {
459
+ const isLocal = await ensureLocalGroupTreeMember(params.group);
460
+ if (!isLocal) return { ok: false, error: "Only local groups can manage local groups", status: 400 };
461
+ const [existing] = await sql<DbRow[]>`
462
+ SELECT 1
463
+ FROM auth.group_manager_groups_v2
464
+ WHERE group_id = ${group.id}::uuid
465
+ AND manager_group_id = ${params.group}::uuid
466
+ LIMIT 1
467
+ `;
468
+ if (existing) return { ok: false, error: "Group is already a direct manager of this group", status: 409 };
469
+ await sql`INSERT INTO auth.group_manager_groups_v2 (group_id, manager_group_id) VALUES (${group.id}, ${params.group}) ON CONFLICT DO NOTHING`;
470
+ return { ok: true, data: undefined };
471
+ }
472
+
473
+ return { ok: false, error: "Missing manager", status: 400 };
474
+ };
475
+
476
+ export const removeManager = async (params: { id: string; user?: string; group?: string }): Promise<MutationResult<void>> => {
477
+ const group = await getLocalGroupById(params.id);
478
+ if (!group) return { ok: false, error: "Group not found", status: 404 };
479
+
480
+ if (params.user) {
481
+ await sql`DELETE FROM auth.group_manager_users_v2 WHERE group_id = ${group.id}::uuid AND user_id = ${params.user}::uuid`;
482
+ return { ok: true, data: undefined };
483
+ }
484
+
485
+ if (params.group) {
486
+ await sql`DELETE FROM auth.group_manager_groups_v2 WHERE group_id = ${group.id}::uuid AND manager_group_id = ${params.group}::uuid`;
487
+ return { ok: true, data: undefined };
488
+ }
489
+
490
+ return { ok: false, error: "Missing manager", status: 400 };
491
+ };
@@ -0,0 +1,135 @@
1
+ import type { UserProfile, UserProvider } from "../../contracts/shared";
2
+ import * as settings from "../settings";
3
+
4
+ export type IpaMatchMode = "ignore" | "migrate";
5
+ export type IpaAccountTransitionPolicy =
6
+ | "delete"
7
+ | "demote_to_local"
8
+ | "demote_to_local_guest"
9
+ | "demote_to_local_user";
10
+
11
+ export const isGuestProfile = (profile: UserProfile): boolean => profile === "guest";
12
+ export const isIpaProvider = (provider: UserProvider): boolean => provider === "ipa";
13
+ export const isLocalProvider = (provider: UserProvider): boolean => provider === "local";
14
+ export const canPersistStoredAdmin = (provider: UserProvider, profile: UserProfile): boolean =>
15
+ provider === "local" && profile === "user";
16
+
17
+ export const parseIpaMatchMode = (value: string | null | undefined): IpaMatchMode => (value === "migrate" ? "migrate" : "ignore");
18
+
19
+ export const parseIpaAccountTransitionPolicy = (value: string | null | undefined): IpaAccountTransitionPolicy => {
20
+ if (value === "delete" || value === "demote_to_local" || value === "demote_to_local_user") return value;
21
+ return "demote_to_local_guest";
22
+ };
23
+
24
+ /**
25
+ * Pure helpers — caller passes the relevant FreeIPA group lists in. Avoids
26
+ * an implicit settings dependency inside what is otherwise a pure data
27
+ * transformation; the caller already needs `getFreeIpaConfig()` for other
28
+ * fields, so reading both lists at once is a single roundtrip.
29
+ */
30
+ export const calculateIpaProfileFromGroupNames = (
31
+ groupNames: string[],
32
+ groupsBaseIpaRealm: string[],
33
+ ): UserProfile =>
34
+ groupsBaseIpaRealm.some((group) => groupNames.includes(group)) ? "user" : "guest";
35
+
36
+ export const deriveIpaAdminFromGroupNames = (
37
+ groupNames: string[],
38
+ groupsAdmin: string[],
39
+ ): boolean => groupsAdmin.some((group) => groupNames.includes(group));
40
+
41
+ export const resolveStoredAdminState = (params: {
42
+ provider: UserProvider;
43
+ profile: UserProfile;
44
+ currentAdmin?: boolean;
45
+ requestedAdmin?: boolean;
46
+ }): boolean => {
47
+ if (!canPersistStoredAdmin(params.provider, params.profile)) return false;
48
+ return params.requestedAdmin ?? params.currentAdmin ?? false;
49
+ };
50
+
51
+ /**
52
+ * `groupsAdmin` is required when `provider === "ipa"`; defaults to `[]` (no
53
+ * admin grant via group membership) when omitted, matching the previous sync
54
+ * behaviour where an unconfigured/disabled FreeIPA returned an empty list.
55
+ */
56
+ export const resolveEffectiveAdminState = (params: {
57
+ provider: UserProvider;
58
+ storedAdmin?: boolean;
59
+ memberofGroup?: string[];
60
+ groupsAdmin?: string[];
61
+ }): boolean => {
62
+ if (params.provider === "ipa") {
63
+ return deriveIpaAdminFromGroupNames(params.memberofGroup ?? [], params.groupsAdmin ?? []);
64
+ }
65
+ return params.storedAdmin ?? false;
66
+ };
67
+
68
+ export const resolveAccountExpires = (row: Record<string, unknown>): Date | null => {
69
+ return (row.account_expires as Date | null | undefined) ?? null;
70
+ };
71
+
72
+ /**
73
+ * Discriminated parse of a user-supplied expiry value. An explicit `null` or
74
+ * absent/empty string means "no expiry"; a malformed non-empty string is an
75
+ * error — callers must surface that as a 400 rather than silently wiping the
76
+ * expiry.
77
+ */
78
+ export type ParsedExpiry =
79
+ | { ok: true; date: Date | null }
80
+ | { ok: false; error: string };
81
+
82
+ export const parseManualAccountExpiry = (value: string | null | undefined): ParsedExpiry => {
83
+ if (value === null || value === undefined || value === "") {
84
+ return { ok: true, date: null };
85
+ }
86
+ const date = new Date(value);
87
+ if (Number.isNaN(date.getTime())) {
88
+ return { ok: false, error: "Invalid expiry date" };
89
+ }
90
+ date.setUTCHours(23, 59, 59, 0);
91
+ return { ok: true, date };
92
+ };
93
+
94
+ /**
95
+ * Legacy name kept for internal defaulting path where the input is already
96
+ * known-valid (Zod has validated it). Prefer `parseManualAccountExpiry` for
97
+ * new code — it distinguishes "no expiry" from "invalid input".
98
+ */
99
+ export const normalizeManualAccountExpiry = (value: string | null | undefined): Date | null => {
100
+ const parsed = parseManualAccountExpiry(value);
101
+ return parsed.ok ? parsed.date : null;
102
+ };
103
+
104
+ export const getConfiguredExpiryDays = async (provider: UserProvider, profile: UserProfile): Promise<number> => {
105
+ if (provider === "ipa") {
106
+ const configured = await settings.get<number | null>("user.account.ipa_expires_days");
107
+ return typeof configured === "number" ? configured : 365;
108
+ }
109
+
110
+ if (profile === "guest") {
111
+ const configured = await settings.get<number | null>("user.account.local_guest_expires_days");
112
+ return typeof configured === "number" ? configured : 365;
113
+ }
114
+
115
+ const configured = await settings.get<number | null>("user.account.local_user_expires_days");
116
+ return typeof configured === "number" ? configured : 0;
117
+ };
118
+
119
+ export const getDefaultAccountExpiry = async (provider: UserProvider, profile: UserProfile): Promise<Date | null> => {
120
+ const days = await getConfiguredExpiryDays(provider, profile);
121
+ if (days <= 0) return null;
122
+ const expiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000);
123
+ if (provider === "ipa") expiresAt.setUTCHours(23, 59, 59, 0);
124
+ return expiresAt;
125
+ };
126
+
127
+ export const resolveTargetAccountExpiry = async (params: {
128
+ provider: UserProvider;
129
+ profile: UserProfile;
130
+ requested?: string | null;
131
+ }): Promise<Date | null> => {
132
+ const manual = normalizeManualAccountExpiry(params.requested);
133
+ if (params.requested !== undefined) return manual;
134
+ return getDefaultAccountExpiry(params.provider, params.profile);
135
+ };