@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,740 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
2
|
+
import { applyIpaAccountTransitionPolicy } from "../accounts/switching";
|
|
3
|
+
import {
|
|
4
|
+
parseIpaAccountTransitionPolicy,
|
|
5
|
+
parseIpaMatchMode,
|
|
6
|
+
} from "../account-model";
|
|
7
|
+
import { writeDeletedAccountAudit } from "../account-lifecycle/audit";
|
|
8
|
+
import { logger } from "../logging";
|
|
9
|
+
import * as settings from "../settings";
|
|
10
|
+
import { session } from "../session";
|
|
11
|
+
import { freeipa } from "../../server/services";
|
|
12
|
+
import { getFreeIpaConfig } from "../freeipa-config";
|
|
13
|
+
import { calculateIpaProfile, calculateIpaProfileFromLocalDb } from "./profile";
|
|
14
|
+
|
|
15
|
+
type DbRow = Record<string, unknown>;
|
|
16
|
+
|
|
17
|
+
const log = logger("auth:ipa:sync");
|
|
18
|
+
|
|
19
|
+
const upsertUserIpaData = async (
|
|
20
|
+
db: typeof sql,
|
|
21
|
+
params: {
|
|
22
|
+
userId: string;
|
|
23
|
+
uidNumber: number | null;
|
|
24
|
+
phone: string | null;
|
|
25
|
+
ipaPasswordExpires: Date | null;
|
|
26
|
+
lastLoginIpa: Date | null;
|
|
27
|
+
employeeType: string | null;
|
|
28
|
+
addrStreet: string | null;
|
|
29
|
+
addrPostalCode: string | null;
|
|
30
|
+
addrCity: string | null;
|
|
31
|
+
addrState: string | null;
|
|
32
|
+
mobile: string | null;
|
|
33
|
+
sshPublicKeys: string[];
|
|
34
|
+
sshFingerprints: string[];
|
|
35
|
+
},
|
|
36
|
+
) =>
|
|
37
|
+
db`
|
|
38
|
+
INSERT INTO auth.user_ipa_data (
|
|
39
|
+
user_id, uid_number, phone, employee_type, mobile, addr_street, addr_postal_code,
|
|
40
|
+
addr_city, addr_state, ipa_password_expires, last_login_ipa, synced_at, ssh_public_keys, ssh_fingerprints
|
|
41
|
+
)
|
|
42
|
+
VALUES (
|
|
43
|
+
${params.userId},
|
|
44
|
+
${params.uidNumber},
|
|
45
|
+
${params.phone},
|
|
46
|
+
${params.employeeType},
|
|
47
|
+
${params.mobile},
|
|
48
|
+
${params.addrStreet},
|
|
49
|
+
${params.addrPostalCode},
|
|
50
|
+
${params.addrCity},
|
|
51
|
+
${params.addrState},
|
|
52
|
+
${params.ipaPasswordExpires},
|
|
53
|
+
${params.lastLoginIpa},
|
|
54
|
+
now(),
|
|
55
|
+
${freeipa.util.toPgTextArray(params.sshPublicKeys)}::text[],
|
|
56
|
+
${freeipa.util.toPgTextArray(params.sshFingerprints)}::text[]
|
|
57
|
+
)
|
|
58
|
+
ON CONFLICT (user_id) DO UPDATE SET
|
|
59
|
+
uid_number = EXCLUDED.uid_number,
|
|
60
|
+
phone = EXCLUDED.phone,
|
|
61
|
+
employee_type = EXCLUDED.employee_type,
|
|
62
|
+
mobile = EXCLUDED.mobile,
|
|
63
|
+
addr_street = EXCLUDED.addr_street,
|
|
64
|
+
addr_postal_code = EXCLUDED.addr_postal_code,
|
|
65
|
+
addr_city = EXCLUDED.addr_city,
|
|
66
|
+
addr_state = EXCLUDED.addr_state,
|
|
67
|
+
ipa_password_expires = EXCLUDED.ipa_password_expires,
|
|
68
|
+
last_login_ipa = EXCLUDED.last_login_ipa,
|
|
69
|
+
synced_at = EXCLUDED.synced_at,
|
|
70
|
+
ssh_public_keys = EXCLUDED.ssh_public_keys,
|
|
71
|
+
ssh_fingerprints = EXCLUDED.ssh_fingerprints
|
|
72
|
+
`;
|
|
73
|
+
|
|
74
|
+
// ==========================
|
|
75
|
+
// Sync Types
|
|
76
|
+
// ==========================
|
|
77
|
+
|
|
78
|
+
type SyncUser = {
|
|
79
|
+
uid: string;
|
|
80
|
+
uidNumber: number | null;
|
|
81
|
+
givenname: string;
|
|
82
|
+
sn: string;
|
|
83
|
+
displayName: string;
|
|
84
|
+
mail: string | null;
|
|
85
|
+
phone: string | null;
|
|
86
|
+
ipaAccountExpires: Date | null;
|
|
87
|
+
ipaPasswordExpires: Date | null;
|
|
88
|
+
lastLoginIpa: Date | null;
|
|
89
|
+
memberofGroup: string[];
|
|
90
|
+
employeeType: string | null;
|
|
91
|
+
addrStreet: string | null;
|
|
92
|
+
addrPostalCode: string | null;
|
|
93
|
+
addrCity: string | null;
|
|
94
|
+
addrState: string | null;
|
|
95
|
+
mobile: string | null;
|
|
96
|
+
sshPublicKeys: string[];
|
|
97
|
+
sshFingerprints: string[];
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
type SyncGroup = {
|
|
101
|
+
cn: string;
|
|
102
|
+
description: string | null;
|
|
103
|
+
gidnumber: number | null;
|
|
104
|
+
users: string[];
|
|
105
|
+
groups: string[];
|
|
106
|
+
parentGroups: string[];
|
|
107
|
+
managerUsers: string[];
|
|
108
|
+
managerGroups: string[];
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
type IpaCallResponse = Awaited<ReturnType<typeof freeipa.client.call>>;
|
|
112
|
+
|
|
113
|
+
// ==========================
|
|
114
|
+
// Transform helpers
|
|
115
|
+
// ==========================
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Normalizes one raw IPA user record into the sync user model.
|
|
119
|
+
*/
|
|
120
|
+
const transformSyncUser = (raw: Record<string, unknown>): SyncUser => {
|
|
121
|
+
const directGroups = (raw.memberof_group as string[]) ?? [];
|
|
122
|
+
const indirectGroups = (raw.memberofindirect_group as string[]) ?? [];
|
|
123
|
+
return {
|
|
124
|
+
uid: freeipa.util.str(raw.uid),
|
|
125
|
+
uidNumber: freeipa.util.num(raw.uidnumber),
|
|
126
|
+
givenname: freeipa.util.str(raw.givenname),
|
|
127
|
+
sn: freeipa.util.str(raw.sn),
|
|
128
|
+
displayName: freeipa.util.str(raw.displayname) || [freeipa.util.str(raw.givenname), freeipa.util.str(raw.sn)].filter(Boolean).join(" ") || freeipa.util.str(raw.uid),
|
|
129
|
+
mail: freeipa.util.str(raw.mail) || null,
|
|
130
|
+
phone: freeipa.util.str(raw.telephonenumber) || null,
|
|
131
|
+
ipaAccountExpires: freeipa.util.parseGeneralizedTime(raw.krbprincipalexpiration),
|
|
132
|
+
ipaPasswordExpires: freeipa.util.parseGeneralizedTime(raw.krbpasswordexpiration),
|
|
133
|
+
lastLoginIpa: freeipa.util.parseGeneralizedTime(raw.krblastsuccessfulauth),
|
|
134
|
+
memberofGroup: [...directGroups, ...indirectGroups],
|
|
135
|
+
employeeType: freeipa.util.str(raw.employeetype) || null,
|
|
136
|
+
addrStreet: freeipa.util.str(raw.street) || null,
|
|
137
|
+
addrPostalCode: freeipa.util.str(raw.postalcode) || null,
|
|
138
|
+
addrCity: freeipa.util.str(raw.l) || null,
|
|
139
|
+
addrState: freeipa.util.str(raw.st) || null,
|
|
140
|
+
mobile: freeipa.util.str(raw.mobile) || null,
|
|
141
|
+
sshPublicKeys: Array.isArray(raw.ipasshpubkey) ? raw.ipasshpubkey : [],
|
|
142
|
+
sshFingerprints: Array.isArray(raw.sshpubkeyfp) ? raw.sshpubkeyfp : [],
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Normalizes one raw IPA group record into the sync group model.
|
|
148
|
+
* `excludedGroupsSet` is hoisted to the caller so we don't re-read settings
|
|
149
|
+
* once per group.
|
|
150
|
+
*/
|
|
151
|
+
const transformSyncGroup = (raw: Record<string, unknown>, excludedGroupsSet: Set<string>): SyncGroup => ({
|
|
152
|
+
cn: freeipa.util.str(raw.cn),
|
|
153
|
+
description: freeipa.util.str(raw.description) || null,
|
|
154
|
+
gidnumber: freeipa.util.num(raw.gidnumber),
|
|
155
|
+
users: (raw.member_user as string[]) ?? [],
|
|
156
|
+
groups: ((raw.member_group as string[]) ?? []).filter((g) => !excludedGroupsSet.has(g)),
|
|
157
|
+
parentGroups: ((raw.memberof_group as string[]) ?? []).filter((g) => !excludedGroupsSet.has(g)),
|
|
158
|
+
managerUsers: (raw.membermanager_user as string[]) ?? [],
|
|
159
|
+
managerGroups: ((raw.membermanager_group as string[]) ?? []).filter((g) => !excludedGroupsSet.has(g)),
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Evaluates whether an IPA account is already expired based on account metadata.
|
|
164
|
+
*/
|
|
165
|
+
const isExpired = (u: SyncUser): boolean => {
|
|
166
|
+
if (!u.ipaAccountExpires) return false;
|
|
167
|
+
return u.ipaAccountExpires < new Date();
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Reads one IPA list response and throws for transport/protocol failures.
|
|
172
|
+
* Sync must fail hard on invalid payloads to avoid destructive partial updates.
|
|
173
|
+
*/
|
|
174
|
+
const readIpaList = (config: { response: IpaCallResponse; entity: string }): Record<string, unknown>[] => {
|
|
175
|
+
if (config.response.error) {
|
|
176
|
+
throw new Error(`IPA ${config.entity} fetch failed: ${config.response.error.message}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const records = config.response.result?.result;
|
|
180
|
+
if (!Array.isArray(records)) {
|
|
181
|
+
throw new Error(`IPA ${config.entity} fetch returned invalid list payload`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return records as Record<string, unknown>[];
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
// ==========================
|
|
188
|
+
// Sync
|
|
189
|
+
// ==========================
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Runs a full IPA-to-local sync pass for users, groups, and memberships.
|
|
193
|
+
*/
|
|
194
|
+
export const syncFromIpa = async (): Promise<void> => {
|
|
195
|
+
const startedAt = Date.now();
|
|
196
|
+
const config = await getFreeIpaConfig();
|
|
197
|
+
if (!config.enabled) {
|
|
198
|
+
log.info("Sync skipped", { reason: "freeipa_disabled" });
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (!config.configured) {
|
|
202
|
+
throw new Error("FreeIPA is enabled but not fully configured.");
|
|
203
|
+
}
|
|
204
|
+
const excludedGroupsSet = freeipa.util.toExcludedGroupsSet(config.groupsExcluded);
|
|
205
|
+
const ipaSession = await freeipa.session.getServiceSession({
|
|
206
|
+
url: config.url,
|
|
207
|
+
serviceUser: config.serviceUser,
|
|
208
|
+
servicePassword: config.servicePassword,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const [usersRes, groupsRes] = await Promise.all([
|
|
212
|
+
freeipa.client.call({ url: config.url, ipaSession, method: "user_find", args: [], options: { sizelimit: 0, all: true } }),
|
|
213
|
+
freeipa.client.call({
|
|
214
|
+
url: config.url,
|
|
215
|
+
ipaSession,
|
|
216
|
+
method: "group_find",
|
|
217
|
+
args: [],
|
|
218
|
+
options: {
|
|
219
|
+
sizelimit: 0,
|
|
220
|
+
no_members: false,
|
|
221
|
+
all: true,
|
|
222
|
+
},
|
|
223
|
+
}),
|
|
224
|
+
]);
|
|
225
|
+
|
|
226
|
+
const allRawUsers = readIpaList({ response: usersRes, entity: "users" });
|
|
227
|
+
|
|
228
|
+
const users = allRawUsers
|
|
229
|
+
.filter((raw) => {
|
|
230
|
+
const groups = (raw.memberof_group as string[]) ?? [];
|
|
231
|
+
return config.groupsBaseSync.some((g) => groups.includes(g));
|
|
232
|
+
})
|
|
233
|
+
.map(transformSyncUser);
|
|
234
|
+
|
|
235
|
+
const allRawGroups = readIpaList({ response: groupsRes, entity: "groups" });
|
|
236
|
+
const groups = allRawGroups.map((raw) => transformSyncGroup(raw, excludedGroupsSet)).filter((g) => !excludedGroupsSet.has(g.cn));
|
|
237
|
+
|
|
238
|
+
const activeUsers = users.filter((u) => !isExpired(u));
|
|
239
|
+
const expiredUsers = users.length - activeUsers.length;
|
|
240
|
+
// Only ACTIVE remote users are considered in scope. Expired ones fall through to the
|
|
241
|
+
// stale/transition branch so their local mirror is either demoted or deleted per policy,
|
|
242
|
+
// and their sessions are revoked. Treating expired users as in-scope would leave a stale
|
|
243
|
+
// unexpired local row plus live sessions.
|
|
244
|
+
const inScopeUids = new Set(activeUsers.map((u) => u.uid));
|
|
245
|
+
const groupCns = new Set(groups.map((g) => g.cn));
|
|
246
|
+
const matchMode = parseIpaMatchMode(await settings.get<string | null>("freeipa.user_match_mode"));
|
|
247
|
+
const transitionPolicy = parseIpaAccountTransitionPolicy(
|
|
248
|
+
await settings.get<string | null>("freeipa.account_transition_policy"),
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
let matchedExistingUsersByMail = 0;
|
|
252
|
+
let migratedLocalUsers = 0;
|
|
253
|
+
let skippedLocalMailConflicts = 0;
|
|
254
|
+
let skippedLocalUidConflicts = 0;
|
|
255
|
+
let upsertedUsersByUid = 0;
|
|
256
|
+
let insertedUsersByUid = 0;
|
|
257
|
+
let updatedUsersByUid = 0;
|
|
258
|
+
let deletedGroups = 0;
|
|
259
|
+
|
|
260
|
+
const [localCountsRow] = await sql<DbRow[]>`
|
|
261
|
+
SELECT
|
|
262
|
+
(SELECT COUNT(*)::int FROM auth.users WHERE provider = 'ipa') AS ipa_users,
|
|
263
|
+
(SELECT COUNT(*)::int FROM auth.groups WHERE provider = 'ipa') AS groups
|
|
264
|
+
`;
|
|
265
|
+
|
|
266
|
+
const localIpaUsers = Number(localCountsRow?.ipa_users ?? 0);
|
|
267
|
+
const localGroups = Number(localCountsRow?.groups ?? 0);
|
|
268
|
+
|
|
269
|
+
if (activeUsers.length === 0 && localIpaUsers > 0) {
|
|
270
|
+
throw new Error(`Refusing IPA sync: remote active users list is empty while local has ${localIpaUsers} IPA users`);
|
|
271
|
+
}
|
|
272
|
+
if (groupCns.size === 0 && localGroups > 0) {
|
|
273
|
+
throw new Error(`Refusing IPA sync: remote groups list is empty while local has ${localGroups} groups`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const localIpaRows = await sql<DbRow[]>`
|
|
277
|
+
SELECT id, uid, mail, display_name, profile
|
|
278
|
+
FROM auth.users
|
|
279
|
+
WHERE provider = 'ipa'
|
|
280
|
+
ORDER BY uid
|
|
281
|
+
`;
|
|
282
|
+
const staleLocalUsers = localIpaRows.filter((row) => !inScopeUids.has(row.uid as string));
|
|
283
|
+
const staleLimit = Math.max(10, Math.ceil(Math.max(localIpaUsers, 1) * 0.2));
|
|
284
|
+
if (staleLocalUsers.length > staleLimit) {
|
|
285
|
+
throw new Error(
|
|
286
|
+
`Refusing IPA sync: ${staleLocalUsers.length} local IPA users disappeared from sync scope (limit ${staleLimit})`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const staleDemotedUsers: Array<{ id: string; uid: string }> = [];
|
|
291
|
+
await sql.begin(async (tx) => {
|
|
292
|
+
// 1. Upsert active IPA users
|
|
293
|
+
// Match order: mail (existing IPA user, handles UID renames) → mail (guest promotion) → uid (new or unchanged)
|
|
294
|
+
for (const u of activeUsers) {
|
|
295
|
+
const profile = await calculateIpaProfile(u.memberofGroup);
|
|
296
|
+
const provider = "ipa";
|
|
297
|
+
|
|
298
|
+
if (u.mail) {
|
|
299
|
+
// First: match existing IPA user by mail (handles UID renames)
|
|
300
|
+
const updated = await tx`
|
|
301
|
+
UPDATE auth.users SET
|
|
302
|
+
uid = ${u.uid}, provider = ${provider}, profile = ${profile}, admin = false,
|
|
303
|
+
given_name = ${u.givenname}, sn = ${u.sn},
|
|
304
|
+
display_name = ${u.displayName}, mail = ${u.mail},
|
|
305
|
+
account_expires = ${u.ipaAccountExpires}
|
|
306
|
+
WHERE mail = ${u.mail} AND provider = 'ipa'
|
|
307
|
+
RETURNING id`;
|
|
308
|
+
if (updated.length > 0) {
|
|
309
|
+
await upsertUserIpaData(tx, { userId: updated[0]!.id as string, ...u });
|
|
310
|
+
matchedExistingUsersByMail += 1;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Second: optionally migrate a unique local account to IPA.
|
|
315
|
+
if (matchMode === "migrate") {
|
|
316
|
+
const localMatches = await tx<DbRow[]>`
|
|
317
|
+
SELECT id
|
|
318
|
+
FROM auth.users
|
|
319
|
+
WHERE mail = ${u.mail} AND provider = 'local'
|
|
320
|
+
ORDER BY profile = 'user' DESC, created_at ASC
|
|
321
|
+
LIMIT 2
|
|
322
|
+
`;
|
|
323
|
+
|
|
324
|
+
if (localMatches.length === 1) {
|
|
325
|
+
const migrated = await tx`
|
|
326
|
+
UPDATE auth.users SET
|
|
327
|
+
uid = ${u.uid}, provider = ${provider}, profile = ${profile}, admin = false,
|
|
328
|
+
given_name = ${u.givenname}, sn = ${u.sn},
|
|
329
|
+
display_name = ${u.displayName}, mail = ${u.mail},
|
|
330
|
+
account_expires = ${u.ipaAccountExpires}
|
|
331
|
+
WHERE id = ${localMatches[0]!.id as string}::uuid
|
|
332
|
+
RETURNING id`;
|
|
333
|
+
if (migrated.length > 0) {
|
|
334
|
+
await upsertUserIpaData(tx, { userId: migrated[0]!.id as string, ...u });
|
|
335
|
+
migratedLocalUsers += 1;
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
} else if (localMatches.length > 1) {
|
|
339
|
+
skippedLocalMailConflicts += 1;
|
|
340
|
+
log.warn("Skipping IPA provider migration because multiple local accounts matched by mail", {
|
|
341
|
+
mail: u.mail,
|
|
342
|
+
uid: u.uid,
|
|
343
|
+
});
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const uidConflictRows = await tx<DbRow[]>`
|
|
350
|
+
SELECT id
|
|
351
|
+
FROM auth.users
|
|
352
|
+
WHERE uid = ${u.uid} AND provider = 'local'
|
|
353
|
+
LIMIT 1
|
|
354
|
+
`;
|
|
355
|
+
if (uidConflictRows.length > 0) {
|
|
356
|
+
skippedLocalUidConflicts += 1;
|
|
357
|
+
log.warn("Skipping IPA sync user upsert because a local account already uses the UID", {
|
|
358
|
+
uid: u.uid,
|
|
359
|
+
mail: u.mail,
|
|
360
|
+
});
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Third: insert new or update existing by uid
|
|
365
|
+
const upserted = await tx<DbRow[]>`
|
|
366
|
+
INSERT INTO auth.users (uid, provider, profile, admin, given_name, sn, display_name, mail, account_expires)
|
|
367
|
+
VALUES (${u.uid}, ${provider}, ${profile}, false, ${u.givenname}, ${u.sn}, ${
|
|
368
|
+
u.displayName
|
|
369
|
+
}, ${u.mail}, ${u.ipaAccountExpires})
|
|
370
|
+
ON CONFLICT (uid) DO UPDATE SET
|
|
371
|
+
provider = ${provider},
|
|
372
|
+
profile = ${profile},
|
|
373
|
+
admin = false,
|
|
374
|
+
given_name = EXCLUDED.given_name, sn = EXCLUDED.sn,
|
|
375
|
+
display_name = EXCLUDED.display_name, mail = EXCLUDED.mail,
|
|
376
|
+
account_expires = EXCLUDED.account_expires
|
|
377
|
+
RETURNING id, (xmax = 0) AS inserted`;
|
|
378
|
+
await upsertUserIpaData(tx, { userId: upserted[0]!.id as string, ...u });
|
|
379
|
+
upsertedUsersByUid += 1;
|
|
380
|
+
if (Boolean(upserted[0]?.inserted)) insertedUsersByUid += 1;
|
|
381
|
+
else updatedUsersByUid += 1;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
for (const stale of staleLocalUsers) {
|
|
385
|
+
const userId = stale.id as string;
|
|
386
|
+
const uid = stale.uid as string;
|
|
387
|
+
const previousProfile = (stale.profile as "user" | "guest" | null) ?? "guest";
|
|
388
|
+
|
|
389
|
+
if (transitionPolicy === "delete") {
|
|
390
|
+
await writeDeletedAccountAudit({
|
|
391
|
+
db: tx,
|
|
392
|
+
userId,
|
|
393
|
+
uid,
|
|
394
|
+
mail: (stale.mail as string) ?? null,
|
|
395
|
+
displayName: (stale.display_name as string) ?? null,
|
|
396
|
+
previousProvider: "ipa",
|
|
397
|
+
previousProfile,
|
|
398
|
+
reason: "sync_out_of_scope_deleted",
|
|
399
|
+
meta: {
|
|
400
|
+
reason: "missing_from_ipa_sync_scope",
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
await tx`DELETE FROM auth.users WHERE id = ${userId}::uuid`;
|
|
404
|
+
staleDemotedUsers.push({ id: userId, uid });
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const target = await applyIpaAccountTransitionPolicy({
|
|
409
|
+
userId,
|
|
410
|
+
currentProfile: previousProfile,
|
|
411
|
+
policy: transitionPolicy,
|
|
412
|
+
db: tx,
|
|
413
|
+
});
|
|
414
|
+
await writeDeletedAccountAudit({
|
|
415
|
+
db: tx,
|
|
416
|
+
userId,
|
|
417
|
+
uid,
|
|
418
|
+
mail: (stale.mail as string) ?? null,
|
|
419
|
+
displayName: (stale.display_name as string) ?? null,
|
|
420
|
+
previousProvider: "ipa",
|
|
421
|
+
previousProfile,
|
|
422
|
+
reason: "sync_out_of_scope_demoted",
|
|
423
|
+
meta: {
|
|
424
|
+
accountExpiresAt: target.accountExpires?.toISOString() ?? null,
|
|
425
|
+
targetProfile: target.targetProfile,
|
|
426
|
+
reason: "missing_from_ipa_sync_scope",
|
|
427
|
+
},
|
|
428
|
+
});
|
|
429
|
+
staleDemotedUsers.push({ id: userId, uid });
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// 2. Upsert groups + delete stale
|
|
433
|
+
for (const g of groups) {
|
|
434
|
+
await tx`
|
|
435
|
+
INSERT INTO auth.groups (id, cn, name, provider, description, gid_number, synced_at)
|
|
436
|
+
VALUES (gen_random_uuid(), ${g.cn}, ${g.cn}, 'ipa', ${g.description}, ${g.gidnumber}, now())
|
|
437
|
+
ON CONFLICT (provider, name) DO UPDATE SET
|
|
438
|
+
cn = EXCLUDED.cn,
|
|
439
|
+
description = EXCLUDED.description,
|
|
440
|
+
gid_number = EXCLUDED.gid_number,
|
|
441
|
+
synced_at = now()`;
|
|
442
|
+
}
|
|
443
|
+
const groupCnArray = [...groupCns];
|
|
444
|
+
if (groupCnArray.length > 0) {
|
|
445
|
+
const deleted = await tx<DbRow[]>`
|
|
446
|
+
DELETE FROM auth.groups
|
|
447
|
+
WHERE provider = 'ipa'
|
|
448
|
+
AND name <> ALL(${freeipa.util.toPgTextArray(groupCnArray)}::text[])
|
|
449
|
+
RETURNING name
|
|
450
|
+
`;
|
|
451
|
+
deletedGroups = deleted.length;
|
|
452
|
+
} else {
|
|
453
|
+
log.warn("Skipping stale group deletion because resolved group list is empty");
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const groupRows = await tx<DbRow[]>`SELECT id, name FROM auth.groups WHERE provider = 'ipa'`;
|
|
457
|
+
const groupNameToId = new Map<string, string>(groupRows.map((row) => [row.name as string, row.id as string]));
|
|
458
|
+
|
|
459
|
+
// 3. Rebuild junction tables
|
|
460
|
+
await tx`DELETE FROM auth.user_groups_v2 WHERE group_id IN (SELECT id FROM auth.groups WHERE provider = 'ipa')`;
|
|
461
|
+
await tx`
|
|
462
|
+
DELETE FROM auth.group_groups_v2
|
|
463
|
+
WHERE parent_group_id IN (SELECT id FROM auth.groups WHERE provider = 'ipa')
|
|
464
|
+
OR child_group_id IN (SELECT id FROM auth.groups WHERE provider = 'ipa')
|
|
465
|
+
`;
|
|
466
|
+
await tx`DELETE FROM auth.group_manager_users_v2 WHERE group_id IN (SELECT id FROM auth.groups WHERE provider = 'ipa')`;
|
|
467
|
+
await tx`
|
|
468
|
+
DELETE FROM auth.group_manager_groups_v2
|
|
469
|
+
WHERE group_id IN (SELECT id FROM auth.groups WHERE provider = 'ipa')
|
|
470
|
+
OR manager_group_id IN (SELECT id FROM auth.groups WHERE provider = 'ipa')
|
|
471
|
+
`;
|
|
472
|
+
|
|
473
|
+
const userIdRows: DbRow[] = await tx`SELECT id, uid FROM auth.users WHERE provider = 'ipa'`;
|
|
474
|
+
const uidToId = new Map<string, string>(userIdRows.map((r) => [r.uid as string, r.id as string]));
|
|
475
|
+
|
|
476
|
+
// Bulk INSERT helper. Bun's `sql(rows)` only generates valid VALUES
|
|
477
|
+
// syntax when fed an array of OBJECTS (it derives the column list from
|
|
478
|
+
// object keys). Passing array-of-arrays produces broken SQL like
|
|
479
|
+
// `VALUES ("0", "1") VALUES($1, $2),...` — see Bun postgres bug. So we
|
|
480
|
+
// build typed objects and use the column-form `sql(rows, "col_a", "col_b")`
|
|
481
|
+
// to control column ordering explicitly.
|
|
482
|
+
|
|
483
|
+
// user_groups — built from group's member_user (authoritative source)
|
|
484
|
+
const userGroupRows: { user_id: string; group_id: string }[] = [];
|
|
485
|
+
for (const g of groups) {
|
|
486
|
+
const groupId = groupNameToId.get(g.cn);
|
|
487
|
+
if (!groupId) continue;
|
|
488
|
+
for (const uid of g.users) {
|
|
489
|
+
const userId = uidToId.get(uid);
|
|
490
|
+
if (userId) userGroupRows.push({ user_id: userId, group_id: groupId });
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if (userGroupRows.length > 0) {
|
|
494
|
+
await tx`INSERT INTO auth.user_groups_v2 ${sql(userGroupRows, "user_id", "group_id")} ON CONFLICT DO NOTHING`;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// group_groups + manager junctions
|
|
498
|
+
const groupGroupRows: { parent_group_id: string; child_group_id: string }[] = [];
|
|
499
|
+
const managerUserRows: { group_id: string; user_id: string }[] = [];
|
|
500
|
+
const managerGroupRows: { group_id: string; manager_group_id: string }[] = [];
|
|
501
|
+
for (const g of groups) {
|
|
502
|
+
const groupId = groupNameToId.get(g.cn);
|
|
503
|
+
if (!groupId) continue;
|
|
504
|
+
for (const child of g.groups) {
|
|
505
|
+
const childGroupId = groupNameToId.get(child);
|
|
506
|
+
if (childGroupId) groupGroupRows.push({ parent_group_id: groupId, child_group_id: childGroupId });
|
|
507
|
+
}
|
|
508
|
+
for (const uid of g.managerUsers) {
|
|
509
|
+
const userId = uidToId.get(uid);
|
|
510
|
+
if (userId) managerUserRows.push({ group_id: groupId, user_id: userId });
|
|
511
|
+
}
|
|
512
|
+
for (const mgr of g.managerGroups) {
|
|
513
|
+
const managerGroupId = groupNameToId.get(mgr);
|
|
514
|
+
if (managerGroupId) managerGroupRows.push({ group_id: groupId, manager_group_id: managerGroupId });
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
if (groupGroupRows.length > 0) {
|
|
518
|
+
await tx`INSERT INTO auth.group_groups_v2 ${sql(groupGroupRows, "parent_group_id", "child_group_id")} ON CONFLICT DO NOTHING`;
|
|
519
|
+
}
|
|
520
|
+
if (managerUserRows.length > 0) {
|
|
521
|
+
await tx`INSERT INTO auth.group_manager_users_v2 ${sql(managerUserRows, "group_id", "user_id")} ON CONFLICT DO NOTHING`;
|
|
522
|
+
}
|
|
523
|
+
if (managerGroupRows.length > 0) {
|
|
524
|
+
await tx`INSERT INTO auth.group_manager_groups_v2 ${sql(managerGroupRows, "group_id", "manager_group_id")} ON CONFLICT DO NOTHING`;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
for (const staleUser of staleDemotedUsers) {
|
|
530
|
+
await session.revokeAllForUser(staleUser.id);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
log.info("Sync complete", {
|
|
534
|
+
durationMs: Date.now() - startedAt,
|
|
535
|
+
remoteUsersFetched: allRawUsers.length,
|
|
536
|
+
remoteUsersInScope: users.length,
|
|
537
|
+
remoteExpiredUsers: expiredUsers,
|
|
538
|
+
activeUsersSynced: activeUsers.length,
|
|
539
|
+
matchedExistingUsersByMail,
|
|
540
|
+
migratedLocalUsers,
|
|
541
|
+
skippedLocalMailConflicts,
|
|
542
|
+
skippedLocalUidConflicts,
|
|
543
|
+
staleUsersDemoted: staleDemotedUsers.length,
|
|
544
|
+
upsertedUsersByUid,
|
|
545
|
+
insertedUsersByUid,
|
|
546
|
+
updatedUsersByUid,
|
|
547
|
+
groupsSynced: groups.length,
|
|
548
|
+
deletedGroups,
|
|
549
|
+
localIpaUsersBefore: localIpaUsers,
|
|
550
|
+
localGroupsBefore: localGroups,
|
|
551
|
+
});
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Outcome of a single-user sync attempt. The login flow must reject any outcome
|
|
556
|
+
* other than `synced`; stale mirror state must never grant a fresh session when
|
|
557
|
+
* FreeIPA says the user is expired, missing, or out of sync scope.
|
|
558
|
+
*/
|
|
559
|
+
export type SyncUserOutcome =
|
|
560
|
+
| { status: "synced"; userId: string }
|
|
561
|
+
| { status: "skipped_disabled" }
|
|
562
|
+
| { status: "fetch_failed"; error: string }
|
|
563
|
+
| { status: "expired"; userId: string | null }
|
|
564
|
+
| { status: "out_of_scope"; userId: string | null }
|
|
565
|
+
| { status: "not_found_local" };
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Reconcile a local IPA mirror row after discovering the remote user is no
|
|
569
|
+
* longer in sync scope (expired or removed from base-sync groups). Applies the
|
|
570
|
+
* configured account-transition policy and revokes all sessions for the user.
|
|
571
|
+
*/
|
|
572
|
+
const reconcileOutOfScopeUser = async (params: {
|
|
573
|
+
userId: string;
|
|
574
|
+
uid: string;
|
|
575
|
+
mail: string | null;
|
|
576
|
+
displayName: string | null;
|
|
577
|
+
previousProfile: "user" | "guest";
|
|
578
|
+
reason: "ipa_expired_demoted" | "ipa_expired_deleted" | "sync_out_of_scope_demoted" | "sync_out_of_scope_deleted";
|
|
579
|
+
meta?: Record<string, unknown>;
|
|
580
|
+
}): Promise<void> => {
|
|
581
|
+
const transitionPolicy = parseIpaAccountTransitionPolicy(
|
|
582
|
+
await settings.get<string | null>("freeipa.account_transition_policy"),
|
|
583
|
+
);
|
|
584
|
+
|
|
585
|
+
await sql.begin(async (tx) => {
|
|
586
|
+
if (transitionPolicy === "delete") {
|
|
587
|
+
await writeDeletedAccountAudit({
|
|
588
|
+
db: tx,
|
|
589
|
+
userId: params.userId,
|
|
590
|
+
uid: params.uid,
|
|
591
|
+
mail: params.mail,
|
|
592
|
+
displayName: params.displayName,
|
|
593
|
+
previousProvider: "ipa",
|
|
594
|
+
previousProfile: params.previousProfile,
|
|
595
|
+
reason: params.reason.endsWith("_demoted")
|
|
596
|
+
? (params.reason.replace("_demoted", "_deleted") as typeof params.reason)
|
|
597
|
+
: params.reason,
|
|
598
|
+
meta: params.meta ?? {},
|
|
599
|
+
});
|
|
600
|
+
await tx`DELETE FROM auth.users WHERE id = ${params.userId}::uuid`;
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const target = await applyIpaAccountTransitionPolicy({
|
|
605
|
+
userId: params.userId,
|
|
606
|
+
currentProfile: params.previousProfile,
|
|
607
|
+
policy: transitionPolicy,
|
|
608
|
+
db: tx,
|
|
609
|
+
});
|
|
610
|
+
await writeDeletedAccountAudit({
|
|
611
|
+
db: tx,
|
|
612
|
+
userId: params.userId,
|
|
613
|
+
uid: params.uid,
|
|
614
|
+
mail: params.mail,
|
|
615
|
+
displayName: params.displayName,
|
|
616
|
+
previousProvider: "ipa",
|
|
617
|
+
previousProfile: params.previousProfile,
|
|
618
|
+
reason: params.reason,
|
|
619
|
+
meta: {
|
|
620
|
+
...(params.meta ?? {}),
|
|
621
|
+
accountExpiresAt: target.accountExpires?.toISOString() ?? null,
|
|
622
|
+
targetProfile: target.targetProfile,
|
|
623
|
+
},
|
|
624
|
+
});
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
await session.revokeAllForUser(params.userId);
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Sync a single user's attributes from FreeIPA.
|
|
632
|
+
* Called on login to ensure time-sensitive data is up-to-date.
|
|
633
|
+
*
|
|
634
|
+
* IMPORTANT: Only syncs user attributes (name, mail, expiry, aliases).
|
|
635
|
+
* Does NOT sync group memberships - those are only synced via periodic syncFromIpa().
|
|
636
|
+
* Realm is calculated from LOCAL DB group memberships (optimistically updated by mutations).
|
|
637
|
+
*
|
|
638
|
+
* Returns a typed outcome so callers (notably `authFlows.ipa.login`) can decide
|
|
639
|
+
* whether to grant a session. Non-`synced` outcomes reconcile local mirror state
|
|
640
|
+
* where possible (expired / out_of_scope → transition policy + session revocation).
|
|
641
|
+
*/
|
|
642
|
+
export const syncUser = async (username: string): Promise<SyncUserOutcome> => {
|
|
643
|
+
const config = await getFreeIpaConfig();
|
|
644
|
+
if (!config.enabled) {
|
|
645
|
+
log.info("Single-user sync skipped", { reason: "freeipa_disabled", username });
|
|
646
|
+
return { status: "skipped_disabled" };
|
|
647
|
+
}
|
|
648
|
+
if (!config.configured) {
|
|
649
|
+
throw new Error("FreeIPA is enabled but not fully configured.");
|
|
650
|
+
}
|
|
651
|
+
const ipaSession = await freeipa.session.getServiceSession({
|
|
652
|
+
url: config.url,
|
|
653
|
+
serviceUser: config.serviceUser,
|
|
654
|
+
servicePassword: config.servicePassword,
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
// Fetch user from FreeIPA
|
|
658
|
+
const userRes = await freeipa.client.call({
|
|
659
|
+
url: config.url,
|
|
660
|
+
ipaSession,
|
|
661
|
+
method: "user_show",
|
|
662
|
+
args: [username],
|
|
663
|
+
options: { all: true },
|
|
664
|
+
});
|
|
665
|
+
if (userRes.error || !userRes.result?.result) {
|
|
666
|
+
const error = userRes.error?.message ?? "user_show returned empty result";
|
|
667
|
+
log.warn("Could not fetch user", { username, error });
|
|
668
|
+
return { status: "fetch_failed", error };
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const raw = userRes.result.result as Record<string, unknown>;
|
|
672
|
+
const user = transformSyncUser(raw);
|
|
673
|
+
|
|
674
|
+
const existingRow: DbRow | undefined = (
|
|
675
|
+
await sql<DbRow[]>`
|
|
676
|
+
SELECT id, mail, display_name, profile
|
|
677
|
+
FROM auth.users
|
|
678
|
+
WHERE uid = ${user.uid} AND provider = 'ipa'
|
|
679
|
+
`
|
|
680
|
+
)[0];
|
|
681
|
+
const existingUserId = (existingRow?.id as string | undefined) ?? null;
|
|
682
|
+
const previousProfile: "user" | "guest" = (existingRow?.profile as "user" | "guest" | undefined) ?? "guest";
|
|
683
|
+
|
|
684
|
+
if (isExpired(user)) {
|
|
685
|
+
log.warn("User expired during single-user sync", { username });
|
|
686
|
+
if (existingUserId) {
|
|
687
|
+
await reconcileOutOfScopeUser({
|
|
688
|
+
userId: existingUserId,
|
|
689
|
+
uid: user.uid,
|
|
690
|
+
mail: (existingRow?.mail as string | null) ?? user.mail,
|
|
691
|
+
displayName: (existingRow?.display_name as string | null) ?? user.displayName,
|
|
692
|
+
previousProfile,
|
|
693
|
+
reason: "ipa_expired_demoted",
|
|
694
|
+
meta: { accountExpiresAt: user.ipaAccountExpires?.toISOString() ?? null },
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
return { status: "expired", userId: existingUserId };
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const inSyncGroups = config.groupsBaseSync.some((g) => user.memberofGroup.includes(g));
|
|
701
|
+
if (!inSyncGroups) {
|
|
702
|
+
log.warn("User not in sync groups during single-user sync", { username });
|
|
703
|
+
if (existingUserId) {
|
|
704
|
+
await reconcileOutOfScopeUser({
|
|
705
|
+
userId: existingUserId,
|
|
706
|
+
uid: user.uid,
|
|
707
|
+
mail: (existingRow?.mail as string | null) ?? user.mail,
|
|
708
|
+
displayName: (existingRow?.display_name as string | null) ?? user.displayName,
|
|
709
|
+
previousProfile,
|
|
710
|
+
reason: "sync_out_of_scope_demoted",
|
|
711
|
+
meta: { reason: "missing_from_ipa_sync_scope" },
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
return { status: "out_of_scope", userId: existingUserId };
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
if (!existingUserId) {
|
|
718
|
+
log.warn("User not found in local DB during single-user sync", { username });
|
|
719
|
+
return { status: "not_found_local" };
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const profile = await calculateIpaProfileFromLocalDb(existingUserId);
|
|
723
|
+
const provider = "ipa";
|
|
724
|
+
|
|
725
|
+
// Update user attributes only (no group sync!)
|
|
726
|
+
await sql`
|
|
727
|
+
UPDATE auth.users SET
|
|
728
|
+
provider = ${provider},
|
|
729
|
+
profile = ${profile},
|
|
730
|
+
admin = false,
|
|
731
|
+
given_name = ${user.givenname},
|
|
732
|
+
sn = ${user.sn},
|
|
733
|
+
display_name = ${user.displayName},
|
|
734
|
+
mail = ${user.mail},
|
|
735
|
+
account_expires = ${user.ipaAccountExpires}
|
|
736
|
+
WHERE uid = ${user.uid}
|
|
737
|
+
`;
|
|
738
|
+
await upsertUserIpaData(sql, { userId: existingUserId, ...user });
|
|
739
|
+
return { status: "synced", userId: existingUserId };
|
|
740
|
+
};
|