@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.
- package/package.json +69 -0
- package/public/logo.svg +1 -0
- package/scripts/build.ts +113 -0
- package/scripts/preload.ts +73 -0
- package/src/_internal/define-app.ts +399 -0
- package/src/_internal/heartbeat.ts +33 -0
- package/src/_internal/registry.ts +100 -0
- package/src/_internal/runtime-context.ts +38 -0
- package/src/api/accounts-entities.ts +134 -0
- package/src/api/admin-lifecycle.ts +210 -0
- package/src/api/auth/schemas.ts +28 -0
- package/src/api/auth.ts +230 -0
- package/src/api/index.ts +66 -0
- package/src/api/me.ts +206 -0
- package/src/api/search/schemas.ts +43 -0
- package/src/api/search.ts +130 -0
- package/src/clients/core.ts +19 -0
- package/src/config/env.ts +23 -0
- package/src/config/index.ts +6 -0
- package/src/config/ssr.ts +58 -0
- package/src/contracts/app.ts +140 -0
- package/src/contracts/index.ts +5 -0
- package/src/contracts/profile.ts +67 -0
- package/src/contracts/registry.ts +50 -0
- package/src/contracts/settings-types.ts +84 -0
- package/src/contracts/shared.ts +258 -0
- package/src/contracts/widgets.ts +121 -0
- package/src/index.ts +6 -0
- package/src/server/api/index.ts +1 -0
- package/src/server/api/respond.ts +55 -0
- package/src/server/api-client.ts +54 -0
- package/src/server/app-context.ts +39 -0
- package/src/server/index.ts +62 -0
- package/src/server/middleware/auth.ts +168 -0
- package/src/server/middleware/index.ts +7 -0
- package/src/server/middleware/middleware.ts +47 -0
- package/src/server/middleware/openapi.ts +126 -0
- package/src/server/middleware/rate-limit.ts +126 -0
- package/src/server/middleware/request-logger.ts +41 -0
- package/src/server/middleware/validator.ts +35 -0
- package/src/server/services/access.ts +294 -0
- package/src/server/services/freeipa/client.ts +100 -0
- package/src/server/services/freeipa/index.ts +9 -0
- package/src/server/services/freeipa/session.ts +78 -0
- package/src/server/services/freeipa/tls.ts +48 -0
- package/src/server/services/freeipa/util.ts +60 -0
- package/src/server/services/geo.ts +154 -0
- package/src/server/services/index.ts +28 -0
- package/src/server/services/services.ts +13 -0
- package/src/services/account-lifecycle/audit.ts +41 -0
- package/src/services/account-lifecycle/index.ts +907 -0
- package/src/services/account-lifecycle/scheduler.ts +347 -0
- package/src/services/account-model.ts +21 -0
- package/src/services/accounts/app.ts +966 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/base-group.ts +11 -0
- package/src/services/accounts/base-user.ts +45 -0
- package/src/services/accounts/entities.ts +529 -0
- package/src/services/accounts/group-sql.ts +106 -0
- package/src/services/accounts/groups.ts +246 -0
- package/src/services/accounts/index.ts +14 -0
- package/src/services/accounts/ipa-data.ts +64 -0
- package/src/services/accounts/lifecycle.ts +2 -0
- package/src/services/accounts/local-groups.ts +491 -0
- package/src/services/accounts/model.ts +135 -0
- package/src/services/accounts/switching.ts +117 -0
- package/src/services/accounts/users.ts +714 -0
- package/src/services/auth-flows/index.ts +6 -0
- package/src/services/auth-flows/ipa.ts +128 -0
- package/src/services/auth-flows/magic-link.ts +119 -0
- package/src/services/freeipa-config.ts +89 -0
- package/src/services/index.ts +46 -0
- package/src/services/ipa/auth.ts +122 -0
- package/src/services/ipa/groups.ts +684 -0
- package/src/services/ipa/guard.ts +17 -0
- package/src/services/ipa/index.ts +17 -0
- package/src/services/ipa/profile.ts +90 -0
- package/src/services/ipa/search.ts +154 -0
- package/src/services/ipa/sync.ts +740 -0
- package/src/services/ipa/users.ts +794 -0
- package/src/services/logging/index.ts +294 -0
- package/src/services/notifications/email.ts +123 -0
- package/src/services/notifications/index.ts +413 -0
- package/src/services/postgres.ts +51 -0
- package/src/services/providers/index.ts +27 -0
- package/src/services/providers/local/auth.ts +13 -0
- package/src/services/providers/local/index.ts +4 -0
- package/src/services/providers/local/users.ts +255 -0
- package/src/services/session/index.ts +137 -0
- package/src/services/settings/api.ts +61 -0
- package/src/services/settings/app.ts +101 -0
- package/src/services/settings/crypto.ts +69 -0
- package/src/services/settings/defaults.ts +824 -0
- package/src/services/settings/index.ts +203 -0
- package/src/services/settings/namespace.ts +9 -0
- package/src/services/settings/snapshot.ts +49 -0
- package/src/services/settings/store.ts +179 -0
- package/src/services/settings/templates.ts +10 -0
- package/src/services/weather/forecast.ts +287 -0
- package/src/services/weather/geo.ts +110 -0
- package/src/services/weather/index.ts +99 -0
- package/src/services/weather/location.ts +24 -0
- package/src/services/weather/locations.ts +125 -0
- package/src/services/weather/migrate.ts +22 -0
- package/src/services/weather/types.ts +61 -0
- package/src/services/weather/ui.ts +50 -0
- package/src/shared/account-display.ts +17 -0
- package/src/shared/account-session.ts +15 -0
- package/src/shared/icons.ts +109 -0
- package/src/shared/index.ts +10 -0
- package/src/shared/markdown/client.ts +130 -0
- package/src/shared/markdown/extensions/code.ts +58 -0
- package/src/shared/markdown/extensions/images.ts +43 -0
- package/src/shared/markdown/extensions/info-blocks.ts +93 -0
- package/src/shared/markdown/extensions/katex.ts +120 -0
- package/src/shared/markdown/extensions/links.ts +34 -0
- package/src/shared/markdown/extensions/tables.ts +88 -0
- package/src/shared/markdown/extensions/task-list.ts +53 -0
- package/src/shared/markdown/index.ts +97 -0
- package/src/shared/markdown/shared.ts +36 -0
- package/src/ssr/AdminLayout.tsx +42 -0
- package/src/ssr/AdminSidebar.tsx +95 -0
- package/src/ssr/Footer.island.tsx +62 -0
- package/src/ssr/GlobalSearchDialog.tsx +389 -0
- package/src/ssr/GlobalSearchHelpDialog.tsx +106 -0
- package/src/ssr/GlobalSearchTrigger.island.tsx +42 -0
- package/src/ssr/HotkeysHelpRail.island.tsx +99 -0
- package/src/ssr/Layout.tsx +326 -0
- package/src/ssr/MoreAppsDropdown.island.tsx +61 -0
- package/src/ssr/NavMenu.island.tsx +108 -0
- package/src/ssr/ThemeToggleRail.island.tsx +27 -0
- package/src/ssr/index.ts +5 -0
- package/src/ssr/islands/SearchBar.island.tsx +77 -0
- package/src/ssr/islands/index.ts +1 -0
- package/src/ssr/runtime.ts +22 -0
- package/src/styles/base-popover.css +28 -0
- package/src/styles/effects.css +65 -0
- package/src/styles/global.css +133 -0
- package/src/styles/input.css +54 -0
- package/src/styles/tokens.css +35 -0
- package/src/styles/utilities-buttons.css +125 -0
- package/src/styles/utilities-feedback.css +65 -0
- package/src/styles/utilities-layout.css +122 -0
- package/src/styles/utilities-navigation.css +196 -0
- package/src/types/ambient.d.ts +8 -0
- package/src/ui/admin-settings.tsx +148 -0
- package/src/ui/dialog-core.ts +146 -0
- package/src/ui/filter/FilterChip.tsx +196 -0
- package/src/ui/filter/index.ts +2 -0
- package/src/ui/index.ts +19 -0
- package/src/ui/input/Checkbox.tsx +55 -0
- package/src/ui/input/ColorInput.tsx +122 -0
- package/src/ui/input/DateTimeInput.tsx +86 -0
- package/src/ui/input/ImageInput.tsx +170 -0
- package/src/ui/input/NumberInput.tsx +113 -0
- package/src/ui/input/PinInput.tsx +169 -0
- package/src/ui/input/SegmentedControl.tsx +99 -0
- package/src/ui/input/Select.tsx +288 -0
- package/src/ui/input/SelectChip.tsx +61 -0
- package/src/ui/input/Slider.tsx +118 -0
- package/src/ui/input/Switch.tsx +62 -0
- package/src/ui/input/TagsInput.tsx +115 -0
- package/src/ui/input/TextInput.tsx +160 -0
- package/src/ui/input/index.ts +13 -0
- package/src/ui/input/types.ts +42 -0
- package/src/ui/input/util.tsx +105 -0
- package/src/ui/ipa/Avatar.tsx +28 -0
- package/src/ui/ipa/GroupView.tsx +36 -0
- package/src/ui/ipa/LoginBtn.tsx +16 -0
- package/src/ui/ipa/UserView.tsx +58 -0
- package/src/ui/ipa/index.ts +4 -0
- package/src/ui/misc/ContextMenu.tsx +211 -0
- package/src/ui/misc/CopyButton.tsx +28 -0
- package/src/ui/misc/Dropdown.tsx +194 -0
- package/src/ui/misc/EntitySearch.tsx +213 -0
- package/src/ui/misc/Lightbox.tsx +194 -0
- package/src/ui/misc/LinkCard.tsx +34 -0
- package/src/ui/misc/LogEntriesTable.tsx +61 -0
- package/src/ui/misc/MarkdownView.tsx +65 -0
- package/src/ui/misc/Pagination.tsx +51 -0
- package/src/ui/misc/PermissionEditor.tsx +379 -0
- package/src/ui/misc/ProgressBar.tsx +47 -0
- package/src/ui/misc/RemoveBtn.tsx +27 -0
- package/src/ui/misc/StatCell.tsx +90 -0
- package/src/ui/misc/index.ts +18 -0
- package/src/ui/navigation.ts +32 -0
- package/src/ui/prompts.tsx +854 -0
- package/src/ui/sidebar.tsx +468 -0
- package/src/ui/widgets/Widget.tsx +62 -0
- package/src/ui/widgets/WidgetCard.tsx +19 -0
- package/src/ui/widgets/WidgetHero.tsx +39 -0
- package/src/ui/widgets/WidgetList.tsx +84 -0
- package/src/ui/widgets/WidgetPills.tsx +68 -0
- package/src/ui/widgets/WidgetStat.tsx +67 -0
- package/src/ui/widgets/WidgetStatus.tsx +62 -0
- 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
|
+
};
|