@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,794 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
2
|
+
import { password } from "@valentinkolb/stdlib";
|
|
3
|
+
import { writeDeletedAccountAudit } from "../account-lifecycle/audit";
|
|
4
|
+
import { resolveProviderProfile } from "../accounts/base-user";
|
|
5
|
+
import { getIpaUrl, ensureFreeIpaMutationAvailable } from "./guard";
|
|
6
|
+
import { logger } from "../logging";
|
|
7
|
+
import { session } from "../session";
|
|
8
|
+
import * as settings from "../settings";
|
|
9
|
+
import type {
|
|
10
|
+
MutationResult,
|
|
11
|
+
UserProfile,
|
|
12
|
+
} from "../../contracts/shared";
|
|
13
|
+
import { freeipa } from "../../server/services";
|
|
14
|
+
|
|
15
|
+
type CreateUser = {
|
|
16
|
+
email: string;
|
|
17
|
+
givenname: string;
|
|
18
|
+
sn: string;
|
|
19
|
+
displayName?: string;
|
|
20
|
+
autoSendNotification?: boolean;
|
|
21
|
+
requestId?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type IpaPatchData = {
|
|
25
|
+
givenname?: string;
|
|
26
|
+
sn?: string;
|
|
27
|
+
displayName?: string;
|
|
28
|
+
mail?: string;
|
|
29
|
+
ipa?: {
|
|
30
|
+
phone?: string;
|
|
31
|
+
address?: {
|
|
32
|
+
street?: string;
|
|
33
|
+
postalCode?: string;
|
|
34
|
+
city?: string;
|
|
35
|
+
state?: string;
|
|
36
|
+
};
|
|
37
|
+
sshPublicKeys?: string[];
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type DbRow = Record<string, unknown>;
|
|
42
|
+
|
|
43
|
+
const log = logger("auth:ipa");
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Full-row replacement upsert. Every column is written on UPDATE; omitted
|
|
47
|
+
* fields become NULL (or empty arrays). Use this only for the initial IPA-data
|
|
48
|
+
* insert after user creation or after a full user_show payload. For partial
|
|
49
|
+
* profile patches call `patchUserIpaData` — otherwise SSH keys, uid_number,
|
|
50
|
+
* password expiry, etc. get wiped.
|
|
51
|
+
*/
|
|
52
|
+
const upsertUserIpaData = async (params: {
|
|
53
|
+
userId: string;
|
|
54
|
+
uidNumber?: number | null;
|
|
55
|
+
phone?: string | null;
|
|
56
|
+
employeeType?: string | null;
|
|
57
|
+
mobile?: string | null;
|
|
58
|
+
street?: string | null;
|
|
59
|
+
postalCode?: string | null;
|
|
60
|
+
city?: string | null;
|
|
61
|
+
state?: string | null;
|
|
62
|
+
passwordExpires?: Date | null;
|
|
63
|
+
lastLoginIpa?: Date | null;
|
|
64
|
+
syncedAt?: Date | null;
|
|
65
|
+
sshPublicKeys?: string[];
|
|
66
|
+
sshFingerprints?: string[];
|
|
67
|
+
}) => {
|
|
68
|
+
await sql`
|
|
69
|
+
INSERT INTO auth.user_ipa_data (
|
|
70
|
+
user_id, uid_number, phone, employee_type, mobile, addr_street, addr_postal_code,
|
|
71
|
+
addr_city, addr_state, ipa_password_expires, last_login_ipa, synced_at, ssh_public_keys, ssh_fingerprints
|
|
72
|
+
)
|
|
73
|
+
VALUES (
|
|
74
|
+
${params.userId},
|
|
75
|
+
${params.uidNumber ?? null},
|
|
76
|
+
${params.phone ?? null},
|
|
77
|
+
${params.employeeType ?? null},
|
|
78
|
+
${params.mobile ?? null},
|
|
79
|
+
${params.street ?? null},
|
|
80
|
+
${params.postalCode ?? null},
|
|
81
|
+
${params.city ?? null},
|
|
82
|
+
${params.state ?? null},
|
|
83
|
+
${params.passwordExpires ?? null},
|
|
84
|
+
${params.lastLoginIpa ?? null},
|
|
85
|
+
${params.syncedAt ?? null},
|
|
86
|
+
${freeipa.util.toPgTextArray(params.sshPublicKeys ?? [])}::text[],
|
|
87
|
+
${freeipa.util.toPgTextArray(params.sshFingerprints ?? [])}::text[]
|
|
88
|
+
)
|
|
89
|
+
ON CONFLICT (user_id) DO UPDATE SET
|
|
90
|
+
uid_number = EXCLUDED.uid_number,
|
|
91
|
+
phone = EXCLUDED.phone,
|
|
92
|
+
employee_type = EXCLUDED.employee_type,
|
|
93
|
+
mobile = EXCLUDED.mobile,
|
|
94
|
+
addr_street = EXCLUDED.addr_street,
|
|
95
|
+
addr_postal_code = EXCLUDED.addr_postal_code,
|
|
96
|
+
addr_city = EXCLUDED.addr_city,
|
|
97
|
+
addr_state = EXCLUDED.addr_state,
|
|
98
|
+
ipa_password_expires = EXCLUDED.ipa_password_expires,
|
|
99
|
+
last_login_ipa = EXCLUDED.last_login_ipa,
|
|
100
|
+
synced_at = EXCLUDED.synced_at,
|
|
101
|
+
ssh_public_keys = EXCLUDED.ssh_public_keys,
|
|
102
|
+
ssh_fingerprints = EXCLUDED.ssh_fingerprints
|
|
103
|
+
`;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Partial patch for an existing IPA-data row. Only columns whose value is not
|
|
108
|
+
* `undefined` are updated — `null` is still treated as an explicit clear.
|
|
109
|
+
* If no row exists yet the UPDATE is a no-op; callers that need to guarantee a
|
|
110
|
+
* row must use `upsertUserIpaData` first.
|
|
111
|
+
*/
|
|
112
|
+
const patchUserIpaData = async (params: {
|
|
113
|
+
userId: string;
|
|
114
|
+
phone?: string | null;
|
|
115
|
+
street?: string | null;
|
|
116
|
+
postalCode?: string | null;
|
|
117
|
+
city?: string | null;
|
|
118
|
+
state?: string | null;
|
|
119
|
+
sshPublicKeys?: string[];
|
|
120
|
+
sshFingerprints?: string[];
|
|
121
|
+
syncedAt?: Date | null;
|
|
122
|
+
}) => {
|
|
123
|
+
const has = (v: unknown) => v !== undefined;
|
|
124
|
+
await sql`
|
|
125
|
+
UPDATE auth.user_ipa_data SET
|
|
126
|
+
phone = CASE WHEN ${has(params.phone)} THEN ${params.phone ?? null} ELSE phone END,
|
|
127
|
+
addr_street = CASE WHEN ${has(params.street)} THEN ${params.street ?? null} ELSE addr_street END,
|
|
128
|
+
addr_postal_code = CASE WHEN ${has(params.postalCode)} THEN ${params.postalCode ?? null} ELSE addr_postal_code END,
|
|
129
|
+
addr_city = CASE WHEN ${has(params.city)} THEN ${params.city ?? null} ELSE addr_city END,
|
|
130
|
+
addr_state = CASE WHEN ${has(params.state)} THEN ${params.state ?? null} ELSE addr_state END,
|
|
131
|
+
ssh_public_keys = CASE WHEN ${has(params.sshPublicKeys)}
|
|
132
|
+
THEN ${freeipa.util.toPgTextArray(params.sshPublicKeys ?? [])}::text[]
|
|
133
|
+
ELSE ssh_public_keys END,
|
|
134
|
+
ssh_fingerprints = CASE WHEN ${has(params.sshFingerprints)}
|
|
135
|
+
THEN ${freeipa.util.toPgTextArray(params.sshFingerprints ?? [])}::text[]
|
|
136
|
+
ELSE ssh_fingerprints END,
|
|
137
|
+
synced_at = CASE WHEN ${has(params.syncedAt)} THEN ${params.syncedAt ?? null} ELSE synced_at END
|
|
138
|
+
WHERE user_id = ${params.userId}
|
|
139
|
+
`;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// ==========================
|
|
143
|
+
// MUTATION: addIpa (create FreeIPA user)
|
|
144
|
+
// ==========================
|
|
145
|
+
|
|
146
|
+
/** Generate random lowercase abbreviation of specified length */
|
|
147
|
+
export const generateAbbreviation = (length: number): string => {
|
|
148
|
+
const chars = "abcdefghijklmnopqrstuvwxyz";
|
|
149
|
+
const limit = Math.floor(256 / chars.length) * chars.length;
|
|
150
|
+
let value = "";
|
|
151
|
+
while (value.length < length) {
|
|
152
|
+
const bytes = new Uint8Array(length - value.length);
|
|
153
|
+
crypto.getRandomValues(bytes);
|
|
154
|
+
for (const byte of bytes) {
|
|
155
|
+
if (byte >= limit) continue;
|
|
156
|
+
value += chars[byte % chars.length]!;
|
|
157
|
+
if (value.length === length) break;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return value;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/** Check if a UID exists in auth.users */
|
|
164
|
+
const uidExists = async (uid: string): Promise<boolean> => {
|
|
165
|
+
const rows: DbRow[] = await sql`
|
|
166
|
+
SELECT 1 FROM auth.users
|
|
167
|
+
WHERE uid = ${uid}
|
|
168
|
+
LIMIT 1
|
|
169
|
+
`;
|
|
170
|
+
return rows.length > 0;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/** Generate a unique abbreviation that doesn't exist in the database */
|
|
174
|
+
export const generateUniqueAbbreviation = async (length: number, maxAttempts = 100): Promise<string> => {
|
|
175
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
176
|
+
const abbr = generateAbbreviation(length);
|
|
177
|
+
if (!(await uidExists(abbr))) {
|
|
178
|
+
return abbr;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
throw new Error(`Failed to generate unique abbreviation after ${maxAttempts} attempts`);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Generate a secure random password that meets FreeIPA policy requirements.
|
|
186
|
+
* Delegates to @valentinkolb/stdlib `password.random` which uses rejection
|
|
187
|
+
* sampling over `crypto.getRandomValues`. A length of 20 with all 4 classes
|
|
188
|
+
* makes the probability of any one class being entirely absent astronomically
|
|
189
|
+
* small (~4 × (3/4)^20 ≈ 0.4%) — acceptable for a temporary admin-generated
|
|
190
|
+
* password; operators can retry if FreeIPA rejects on policy.
|
|
191
|
+
*/
|
|
192
|
+
const generateFreeIpaPassword = (): string =>
|
|
193
|
+
password.random({ length: 20, uppercase: true, numbers: true, symbols: true });
|
|
194
|
+
|
|
195
|
+
/** Calculate account expiration date based on config */
|
|
196
|
+
const calculateAccountExpiration = async (): Promise<Date | null> => {
|
|
197
|
+
const expiresDays = await settings.get<number | null>("user.account.ipa_expires_days");
|
|
198
|
+
const days = expiresDays;
|
|
199
|
+
|
|
200
|
+
if (days && days > 0) {
|
|
201
|
+
const expiry = new Date();
|
|
202
|
+
expiry.setDate(expiry.getDate() + days);
|
|
203
|
+
return expiry;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return null;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
export type AddIpaResult = {
|
|
210
|
+
id: string;
|
|
211
|
+
uid: string;
|
|
212
|
+
accountExpires: string | null;
|
|
213
|
+
/** The temporary password - only for internal use (email sending), not exposed to client */
|
|
214
|
+
_temporaryPassword: string;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Create a new user in FreeIPA and the local database.
|
|
219
|
+
* UID = existing guest UID (if promoting) or new abbreviation.
|
|
220
|
+
*/
|
|
221
|
+
export const addIpa = async (params: {
|
|
222
|
+
ipaSession: string;
|
|
223
|
+
data: CreateUser;
|
|
224
|
+
profile?: UserProfile;
|
|
225
|
+
accountExpires?: Date | null;
|
|
226
|
+
}): Promise<MutationResult<AddIpaResult>> => {
|
|
227
|
+
const unavailable = await ensureFreeIpaMutationAvailable();
|
|
228
|
+
if (unavailable) return unavailable;
|
|
229
|
+
const { ipaSession, data } = params;
|
|
230
|
+
const { email, givenname, sn } = data;
|
|
231
|
+
const targetProfile = params.profile ?? "user";
|
|
232
|
+
|
|
233
|
+
const displayName = data.displayName || `${givenname} ${sn}`;
|
|
234
|
+
|
|
235
|
+
// Check if a local account with this email already exists (provider switch case)
|
|
236
|
+
const existingLocalRows: DbRow[] = await sql`
|
|
237
|
+
SELECT id, uid FROM auth.users WHERE mail = ${email} AND provider = 'local'
|
|
238
|
+
`;
|
|
239
|
+
|
|
240
|
+
// Use existing guest UID or generate a new one
|
|
241
|
+
let uid: string;
|
|
242
|
+
if (existingLocalRows.length > 0) {
|
|
243
|
+
uid = existingLocalRows[0]!.uid as string;
|
|
244
|
+
} else {
|
|
245
|
+
try {
|
|
246
|
+
const abbrLen = await settings.get<number>("user.abbr_length");
|
|
247
|
+
uid = await generateUniqueAbbreviation(abbrLen);
|
|
248
|
+
} catch (e) {
|
|
249
|
+
return {
|
|
250
|
+
ok: false,
|
|
251
|
+
error: e instanceof Error ? e.message : "Failed to generate UID",
|
|
252
|
+
status: 500,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if ((await uidExists(uid)) && existingLocalRows.length === 0) {
|
|
258
|
+
return { ok: false, error: `UID '${uid}' already exists`, status: 400 };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const temporaryPassword = generateFreeIpaPassword();
|
|
262
|
+
const accountExpiry = params.accountExpires === undefined ? await calculateAccountExpiration() : params.accountExpires;
|
|
263
|
+
const now = new Date();
|
|
264
|
+
|
|
265
|
+
const ipaOpts: Record<string, unknown> = {
|
|
266
|
+
givenname,
|
|
267
|
+
sn,
|
|
268
|
+
cn: displayName,
|
|
269
|
+
displayname: displayName,
|
|
270
|
+
mail: email,
|
|
271
|
+
userpassword: temporaryPassword,
|
|
272
|
+
};
|
|
273
|
+
if (accountExpiry) {
|
|
274
|
+
ipaOpts.krbprincipalexpiration = freeipa.util.toGeneralizedTime(accountExpiry);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const response = await freeipa.client.call({ url: await getIpaUrl(), ipaSession, method: "user_add", args: [uid], options: ipaOpts });
|
|
278
|
+
if (response.error) {
|
|
279
|
+
const code = response.error.code;
|
|
280
|
+
if (code === 4001)
|
|
281
|
+
return {
|
|
282
|
+
ok: false,
|
|
283
|
+
error: "IPA session expired. Please log in again.",
|
|
284
|
+
status: 401,
|
|
285
|
+
};
|
|
286
|
+
if (code === 4301)
|
|
287
|
+
return {
|
|
288
|
+
ok: false,
|
|
289
|
+
error: "You don't have permission to create users.",
|
|
290
|
+
status: 403,
|
|
291
|
+
};
|
|
292
|
+
return {
|
|
293
|
+
ok: false,
|
|
294
|
+
error: response.error.message || "Failed to create user.",
|
|
295
|
+
status: 400,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Extract uidNumber from IPA response
|
|
300
|
+
const ipaResult = response.result?.result as Record<string, unknown> | undefined;
|
|
301
|
+
const uidNumber = ipaResult ? freeipa.util.num(ipaResult.uidnumber) : null;
|
|
302
|
+
|
|
303
|
+
let id: string;
|
|
304
|
+
try {
|
|
305
|
+
if (existingLocalRows.length > 0) {
|
|
306
|
+
// Provider switch: update existing local account to IPA user
|
|
307
|
+
const guestId = existingLocalRows[0]!.id as string;
|
|
308
|
+
const updateRows: DbRow[] = await sql`
|
|
309
|
+
UPDATE auth.users SET
|
|
310
|
+
provider = 'ipa',
|
|
311
|
+
profile = ${targetProfile},
|
|
312
|
+
admin = false,
|
|
313
|
+
given_name = ${givenname},
|
|
314
|
+
sn = ${sn},
|
|
315
|
+
display_name = ${displayName},
|
|
316
|
+
mail = ${email},
|
|
317
|
+
account_expires = ${accountExpiry}
|
|
318
|
+
WHERE id = ${guestId}
|
|
319
|
+
RETURNING id
|
|
320
|
+
`;
|
|
321
|
+
id = updateRows[0]!.id as string;
|
|
322
|
+
await upsertUserIpaData({
|
|
323
|
+
userId: id,
|
|
324
|
+
uidNumber,
|
|
325
|
+
passwordExpires: now,
|
|
326
|
+
syncedAt: now,
|
|
327
|
+
});
|
|
328
|
+
await session.revokeAllForUser(guestId);
|
|
329
|
+
} else {
|
|
330
|
+
// New user: insert
|
|
331
|
+
const insertRows: DbRow[] = await sql`
|
|
332
|
+
INSERT INTO auth.users (uid, provider, profile, admin, given_name, sn, display_name, mail, account_expires)
|
|
333
|
+
VALUES (${uid}, 'ipa', ${targetProfile}, false, ${givenname}, ${sn}, ${displayName}, ${email}, ${accountExpiry})
|
|
334
|
+
ON CONFLICT (uid) DO UPDATE SET
|
|
335
|
+
provider = EXCLUDED.provider,
|
|
336
|
+
profile = EXCLUDED.profile,
|
|
337
|
+
admin = false,
|
|
338
|
+
given_name = EXCLUDED.given_name,
|
|
339
|
+
sn = EXCLUDED.sn,
|
|
340
|
+
display_name = EXCLUDED.display_name,
|
|
341
|
+
mail = EXCLUDED.mail,
|
|
342
|
+
account_expires = EXCLUDED.account_expires
|
|
343
|
+
RETURNING id
|
|
344
|
+
`;
|
|
345
|
+
id = insertRows[0]!.id as string;
|
|
346
|
+
await upsertUserIpaData({
|
|
347
|
+
userId: id,
|
|
348
|
+
uidNumber,
|
|
349
|
+
passwordExpires: now,
|
|
350
|
+
syncedAt: now,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
} catch (dbError) {
|
|
354
|
+
log.error("CRITICAL: FreeIPA user created but local DB update failed. Manual reconciliation needed.", {
|
|
355
|
+
uid,
|
|
356
|
+
email,
|
|
357
|
+
userId: existingLocalRows.length > 0 ? (existingLocalRows[0]!.id as string) : null,
|
|
358
|
+
uidNumber,
|
|
359
|
+
targetProfile,
|
|
360
|
+
error: dbError instanceof Error ? dbError.message : String(dbError),
|
|
361
|
+
});
|
|
362
|
+
throw dbError;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
ok: true,
|
|
367
|
+
data: {
|
|
368
|
+
id,
|
|
369
|
+
uid,
|
|
370
|
+
accountExpires: accountExpiry ? accountExpiry.toISOString() : null,
|
|
371
|
+
_temporaryPassword: temporaryPassword,
|
|
372
|
+
},
|
|
373
|
+
};
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
// ==========================
|
|
377
|
+
// MUTATION: updateProfile
|
|
378
|
+
// ==========================
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Update user profile. Handles all realms internally.
|
|
382
|
+
*/
|
|
383
|
+
export const updateProfile = async (params: {
|
|
384
|
+
ipaSession?: string | null;
|
|
385
|
+
id: string;
|
|
386
|
+
data: IpaPatchData;
|
|
387
|
+
}): Promise<MutationResult<void>> => {
|
|
388
|
+
const { ipaSession, id, data } = params;
|
|
389
|
+
|
|
390
|
+
const userRows: DbRow[] = await sql`SELECT uid, provider, profile FROM auth.users WHERE id = ${id}`;
|
|
391
|
+
if (userRows.length === 0) {
|
|
392
|
+
return { ok: false, error: "User not found", status: 404 };
|
|
393
|
+
}
|
|
394
|
+
const uid = userRows[0]!.uid as string;
|
|
395
|
+
const { provider } = resolveProviderProfile(userRows[0]!);
|
|
396
|
+
|
|
397
|
+
if (provider === "ipa") {
|
|
398
|
+
const unavailable = await ensureFreeIpaMutationAvailable();
|
|
399
|
+
if (unavailable) return unavailable;
|
|
400
|
+
if (!ipaSession) {
|
|
401
|
+
return {
|
|
402
|
+
ok: false,
|
|
403
|
+
error: "IPA session required to update IPA user",
|
|
404
|
+
status: 400,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const ipaOptions: Record<string, unknown> = {
|
|
409
|
+
};
|
|
410
|
+
if (data.givenname !== undefined) ipaOptions.givenname = data.givenname;
|
|
411
|
+
if (data.sn !== undefined) ipaOptions.sn = data.sn;
|
|
412
|
+
if (data.displayName !== undefined) ipaOptions.displayname = data.displayName;
|
|
413
|
+
if (data.mail !== undefined) ipaOptions.mail = data.mail || "";
|
|
414
|
+
if (data.ipa?.phone !== undefined) ipaOptions.telephonenumber = data.ipa.phone || "";
|
|
415
|
+
if (data.ipa?.address?.street !== undefined) ipaOptions.street = data.ipa.address.street || "";
|
|
416
|
+
if (data.ipa?.address?.postalCode !== undefined) ipaOptions.postalcode = data.ipa.address.postalCode || "";
|
|
417
|
+
if (data.ipa?.address?.city !== undefined) ipaOptions.l = data.ipa.address.city || "";
|
|
418
|
+
if (data.ipa?.address?.state !== undefined) ipaOptions.st = data.ipa.address.state || "";
|
|
419
|
+
if (data.ipa?.sshPublicKeys !== undefined) {
|
|
420
|
+
ipaOptions.ipasshpubkey = data.ipa.sshPublicKeys.length > 0 ? data.ipa.sshPublicKeys : "";
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const response = await freeipa.client.call({ url: await getIpaUrl(), ipaSession, method: "user_mod", args: [uid], options: ipaOptions });
|
|
424
|
+
if (response.error) {
|
|
425
|
+
return {
|
|
426
|
+
ok: false,
|
|
427
|
+
error: response.error.message ?? "Failed to update user in FreeIPA",
|
|
428
|
+
status: freeipa.util.mapIpaErrorCode(response.error.code),
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const result = response.result?.result as Record<string, unknown> | undefined;
|
|
433
|
+
// Partial patch: never touch uid_number, password expiry, last-login, or
|
|
434
|
+
// other full-sync fields when the user is only editing profile attributes.
|
|
435
|
+
await patchUserIpaData({
|
|
436
|
+
userId: id,
|
|
437
|
+
phone: data.ipa?.phone,
|
|
438
|
+
street: data.ipa?.address?.street,
|
|
439
|
+
postalCode: data.ipa?.address?.postalCode,
|
|
440
|
+
city: data.ipa?.address?.city,
|
|
441
|
+
state: data.ipa?.address?.state,
|
|
442
|
+
sshPublicKeys:
|
|
443
|
+
data.ipa?.sshPublicKeys !== undefined
|
|
444
|
+
? Array.isArray(result?.ipasshpubkey)
|
|
445
|
+
? (result?.ipasshpubkey as string[])
|
|
446
|
+
: []
|
|
447
|
+
: undefined,
|
|
448
|
+
sshFingerprints:
|
|
449
|
+
data.ipa?.sshPublicKeys !== undefined
|
|
450
|
+
? Array.isArray(result?.sshpubkeyfp)
|
|
451
|
+
? (result?.sshpubkeyfp as string[])
|
|
452
|
+
: []
|
|
453
|
+
: undefined,
|
|
454
|
+
syncedAt: new Date(),
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
await sql`
|
|
459
|
+
UPDATE auth.users
|
|
460
|
+
SET given_name = CASE WHEN ${data.givenname !== undefined} THEN ${data.givenname ?? ""} ELSE given_name END,
|
|
461
|
+
sn = CASE WHEN ${data.sn !== undefined} THEN ${data.sn ?? ""} ELSE sn END,
|
|
462
|
+
display_name = CASE WHEN ${data.displayName !== undefined} THEN ${data.displayName ?? ""} ELSE display_name END
|
|
463
|
+
WHERE id = ${id}
|
|
464
|
+
`;
|
|
465
|
+
|
|
466
|
+
if (data.mail !== undefined) {
|
|
467
|
+
await sql`UPDATE auth.users SET mail = ${data.mail || null} WHERE id = ${id}`;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return { ok: true, data: undefined };
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
// ==========================
|
|
474
|
+
// MUTATION: resetPassword
|
|
475
|
+
// ==========================
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Reset a user's password to a new random password.
|
|
479
|
+
*/
|
|
480
|
+
export const resetPassword = async (params: {
|
|
481
|
+
ipaSession: string;
|
|
482
|
+
/** User ID (database UUID) */
|
|
483
|
+
id: string;
|
|
484
|
+
}): Promise<MutationResult<{ password: string }>> => {
|
|
485
|
+
const unavailable = await ensureFreeIpaMutationAvailable();
|
|
486
|
+
if (unavailable) return unavailable;
|
|
487
|
+
const { ipaSession, id } = params;
|
|
488
|
+
|
|
489
|
+
// Look up uid from database
|
|
490
|
+
const userRows: DbRow[] = await sql`SELECT uid FROM auth.users WHERE id = ${id}`;
|
|
491
|
+
if (userRows.length === 0) {
|
|
492
|
+
return { ok: false, error: "User not found", status: 404 };
|
|
493
|
+
}
|
|
494
|
+
const uid = userRows[0]!.uid as string;
|
|
495
|
+
|
|
496
|
+
const newPassword = generateFreeIpaPassword();
|
|
497
|
+
|
|
498
|
+
const response = await freeipa.client.call({
|
|
499
|
+
url: await getIpaUrl(),
|
|
500
|
+
ipaSession,
|
|
501
|
+
method: "user_mod",
|
|
502
|
+
args: [uid],
|
|
503
|
+
options: { userpassword: newPassword },
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
if (response.error) {
|
|
507
|
+
return {
|
|
508
|
+
ok: false,
|
|
509
|
+
error: response.error.message ?? "Failed to reset password",
|
|
510
|
+
status: freeipa.util.mapIpaErrorCode(response.error.code),
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Update password expiry in local DB (password is now "expired" / temporary)
|
|
515
|
+
await sql`
|
|
516
|
+
INSERT INTO auth.user_ipa_data (user_id, ipa_password_expires, synced_at)
|
|
517
|
+
VALUES (${id}, now(), now())
|
|
518
|
+
ON CONFLICT (user_id) DO UPDATE SET
|
|
519
|
+
ipa_password_expires = EXCLUDED.ipa_password_expires,
|
|
520
|
+
synced_at = EXCLUDED.synced_at
|
|
521
|
+
`;
|
|
522
|
+
|
|
523
|
+
return { ok: true, data: { password: newPassword } };
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
// ==========================
|
|
527
|
+
// MUTATION: setExpiry
|
|
528
|
+
// ==========================
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Set or remove account expiration date.
|
|
532
|
+
*/
|
|
533
|
+
export const setExpiry = async (params: {
|
|
534
|
+
ipaSession: string;
|
|
535
|
+
/** User ID (database UUID) */
|
|
536
|
+
id: string;
|
|
537
|
+
expiryDate: string | null;
|
|
538
|
+
}): Promise<MutationResult<void>> => {
|
|
539
|
+
const unavailable = await ensureFreeIpaMutationAvailable();
|
|
540
|
+
if (unavailable) return unavailable;
|
|
541
|
+
const { ipaSession, id, expiryDate } = params;
|
|
542
|
+
|
|
543
|
+
// Look up uid from database
|
|
544
|
+
const userRows: DbRow[] = await sql`SELECT uid, provider, profile FROM auth.users WHERE id = ${id}`;
|
|
545
|
+
if (userRows.length === 0) {
|
|
546
|
+
return { ok: false, error: "User not found", status: 404 };
|
|
547
|
+
}
|
|
548
|
+
const uid = userRows[0]!.uid as string;
|
|
549
|
+
const { provider, profile } = resolveProviderProfile(userRows[0]!);
|
|
550
|
+
|
|
551
|
+
if (provider === "local" && profile === "guest") {
|
|
552
|
+
const guestExpiry = expiryDate ? new Date(expiryDate) : null;
|
|
553
|
+
if (guestExpiry) guestExpiry.setUTCHours(23, 59, 59, 0);
|
|
554
|
+
await sql`
|
|
555
|
+
UPDATE auth.users
|
|
556
|
+
SET account_expires = ${guestExpiry}
|
|
557
|
+
WHERE id = ${id}
|
|
558
|
+
`;
|
|
559
|
+
return { ok: true, data: undefined };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
let dbExpiry: Date | null = null;
|
|
563
|
+
const response = await (async () => {
|
|
564
|
+
if (expiryDate) {
|
|
565
|
+
const date = new Date(expiryDate);
|
|
566
|
+
date.setUTCHours(23, 59, 59, 0);
|
|
567
|
+
const ipaExpiry = date.toISOString().replace(/[-:T]/g, "").slice(0, 14) + "Z";
|
|
568
|
+
dbExpiry = date;
|
|
569
|
+
return freeipa.client.call({
|
|
570
|
+
url: await getIpaUrl(),
|
|
571
|
+
ipaSession,
|
|
572
|
+
method: "user_mod",
|
|
573
|
+
args: [uid],
|
|
574
|
+
options: { krbprincipalexpiration: ipaExpiry },
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
return freeipa.client.call({
|
|
578
|
+
url: await getIpaUrl(),
|
|
579
|
+
ipaSession,
|
|
580
|
+
method: "user_mod",
|
|
581
|
+
args: [uid],
|
|
582
|
+
options: { krbprincipalexpiration: null },
|
|
583
|
+
});
|
|
584
|
+
})();
|
|
585
|
+
|
|
586
|
+
if (response.error) {
|
|
587
|
+
return {
|
|
588
|
+
ok: false,
|
|
589
|
+
error: response.error.message ?? "Failed to update account expiry",
|
|
590
|
+
status: freeipa.util.mapIpaErrorCode(response.error.code),
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
await sql`
|
|
595
|
+
UPDATE auth.users
|
|
596
|
+
SET account_expires = ${dbExpiry}
|
|
597
|
+
WHERE id = ${id}
|
|
598
|
+
`;
|
|
599
|
+
await sql`
|
|
600
|
+
INSERT INTO auth.user_ipa_data (user_id, synced_at)
|
|
601
|
+
VALUES (${id}, now())
|
|
602
|
+
ON CONFLICT (user_id) DO UPDATE SET synced_at = EXCLUDED.synced_at
|
|
603
|
+
`;
|
|
604
|
+
|
|
605
|
+
return { ok: true, data: undefined };
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
// ==========================
|
|
609
|
+
// MUTATION: demoteToGuest
|
|
610
|
+
// ==========================
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Demote IPA user to guest (removes from FreeIPA, keeps in local DB as guest).
|
|
614
|
+
*/
|
|
615
|
+
export const demoteToGuest = async (params: {
|
|
616
|
+
ipaSession: string;
|
|
617
|
+
/** User ID (database UUID) */
|
|
618
|
+
id: string;
|
|
619
|
+
actor: { userId: string; uid: string };
|
|
620
|
+
}): Promise<MutationResult<void>> => {
|
|
621
|
+
const unavailable = await ensureFreeIpaMutationAvailable();
|
|
622
|
+
if (unavailable) return unavailable;
|
|
623
|
+
const { ipaSession, id, actor } = params;
|
|
624
|
+
|
|
625
|
+
const userRows: DbRow[] = await sql`
|
|
626
|
+
SELECT uid, mail, display_name, provider, profile
|
|
627
|
+
FROM auth.users
|
|
628
|
+
WHERE id = ${id} AND provider = 'ipa'
|
|
629
|
+
`;
|
|
630
|
+
if (userRows.length === 0) {
|
|
631
|
+
return {
|
|
632
|
+
ok: false,
|
|
633
|
+
error: "User not found or not an IPA user",
|
|
634
|
+
status: 404,
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
const { provider: previousProvider, profile: previousProfile } = resolveProviderProfile(userRows[0]!);
|
|
638
|
+
const uid = userRows[0]!.uid as string;
|
|
639
|
+
const mail = (userRows[0]!.mail as string) ?? null;
|
|
640
|
+
const displayName = (userRows[0]!.display_name as string) ?? null;
|
|
641
|
+
const guestExpiresDays = await settings.get<number | null>("user.account.local_guest_expires_days");
|
|
642
|
+
const accountExpires = guestExpiresDays && guestExpiresDays > 0 ? new Date(Date.now() + guestExpiresDays * 24 * 60 * 60 * 1000) : null;
|
|
643
|
+
|
|
644
|
+
log.warn("About to delete IPA user, this cannot be undone", { uid, userId: id });
|
|
645
|
+
const response = await freeipa.client.call({ url: await getIpaUrl(), ipaSession, method: "user_del", args: [uid], options: {} });
|
|
646
|
+
const ipaDeleteMessage = (response.error?.message ?? "").toLowerCase();
|
|
647
|
+
const ipaDeleteNotFound = ipaDeleteMessage.includes("not found") || ipaDeleteMessage.includes("does not exist");
|
|
648
|
+
if (response.error && !ipaDeleteNotFound) {
|
|
649
|
+
return {
|
|
650
|
+
ok: false,
|
|
651
|
+
error: response.error.message ?? "Failed to delete user from FreeIPA",
|
|
652
|
+
status: freeipa.util.mapIpaErrorCode(response.error.code),
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
log.info("FreeIPA user deleted, updating local DB", { uid, userId: id });
|
|
657
|
+
try {
|
|
658
|
+
await sql.begin(async (tx) => {
|
|
659
|
+
await tx`
|
|
660
|
+
UPDATE auth.users
|
|
661
|
+
SET provider = 'local', profile = 'guest', admin = false,
|
|
662
|
+
account_expires = ${accountExpires}
|
|
663
|
+
WHERE id = ${id}
|
|
664
|
+
`;
|
|
665
|
+
await tx`DELETE FROM auth.user_ipa_data WHERE user_id = ${id}`;
|
|
666
|
+
|
|
667
|
+
await tx`
|
|
668
|
+
DELETE FROM auth.user_groups_v2
|
|
669
|
+
WHERE user_id = ${id}
|
|
670
|
+
AND group_id IN (SELECT id FROM auth.groups WHERE provider = 'ipa')
|
|
671
|
+
`;
|
|
672
|
+
await tx`
|
|
673
|
+
DELETE FROM auth.group_manager_users_v2
|
|
674
|
+
WHERE user_id = ${id}
|
|
675
|
+
AND group_id IN (SELECT id FROM auth.groups WHERE provider = 'ipa')
|
|
676
|
+
`;
|
|
677
|
+
await writeDeletedAccountAudit({
|
|
678
|
+
db: tx,
|
|
679
|
+
userId: id,
|
|
680
|
+
uid,
|
|
681
|
+
mail,
|
|
682
|
+
displayName,
|
|
683
|
+
previousProvider,
|
|
684
|
+
previousProfile,
|
|
685
|
+
reason: "manual_demote",
|
|
686
|
+
meta: {
|
|
687
|
+
actorUserId: actor.userId,
|
|
688
|
+
actorUid: actor.uid,
|
|
689
|
+
guestExpiresAt: accountExpires?.toISOString() ?? null,
|
|
690
|
+
freeIpaUserAlreadyMissing: ipaDeleteNotFound,
|
|
691
|
+
},
|
|
692
|
+
});
|
|
693
|
+
});
|
|
694
|
+
} catch (dbError) {
|
|
695
|
+
log.error("CRITICAL: FreeIPA user was deleted but local DB update failed. Manual reconciliation needed.", {
|
|
696
|
+
uid,
|
|
697
|
+
userId: id,
|
|
698
|
+
mail,
|
|
699
|
+
error: dbError instanceof Error ? dbError.message : String(dbError),
|
|
700
|
+
});
|
|
701
|
+
throw dbError;
|
|
702
|
+
}
|
|
703
|
+
await session.revokeAllForUser(id);
|
|
704
|
+
|
|
705
|
+
return { ok: true, data: undefined };
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
// ==========================
|
|
709
|
+
// MUTATION: delete
|
|
710
|
+
// ==========================
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Permanently delete user (from FreeIPA if applicable, and from local DB).
|
|
714
|
+
*/
|
|
715
|
+
export const deleteUser = async (params: {
|
|
716
|
+
ipaSession?: string | null;
|
|
717
|
+
/** User ID (database UUID) */
|
|
718
|
+
id: string;
|
|
719
|
+
actor: { userId: string; uid: string };
|
|
720
|
+
}): Promise<MutationResult<void>> => {
|
|
721
|
+
const { ipaSession, id, actor } = params;
|
|
722
|
+
|
|
723
|
+
const userRows: DbRow[] = await sql`
|
|
724
|
+
SELECT uid, provider, profile, mail, display_name
|
|
725
|
+
FROM auth.users
|
|
726
|
+
WHERE id = ${id}
|
|
727
|
+
`;
|
|
728
|
+
if (userRows.length === 0) {
|
|
729
|
+
return { ok: false, error: "User not found", status: 404 };
|
|
730
|
+
}
|
|
731
|
+
const uid = userRows[0]!.uid as string;
|
|
732
|
+
const { provider, profile } = resolveProviderProfile(userRows[0]!);
|
|
733
|
+
const mail = (userRows[0]!.mail as string) ?? null;
|
|
734
|
+
const displayName = (userRows[0]!.display_name as string) ?? null;
|
|
735
|
+
let ipaDeleteNotFound = false;
|
|
736
|
+
|
|
737
|
+
if (provider === "ipa") {
|
|
738
|
+
const unavailable = await ensureFreeIpaMutationAvailable();
|
|
739
|
+
if (unavailable) return unavailable;
|
|
740
|
+
if (!ipaSession) {
|
|
741
|
+
return {
|
|
742
|
+
ok: false,
|
|
743
|
+
error: "IPA session required to delete IPA user",
|
|
744
|
+
status: 400,
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
log.warn("About to delete IPA user, this cannot be undone", { uid, userId: id });
|
|
748
|
+
const response = await freeipa.client.call({ url: await getIpaUrl(), ipaSession, method: "user_del", args: [uid], options: {} });
|
|
749
|
+
const ipaDeleteMessage = (response.error?.message ?? "").toLowerCase();
|
|
750
|
+
ipaDeleteNotFound = ipaDeleteMessage.includes("not found") || ipaDeleteMessage.includes("does not exist");
|
|
751
|
+
if (response.error && !ipaDeleteNotFound) {
|
|
752
|
+
return {
|
|
753
|
+
ok: false,
|
|
754
|
+
error: response.error.message ?? "Failed to delete user from FreeIPA",
|
|
755
|
+
status: freeipa.util.mapIpaErrorCode(response.error.code),
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
log.info("FreeIPA user deleted, updating local DB", { uid, userId: id });
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
try {
|
|
762
|
+
await sql.begin(async (tx) => {
|
|
763
|
+
await writeDeletedAccountAudit({
|
|
764
|
+
db: tx,
|
|
765
|
+
userId: id,
|
|
766
|
+
uid,
|
|
767
|
+
mail,
|
|
768
|
+
displayName,
|
|
769
|
+
previousProvider: provider,
|
|
770
|
+
previousProfile: profile,
|
|
771
|
+
reason: "manual_delete",
|
|
772
|
+
meta: {
|
|
773
|
+
actorUserId: actor.userId,
|
|
774
|
+
actorUid: actor.uid,
|
|
775
|
+
deletedFromFreeIpa: provider === "ipa",
|
|
776
|
+
freeIpaUserAlreadyMissing: provider === "ipa" ? ipaDeleteNotFound : false,
|
|
777
|
+
},
|
|
778
|
+
});
|
|
779
|
+
await tx`DELETE FROM auth.users WHERE id = ${id}`;
|
|
780
|
+
});
|
|
781
|
+
} catch (dbError) {
|
|
782
|
+
log.error("CRITICAL: FreeIPA user was deleted but local DB update failed. Manual reconciliation needed.", {
|
|
783
|
+
uid,
|
|
784
|
+
userId: id,
|
|
785
|
+
mail,
|
|
786
|
+
provider,
|
|
787
|
+
error: dbError instanceof Error ? dbError.message : String(dbError),
|
|
788
|
+
});
|
|
789
|
+
throw dbError;
|
|
790
|
+
}
|
|
791
|
+
await session.revokeAllForUser(id);
|
|
792
|
+
|
|
793
|
+
return { ok: true, data: undefined };
|
|
794
|
+
};
|