@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,684 @@
1
+ import { sql } from "bun";
2
+ import type { BaseGroup, GroupMember, MutationResult } from "../../contracts/shared";
3
+ import { freeipa } from "../../server/services";
4
+ import { updateProfileForAffectedUsers, updateUserIpaProfile } from "./profile";
5
+ import { toPgUuidArray } from "../postgres";
6
+ import { getIpaUrl, ensureFreeIpaMutationAvailable } from "./guard";
7
+
8
+ type DbRow = Record<string, unknown>;
9
+
10
+ type IpaGroupRow = {
11
+ id: string;
12
+ cn: string;
13
+ name: string;
14
+ provider: "ipa" | "local";
15
+ description: string | null;
16
+ gidNumber: number | null;
17
+ };
18
+
19
+ const toBaseGroup = (row: IpaGroupRow): BaseGroup => ({
20
+ id: row.id,
21
+ provider: row.provider,
22
+ name: row.name,
23
+ description: row.description,
24
+ gidnumber: row.gidNumber,
25
+ });
26
+
27
+ const getIpaGroupById = async (id: string): Promise<IpaGroupRow | null> => {
28
+ const [row] = await sql<DbRow[]>`
29
+ SELECT id, cn, name, provider, description, gid_number
30
+ FROM auth.groups
31
+ WHERE id = ${id} AND provider = 'ipa'
32
+ `;
33
+ if (!row) return null;
34
+ return {
35
+ id: row.id as string,
36
+ cn: row.cn as string,
37
+ name: row.name as string,
38
+ provider: row.provider as "ipa" | "local",
39
+ description: row.description as string | null,
40
+ gidNumber: row.gid_number as number | null,
41
+ };
42
+ };
43
+
44
+ const getIpaGroupIdByCn = async (cn: string): Promise<string | null> => {
45
+ const [row] = await sql<DbRow[]>`SELECT id FROM auth.groups WHERE cn = ${cn} AND provider = 'ipa'`;
46
+ return (row?.id as string | undefined) ?? null;
47
+ };
48
+
49
+ const ipaMutationError = (response: Awaited<ReturnType<typeof freeipa.client.call>>): MutationResult<never> => ({
50
+ ok: false,
51
+ error: response.error?.message ?? "FreeIPA request failed",
52
+ status: response.error ? freeipa.util.mapIpaErrorCode(response.error.code) : 500,
53
+ });
54
+
55
+ export const get = async (params: { id: string }): Promise<BaseGroup | null> => {
56
+ const row = await getIpaGroupById(params.id);
57
+ return row ? toBaseGroup(row) : null;
58
+ };
59
+
60
+ export const list = async (params: {
61
+ ids?: string[];
62
+ userId?: string;
63
+ search?: string;
64
+ page?: number;
65
+ perPage?: number;
66
+ }): Promise<{
67
+ groups: BaseGroup[];
68
+ total: number;
69
+ pagination: {
70
+ page: number;
71
+ perPage: number;
72
+ totalPages: number;
73
+ hasNext: boolean;
74
+ };
75
+ }> => {
76
+ const page = params.page ?? 1;
77
+ const perPage = params.perPage ?? 100;
78
+ const offset = (page - 1) * perPage;
79
+ const search = params.search ? `%${freeipa.util.escapeLike(params.search.toLowerCase())}%` : null;
80
+ const ids = params.ids;
81
+
82
+ if (ids && ids.length === 0) {
83
+ return {
84
+ groups: [],
85
+ total: 0,
86
+ pagination: { page, perPage, totalPages: 0, hasNext: false },
87
+ };
88
+ }
89
+
90
+ const conditions = [sql`g.provider = 'ipa'`];
91
+ if (ids) conditions.push(sql`g.id = ANY(${toPgUuidArray(ids)}::uuid[])`);
92
+ if (search) conditions.push(sql`(LOWER(g.name) LIKE ${search} ESCAPE '\\' OR LOWER(g.description) LIKE ${search} ESCAPE '\\')`);
93
+ if (params.userId) {
94
+ conditions.push(sql`(
95
+ g.id IN (
96
+ SELECT ug.group_id
97
+ FROM auth.user_groups_v2 ug
98
+ JOIN auth.groups g_filter ON g_filter.id = ug.group_id
99
+ WHERE ug.user_id = ${params.userId} AND g_filter.provider = 'ipa'
100
+ )
101
+ OR g.id IN (
102
+ WITH RECURSIVE user_all_groups AS (
103
+ SELECT ug.group_id
104
+ FROM auth.user_groups_v2 ug
105
+ JOIN auth.groups g_filter ON g_filter.id = ug.group_id
106
+ WHERE ug.user_id = ${params.userId} AND g_filter.provider = 'ipa'
107
+ UNION
108
+ SELECT gg.parent_group_id
109
+ FROM auth.group_groups_v2 gg
110
+ JOIN auth.groups g_parent ON g_parent.id = gg.parent_group_id
111
+ JOIN user_all_groups ag ON gg.child_group_id = ag.group_id
112
+ WHERE g_parent.provider = 'ipa'
113
+ )
114
+ SELECT DISTINCT g_manage.id
115
+ FROM auth.groups g_manage
116
+ LEFT JOIN auth.group_manager_users_v2 gmu ON gmu.group_id = g_manage.id AND gmu.user_id = ${params.userId}
117
+ LEFT JOIN auth.group_manager_groups_v2 gmg ON gmg.group_id = g_manage.id
118
+ LEFT JOIN user_all_groups ug ON ug.group_id = gmg.manager_group_id
119
+ WHERE g_manage.provider = 'ipa' AND (gmu.user_id IS NOT NULL OR ug.group_id IS NOT NULL)
120
+ )
121
+ )`);
122
+ }
123
+
124
+ const where = conditions.reduce((acc, condition) => sql`${acc} AND ${condition}`);
125
+
126
+ const [countRow] = await sql<DbRow[]>`SELECT COUNT(*)::int AS count FROM auth.groups g WHERE ${where}`;
127
+ const total = Number(countRow?.count ?? 0);
128
+ const totalPages = Math.ceil(total / perPage);
129
+
130
+ const rows = await sql<DbRow[]>`
131
+ SELECT g.id, g.provider, g.name, g.description, g.gid_number
132
+ FROM auth.groups g
133
+ WHERE ${where}
134
+ ORDER BY g.name
135
+ LIMIT ${perPage} OFFSET ${offset}
136
+ `;
137
+
138
+ return {
139
+ groups: rows.map((row) => ({
140
+ id: row.id as string,
141
+ provider: row.provider as "ipa" | "local",
142
+ name: row.name as string,
143
+ description: row.description as string | null,
144
+ gidnumber: row.gid_number as number | null,
145
+ })),
146
+ total,
147
+ pagination: { page, perPage, totalPages, hasNext: page < totalPages },
148
+ };
149
+ };
150
+
151
+ export const getMembers = async (params: { id: string; type?: "user" | "group"; recursive?: boolean }): Promise<GroupMember[]> => {
152
+ const group = await getIpaGroupById(params.id);
153
+ if (!group) return [];
154
+
155
+ const members: GroupMember[] = [];
156
+
157
+ if (!params.type || params.type === "user") {
158
+ const userRows = params.recursive
159
+ ? await sql<DbRow[]>`
160
+ WITH RECURSIVE child_groups AS (
161
+ SELECT ${group.id}::uuid AS group_id
162
+ UNION
163
+ SELECT gg.child_group_id
164
+ FROM auth.group_groups_v2 gg
165
+ JOIN auth.groups g_child ON g_child.id = gg.child_group_id
166
+ JOIN child_groups cg ON gg.parent_group_id = cg.group_id
167
+ WHERE g_child.provider = 'ipa'
168
+ )
169
+ SELECT DISTINCT u.id, u.uid, u.display_name
170
+ FROM auth.user_groups_v2 ug
171
+ JOIN child_groups cg ON ug.group_id = cg.group_id
172
+ JOIN auth.users u ON u.id = ug.user_id
173
+ WHERE u.provider = 'ipa'
174
+ ORDER BY u.uid
175
+ `
176
+ : await sql<DbRow[]>`
177
+ SELECT u.id, u.uid, u.display_name
178
+ FROM auth.user_groups_v2 ug
179
+ JOIN auth.users u ON u.id = ug.user_id
180
+ WHERE ug.group_id = ${group.id} AND u.provider = 'ipa'
181
+ ORDER BY u.uid
182
+ `;
183
+
184
+ for (const row of userRows) {
185
+ members.push({ type: "user", id: row.id as string, displayName: (row.display_name as string | null) ?? (row.uid as string) });
186
+ }
187
+ }
188
+
189
+ if (!params.type || params.type === "group") {
190
+ const groupRows = params.recursive
191
+ ? await sql<DbRow[]>`
192
+ WITH RECURSIVE child_groups AS (
193
+ SELECT gg.child_group_id AS group_id
194
+ FROM auth.group_groups_v2 gg
195
+ JOIN auth.groups g_child ON g_child.id = gg.child_group_id
196
+ WHERE gg.parent_group_id = ${group.id} AND g_child.provider = 'ipa'
197
+ UNION
198
+ SELECT gg.child_group_id AS group_id
199
+ FROM auth.group_groups_v2 gg
200
+ JOIN auth.groups g_child ON g_child.id = gg.child_group_id
201
+ JOIN child_groups cg ON gg.parent_group_id = cg.group_id
202
+ WHERE g_child.provider = 'ipa'
203
+ )
204
+ SELECT DISTINCT g.id, g.name, g.description
205
+ FROM child_groups cg
206
+ JOIN auth.groups g ON g.id = cg.group_id
207
+ WHERE g.provider = 'ipa'
208
+ ORDER BY g.name
209
+ `
210
+ : await sql<DbRow[]>`
211
+ SELECT g.id, g.name, g.description
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} AND g.provider = 'ipa'
215
+ ORDER BY g.name
216
+ `;
217
+
218
+ for (const row of groupRows) {
219
+ members.push({ type: "group", id: row.id as string, displayName: row.name as string });
220
+ }
221
+ }
222
+
223
+ return members;
224
+ };
225
+
226
+ export const getManagers = async (params: { id: string; type?: "user" | "group"; recursive?: boolean }): Promise<GroupMember[]> => {
227
+ const group = await getIpaGroupById(params.id);
228
+ if (!group) return [];
229
+
230
+ const managers: GroupMember[] = [];
231
+
232
+ if (!params.type || params.type === "user") {
233
+ const userRows = params.recursive
234
+ ? await sql<DbRow[]>`
235
+ WITH RECURSIVE manager_groups AS (
236
+ SELECT gmg.manager_group_id AS group_id
237
+ FROM auth.group_manager_groups_v2 gmg
238
+ JOIN auth.groups g_manager ON g_manager.id = gmg.manager_group_id
239
+ WHERE gmg.group_id = ${group.id} AND g_manager.provider = 'ipa'
240
+ UNION
241
+ SELECT gg.parent_group_id AS group_id
242
+ FROM auth.group_groups_v2 gg
243
+ JOIN auth.groups g_parent ON g_parent.id = gg.parent_group_id
244
+ JOIN manager_groups mg ON gg.child_group_id = mg.group_id
245
+ WHERE g_parent.provider = 'ipa'
246
+ )
247
+ SELECT DISTINCT u.id, u.uid, u.display_name
248
+ FROM (
249
+ SELECT gmu.user_id
250
+ FROM auth.group_manager_users_v2 gmu
251
+ JOIN auth.users u_direct ON u_direct.id = gmu.user_id
252
+ WHERE gmu.group_id = ${group.id} AND u_direct.provider = 'ipa'
253
+ UNION
254
+ SELECT ug.user_id
255
+ FROM auth.user_groups_v2 ug
256
+ JOIN auth.users u_member ON u_member.id = ug.user_id
257
+ JOIN manager_groups mg ON ug.group_id = mg.group_id
258
+ WHERE u_member.provider = 'ipa'
259
+ ) all_managers
260
+ JOIN auth.users u ON u.id = all_managers.user_id
261
+ ORDER BY u.uid
262
+ `
263
+ : await sql<DbRow[]>`
264
+ SELECT u.id, u.uid, u.display_name
265
+ FROM auth.group_manager_users_v2 gmu
266
+ JOIN auth.users u ON u.id = gmu.user_id
267
+ WHERE gmu.group_id = ${group.id} AND u.provider = 'ipa'
268
+ ORDER BY u.uid
269
+ `;
270
+
271
+ for (const row of userRows) {
272
+ managers.push({ type: "user", id: row.id as string, displayName: (row.display_name as string | null) ?? (row.uid as string) });
273
+ }
274
+ }
275
+
276
+ if (!params.type || params.type === "group") {
277
+ const groupRows = await sql<DbRow[]>`
278
+ SELECT g.id, g.name, g.description
279
+ FROM auth.group_manager_groups_v2 gmg
280
+ JOIN auth.groups g ON g.id = gmg.manager_group_id
281
+ WHERE gmg.group_id = ${group.id} AND g.provider = 'ipa'
282
+ ORDER BY g.name
283
+ `;
284
+
285
+ for (const row of groupRows) {
286
+ managers.push({ type: "group", id: row.id as string, displayName: row.name as string });
287
+ }
288
+ }
289
+
290
+ return managers;
291
+ };
292
+
293
+ export const getParents = async (params: { id: string; recursive?: boolean }): Promise<string[]> => {
294
+ const group = await getIpaGroupById(params.id);
295
+ if (!group) return [];
296
+
297
+ const rows = params.recursive
298
+ ? await sql<DbRow[]>`
299
+ WITH RECURSIVE parent_groups AS (
300
+ SELECT gg.parent_group_id AS group_id
301
+ FROM auth.group_groups_v2 gg
302
+ JOIN auth.groups g_parent ON g_parent.id = gg.parent_group_id
303
+ WHERE gg.child_group_id = ${group.id} AND g_parent.provider = 'ipa'
304
+ UNION
305
+ SELECT gg.parent_group_id AS group_id
306
+ FROM auth.group_groups_v2 gg
307
+ JOIN auth.groups g_parent ON g_parent.id = gg.parent_group_id
308
+ JOIN parent_groups pg ON gg.child_group_id = pg.group_id
309
+ WHERE g_parent.provider = 'ipa'
310
+ )
311
+ SELECT DISTINCT group_id AS parent_group_id
312
+ FROM parent_groups
313
+ `
314
+ : await sql<DbRow[]>`
315
+ SELECT gg.parent_group_id
316
+ FROM auth.group_groups_v2 gg
317
+ JOIN auth.groups g_parent ON g_parent.id = gg.parent_group_id
318
+ WHERE gg.child_group_id = ${group.id} AND g_parent.provider = 'ipa'
319
+ `;
320
+
321
+ return rows.map((row) => row.parent_group_id as string);
322
+ };
323
+
324
+ export const getManagedGroups = async (params: { id: string }): Promise<string[]> => {
325
+ const group = await getIpaGroupById(params.id);
326
+ if (!group) return [];
327
+
328
+ const rows = await sql<DbRow[]>`
329
+ SELECT gmg.group_id
330
+ FROM auth.group_manager_groups_v2 gmg
331
+ JOIN auth.groups g ON g.id = gmg.group_id
332
+ WHERE gmg.manager_group_id = ${group.id} AND g.provider = 'ipa'
333
+ ORDER BY g.name
334
+ `;
335
+
336
+ return rows.map((row) => row.group_id as string);
337
+ };
338
+
339
+ export const add = async (params: {
340
+ ipaSession: string;
341
+ cn: string;
342
+ description?: string;
343
+ posix?: boolean;
344
+ }): Promise<MutationResult<BaseGroup>> => {
345
+ const unavailable = await ensureFreeIpaMutationAvailable();
346
+ if (unavailable) return unavailable;
347
+ const options: Record<string, unknown> = { nonposix: params.posix ? false : true };
348
+ if (params.description) options.description = params.description;
349
+
350
+ const response = await freeipa.client.call({ url: await getIpaUrl(), ipaSession: params.ipaSession, method: "group_add", args: [params.cn], options });
351
+ if (response.error) return ipaMutationError(response);
352
+
353
+ const gidnumber = freeipa.util.num((response.result?.result as Record<string, unknown> | undefined)?.gidnumber);
354
+ const [row] = await sql<DbRow[]>`
355
+ INSERT INTO auth.groups (id, cn, name, provider, description, gid_number, synced_at)
356
+ VALUES (gen_random_uuid(), ${params.cn}, ${params.cn}, 'ipa', ${params.description ?? null}, ${gidnumber}, now())
357
+ ON CONFLICT (provider, name) DO UPDATE
358
+ SET cn = EXCLUDED.cn,
359
+ description = EXCLUDED.description,
360
+ gid_number = EXCLUDED.gid_number,
361
+ synced_at = now()
362
+ RETURNING id, provider, name, description, gid_number
363
+ `;
364
+ if (!row) return { ok: false, error: "Failed to persist IPA group mirror", status: 500 };
365
+
366
+ return {
367
+ ok: true,
368
+ data: {
369
+ id: row.id as string,
370
+ provider: row.provider as "ipa" | "local",
371
+ name: row.name as string,
372
+ description: row.description as string | null,
373
+ gidnumber: row.gid_number as number | null,
374
+ },
375
+ };
376
+ };
377
+
378
+ export const update = async (params: { ipaSession: string; id: string; description: string }): Promise<MutationResult<void>> => {
379
+ const unavailable = await ensureFreeIpaMutationAvailable();
380
+ if (unavailable) return unavailable;
381
+ const group = await getIpaGroupById(params.id);
382
+ if (!group) return { ok: false, error: "IPA group not found", status: 404 };
383
+
384
+ const response = await freeipa.client.call({
385
+ url: await getIpaUrl(),
386
+ ipaSession: params.ipaSession,
387
+ method: "group_mod",
388
+ args: [group.cn],
389
+ options: { description: params.description },
390
+ });
391
+ if (response.error) return ipaMutationError(response);
392
+
393
+ await sql`UPDATE auth.groups SET description = ${params.description}, synced_at = now() WHERE id = ${group.id}`;
394
+ return { ok: true, data: undefined };
395
+ };
396
+
397
+ export const del = async (params: { ipaSession: string; id: string }): Promise<MutationResult<void>> => {
398
+ const unavailable = await ensureFreeIpaMutationAvailable();
399
+ if (unavailable) return unavailable;
400
+ const group = await getIpaGroupById(params.id);
401
+ if (!group) return { ok: false, error: "IPA group not found", status: 404 };
402
+
403
+ const response = await freeipa.client.call({ url: await getIpaUrl(), ipaSession: params.ipaSession, method: "group_del", args: [group.cn], options: {} });
404
+ if (response.error) return ipaMutationError(response);
405
+
406
+ await sql`DELETE FROM auth.groups WHERE id = ${group.id}`;
407
+ return { ok: true, data: undefined };
408
+ };
409
+
410
+ export const makePosix = async (params: { ipaSession: string; id: string }): Promise<MutationResult<{ gidnumber: number | null }>> => {
411
+ const unavailable = await ensureFreeIpaMutationAvailable();
412
+ if (unavailable) return unavailable;
413
+ const group = await getIpaGroupById(params.id);
414
+ if (!group) return { ok: false, error: "IPA group not found", status: 404 };
415
+
416
+ const response = await freeipa.client.call({ url: await getIpaUrl(), ipaSession: params.ipaSession, method: "group_mod", args: [group.cn], options: { posix: true } });
417
+ if (response.error) return ipaMutationError(response);
418
+
419
+ const gidnumber = freeipa.util.num((response.result?.result as Record<string, unknown> | undefined)?.gidnumber);
420
+ await sql`UPDATE auth.groups SET gid_number = ${gidnumber}, synced_at = now() WHERE id = ${group.id}`;
421
+ return { ok: true, data: { gidnumber } };
422
+ };
423
+
424
+ export const addMember = async (params: { ipaSession: string; id: string; user?: string; group?: string }): Promise<MutationResult<void>> => {
425
+ const unavailable = await ensureFreeIpaMutationAvailable();
426
+ if (unavailable) return unavailable;
427
+ const group = await getIpaGroupById(params.id);
428
+ if (!group) return { ok: false, error: "IPA group not found", status: 404 };
429
+
430
+ let userUid: string | undefined;
431
+ if (params.user) {
432
+ const [userRow] = await sql<DbRow[]>`SELECT uid FROM auth.users WHERE id = ${params.user} AND provider = 'ipa'`;
433
+ if (!userRow) return { ok: false, error: "IPA user not found", status: 404 };
434
+ const [existing] = await sql<DbRow[]>`
435
+ SELECT 1
436
+ FROM auth.user_groups_v2
437
+ WHERE user_id = ${params.user}::uuid
438
+ AND group_id = ${group.id}::uuid
439
+ LIMIT 1
440
+ `;
441
+ if (existing) return { ok: false, error: "User is already a direct member of this group", status: 409 };
442
+ userUid = userRow.uid as string;
443
+ }
444
+
445
+ let childGroup: IpaGroupRow | null = null;
446
+ if (params.group) {
447
+ childGroup = await getIpaGroupById(params.group);
448
+ if (!childGroup) return { ok: false, error: "IPA group not found", status: 404 };
449
+ const [existing] = await sql<DbRow[]>`
450
+ SELECT 1
451
+ FROM auth.group_groups_v2
452
+ WHERE parent_group_id = ${group.id}::uuid
453
+ AND child_group_id = ${childGroup.id}::uuid
454
+ LIMIT 1
455
+ `;
456
+ if (existing) return { ok: false, error: "Group is already a direct member of this group", status: 409 };
457
+ }
458
+
459
+ const options: Record<string, unknown> = {};
460
+ if (userUid) options.user = userUid;
461
+ if (childGroup) options.group = childGroup.cn;
462
+
463
+ const response = await freeipa.client.call({
464
+ url: await getIpaUrl(),
465
+ ipaSession: params.ipaSession,
466
+ method: "group_add_member",
467
+ args: [group.cn],
468
+ options,
469
+ });
470
+ if (response.error) return ipaMutationError(response);
471
+
472
+ const result = response.result?.result as Record<string, unknown> | undefined;
473
+ const memberFailed = (result?.failed as Record<string, unknown> | undefined)?.member as Record<string, unknown> | undefined;
474
+ if (userUid && Array.isArray(memberFailed?.user) && memberFailed.user.length > 0) {
475
+ return { ok: false, error: (memberFailed.user[0] as [string, string])[1] || "Failed to add user to group", status: 400 };
476
+ }
477
+ if (childGroup && Array.isArray(memberFailed?.group) && memberFailed.group.length > 0) {
478
+ return { ok: false, error: (memberFailed.group[0] as [string, string])[1] || "Failed to add group to group", status: 400 };
479
+ }
480
+
481
+ if (params.user) {
482
+ await sql`INSERT INTO auth.user_groups_v2 (user_id, group_id) VALUES (${params.user}, ${group.id}) ON CONFLICT DO NOTHING`;
483
+ await updateUserIpaProfile(params.user);
484
+ }
485
+ if (childGroup) {
486
+ await sql`INSERT INTO auth.group_groups_v2 (parent_group_id, child_group_id) VALUES (${group.id}, ${childGroup.id}) ON CONFLICT DO NOTHING`;
487
+ await updateProfileForAffectedUsers(childGroup.id);
488
+ }
489
+
490
+ return { ok: true, data: undefined };
491
+ };
492
+
493
+ export const removeMember = async (params: { ipaSession: string; id: string; user?: string; group?: string }): Promise<MutationResult<void>> => {
494
+ const unavailable = await ensureFreeIpaMutationAvailable();
495
+ if (unavailable) return unavailable;
496
+ const group = await getIpaGroupById(params.id);
497
+ if (!group) return { ok: false, error: "IPA group not found", status: 404 };
498
+
499
+ let userUid: string | undefined;
500
+ if (params.user) {
501
+ const [userRow] = await sql<DbRow[]>`SELECT uid FROM auth.users WHERE id = ${params.user} AND provider = 'ipa'`;
502
+ if (!userRow) return { ok: false, error: "IPA user not found", status: 404 };
503
+ userUid = userRow.uid as string;
504
+ }
505
+
506
+ let childGroup: IpaGroupRow | null = null;
507
+ if (params.group) {
508
+ childGroup = await getIpaGroupById(params.group);
509
+ if (!childGroup) return { ok: false, error: "IPA group not found", status: 404 };
510
+ }
511
+
512
+ const options: Record<string, unknown> = {};
513
+ if (userUid) options.user = userUid;
514
+ if (childGroup) options.group = childGroup.cn;
515
+
516
+ const response = await freeipa.client.call({
517
+ url: await getIpaUrl(),
518
+ ipaSession: params.ipaSession,
519
+ method: "group_remove_member",
520
+ args: [group.cn],
521
+ options,
522
+ });
523
+ if (response.error) return ipaMutationError(response);
524
+
525
+ const result = response.result?.result as Record<string, unknown> | undefined;
526
+ const memberFailed = (result?.failed as Record<string, unknown> | undefined)?.member as Record<string, unknown> | undefined;
527
+ if (userUid && Array.isArray(memberFailed?.user) && memberFailed.user.length > 0) {
528
+ return { ok: false, error: (memberFailed.user[0] as [string, string])[1] || "Failed to remove user from group", status: 400 };
529
+ }
530
+ if (childGroup && Array.isArray(memberFailed?.group) && memberFailed.group.length > 0) {
531
+ return { ok: false, error: (memberFailed.group[0] as [string, string])[1] || "Failed to remove group from group", status: 400 };
532
+ }
533
+
534
+ if (params.user) {
535
+ await sql`DELETE FROM auth.user_groups_v2 WHERE user_id = ${params.user} AND group_id = ${group.id}`;
536
+ await updateUserIpaProfile(params.user);
537
+ }
538
+ if (childGroup) {
539
+ const affectedUsers = await sql<DbRow[]>`
540
+ WITH RECURSIVE child_groups AS (
541
+ SELECT ${childGroup.id}::uuid AS group_id
542
+ UNION
543
+ SELECT gg.child_group_id
544
+ FROM auth.group_groups_v2 gg
545
+ JOIN child_groups cg ON gg.parent_group_id = cg.group_id
546
+ )
547
+ SELECT DISTINCT ug.user_id
548
+ FROM auth.user_groups_v2 ug
549
+ JOIN child_groups cg ON ug.group_id = cg.group_id
550
+ `;
551
+
552
+ await sql`DELETE FROM auth.group_groups_v2 WHERE parent_group_id = ${group.id} AND child_group_id = ${childGroup.id}`;
553
+ for (const row of affectedUsers) {
554
+ await updateUserIpaProfile(row.user_id as string);
555
+ }
556
+ }
557
+
558
+ return { ok: true, data: undefined };
559
+ };
560
+
561
+ export const addManager = async (params: { ipaSession: string; id: string; user?: string; group?: string }): Promise<MutationResult<void>> => {
562
+ const unavailable = await ensureFreeIpaMutationAvailable();
563
+ if (unavailable) return unavailable;
564
+ const group = await getIpaGroupById(params.id);
565
+ if (!group) return { ok: false, error: "IPA group not found", status: 404 };
566
+
567
+ let userUid: string | undefined;
568
+ if (params.user) {
569
+ const [userRow] = await sql<DbRow[]>`SELECT uid FROM auth.users WHERE id = ${params.user} AND provider = 'ipa'`;
570
+ if (!userRow) return { ok: false, error: "IPA user not found", status: 404 };
571
+ const [existing] = await sql<DbRow[]>`
572
+ SELECT 1
573
+ FROM auth.group_manager_users_v2
574
+ WHERE group_id = ${group.id}::uuid
575
+ AND user_id = ${params.user}::uuid
576
+ LIMIT 1
577
+ `;
578
+ if (existing) return { ok: false, error: "User is already a direct manager of this group", status: 409 };
579
+ userUid = userRow.uid as string;
580
+ }
581
+
582
+ let managerGroup: IpaGroupRow | null = null;
583
+ if (params.group) {
584
+ managerGroup = await getIpaGroupById(params.group);
585
+ if (!managerGroup) return { ok: false, error: "IPA group not found", status: 404 };
586
+ const [existing] = await sql<DbRow[]>`
587
+ SELECT 1
588
+ FROM auth.group_manager_groups_v2
589
+ WHERE group_id = ${group.id}::uuid
590
+ AND manager_group_id = ${managerGroup.id}::uuid
591
+ LIMIT 1
592
+ `;
593
+ if (existing) return { ok: false, error: "Group is already a direct manager of this group", status: 409 };
594
+ }
595
+
596
+ const options: Record<string, unknown> = {};
597
+ if (userUid) options.user = userUid;
598
+ if (managerGroup) options.group = managerGroup.cn;
599
+
600
+ const response = await freeipa.client.call({
601
+ url: await getIpaUrl(),
602
+ ipaSession: params.ipaSession,
603
+ method: "group_add_member_manager",
604
+ args: [group.cn],
605
+ options,
606
+ });
607
+ if (response.error) return ipaMutationError(response);
608
+
609
+ const result = response.result?.result as Record<string, unknown> | undefined;
610
+ const managerFailed = (result?.failed as Record<string, unknown> | undefined)?.membermanager as Record<string, unknown> | undefined;
611
+ if (userUid && Array.isArray(managerFailed?.user) && managerFailed.user.length > 0) {
612
+ return { ok: false, error: (managerFailed.user[0] as [string, string])[1] || "Failed to add user as manager", status: 400 };
613
+ }
614
+ if (managerGroup && Array.isArray(managerFailed?.group) && managerFailed.group.length > 0) {
615
+ return { ok: false, error: (managerFailed.group[0] as [string, string])[1] || "Failed to add group as manager", status: 400 };
616
+ }
617
+
618
+ if (params.user) {
619
+ await sql`INSERT INTO auth.group_manager_users_v2 (group_id, user_id) VALUES (${group.id}, ${params.user}) ON CONFLICT DO NOTHING`;
620
+ }
621
+ if (managerGroup) {
622
+ await sql`INSERT INTO auth.group_manager_groups_v2 (group_id, manager_group_id) VALUES (${group.id}, ${managerGroup.id}) ON CONFLICT DO NOTHING`;
623
+ }
624
+
625
+ return { ok: true, data: undefined };
626
+ };
627
+
628
+ export const removeManager = async (params: { ipaSession: string; id: string; user?: string; group?: string }): Promise<MutationResult<void>> => {
629
+ const unavailable = await ensureFreeIpaMutationAvailable();
630
+ if (unavailable) return unavailable;
631
+ const group = await getIpaGroupById(params.id);
632
+ if (!group) return { ok: false, error: "IPA group not found", status: 404 };
633
+
634
+ let userUid: string | undefined;
635
+ if (params.user) {
636
+ const [userRow] = await sql<DbRow[]>`SELECT uid FROM auth.users WHERE id = ${params.user} AND provider = 'ipa'`;
637
+ if (!userRow) return { ok: false, error: "IPA user not found", status: 404 };
638
+ userUid = userRow.uid as string;
639
+ }
640
+
641
+ let managerGroup: IpaGroupRow | null = null;
642
+ if (params.group) {
643
+ managerGroup = await getIpaGroupById(params.group);
644
+ if (!managerGroup) return { ok: false, error: "IPA group not found", status: 404 };
645
+ }
646
+
647
+ const options: Record<string, unknown> = {};
648
+ if (userUid) options.user = userUid;
649
+ if (managerGroup) options.group = managerGroup.cn;
650
+
651
+ const response = await freeipa.client.call({
652
+ url: await getIpaUrl(),
653
+ ipaSession: params.ipaSession,
654
+ method: "group_remove_member_manager",
655
+ args: [group.cn],
656
+ options,
657
+ });
658
+ if (response.error) return ipaMutationError(response);
659
+
660
+ const result = response.result?.result as Record<string, unknown> | undefined;
661
+ const managerFailed = (result?.failed as Record<string, unknown> | undefined)?.membermanager as Record<string, unknown> | undefined;
662
+ if (userUid && Array.isArray(managerFailed?.user) && managerFailed.user.length > 0) {
663
+ return { ok: false, error: (managerFailed.user[0] as [string, string])[1] || "Failed to remove user as manager", status: 400 };
664
+ }
665
+ if (managerGroup && Array.isArray(managerFailed?.group) && managerFailed.group.length > 0) {
666
+ return { ok: false, error: (managerFailed.group[0] as [string, string])[1] || "Failed to remove group as manager", status: 400 };
667
+ }
668
+
669
+ if (params.user) {
670
+ await sql`DELETE FROM auth.group_manager_users_v2 WHERE group_id = ${group.id} AND user_id = ${params.user}`;
671
+ }
672
+ if (managerGroup) {
673
+ await sql`DELETE FROM auth.group_manager_groups_v2 WHERE group_id = ${group.id} AND manager_group_id = ${managerGroup.id}`;
674
+ }
675
+
676
+ return { ok: true, data: undefined };
677
+ };
678
+
679
+ export const getManagedGroupsByName = async (params: { id: string }): Promise<string[]> => {
680
+ const ids = await getManagedGroups(params);
681
+ if (ids.length === 0) return [];
682
+ const rows = await sql<DbRow[]>`SELECT name FROM auth.groups WHERE id = ANY(${toPgUuidArray(ids)}::uuid[]) ORDER BY name`;
683
+ return rows.map((row) => row.name as string);
684
+ };
@@ -0,0 +1,17 @@
1
+ import type { MutationResult } from "../../contracts/shared";
2
+ import { getFreeIpaConfig } from "../freeipa-config";
3
+
4
+ type MutationError = Extract<MutationResult<unknown>, { ok: false }>;
5
+
6
+ export const getIpaUrl = async (): Promise<string> => (await getFreeIpaConfig()).url;
7
+
8
+ export const ensureFreeIpaMutationAvailable = async (): Promise<MutationError | null> => {
9
+ const config = await getFreeIpaConfig();
10
+ if (!config.enabled) {
11
+ return { ok: false, error: "FreeIPA is disabled.", status: 400 };
12
+ }
13
+ if (!config.configured) {
14
+ return { ok: false, error: "FreeIPA is enabled but not fully configured.", status: 500 };
15
+ }
16
+ return null;
17
+ };