@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,22 @@
1
+ import type { Role, UserProfile, UserProvider } from "../../contracts/shared";
2
+
3
+ export const buildRoles = (params: {
4
+ provider: UserProvider;
5
+ profile: UserProfile;
6
+ memberofGroup: string[];
7
+ manages: string[];
8
+ admin?: boolean;
9
+ }): Role[] => {
10
+ const { provider, profile, manages } = params;
11
+ const roles = new Set<Role>();
12
+
13
+ roles.add(profile);
14
+ roles.add(provider);
15
+ roles.add(`${provider}/${profile}` as Extract<Role, "ipa/user" | "ipa/guest" | "local/user" | "local/guest">);
16
+
17
+ if (profile === "guest") return [...roles];
18
+
19
+ if (params.admin) roles.add("admin");
20
+ if (manages.length > 0) roles.add("group-manager");
21
+ return [...roles];
22
+ };
@@ -0,0 +1,11 @@
1
+ import type { BaseGroup, UserProvider } from "../../contracts/shared";
2
+
3
+ type DbRow = Record<string, unknown>;
4
+
5
+ export const buildBaseGroup = (row: DbRow): BaseGroup => ({
6
+ id: row.id as string,
7
+ provider: row.provider as UserProvider,
8
+ name: row.name as string,
9
+ description: (row.description as string | null | undefined) ?? null,
10
+ gidnumber: (row.gid_number as number | null | undefined) ?? null,
11
+ });
@@ -0,0 +1,45 @@
1
+ import type { BaseUser, UserProfile, UserProvider } from "../../contracts/shared";
2
+ import { buildRoles } from "./authz";
3
+
4
+ type DbRow = Record<string, unknown>;
5
+
6
+ export const resolveProviderProfile = (row: DbRow): { provider: UserProvider; profile: UserProfile } => ({
7
+ provider: (row.provider as UserProvider | null | undefined) ?? "local",
8
+ profile: (row.profile as UserProfile | null | undefined) ?? "guest",
9
+ });
10
+
11
+ export const resolveBaseUserDisplayName = (row: DbRow): string => {
12
+ const displayName = (row.display_name as string | null | undefined) ?? "";
13
+ const mail = (row.mail as string | null | undefined) ?? "";
14
+ const uid = (row.uid as string | null | undefined) ?? "";
15
+ return displayName || mail || uid;
16
+ };
17
+
18
+ /**
19
+ * Build a BaseUser from a DB row. `admin` is taken from `row.effective_admin`
20
+ * when present (list queries pre-compute it by joining IPA-admin group
21
+ * membership), otherwise from `row.admin` for local users. The previous
22
+ * implementation routed through `resolveEffectiveAdminState` with an empty
23
+ * `memberofGroup` list, which silently dropped the admin role for IPA users.
24
+ */
25
+ export const buildBaseUser = (row: DbRow): BaseUser => {
26
+ const { provider, profile } = resolveProviderProfile(row);
27
+ const effectiveAdmin = row.effective_admin !== undefined ? Boolean(row.effective_admin) : Boolean(row.admin);
28
+ return {
29
+ id: row.id as string,
30
+ uid: row.uid as string,
31
+ roles: buildRoles({
32
+ provider,
33
+ profile,
34
+ memberofGroup: [],
35
+ manages: [],
36
+ admin: effectiveAdmin,
37
+ }),
38
+ provider,
39
+ profile,
40
+ givenname: (row.given_name as string) ?? "",
41
+ sn: (row.sn as string) ?? "",
42
+ displayName: resolveBaseUserDisplayName(row),
43
+ mail: (row.mail as string) ?? null,
44
+ };
45
+ };
@@ -0,0 +1,529 @@
1
+ import { sql } from "bun";
2
+ import type {
3
+ EntityKind,
4
+ EntityListItem,
5
+ UserProfile,
6
+ UserProvider,
7
+ } from "../../contracts/shared";
8
+ import { getFreeIpaConfig } from "../freeipa-config";
9
+ import { escapeLikePattern, toPgTextArray, toPgUuidArray } from "../postgres";
10
+ import { buildBaseGroup } from "./base-group";
11
+ import { buildBaseUser } from "./base-user";
12
+ import { buildManagedGroupScopeCondition } from "./group-sql";
13
+
14
+ type DbRow = Record<string, unknown>;
15
+ type SqlFragment = any;
16
+
17
+ export type EntityListParams = {
18
+ search?: string;
19
+ kinds?: EntityKind[];
20
+ provider?: UserProvider;
21
+ profile?: UserProfile;
22
+ excludeUserIds?: string[];
23
+ excludeGroupIds?: string[];
24
+ userMemberOfGroupIds?: string[];
25
+ memberOfGroupId?: string;
26
+ managerOfGroupId?: string;
27
+ parentGroupId?: string;
28
+ managedByUserId?: string;
29
+ recursive?: boolean;
30
+ page?: number;
31
+ perPage?: number;
32
+ };
33
+
34
+ type EntityQuerySpec = {
35
+ recursive: boolean;
36
+ prelude: SqlFragment;
37
+ userFrom: SqlFragment;
38
+ userDirectExpr: SqlFragment;
39
+ userWhere: SqlFragment;
40
+ groupFrom: SqlFragment;
41
+ groupDirectExpr: SqlFragment;
42
+ groupWhere: SqlFragment;
43
+ };
44
+
45
+ const buildNoRelationSpec = (): EntityQuerySpec => ({
46
+ recursive: false,
47
+ prelude: sql`scope_seed AS (SELECT 1 AS seed)`,
48
+ userFrom: sql`FROM auth.users u`,
49
+ userDirectExpr: sql`NULL::boolean`,
50
+ userWhere: sql`TRUE`,
51
+ groupFrom: sql`FROM auth.groups g`,
52
+ groupDirectExpr: sql`NULL::boolean`,
53
+ groupWhere: sql`TRUE`,
54
+ });
55
+
56
+ const buildMemberOfGroupSpec = (groupId: string, recursive: boolean): EntityQuerySpec => {
57
+ if (recursive) {
58
+ return {
59
+ recursive: true,
60
+ prelude: sql`
61
+ target_group AS (
62
+ SELECT id, provider
63
+ FROM auth.groups
64
+ WHERE id = ${groupId}::uuid
65
+ ),
66
+ member_group_tree AS (
67
+ SELECT gg.child_group_id AS group_id
68
+ FROM target_group tg
69
+ JOIN auth.group_groups_v2 gg ON gg.parent_group_id = tg.id
70
+ JOIN auth.groups g_child ON g_child.id = gg.child_group_id
71
+ WHERE g_child.provider = tg.provider
72
+ UNION
73
+ SELECT gg.child_group_id AS group_id
74
+ FROM auth.group_groups_v2 gg
75
+ JOIN auth.groups g_child ON g_child.id = gg.child_group_id
76
+ JOIN member_group_tree tree ON gg.parent_group_id = tree.group_id
77
+ JOIN target_group tg ON TRUE
78
+ WHERE g_child.provider = tg.provider
79
+ ),
80
+ member_user_rel AS (
81
+ SELECT ug.user_id AS entity_id, TRUE AS direct
82
+ FROM auth.user_groups_v2 ug
83
+ JOIN target_group tg ON ug.group_id = tg.id
84
+ JOIN auth.users u ON u.id = ug.user_id
85
+ WHERE tg.provider <> 'ipa' OR u.provider = 'ipa'
86
+ UNION ALL
87
+ SELECT ug.user_id AS entity_id, FALSE AS direct
88
+ FROM auth.user_groups_v2 ug
89
+ JOIN member_group_tree tree ON ug.group_id = tree.group_id
90
+ JOIN target_group tg ON TRUE
91
+ JOIN auth.users u ON u.id = ug.user_id
92
+ WHERE tg.provider <> 'ipa' OR u.provider = 'ipa'
93
+ ),
94
+ member_group_rel AS (
95
+ SELECT gg.child_group_id AS entity_id, TRUE AS direct
96
+ FROM target_group tg
97
+ JOIN auth.group_groups_v2 gg ON gg.parent_group_id = tg.id
98
+ JOIN auth.groups g ON g.id = gg.child_group_id
99
+ WHERE g.provider = tg.provider
100
+ UNION ALL
101
+ SELECT tree.group_id AS entity_id, FALSE AS direct
102
+ FROM member_group_tree tree
103
+ ),
104
+ relation_rows AS (
105
+ SELECT 'user'::text AS kind, entity_id, BOOL_OR(direct) AS direct
106
+ FROM member_user_rel
107
+ GROUP BY entity_id
108
+ UNION ALL
109
+ SELECT 'group'::text AS kind, entity_id, BOOL_OR(direct) AS direct
110
+ FROM member_group_rel
111
+ GROUP BY entity_id
112
+ )
113
+ `,
114
+ userFrom: sql`FROM auth.users u JOIN relation_rows rr ON rr.kind = 'user' AND rr.entity_id = u.id`,
115
+ userDirectExpr: sql`rr.direct`,
116
+ userWhere: sql`TRUE`,
117
+ groupFrom: sql`FROM auth.groups g JOIN relation_rows rr ON rr.kind = 'group' AND rr.entity_id = g.id`,
118
+ groupDirectExpr: sql`rr.direct`,
119
+ groupWhere: sql`TRUE`,
120
+ };
121
+ }
122
+
123
+ return {
124
+ recursive: false,
125
+ prelude: sql`
126
+ target_group AS (
127
+ SELECT id, provider
128
+ FROM auth.groups
129
+ WHERE id = ${groupId}::uuid
130
+ ),
131
+ relation_rows AS (
132
+ SELECT 'user'::text AS kind, ug.user_id AS entity_id, TRUE AS direct
133
+ FROM auth.user_groups_v2 ug
134
+ JOIN target_group tg ON ug.group_id = tg.id
135
+ JOIN auth.users u ON u.id = ug.user_id
136
+ WHERE tg.provider <> 'ipa' OR u.provider = 'ipa'
137
+ UNION ALL
138
+ SELECT 'group'::text AS kind, gg.child_group_id AS entity_id, TRUE AS direct
139
+ FROM target_group tg
140
+ JOIN auth.group_groups_v2 gg ON gg.parent_group_id = tg.id
141
+ JOIN auth.groups g ON g.id = gg.child_group_id
142
+ WHERE g.provider = tg.provider
143
+ )
144
+ `,
145
+ userFrom: sql`FROM auth.users u JOIN relation_rows rr ON rr.kind = 'user' AND rr.entity_id = u.id`,
146
+ userDirectExpr: sql`rr.direct`,
147
+ userWhere: sql`TRUE`,
148
+ groupFrom: sql`FROM auth.groups g JOIN relation_rows rr ON rr.kind = 'group' AND rr.entity_id = g.id`,
149
+ groupDirectExpr: sql`rr.direct`,
150
+ groupWhere: sql`TRUE`,
151
+ };
152
+ };
153
+
154
+ const buildManagerOfGroupSpec = (groupId: string, recursive: boolean): EntityQuerySpec => {
155
+ if (recursive) {
156
+ return {
157
+ recursive: true,
158
+ prelude: sql`
159
+ target_group AS (
160
+ SELECT id, provider
161
+ FROM auth.groups
162
+ WHERE id = ${groupId}::uuid
163
+ ),
164
+ manager_group_tree AS (
165
+ SELECT gmg.manager_group_id AS group_id
166
+ FROM target_group tg
167
+ JOIN auth.group_manager_groups_v2 gmg ON gmg.group_id = tg.id
168
+ JOIN auth.groups g_manager ON g_manager.id = gmg.manager_group_id
169
+ WHERE g_manager.provider = tg.provider
170
+ UNION
171
+ SELECT gg.parent_group_id AS group_id
172
+ FROM auth.group_groups_v2 gg
173
+ JOIN auth.groups g_parent ON g_parent.id = gg.parent_group_id
174
+ JOIN manager_group_tree tree ON gg.child_group_id = tree.group_id
175
+ JOIN target_group tg ON TRUE
176
+ WHERE g_parent.provider = tg.provider
177
+ ),
178
+ manager_user_rel AS (
179
+ SELECT gmu.user_id AS entity_id, TRUE AS direct
180
+ FROM auth.group_manager_users_v2 gmu
181
+ JOIN target_group tg ON gmu.group_id = tg.id
182
+ JOIN auth.users u ON u.id = gmu.user_id
183
+ WHERE tg.provider <> 'ipa' OR u.provider = 'ipa'
184
+ UNION ALL
185
+ SELECT ug.user_id AS entity_id, FALSE AS direct
186
+ FROM auth.user_groups_v2 ug
187
+ JOIN manager_group_tree tree ON ug.group_id = tree.group_id
188
+ JOIN target_group tg ON TRUE
189
+ JOIN auth.users u ON u.id = ug.user_id
190
+ WHERE tg.provider <> 'ipa' OR u.provider = 'ipa'
191
+ ),
192
+ manager_group_rel AS (
193
+ SELECT gmg.manager_group_id AS entity_id, TRUE AS direct
194
+ FROM target_group tg
195
+ JOIN auth.group_manager_groups_v2 gmg ON gmg.group_id = tg.id
196
+ JOIN auth.groups g ON g.id = gmg.manager_group_id
197
+ WHERE g.provider = tg.provider
198
+ UNION ALL
199
+ SELECT tree.group_id AS entity_id, FALSE AS direct
200
+ FROM manager_group_tree tree
201
+ ),
202
+ relation_rows AS (
203
+ SELECT 'user'::text AS kind, entity_id, BOOL_OR(direct) AS direct
204
+ FROM manager_user_rel
205
+ GROUP BY entity_id
206
+ UNION ALL
207
+ SELECT 'group'::text AS kind, entity_id, BOOL_OR(direct) AS direct
208
+ FROM manager_group_rel
209
+ GROUP BY entity_id
210
+ )
211
+ `,
212
+ userFrom: sql`FROM auth.users u JOIN relation_rows rr ON rr.kind = 'user' AND rr.entity_id = u.id`,
213
+ userDirectExpr: sql`rr.direct`,
214
+ userWhere: sql`TRUE`,
215
+ groupFrom: sql`FROM auth.groups g JOIN relation_rows rr ON rr.kind = 'group' AND rr.entity_id = g.id`,
216
+ groupDirectExpr: sql`rr.direct`,
217
+ groupWhere: sql`TRUE`,
218
+ };
219
+ }
220
+
221
+ return {
222
+ recursive: false,
223
+ prelude: sql`
224
+ target_group AS (
225
+ SELECT id, provider
226
+ FROM auth.groups
227
+ WHERE id = ${groupId}::uuid
228
+ ),
229
+ relation_rows AS (
230
+ SELECT 'user'::text AS kind, gmu.user_id AS entity_id, TRUE AS direct
231
+ FROM auth.group_manager_users_v2 gmu
232
+ JOIN target_group tg ON gmu.group_id = tg.id
233
+ JOIN auth.users u ON u.id = gmu.user_id
234
+ WHERE tg.provider <> 'ipa' OR u.provider = 'ipa'
235
+ UNION ALL
236
+ SELECT 'group'::text AS kind, gmg.manager_group_id AS entity_id, TRUE AS direct
237
+ FROM target_group tg
238
+ JOIN auth.group_manager_groups_v2 gmg ON gmg.group_id = tg.id
239
+ JOIN auth.groups g ON g.id = gmg.manager_group_id
240
+ WHERE g.provider = tg.provider
241
+ )
242
+ `,
243
+ userFrom: sql`FROM auth.users u JOIN relation_rows rr ON rr.kind = 'user' AND rr.entity_id = u.id`,
244
+ userDirectExpr: sql`rr.direct`,
245
+ userWhere: sql`TRUE`,
246
+ groupFrom: sql`FROM auth.groups g JOIN relation_rows rr ON rr.kind = 'group' AND rr.entity_id = g.id`,
247
+ groupDirectExpr: sql`rr.direct`,
248
+ groupWhere: sql`TRUE`,
249
+ };
250
+ };
251
+
252
+ const buildParentGroupSpec = (groupId: string, recursive: boolean): EntityQuerySpec => {
253
+ if (recursive) {
254
+ return {
255
+ recursive: true,
256
+ prelude: sql`
257
+ target_group AS (
258
+ SELECT id, provider
259
+ FROM auth.groups
260
+ WHERE id = ${groupId}::uuid
261
+ ),
262
+ parent_group_tree AS (
263
+ SELECT gg.parent_group_id AS group_id
264
+ FROM target_group tg
265
+ JOIN auth.group_groups_v2 gg ON gg.child_group_id = tg.id
266
+ JOIN auth.groups g_parent ON g_parent.id = gg.parent_group_id
267
+ WHERE g_parent.provider = tg.provider
268
+ UNION
269
+ SELECT gg.parent_group_id AS group_id
270
+ FROM auth.group_groups_v2 gg
271
+ JOIN auth.groups g_parent ON g_parent.id = gg.parent_group_id
272
+ JOIN parent_group_tree tree ON gg.child_group_id = tree.group_id
273
+ JOIN target_group tg ON TRUE
274
+ WHERE g_parent.provider = tg.provider
275
+ ),
276
+ parent_group_rel AS (
277
+ SELECT gg.parent_group_id AS entity_id, TRUE AS direct
278
+ FROM target_group tg
279
+ JOIN auth.group_groups_v2 gg ON gg.child_group_id = tg.id
280
+ JOIN auth.groups g_parent ON g_parent.id = gg.parent_group_id
281
+ WHERE g_parent.provider = tg.provider
282
+ UNION ALL
283
+ SELECT tree.group_id AS entity_id, FALSE AS direct
284
+ FROM parent_group_tree tree
285
+ ),
286
+ relation_rows AS (
287
+ SELECT 'group'::text AS kind, entity_id, BOOL_OR(direct) AS direct
288
+ FROM parent_group_rel
289
+ GROUP BY entity_id
290
+ )
291
+ `,
292
+ userFrom: sql`FROM auth.users u JOIN relation_rows rr ON rr.kind = 'user' AND rr.entity_id = u.id`,
293
+ userDirectExpr: sql`rr.direct`,
294
+ userWhere: sql`FALSE`,
295
+ groupFrom: sql`FROM auth.groups g JOIN relation_rows rr ON rr.kind = 'group' AND rr.entity_id = g.id`,
296
+ groupDirectExpr: sql`rr.direct`,
297
+ groupWhere: sql`TRUE`,
298
+ };
299
+ }
300
+
301
+ return {
302
+ recursive: false,
303
+ prelude: sql`
304
+ target_group AS (
305
+ SELECT id, provider
306
+ FROM auth.groups
307
+ WHERE id = ${groupId}::uuid
308
+ ),
309
+ relation_rows AS (
310
+ SELECT 'group'::text AS kind, gg.parent_group_id AS entity_id, TRUE AS direct
311
+ FROM target_group tg
312
+ JOIN auth.group_groups_v2 gg ON gg.child_group_id = tg.id
313
+ JOIN auth.groups g_parent ON g_parent.id = gg.parent_group_id
314
+ WHERE g_parent.provider = tg.provider
315
+ )
316
+ `,
317
+ userFrom: sql`FROM auth.users u JOIN relation_rows rr ON rr.kind = 'user' AND rr.entity_id = u.id`,
318
+ userDirectExpr: sql`rr.direct`,
319
+ userWhere: sql`FALSE`,
320
+ groupFrom: sql`FROM auth.groups g JOIN relation_rows rr ON rr.kind = 'group' AND rr.entity_id = g.id`,
321
+ groupDirectExpr: sql`rr.direct`,
322
+ groupWhere: sql`TRUE`,
323
+ };
324
+ };
325
+
326
+ const buildManagedByUserSpec = (userId: string, recursive: boolean): EntityQuerySpec => {
327
+ const directCondition = sql`
328
+ (
329
+ g.id IN (
330
+ SELECT DISTINCT g_manage.id
331
+ FROM auth.groups g_manage
332
+ LEFT JOIN auth.group_manager_users_v2 gmu ON gmu.group_id = g_manage.id AND gmu.user_id = ${userId}::uuid
333
+ LEFT JOIN auth.group_manager_groups_v2 gmg ON gmg.group_id = g_manage.id
334
+ LEFT JOIN auth.user_groups_v2 ug ON ug.group_id = gmg.manager_group_id AND ug.user_id = ${userId}::uuid
335
+ LEFT JOIN auth.groups g_manager ON g_manager.id = gmg.manager_group_id
336
+ WHERE g_manage.provider = g.provider
337
+ AND (gmu.user_id IS NOT NULL OR (ug.user_id IS NOT NULL AND g_manager.provider = g_manage.provider))
338
+ )
339
+ )
340
+ `;
341
+
342
+ return {
343
+ recursive: false,
344
+ prelude: sql`scope_seed AS (SELECT 1 AS seed)`,
345
+ userFrom: sql`FROM auth.users u`,
346
+ userDirectExpr: sql`NULL::boolean`,
347
+ userWhere: sql`FALSE`,
348
+ groupFrom: sql`FROM auth.groups g`,
349
+ groupDirectExpr: sql`NULL::boolean`,
350
+ groupWhere: recursive
351
+ ? buildManagedGroupScopeCondition({ userId, groupProvider: sql`g.provider` })
352
+ : directCondition,
353
+ };
354
+ };
355
+
356
+ const buildQuerySpec = (params: EntityListParams): EntityQuerySpec => {
357
+ const relationFilters = [
358
+ params.memberOfGroupId,
359
+ params.managerOfGroupId,
360
+ params.parentGroupId,
361
+ params.managedByUserId,
362
+ ].filter(Boolean);
363
+
364
+ if (relationFilters.length > 1) {
365
+ throw new Error("Only one relation filter can be used at a time.");
366
+ }
367
+
368
+ if (params.memberOfGroupId) return buildMemberOfGroupSpec(params.memberOfGroupId, Boolean(params.recursive));
369
+ if (params.managerOfGroupId) return buildManagerOfGroupSpec(params.managerOfGroupId, Boolean(params.recursive));
370
+ if (params.parentGroupId) return buildParentGroupSpec(params.parentGroupId, Boolean(params.recursive));
371
+ if (params.managedByUserId) return buildManagedByUserSpec(params.managedByUserId, params.recursive !== false);
372
+ return buildNoRelationSpec();
373
+ };
374
+
375
+ const mapEntityRow = (row: DbRow): EntityListItem => {
376
+ const direct = typeof row.direct === "boolean" ? row.direct : undefined;
377
+
378
+ if (row.kind === "user") {
379
+ return {
380
+ kind: "user",
381
+ user: buildBaseUser(row),
382
+ relation: direct === undefined ? undefined : { direct },
383
+ };
384
+ }
385
+
386
+ return {
387
+ kind: "group",
388
+ group: buildBaseGroup(row),
389
+ relation: direct === undefined ? undefined : { direct },
390
+ };
391
+ };
392
+
393
+ export const list = async (params: EntityListParams): Promise<{
394
+ items: EntityListItem[];
395
+ total: number;
396
+ pagination: { page: number; perPage: number; totalPages: number; hasNext: boolean };
397
+ }> => {
398
+ const page = params.page ?? 1;
399
+ const perPage = params.perPage ?? 100;
400
+ const offset = (page - 1) * perPage;
401
+ const pattern = params.search ? `%${escapeLikePattern(params.search.trim().toLowerCase())}%` : null;
402
+ const groupsAdmin = (await getFreeIpaConfig()).groupsAdmin;
403
+ const groupsAdminLiteral = toPgTextArray(groupsAdmin);
404
+ const spec = buildQuerySpec(params);
405
+ const kindsCondition =
406
+ (params.kinds?.length ?? 0) === 0 ? sql`TRUE` : sql`kind = ANY(${toPgTextArray(params.kinds ?? [])}::text[])`;
407
+ const excludeUserCondition =
408
+ (params.excludeUserIds?.length ?? 0) === 0
409
+ ? sql`TRUE`
410
+ : sql`(kind <> 'user' OR id <> ALL(${toPgUuidArray(params.excludeUserIds ?? [])}::uuid[]))`;
411
+ const excludeGroupCondition =
412
+ (params.excludeGroupIds?.length ?? 0) === 0
413
+ ? sql`TRUE`
414
+ : sql`(kind <> 'group' OR id <> ALL(${toPgUuidArray(params.excludeGroupIds ?? [])}::uuid[]))`;
415
+ const userMemberOfGroupCondition =
416
+ (params.userMemberOfGroupIds?.length ?? 0) === 0
417
+ ? sql`TRUE`
418
+ : sql`(kind <> 'user' OR EXISTS (
419
+ SELECT 1
420
+ FROM auth.user_groups_v2 ug
421
+ WHERE ug.user_id = id
422
+ AND ug.group_id = ANY(${toPgUuidArray(params.userMemberOfGroupIds ?? [])}::uuid[])
423
+ ))`;
424
+
425
+ const where = sql`
426
+ ${kindsCondition}
427
+ AND (${params.provider ?? null}::text IS NULL OR provider = ${params.provider ?? null})
428
+ AND (${params.profile ?? null}::text IS NULL OR kind = 'group' OR profile = ${params.profile ?? null})
429
+ AND ${excludeUserCondition}
430
+ AND ${excludeGroupCondition}
431
+ AND ${userMemberOfGroupCondition}
432
+ AND (
433
+ ${pattern}::text IS NULL
434
+ OR (
435
+ kind = 'user' AND (
436
+ LOWER(uid) LIKE ${pattern} ESCAPE '\\'
437
+ OR LOWER(COALESCE(display_name, '')) LIKE ${pattern} ESCAPE '\\'
438
+ OR LOWER(COALESCE(given_name, '')) LIKE ${pattern} ESCAPE '\\'
439
+ OR LOWER(COALESCE(sn, '')) LIKE ${pattern} ESCAPE '\\'
440
+ OR LOWER(COALESCE(mail, '')) LIKE ${pattern} ESCAPE '\\'
441
+ )
442
+ )
443
+ OR (
444
+ kind = 'group' AND (
445
+ LOWER(name) LIKE ${pattern} ESCAPE '\\'
446
+ OR LOWER(COALESCE(description, '')) LIKE ${pattern} ESCAPE '\\'
447
+ )
448
+ )
449
+ )
450
+ `;
451
+
452
+ const rows = await sql<DbRow[]>`
453
+ WITH ${spec.recursive ? sql`RECURSIVE` : sql``}
454
+ ${spec.prelude},
455
+ user_rows AS (
456
+ SELECT
457
+ 'user'::text AS kind,
458
+ ${spec.userDirectExpr} AS direct,
459
+ u.id,
460
+ u.provider,
461
+ u.profile,
462
+ u.uid,
463
+ u.given_name,
464
+ u.sn,
465
+ u.display_name,
466
+ u.mail,
467
+ NULL::text AS name,
468
+ NULL::text AS description,
469
+ NULL::int AS gid_number,
470
+ CASE
471
+ WHEN u.provider = 'local' THEN u.admin
472
+ ELSE EXISTS(
473
+ SELECT 1
474
+ FROM auth.user_groups_v2 ug_admin
475
+ JOIN auth.groups g_admin ON g_admin.id = ug_admin.group_id
476
+ WHERE ug_admin.user_id = u.id
477
+ AND g_admin.provider = 'ipa'
478
+ AND g_admin.name = ANY(${groupsAdminLiteral}::text[])
479
+ )
480
+ END AS effective_admin,
481
+ LOWER(COALESCE(NULLIF(u.display_name, ''), NULLIF(u.mail, ''), u.uid)) AS sort_label
482
+ ${spec.userFrom}
483
+ WHERE ${spec.userWhere}
484
+ ),
485
+ group_rows AS (
486
+ SELECT
487
+ 'group'::text AS kind,
488
+ ${spec.groupDirectExpr} AS direct,
489
+ g.id,
490
+ g.provider,
491
+ NULL::text AS profile,
492
+ NULL::text AS uid,
493
+ NULL::text AS given_name,
494
+ NULL::text AS sn,
495
+ NULL::text AS display_name,
496
+ NULL::text AS mail,
497
+ g.name,
498
+ g.description,
499
+ g.gid_number,
500
+ NULL::boolean AS effective_admin,
501
+ LOWER(g.name) AS sort_label
502
+ ${spec.groupFrom}
503
+ WHERE ${spec.groupWhere}
504
+ ),
505
+ entity_rows AS (
506
+ SELECT * FROM user_rows
507
+ UNION ALL
508
+ SELECT * FROM group_rows
509
+ )
510
+ SELECT *, COUNT(*) OVER() AS total
511
+ FROM entity_rows
512
+ WHERE ${where}
513
+ ORDER BY sort_label, kind, id
514
+ LIMIT ${perPage}
515
+ OFFSET ${offset}
516
+ `;
517
+
518
+ const total = rows.length > 0 ? Number((rows[0] as Record<string, unknown>).total) : 0;
519
+ return {
520
+ items: rows.map(mapEntityRow),
521
+ total,
522
+ pagination: {
523
+ page,
524
+ perPage,
525
+ totalPages: Math.ceil(total / perPage),
526
+ hasNext: page * perPage < total,
527
+ },
528
+ };
529
+ };