@valentinkolb/cloud 0.4.0 → 0.5.1
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 +18 -6
- package/scripts/preload.ts +78 -23
- package/src/_internal/define-app.ts +53 -46
- package/src/api/accounts-entities.ts +4 -0
- package/src/api/admin-core-settings.ts +98 -0
- package/src/api/announcements.ts +131 -0
- package/src/api/auth/schemas.ts +24 -0
- package/src/api/auth.ts +116 -13
- package/src/api/index.ts +7 -2
- package/src/api/me.ts +203 -14
- package/src/api/search/schemas.ts +1 -0
- package/src/api/search.ts +62 -8
- package/src/config/ssr.ts +2 -9
- package/src/contracts/announcements.test.ts +37 -0
- package/src/contracts/announcements.ts +121 -0
- package/src/contracts/app.ts +2 -0
- package/src/contracts/index.ts +3 -2
- package/src/contracts/registry.ts +2 -0
- package/src/contracts/shared.ts +108 -1
- package/src/desktop/index.ts +704 -0
- package/src/desktop/solid.tsx +938 -0
- package/src/server/api/index.ts +1 -1
- package/src/server/api/respond.ts +50 -10
- package/src/server/index.ts +44 -38
- package/src/server/middleware/auth.ts +98 -9
- package/src/server/middleware/index.ts +2 -1
- package/src/server/middleware/settings.ts +26 -0
- package/src/server/services/access.test.ts +197 -0
- package/src/server/services/access.ts +254 -6
- package/src/server/services/index.ts +14 -11
- package/src/server/services/pagination.ts +22 -0
- package/src/server/time.ts +45 -0
- package/src/services/account-lifecycle/index.ts +142 -18
- package/src/services/accounts/app.ts +658 -170
- package/src/services/accounts/authz.test.ts +77 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/entities.ts +84 -5
- package/src/services/accounts/groups.ts +30 -24
- package/src/services/accounts/model.test.ts +30 -0
- package/src/services/accounts/switching.test.ts +14 -0
- package/src/services/accounts/switching.ts +15 -6
- package/src/services/accounts/users.ts +75 -52
- package/src/services/announcements/index.test.ts +32 -0
- package/src/services/announcements/index.ts +224 -0
- package/src/services/audit/index.test.ts +84 -0
- package/src/services/audit/index.ts +431 -0
- package/src/services/auth-flows/index.ts +9 -2
- package/src/services/auth-flows/ipa.ts +47 -7
- package/src/services/auth-flows/magic-link.ts +92 -20
- package/src/services/auth-flows/password-reset.ts +284 -0
- package/src/services/auth-flows/proxy-return.test.ts +24 -0
- package/src/services/auth-flows/proxy-return.ts +49 -0
- package/src/services/gateway.ts +162 -0
- package/src/services/index.ts +44 -2
- package/src/services/ipa/effective-groups.test.ts +33 -0
- package/src/services/ipa/effective-groups.ts +70 -0
- package/src/services/ipa/profile.ts +45 -3
- package/src/services/ipa/search.ts +3 -5
- package/src/services/ipa/service-account.ts +15 -0
- package/src/services/ipa/sync-planning.test.ts +32 -0
- package/src/services/ipa/sync-planning.ts +22 -0
- package/src/services/ipa/sync.ts +110 -38
- package/src/services/notifications/index.ts +82 -11
- package/src/services/oauth-tokens.ts +104 -0
- package/src/services/postgres.ts +21 -6
- package/src/services/providers/local/auth.test.ts +22 -0
- package/src/services/providers/local/auth.ts +46 -3
- package/src/services/secrets.ts +10 -0
- package/src/services/service-account-credentials.test.ts +210 -0
- package/src/services/service-account-credentials.ts +715 -0
- package/src/services/service-accounts.ts +188 -0
- package/src/services/session/index.ts +7 -8
- package/src/services/settings/app.ts +4 -20
- package/src/services/settings/defaults.ts +79 -22
- package/src/services/settings/store.ts +47 -0
- package/src/services/weather/forecast.ts +40 -7
- package/src/services/webauthn.test.ts +36 -0
- package/src/services/webauthn.ts +384 -0
- package/src/shared/icons.ts +391 -100
- package/src/shared/index.ts +7 -0
- package/src/shared/markdown/extensions/code.ts +38 -1
- package/src/shared/markdown/extensions/images.ts +39 -3
- package/src/shared/markdown/extensions/info-blocks.ts +5 -5
- package/src/shared/markdown/extensions/mark.ts +48 -0
- package/src/shared/markdown/extensions/sub-sup.ts +60 -0
- package/src/shared/markdown/extensions/tables.ts +79 -58
- package/src/shared/markdown/formula.test.ts +1089 -0
- package/src/shared/markdown/formula.ts +1187 -0
- package/src/shared/markdown/index.ts +76 -2
- package/src/shared/mock-cover.ts +130 -0
- package/src/shared/redirect.test.ts +58 -0
- package/src/shared/redirect.ts +56 -0
- package/src/shared/theme.test.ts +24 -0
- package/src/shared/theme.ts +68 -0
- package/src/shared/time.ts +13 -0
- package/src/ssr/AdminLayout.tsx +7 -3
- package/src/ssr/AdminSidebar.tsx +115 -49
- package/src/ssr/AppLaunchpad.island.tsx +176 -0
- package/src/ssr/Footer.island.tsx +3 -8
- package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
- package/src/ssr/GlobalSearchDialog.tsx +545 -117
- package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
- package/src/ssr/Layout.tsx +74 -66
- package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
- package/src/ssr/LayoutHelp.tsx +266 -0
- package/src/ssr/NavMenu.island.tsx +0 -39
- package/src/ssr/ThemeToggleRail.island.tsx +3 -3
- package/src/ssr/TimezoneCookie.island.tsx +23 -0
- package/src/ssr/islands/index.ts +13 -0
- package/src/styles/base-popover.css +5 -2
- package/src/styles/effects.css +87 -6
- package/src/styles/global.css +146 -9
- package/src/styles/input.css +3 -1
- package/src/styles/utilities-buttons.css +133 -27
- package/src/styles/utilities-code-display.css +67 -0
- package/src/styles/utilities-completion.css +223 -0
- package/src/styles/utilities-detail.css +73 -0
- package/src/styles/utilities-feedback.css +16 -15
- package/src/styles/utilities-layout.css +42 -2
- package/src/styles/utilities-markdown-editor.css +472 -0
- package/src/styles/utilities-navigation.css +63 -8
- package/src/styles/utilities-script.css +84 -0
- package/src/styles/utilities-table-tile.css +229 -0
- package/src/types/ambient.d.ts +9 -0
- package/src/ui/completion/behaviors.test.ts +95 -0
- package/src/ui/completion/behaviors.ts +205 -0
- package/src/ui/completion/engine.ts +368 -0
- package/src/ui/completion/index.ts +40 -0
- package/src/ui/completion/overlay.ts +92 -0
- package/src/ui/dialog-core.ts +173 -45
- package/src/ui/filter/FilterChip.tsx +42 -40
- package/src/ui/index.ts +11 -12
- package/src/ui/input/AutocompleteEditor.tsx +656 -0
- package/src/ui/input/CheckboxCard.tsx +91 -0
- package/src/ui/input/Combobox.tsx +375 -0
- package/src/ui/input/DatePicker.tsx +846 -0
- package/src/ui/input/DateTimeInput.tsx +29 -4
- package/src/ui/input/FileDropzone.tsx +116 -0
- package/src/ui/input/IconInput.tsx +116 -0
- package/src/ui/input/ImageInput.tsx +19 -2
- package/src/ui/input/MultiSelectInput.tsx +448 -0
- package/src/ui/input/NumberInput.tsx +417 -61
- package/src/ui/input/SegmentedControl.tsx +2 -2
- package/src/ui/input/Select.tsx +172 -10
- package/src/ui/input/Slider.tsx +3 -4
- package/src/ui/input/Switch.tsx +3 -2
- package/src/ui/input/TemplateEditor.tsx +212 -0
- package/src/ui/input/TextInput.tsx +144 -13
- package/src/ui/input/index.ts +53 -8
- package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
- package/src/ui/input/markdown/Toolbar.tsx +90 -0
- package/src/ui/input/markdown/actions.ts +233 -0
- package/src/ui/input/markdown/active-formats.ts +94 -0
- package/src/ui/input/markdown/behaviors.ts +193 -0
- package/src/ui/input/markdown/code-zone.ts +23 -0
- package/src/ui/input/markdown/highlight.ts +316 -0
- package/src/ui/layout.ts +22 -0
- package/src/ui/misc/AppOverview.tsx +105 -0
- package/src/ui/misc/AppWorkspace.tsx +607 -0
- package/src/ui/misc/Calendar.tsx +1291 -0
- package/src/ui/misc/Chart.tsx +162 -0
- package/src/ui/misc/CodeDisplay.tsx +54 -0
- package/src/ui/misc/ContextMenu.tsx +2 -2
- package/src/ui/misc/DataTable.tsx +269 -0
- package/src/ui/misc/DockWorkspace.tsx +425 -0
- package/src/ui/misc/Docs.tsx +153 -0
- package/src/ui/misc/Dropdown.tsx +2 -2
- package/src/ui/misc/EntitySearch.tsx +260 -129
- package/src/ui/misc/LinkCard.tsx +14 -2
- package/src/ui/misc/LogEntriesTable.tsx +34 -31
- package/src/ui/misc/Pagination.tsx +31 -12
- package/src/ui/misc/PanelDialog.tsx +109 -0
- package/src/ui/misc/Panes.tsx +873 -0
- package/src/ui/misc/PermissionEditor.tsx +358 -262
- package/src/ui/misc/Placeholder.tsx +40 -0
- package/src/ui/misc/ProgressBar.tsx +1 -1
- package/src/ui/misc/ResourceApiKeys.tsx +260 -0
- package/src/ui/misc/SettingsModal.tsx +150 -0
- package/src/ui/misc/StatCell.tsx +182 -40
- package/src/ui/misc/StatGrid.tsx +149 -0
- package/src/ui/misc/StructuredDataPreview.tsx +107 -0
- package/src/ui/misc/code-highlight.ts +213 -0
- package/src/ui/misc/index.ts +93 -12
- package/src/ui/prompts.tsx +362 -312
- package/src/ui/toast.ts +384 -0
- package/src/ui/widgets/Widget.tsx +12 -4
- package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
- package/src/ui/ipa/GroupView.tsx +0 -36
- package/src/ui/ipa/LoginBtn.tsx +0 -16
- package/src/ui/ipa/UserView.tsx +0 -58
- package/src/ui/ipa/index.ts +0 -4
- package/src/ui/navigation.ts +0 -32
- package/src/ui/sidebar.tsx +0 -468
- /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
package/src/services/index.ts
CHANGED
|
@@ -1,12 +1,27 @@
|
|
|
1
|
+
// biome-ignore-all assist/source/organizeImports: Preserve grouped service barrel exports.
|
|
1
2
|
export { ipa } from "./ipa";
|
|
2
3
|
export { accounts } from "./accounts";
|
|
3
4
|
export { accountsAppService } from "./accounts";
|
|
4
5
|
export { providers } from "./providers";
|
|
5
6
|
export { authFlows } from "./auth-flows";
|
|
6
|
-
export { toPgTextArray, toPgUuidArray, escapeLikePattern } from "./postgres";
|
|
7
|
+
export { toPgTextArray, toPgUuidArray, escapeLikePattern, isUniqueViolation } from "./postgres";
|
|
7
8
|
|
|
8
9
|
export { logger, logging } from "./logging";
|
|
9
10
|
export type { LogEntry } from "./logging";
|
|
11
|
+
export { audit } from "./audit";
|
|
12
|
+
export type { AuditActionGroup, AuditActor, AuditEvent, AuditListFilter, AuditOutcome, AuditRecordParams, AuditTarget } from "./audit";
|
|
13
|
+
|
|
14
|
+
export {
|
|
15
|
+
GATEWAY_TELEMETRY_TENANT,
|
|
16
|
+
buildGatewayRouteSnapshot,
|
|
17
|
+
gatewayTelemetryTopic,
|
|
18
|
+
latestGatewayRouteSnapshot,
|
|
19
|
+
listGatewayRouteSnapshots,
|
|
20
|
+
publishGatewayRouteSnapshot,
|
|
21
|
+
publishRequestTelemetry,
|
|
22
|
+
removeGatewayRouteSnapshot,
|
|
23
|
+
} from "./gateway";
|
|
24
|
+
export type { GatewayRouteSnapshot, GatewayRouteSnapshotInput, GatewayRouteWarning, GatewayTelemetryEvent } from "./gateway";
|
|
10
25
|
|
|
11
26
|
export { notifications } from "./notifications";
|
|
12
27
|
export type {
|
|
@@ -16,8 +31,25 @@ export type {
|
|
|
16
31
|
SendToUserParams,
|
|
17
32
|
NotificationMessage,
|
|
18
33
|
} from "./notifications";
|
|
34
|
+
export { announcements } from "./announcements";
|
|
35
|
+
export type { AnnouncementsService } from "./announcements";
|
|
19
36
|
|
|
20
37
|
export { session } from "./session";
|
|
38
|
+
export { serviceAccounts } from "./service-accounts";
|
|
39
|
+
export type { ServiceAccount, ServiceAccountKind, ServiceAccountStatus } from "./service-accounts";
|
|
40
|
+
export { serviceAccountCredentials } from "./service-account-credentials";
|
|
41
|
+
export type {
|
|
42
|
+
AuthenticatedServiceAccountCredential,
|
|
43
|
+
ServiceAccountCredential,
|
|
44
|
+
ServiceAccountCredentialKind,
|
|
45
|
+
ServiceAccountCredentialOverview,
|
|
46
|
+
ServiceAccountCredentialOwner,
|
|
47
|
+
ServiceAccountCredentialStatus,
|
|
48
|
+
} from "./service-account-credentials";
|
|
49
|
+
export { oauthTokens } from "./oauth-tokens";
|
|
50
|
+
export type { AuthenticatedOAuthToken } from "./oauth-tokens";
|
|
51
|
+
export { webauthn } from "./webauthn";
|
|
52
|
+
export type { WebAuthnRp } from "./webauthn";
|
|
21
53
|
|
|
22
54
|
export { accountLifecycle } from "./account-lifecycle";
|
|
23
55
|
export type { AccountLifecycleService } from "./account-lifecycle";
|
|
@@ -32,11 +64,21 @@ export type { SettingDef, SettingKind, SettingOption } from "./settings/defaults
|
|
|
32
64
|
export { renderTemplate } from "./settings/templates";
|
|
33
65
|
export { settingsService } from "./settings/app";
|
|
34
66
|
export type { SettingsService } from "./settings/app";
|
|
67
|
+
export { decryptSecret, encryptSecret, secrets } from "./secrets";
|
|
35
68
|
|
|
36
69
|
// Typed async API + cache-aside primitives.
|
|
37
70
|
export { coreSettings, createSettingsAPI } from "./settings/api";
|
|
38
71
|
export type { SettingsAPI } from "./settings/api";
|
|
39
|
-
export {
|
|
72
|
+
export {
|
|
73
|
+
readKey as settingsReadKey,
|
|
74
|
+
writeKey as settingsWriteKey,
|
|
75
|
+
deleteKey as settingsDeleteKey,
|
|
76
|
+
bulkRead as settingsBulkRead,
|
|
77
|
+
allKnownKeys as settingsAllKnownKeys,
|
|
78
|
+
listLegacyKeys as settingsListLegacyKeys,
|
|
79
|
+
deleteLegacyKeys as settingsDeleteLegacyKeys,
|
|
80
|
+
} from "./settings/store";
|
|
81
|
+
export type { LegacySettingRow } from "./settings/store";
|
|
40
82
|
export { loadSnapshot as loadSettingsSnapshot } from "./settings/snapshot";
|
|
41
83
|
|
|
42
84
|
export { weatherService } from "./weather";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildEffectiveIpaGroupsByUid } from "./effective-groups";
|
|
3
|
+
|
|
4
|
+
describe("buildEffectiveIpaGroupsByUid", () => {
|
|
5
|
+
test("includes direct and inherited parent groups", () => {
|
|
6
|
+
const effective = buildEffectiveIpaGroupsByUid([
|
|
7
|
+
{ cn: "base-sync", users: [], groups: ["team"] },
|
|
8
|
+
{ cn: "base-realm", users: [], groups: ["base-sync"] },
|
|
9
|
+
{ cn: "team", users: ["eva"], groups: [] },
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
expect(effective.get("eva")).toEqual(["base-realm", "base-sync", "team"]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("keeps transit groups available even when callers hide them from display", () => {
|
|
16
|
+
const effective = buildEffectiveIpaGroupsByUid([
|
|
17
|
+
{ cn: "base-realm", users: [], groups: ["excluded-transit"] },
|
|
18
|
+
{ cn: "excluded-transit", users: [], groups: ["team"] },
|
|
19
|
+
{ cn: "team", users: ["eva"], groups: [] },
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
expect(effective.get("eva")).toEqual(["base-realm", "excluded-transit", "team"]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("terminates on cyclic group nesting", () => {
|
|
26
|
+
const effective = buildEffectiveIpaGroupsByUid([
|
|
27
|
+
{ cn: "a", users: ["eva"], groups: ["b"] },
|
|
28
|
+
{ cn: "b", users: [], groups: ["a"] },
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
expect(effective.get("eva")).toEqual(["a", "b"]);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export type IpaGroupMembership = {
|
|
2
|
+
cn: string;
|
|
3
|
+
users: string[];
|
|
4
|
+
groups: string[];
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
const sorted = (values: Iterable<string>): string[] => [...values].sort((a, b) => a.localeCompare(b));
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Build effective IPA group membership from authoritative group records.
|
|
11
|
+
* A group contains direct users and child groups; user effective membership is
|
|
12
|
+
* direct groups plus every parent group reached through group nesting.
|
|
13
|
+
*/
|
|
14
|
+
export const buildEffectiveIpaGroupsByUid = (groups: IpaGroupMembership[]): Map<string, string[]> => {
|
|
15
|
+
const groupNames = new Set(groups.map((group) => group.cn).filter(Boolean));
|
|
16
|
+
const childToParents = new Map<string, Set<string>>();
|
|
17
|
+
const directGroupsByUid = new Map<string, Set<string>>();
|
|
18
|
+
|
|
19
|
+
for (const group of groups) {
|
|
20
|
+
if (!group.cn) continue;
|
|
21
|
+
|
|
22
|
+
for (const uid of group.users) {
|
|
23
|
+
if (!uid) continue;
|
|
24
|
+
const directGroups = directGroupsByUid.get(uid) ?? new Set<string>();
|
|
25
|
+
directGroups.add(group.cn);
|
|
26
|
+
directGroupsByUid.set(uid, directGroups);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for (const child of group.groups) {
|
|
30
|
+
if (!child || !groupNames.has(child)) continue;
|
|
31
|
+
const parents = childToParents.get(child) ?? new Set<string>();
|
|
32
|
+
parents.add(group.cn);
|
|
33
|
+
childToParents.set(child, parents);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const memo = new Map<string, Set<string>>();
|
|
38
|
+
const resolveGroupClosure = (groupName: string, visiting = new Set<string>()): Set<string> => {
|
|
39
|
+
const cached = memo.get(groupName);
|
|
40
|
+
if (cached) return cached;
|
|
41
|
+
|
|
42
|
+
const closure = new Set<string>([groupName]);
|
|
43
|
+
if (visiting.has(groupName)) return closure;
|
|
44
|
+
|
|
45
|
+
const nextVisiting = new Set(visiting);
|
|
46
|
+
nextVisiting.add(groupName);
|
|
47
|
+
|
|
48
|
+
for (const parent of childToParents.get(groupName) ?? []) {
|
|
49
|
+
for (const inherited of resolveGroupClosure(parent, nextVisiting)) {
|
|
50
|
+
closure.add(inherited);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
memo.set(groupName, closure);
|
|
55
|
+
return closure;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const effectiveByUid = new Map<string, string[]>();
|
|
59
|
+
for (const [uid, directGroups] of directGroupsByUid) {
|
|
60
|
+
const effective = new Set<string>();
|
|
61
|
+
for (const groupName of directGroups) {
|
|
62
|
+
for (const inherited of resolveGroupClosure(groupName)) {
|
|
63
|
+
effective.add(inherited);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
effectiveByUid.set(uid, sorted(effective));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return effectiveByUid;
|
|
70
|
+
};
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Centralized profile calculation for IPA-backed users.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Full sync writes `auth.ipa_user_effective_groups` from FreeIPA group_find.
|
|
5
|
+
* Local group mutations keep that projection as source of truth; the local
|
|
6
|
+
* display mirror is only a bootstrap fallback before the first full sync.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import { sql } from "bun";
|
|
@@ -37,6 +38,17 @@ export const getAllUserGroups = async (userId: string): Promise<string[]> => {
|
|
|
37
38
|
return rows.map((row) => row.name as string);
|
|
38
39
|
};
|
|
39
40
|
|
|
41
|
+
export const getEffectiveUserGroups = async (userId: string): Promise<string[]> => {
|
|
42
|
+
const rows: DbRow[] = await sql`
|
|
43
|
+
SELECT group_name
|
|
44
|
+
FROM auth.ipa_user_effective_groups
|
|
45
|
+
WHERE user_id = ${userId}::uuid
|
|
46
|
+
ORDER BY group_name
|
|
47
|
+
`;
|
|
48
|
+
|
|
49
|
+
return rows.map((row) => row.group_name as string);
|
|
50
|
+
};
|
|
51
|
+
|
|
40
52
|
/**
|
|
41
53
|
* Calculate canonical IPA profile from effective group names. Reads
|
|
42
54
|
* `freeipa.groups.base_ipa_realm` from settings (cache-aside).
|
|
@@ -54,11 +66,41 @@ export const calculateIpaProfileFromLocalDb = async (userId: string): Promise<"u
|
|
|
54
66
|
return calculateIpaProfile(groups);
|
|
55
67
|
};
|
|
56
68
|
|
|
69
|
+
export const calculateIpaProfileFromEffectiveProjection = async (userId: string): Promise<"user" | "guest"> => {
|
|
70
|
+
const groups = await getEffectiveUserGroups(userId);
|
|
71
|
+
return calculateIpaProfile(groups);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const rebuildEffectiveProjectionFromLocalMirror = async (userId: string): Promise<string[]> => {
|
|
75
|
+
const groups = await getAllUserGroups(userId);
|
|
76
|
+
await sql`
|
|
77
|
+
DELETE FROM auth.ipa_user_effective_groups
|
|
78
|
+
WHERE user_id = ${userId}::uuid
|
|
79
|
+
`;
|
|
80
|
+
|
|
81
|
+
for (const group of groups) {
|
|
82
|
+
await sql`
|
|
83
|
+
INSERT INTO auth.ipa_user_effective_groups (user_id, group_name)
|
|
84
|
+
VALUES (${userId}::uuid, ${group})
|
|
85
|
+
ON CONFLICT DO NOTHING
|
|
86
|
+
`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return groups;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const getEffectiveUserGroupsWithMirrorFallback = async (userId: string): Promise<string[]> => {
|
|
93
|
+
const projectedGroups = await getEffectiveUserGroups(userId);
|
|
94
|
+
if (projectedGroups.length > 0) return projectedGroups;
|
|
95
|
+
return rebuildEffectiveProjectionFromLocalMirror(userId);
|
|
96
|
+
};
|
|
97
|
+
|
|
57
98
|
/**
|
|
58
99
|
* Update one IPA-backed user's canonical profile projection.
|
|
59
100
|
*/
|
|
60
101
|
export const updateUserIpaProfile = async (userId: string): Promise<void> => {
|
|
61
|
-
const
|
|
102
|
+
const groups = await getEffectiveUserGroupsWithMirrorFallback(userId);
|
|
103
|
+
const profile = await calculateIpaProfile(groups);
|
|
62
104
|
await sql`
|
|
63
105
|
UPDATE auth.users
|
|
64
106
|
SET provider = 'ipa',
|
|
@@ -63,11 +63,9 @@ export const search = async (query: string, options: SearchOptions): Promise<{ u
|
|
|
63
63
|
SELECT u.id, u.uid, u.provider, u.profile, u.given_name, u.sn, u.display_name, u.mail,
|
|
64
64
|
EXISTS(
|
|
65
65
|
SELECT 1
|
|
66
|
-
FROM auth.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
AND g_admin.provider = 'ipa'
|
|
70
|
-
AND g_admin.name = ANY(${toPgTextArray(groupsAdmin)}::text[])
|
|
66
|
+
FROM auth.ipa_user_effective_groups eg
|
|
67
|
+
WHERE eg.user_id = u.id
|
|
68
|
+
AND eg.group_name = ANY(${toPgTextArray(groupsAdmin)}::text[])
|
|
71
69
|
) AS effective_admin
|
|
72
70
|
FROM auth.users u
|
|
73
71
|
WHERE u.provider = 'ipa'
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { MutationResult } from "../../contracts/shared";
|
|
2
|
+
import { providers } from "../providers";
|
|
3
|
+
import { getFreeIpaConfig } from "../freeipa-config";
|
|
4
|
+
|
|
5
|
+
export const getServiceIpaSession = async (): Promise<MutationResult<string>> => {
|
|
6
|
+
if (!(await getFreeIpaConfig()).enabled) {
|
|
7
|
+
return { ok: false, error: "FreeIPA is disabled.", status: 400 };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
return { ok: true, data: await providers.ipa.auth.getServiceSession() };
|
|
12
|
+
} catch {
|
|
13
|
+
return { ok: false, error: "Internal FreeIPA service session unavailable.", status: 500 };
|
|
14
|
+
}
|
|
15
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { selectStaleLocalIpaRows } from "./sync-planning";
|
|
3
|
+
|
|
4
|
+
describe("selectStaleLocalIpaRows", () => {
|
|
5
|
+
test("keeps unchanged active IPA users out of stale transitions", () => {
|
|
6
|
+
const stale = selectStaleLocalIpaRows({
|
|
7
|
+
localRows: [{ uid: "eva", mail: "eva@example.test" }],
|
|
8
|
+
activeRemoteUsers: [{ uid: "eva", mail: "eva@example.test" }],
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
expect(stale).toEqual([]);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("keeps UID-renamed IPA users out of stale transitions when mail still matches", () => {
|
|
15
|
+
const stale = selectStaleLocalIpaRows({
|
|
16
|
+
localRows: [{ uid: "old-eva", mail: "eva@example.test" }],
|
|
17
|
+
activeRemoteUsers: [{ uid: "new-eva", mail: "eva@example.test" }],
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
expect(stale).toEqual([]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("returns IPA users with no active UID or mail match as stale", () => {
|
|
24
|
+
const local = { uid: "old-eva", mail: "old-eva@example.test" };
|
|
25
|
+
const stale = selectStaleLocalIpaRows({
|
|
26
|
+
localRows: [local],
|
|
27
|
+
activeRemoteUsers: [{ uid: "new-eva", mail: "eva@example.test" }],
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
expect(stale).toEqual([local]);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type IpaIdentity = {
|
|
2
|
+
uid: string;
|
|
3
|
+
mail: string | null;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export const selectStaleLocalIpaRows = <T extends IpaIdentity>(params: {
|
|
7
|
+
localRows: T[];
|
|
8
|
+
activeRemoteUsers: IpaIdentity[];
|
|
9
|
+
}): T[] => {
|
|
10
|
+
const activeUids = new Set(params.activeRemoteUsers.map((user) => user.uid));
|
|
11
|
+
const activeMails = new Set(
|
|
12
|
+
params.activeRemoteUsers
|
|
13
|
+
.map((user) => user.mail)
|
|
14
|
+
.filter((mail): mail is string => Boolean(mail)),
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
return params.localRows.filter((row) => {
|
|
18
|
+
if (activeUids.has(row.uid)) return false;
|
|
19
|
+
if (row.mail && activeMails.has(row.mail)) return false;
|
|
20
|
+
return true;
|
|
21
|
+
});
|
|
22
|
+
};
|
package/src/services/ipa/sync.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { sql } from "bun";
|
|
2
2
|
import { applyIpaAccountTransitionPolicy } from "../accounts/switching";
|
|
3
3
|
import {
|
|
4
|
+
calculateIpaProfileFromGroupNames,
|
|
4
5
|
parseIpaAccountTransitionPolicy,
|
|
5
6
|
parseIpaMatchMode,
|
|
6
7
|
} from "../account-model";
|
|
@@ -10,9 +11,12 @@ import * as settings from "../settings";
|
|
|
10
11
|
import { session } from "../session";
|
|
11
12
|
import { freeipa } from "../../server/services";
|
|
12
13
|
import { getFreeIpaConfig } from "../freeipa-config";
|
|
13
|
-
import {
|
|
14
|
+
import { buildEffectiveIpaGroupsByUid } from "./effective-groups";
|
|
15
|
+
import { calculateIpaProfileFromEffectiveProjection, getEffectiveUserGroups } from "./profile";
|
|
16
|
+
import { selectStaleLocalIpaRows } from "./sync-planning";
|
|
14
17
|
|
|
15
18
|
type DbRow = Record<string, unknown>;
|
|
19
|
+
type LocalIpaRow = DbRow & { uid: string; mail: string | null };
|
|
16
20
|
|
|
17
21
|
const log = logger("auth:ipa:sync");
|
|
18
22
|
|
|
@@ -148,7 +152,7 @@ const transformSyncUser = (raw: Record<string, unknown>): SyncUser => {
|
|
|
148
152
|
* `excludedGroupsSet` is hoisted to the caller so we don't re-read settings
|
|
149
153
|
* once per group.
|
|
150
154
|
*/
|
|
151
|
-
const transformSyncGroup = (raw: Record<string, unknown>, excludedGroupsSet: Set<string>): SyncGroup => ({
|
|
155
|
+
const transformSyncGroup = (raw: Record<string, unknown>, excludedGroupsSet: Set<string> = new Set()): SyncGroup => ({
|
|
152
156
|
cn: freeipa.util.str(raw.cn),
|
|
153
157
|
description: freeipa.util.str(raw.description) || null,
|
|
154
158
|
gidnumber: freeipa.util.num(raw.gidnumber),
|
|
@@ -224,15 +228,13 @@ export const syncFromIpa = async (): Promise<void> => {
|
|
|
224
228
|
]);
|
|
225
229
|
|
|
226
230
|
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
|
-
|
|
231
|
+
const allUsers = allRawUsers.map(transformSyncUser);
|
|
235
232
|
const allRawGroups = readIpaList({ response: groupsRes, entity: "groups" });
|
|
233
|
+
const effectiveGroupsByUid = buildEffectiveIpaGroupsByUid(allRawGroups.map((raw) => transformSyncGroup(raw)));
|
|
234
|
+
const users = allUsers.filter((user) => {
|
|
235
|
+
const effectiveGroups = effectiveGroupsByUid.get(user.uid) ?? [];
|
|
236
|
+
return config.groupsBaseSync.some((group) => effectiveGroups.includes(group));
|
|
237
|
+
});
|
|
236
238
|
const groups = allRawGroups.map((raw) => transformSyncGroup(raw, excludedGroupsSet)).filter((g) => !excludedGroupsSet.has(g.cn));
|
|
237
239
|
|
|
238
240
|
const activeUsers = users.filter((u) => !isExpired(u));
|
|
@@ -241,7 +243,7 @@ export const syncFromIpa = async (): Promise<void> => {
|
|
|
241
243
|
// stale/transition branch so their local mirror is either demoted or deleted per policy,
|
|
242
244
|
// and their sessions are revoked. Treating expired users as in-scope would leave a stale
|
|
243
245
|
// unexpired local row plus live sessions.
|
|
244
|
-
const
|
|
246
|
+
const remoteGroupCns = new Set(allRawGroups.map((raw) => freeipa.util.str(raw.cn)).filter(Boolean));
|
|
245
247
|
const groupCns = new Set(groups.map((g) => g.cn));
|
|
246
248
|
const matchMode = parseIpaMatchMode(await settings.get<string | null>("freeipa.user_match_mode"));
|
|
247
249
|
const transitionPolicy = parseIpaAccountTransitionPolicy(
|
|
@@ -269,7 +271,7 @@ export const syncFromIpa = async (): Promise<void> => {
|
|
|
269
271
|
if (activeUsers.length === 0 && localIpaUsers > 0) {
|
|
270
272
|
throw new Error(`Refusing IPA sync: remote active users list is empty while local has ${localIpaUsers} IPA users`);
|
|
271
273
|
}
|
|
272
|
-
if (
|
|
274
|
+
if (remoteGroupCns.size === 0 && localGroups > 0) {
|
|
273
275
|
throw new Error(`Refusing IPA sync: remote groups list is empty while local has ${localGroups} groups`);
|
|
274
276
|
}
|
|
275
277
|
|
|
@@ -279,20 +281,65 @@ export const syncFromIpa = async (): Promise<void> => {
|
|
|
279
281
|
WHERE provider = 'ipa'
|
|
280
282
|
ORDER BY uid
|
|
281
283
|
`;
|
|
282
|
-
const
|
|
284
|
+
const currentProfileByUid = new Map(
|
|
285
|
+
localIpaRows.map((row) => [row.uid as string, row.profile as "user" | "guest"]),
|
|
286
|
+
);
|
|
287
|
+
let profileDriftCount = 0;
|
|
288
|
+
const profileDriftSamples: string[] = [];
|
|
289
|
+
let profilesPromoted = 0;
|
|
290
|
+
let profilesDemoted = 0;
|
|
291
|
+
for (const user of activeUsers) {
|
|
292
|
+
const effectiveGroups = effectiveGroupsByUid.get(user.uid) ?? [];
|
|
293
|
+
const graphProfile = calculateIpaProfileFromGroupNames(effectiveGroups, config.groupsBaseIpaRealm);
|
|
294
|
+
const userSideProfile = calculateIpaProfileFromGroupNames(user.memberofGroup, config.groupsBaseIpaRealm);
|
|
295
|
+
if (graphProfile !== userSideProfile) {
|
|
296
|
+
profileDriftCount += 1;
|
|
297
|
+
if (profileDriftSamples.length < 10) profileDriftSamples.push(user.uid);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const previousProfile = currentProfileByUid.get(user.uid);
|
|
301
|
+
if (previousProfile === "guest" && graphProfile === "user") profilesPromoted += 1;
|
|
302
|
+
if (previousProfile === "user" && graphProfile === "guest") profilesDemoted += 1;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const localIpaIdentityRows: LocalIpaRow[] = localIpaRows.map((row) => ({
|
|
306
|
+
...row,
|
|
307
|
+
uid: row.uid as string,
|
|
308
|
+
mail: (row.mail as string | null) ?? null,
|
|
309
|
+
}));
|
|
310
|
+
const staleLocalUsers = selectStaleLocalIpaRows({
|
|
311
|
+
localRows: localIpaIdentityRows,
|
|
312
|
+
activeRemoteUsers: activeUsers.map((user) => ({ uid: user.uid, mail: user.mail })),
|
|
313
|
+
});
|
|
283
314
|
const staleLimit = Math.max(10, Math.ceil(Math.max(localIpaUsers, 1) * 0.2));
|
|
284
315
|
if (staleLocalUsers.length > staleLimit) {
|
|
285
316
|
throw new Error(
|
|
286
317
|
`Refusing IPA sync: ${staleLocalUsers.length} local IPA users disappeared from sync scope (limit ${staleLimit})`,
|
|
287
318
|
);
|
|
288
319
|
}
|
|
320
|
+
if (profilesDemoted > staleLimit) {
|
|
321
|
+
throw new Error(
|
|
322
|
+
`Refusing IPA sync: ${profilesDemoted} IPA users would be downgraded from user to guest (limit ${staleLimit})`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
if (profilesDemoted > 0) {
|
|
326
|
+
log.warn("IPA sync will downgrade user profiles", { profilesDemoted, limit: staleLimit });
|
|
327
|
+
}
|
|
328
|
+
if (profileDriftCount > 0) {
|
|
329
|
+
log.warn("IPA user memberOf drift detected; using group graph projection", {
|
|
330
|
+
profileDriftCount,
|
|
331
|
+
sampleUids: profileDriftSamples,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
289
334
|
|
|
290
335
|
const staleDemotedUsers: Array<{ id: string; uid: string }> = [];
|
|
336
|
+
let effectiveGroupsRebuilt = 0;
|
|
291
337
|
await sql.begin(async (tx) => {
|
|
292
338
|
// 1. Upsert active IPA users
|
|
293
339
|
// Match order: mail (existing IPA user, handles UID renames) → mail (guest promotion) → uid (new or unchanged)
|
|
294
340
|
for (const u of activeUsers) {
|
|
295
|
-
const
|
|
341
|
+
const effectiveGroups = effectiveGroupsByUid.get(u.uid) ?? [];
|
|
342
|
+
const profile = calculateIpaProfileFromGroupNames(effectiveGroups, config.groupsBaseIpaRealm);
|
|
296
343
|
const provider = "ipa";
|
|
297
344
|
|
|
298
345
|
if (u.mail) {
|
|
@@ -473,6 +520,23 @@ export const syncFromIpa = async (): Promise<void> => {
|
|
|
473
520
|
const userIdRows: DbRow[] = await tx`SELECT id, uid FROM auth.users WHERE provider = 'ipa'`;
|
|
474
521
|
const uidToId = new Map<string, string>(userIdRows.map((r) => [r.uid as string, r.id as string]));
|
|
475
522
|
|
|
523
|
+
await tx`
|
|
524
|
+
DELETE FROM auth.ipa_user_effective_groups
|
|
525
|
+
WHERE user_id IN (SELECT id FROM auth.users WHERE provider = 'ipa')
|
|
526
|
+
`;
|
|
527
|
+
const effectiveGroupRows: { user_id: string; group_name: string }[] = [];
|
|
528
|
+
for (const [uid, groupNames] of effectiveGroupsByUid) {
|
|
529
|
+
const userId = uidToId.get(uid);
|
|
530
|
+
if (!userId) continue;
|
|
531
|
+
for (const groupName of groupNames) {
|
|
532
|
+
effectiveGroupRows.push({ user_id: userId, group_name: groupName });
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
if (effectiveGroupRows.length > 0) {
|
|
536
|
+
await tx`INSERT INTO auth.ipa_user_effective_groups ${sql(effectiveGroupRows, "user_id", "group_name")} ON CONFLICT DO NOTHING`;
|
|
537
|
+
}
|
|
538
|
+
effectiveGroupsRebuilt = effectiveGroupRows.length;
|
|
539
|
+
|
|
476
540
|
// Bulk INSERT helper. Bun's `sql(rows)` only generates valid VALUES
|
|
477
541
|
// syntax when fed an array of OBJECTS (it derives the column list from
|
|
478
542
|
// object keys). Passing array-of-arrays produces broken SQL like
|
|
@@ -541,6 +605,11 @@ export const syncFromIpa = async (): Promise<void> => {
|
|
|
541
605
|
skippedLocalMailConflicts,
|
|
542
606
|
skippedLocalUidConflicts,
|
|
543
607
|
staleUsersDemoted: staleDemotedUsers.length,
|
|
608
|
+
scopeTransitions: staleDemotedUsers.length,
|
|
609
|
+
profileDriftCount,
|
|
610
|
+
profilesPromoted,
|
|
611
|
+
profilesDemoted,
|
|
612
|
+
effectiveGroupsRebuilt,
|
|
544
613
|
upsertedUsersByUid,
|
|
545
614
|
insertedUsersByUid,
|
|
546
615
|
updatedUsersByUid,
|
|
@@ -566,8 +635,9 @@ export type SyncUserOutcome =
|
|
|
566
635
|
|
|
567
636
|
/**
|
|
568
637
|
* Reconcile a local IPA mirror row after discovering the remote user is no
|
|
569
|
-
* longer
|
|
570
|
-
*
|
|
638
|
+
* longer valid because the IPA account is expired. Full sync also uses this
|
|
639
|
+
* transition policy for graph-derived out-of-scope users; single-user sync does
|
|
640
|
+
* not demote based on FreeIPA user-side memberOf data.
|
|
571
641
|
*/
|
|
572
642
|
const reconcileOutOfScopeUser = async (params: {
|
|
573
643
|
userId: string;
|
|
@@ -632,12 +702,13 @@ const reconcileOutOfScopeUser = async (params: {
|
|
|
632
702
|
* Called on login to ensure time-sensitive data is up-to-date.
|
|
633
703
|
*
|
|
634
704
|
* IMPORTANT: Only syncs user attributes (name, mail, expiry, aliases).
|
|
635
|
-
* Does NOT sync group memberships
|
|
636
|
-
*
|
|
705
|
+
* Does NOT sync group memberships. Scope/profile come from the last full-sync
|
|
706
|
+
* effective group projection; user-side memberOf drift is logged but never used
|
|
707
|
+
* for destructive transitions here.
|
|
637
708
|
*
|
|
638
709
|
* Returns a typed outcome so callers (notably `authFlows.ipa.login`) can decide
|
|
639
|
-
* whether to grant a session.
|
|
640
|
-
*
|
|
710
|
+
* whether to grant a session. Expired users are reconciled immediately; group
|
|
711
|
+
* scope changes are handled by full sync.
|
|
641
712
|
*/
|
|
642
713
|
export const syncUser = async (username: string): Promise<SyncUserOutcome> => {
|
|
643
714
|
const config = await getFreeIpaConfig();
|
|
@@ -697,29 +768,30 @@ export const syncUser = async (username: string): Promise<SyncUserOutcome> => {
|
|
|
697
768
|
return { status: "expired", userId: existingUserId };
|
|
698
769
|
}
|
|
699
770
|
|
|
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
771
|
if (!existingUserId) {
|
|
718
772
|
log.warn("User not found in local DB during single-user sync", { username });
|
|
719
773
|
return { status: "not_found_local" };
|
|
720
774
|
}
|
|
721
775
|
|
|
722
|
-
const
|
|
776
|
+
const effectiveGroups = await getEffectiveUserGroups(existingUserId);
|
|
777
|
+
const inSyncGroups = config.groupsBaseSync.some((g) => effectiveGroups.includes(g));
|
|
778
|
+
if (!inSyncGroups) {
|
|
779
|
+
log.warn("User not in projected sync groups during single-user sync", {
|
|
780
|
+
username,
|
|
781
|
+
reason: "missing_from_last_full_sync_projection",
|
|
782
|
+
});
|
|
783
|
+
return { status: "out_of_scope", userId: existingUserId };
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const profile = await calculateIpaProfileFromEffectiveProjection(existingUserId);
|
|
787
|
+
const userSideProfile = calculateIpaProfileFromGroupNames(user.memberofGroup, config.groupsBaseIpaRealm);
|
|
788
|
+
if (profile !== userSideProfile) {
|
|
789
|
+
log.warn("IPA user memberOf drift detected during single-user sync", {
|
|
790
|
+
username,
|
|
791
|
+
projectedProfile: profile,
|
|
792
|
+
userSideProfile,
|
|
793
|
+
});
|
|
794
|
+
}
|
|
723
795
|
const provider = "ipa";
|
|
724
796
|
|
|
725
797
|
// Update user attributes only (no group sync!)
|