@valentinkolb/cloud 0.3.1 → 0.5.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 +18 -8
- package/scripts/preload.ts +78 -23
- package/src/_internal/define-app.ts +119 -47
- package/src/_internal/runtime-context.ts +1 -0
- 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 +113 -10
- package/src/api/index.ts +15 -25
- 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 +4 -0
- package/src/contracts/index.ts +3 -2
- package/src/contracts/registry.ts +4 -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 +0 -2
- package/src/services/auth-flows/magic-link.ts +3 -2
- 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/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 +64 -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 +49 -0
- package/src/shared/redirect.ts +52 -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
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { canMutateManagedGroup, hasOnlySelfUpdateFields, isAdminActor, isSelfTarget, type AccountsActor } from "./authz";
|
|
3
|
+
|
|
4
|
+
const actor = (overrides: Partial<AccountsActor> = {}): AccountsActor => ({
|
|
5
|
+
userId: "user-1",
|
|
6
|
+
uid: "eva",
|
|
7
|
+
roles: ["user", "local/user"],
|
|
8
|
+
provider: "local",
|
|
9
|
+
...overrides,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("accounts service authorization helpers", () => {
|
|
13
|
+
test("recognizes admin actors from roles", () => {
|
|
14
|
+
expect(isAdminActor(actor({ roles: ["user", "admin"] }))).toBe(true);
|
|
15
|
+
expect(isAdminActor(actor({ roles: ["user", "group-manager"] }))).toBe(false);
|
|
16
|
+
expect(isAdminActor(null)).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("recognizes self-service targets by user id", () => {
|
|
20
|
+
expect(isSelfTarget({ actor: actor({ userId: "user-1" }), targetUserId: "user-1" })).toBe(true);
|
|
21
|
+
expect(isSelfTarget({ actor: actor({ userId: "user-1" }), targetUserId: "user-2" })).toBe(false);
|
|
22
|
+
expect(isSelfTarget({ actor: null, targetUserId: "user-1" })).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("allows managed group mutations for admins or recursive managers only", () => {
|
|
26
|
+
expect(
|
|
27
|
+
canMutateManagedGroup({
|
|
28
|
+
actor: actor({ roles: ["admin"] }),
|
|
29
|
+
groupId: "group-1",
|
|
30
|
+
managedGroupIds: [],
|
|
31
|
+
}),
|
|
32
|
+
).toBe(true);
|
|
33
|
+
|
|
34
|
+
expect(
|
|
35
|
+
canMutateManagedGroup({
|
|
36
|
+
actor: actor({ roles: ["user", "group-manager"] }),
|
|
37
|
+
groupId: "group-1",
|
|
38
|
+
managedGroupIds: ["group-1", "child-group"],
|
|
39
|
+
}),
|
|
40
|
+
).toBe(true);
|
|
41
|
+
|
|
42
|
+
expect(
|
|
43
|
+
canMutateManagedGroup({
|
|
44
|
+
actor: actor({ roles: ["user", "group-manager"] }),
|
|
45
|
+
groupId: "group-1",
|
|
46
|
+
managedGroupIds: ["other-group"],
|
|
47
|
+
}),
|
|
48
|
+
).toBe(false);
|
|
49
|
+
|
|
50
|
+
expect(canMutateManagedGroup({ actor: null, groupId: "group-1", managedGroupIds: ["group-1"] })).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("rejects managed group mutations for stale or guest manager relations", () => {
|
|
54
|
+
expect(
|
|
55
|
+
canMutateManagedGroup({
|
|
56
|
+
actor: actor({ roles: ["guest", "local/guest"] }),
|
|
57
|
+
groupId: "group-1",
|
|
58
|
+
managedGroupIds: ["group-1"],
|
|
59
|
+
}),
|
|
60
|
+
).toBe(false);
|
|
61
|
+
|
|
62
|
+
expect(
|
|
63
|
+
canMutateManagedGroup({
|
|
64
|
+
actor: actor({ roles: ["user", "local/user"] }),
|
|
65
|
+
groupId: "group-1",
|
|
66
|
+
managedGroupIds: ["group-1"],
|
|
67
|
+
}),
|
|
68
|
+
).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("allows only self-service profile fields for self updates", () => {
|
|
72
|
+
expect(hasOnlySelfUpdateFields({ givenname: "Eva", sn: "Becker", displayName: "Eva Becker" })).toBe(true);
|
|
73
|
+
expect(hasOnlySelfUpdateFields({ ipa: { phone: "+49" } })).toBe(true);
|
|
74
|
+
expect(hasOnlySelfUpdateFields({ mail: "eva@example.com" })).toBe(false);
|
|
75
|
+
expect(hasOnlySelfUpdateFields({ givenname: "Eva", mail: "eva@example.com" })).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { Role, UserProfile, UserProvider } from "../../contracts/shared";
|
|
2
2
|
|
|
3
|
+
export type AccountsActor = { userId: string; uid: string; roles: string[]; provider?: string | null };
|
|
4
|
+
|
|
5
|
+
const SELF_UPDATE_FIELDS = new Set(["givenname", "sn", "displayName", "ipa"]);
|
|
6
|
+
|
|
3
7
|
export const buildRoles = (params: {
|
|
4
8
|
provider: UserProvider;
|
|
5
9
|
profile: UserProfile;
|
|
@@ -20,3 +24,21 @@ export const buildRoles = (params: {
|
|
|
20
24
|
if (manages.length > 0) roles.add("group-manager");
|
|
21
25
|
return [...roles];
|
|
22
26
|
};
|
|
27
|
+
|
|
28
|
+
export const isAdminActor = (actor: AccountsActor | null | undefined): boolean => !!actor?.roles.includes("admin");
|
|
29
|
+
|
|
30
|
+
export const isSelfTarget = (params: { actor: AccountsActor | null | undefined; targetUserId: string }): boolean =>
|
|
31
|
+
params.actor?.userId === params.targetUserId;
|
|
32
|
+
|
|
33
|
+
export const canMutateManagedGroup = (params: {
|
|
34
|
+
actor: AccountsActor | null | undefined;
|
|
35
|
+
groupId: string;
|
|
36
|
+
managedGroupIds: string[];
|
|
37
|
+
}): boolean => {
|
|
38
|
+
if (isAdminActor(params.actor)) return true;
|
|
39
|
+
if (!params.actor?.roles.includes("user") || !params.actor.roles.includes("group-manager")) return false;
|
|
40
|
+
return params.managedGroupIds.includes(params.groupId);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const hasOnlySelfUpdateFields = (data: Record<string, unknown>): boolean =>
|
|
44
|
+
Object.keys(data).every((field) => SELF_UPDATE_FIELDS.has(field));
|
|
@@ -21,6 +21,7 @@ export type EntityListParams = {
|
|
|
21
21
|
profile?: UserProfile;
|
|
22
22
|
excludeUserIds?: string[];
|
|
23
23
|
excludeGroupIds?: string[];
|
|
24
|
+
excludeServiceAccountIds?: string[];
|
|
24
25
|
userMemberOfGroupIds?: string[];
|
|
25
26
|
memberOfGroupId?: string;
|
|
26
27
|
managerOfGroupId?: string;
|
|
@@ -383,6 +384,25 @@ const mapEntityRow = (row: DbRow): EntityListItem => {
|
|
|
383
384
|
};
|
|
384
385
|
}
|
|
385
386
|
|
|
387
|
+
if (row.kind === "service_account") {
|
|
388
|
+
return {
|
|
389
|
+
kind: "service_account",
|
|
390
|
+
serviceAccount: {
|
|
391
|
+
id: String(row.id),
|
|
392
|
+
name: String(row.name ?? ""),
|
|
393
|
+
kind: row.service_account_kind === "resource_bound" ? "resource_bound" : "user_delegated",
|
|
394
|
+
status: row.status === "disabled" ? "disabled" : "active",
|
|
395
|
+
delegatedUserId: typeof row.delegated_user_id === "string" ? row.delegated_user_id : null,
|
|
396
|
+
appId: typeof row.app_id === "string" ? row.app_id : null,
|
|
397
|
+
resourceType: typeof row.resource_type === "string" ? row.resource_type : null,
|
|
398
|
+
resourceId: typeof row.resource_id === "string" ? row.resource_id : null,
|
|
399
|
+
createdBy: typeof row.created_by === "string" ? row.created_by : null,
|
|
400
|
+
createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at),
|
|
401
|
+
},
|
|
402
|
+
relation: direct === undefined ? undefined : { direct },
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
386
406
|
return {
|
|
387
407
|
kind: "group",
|
|
388
408
|
group: buildBaseGroup(row),
|
|
@@ -412,6 +432,10 @@ export const list = async (params: EntityListParams): Promise<{
|
|
|
412
432
|
(params.excludeGroupIds?.length ?? 0) === 0
|
|
413
433
|
? sql`TRUE`
|
|
414
434
|
: sql`(kind <> 'group' OR id <> ALL(${toPgUuidArray(params.excludeGroupIds ?? [])}::uuid[]))`;
|
|
435
|
+
const excludeServiceAccountCondition =
|
|
436
|
+
(params.excludeServiceAccountIds?.length ?? 0) === 0
|
|
437
|
+
? sql`TRUE`
|
|
438
|
+
: sql`(kind <> 'service_account' OR id <> ALL(${toPgUuidArray(params.excludeServiceAccountIds ?? [])}::uuid[]))`;
|
|
415
439
|
const userMemberOfGroupCondition =
|
|
416
440
|
(params.userMemberOfGroupIds?.length ?? 0) === 0
|
|
417
441
|
? sql`TRUE`
|
|
@@ -428,6 +452,7 @@ export const list = async (params: EntityListParams): Promise<{
|
|
|
428
452
|
AND (${params.profile ?? null}::text IS NULL OR kind = 'group' OR profile = ${params.profile ?? null})
|
|
429
453
|
AND ${excludeUserCondition}
|
|
430
454
|
AND ${excludeGroupCondition}
|
|
455
|
+
AND ${excludeServiceAccountCondition}
|
|
431
456
|
AND ${userMemberOfGroupCondition}
|
|
432
457
|
AND (
|
|
433
458
|
${pattern}::text IS NULL
|
|
@@ -446,6 +471,14 @@ export const list = async (params: EntityListParams): Promise<{
|
|
|
446
471
|
OR LOWER(COALESCE(description, '')) LIKE ${pattern} ESCAPE '\\'
|
|
447
472
|
)
|
|
448
473
|
)
|
|
474
|
+
OR (
|
|
475
|
+
kind = 'service_account' AND (
|
|
476
|
+
LOWER(name) LIKE ${pattern} ESCAPE '\\'
|
|
477
|
+
OR LOWER(COALESCE(app_id, '')) LIKE ${pattern} ESCAPE '\\'
|
|
478
|
+
OR LOWER(COALESCE(resource_type, '')) LIKE ${pattern} ESCAPE '\\'
|
|
479
|
+
OR LOWER(COALESCE(resource_id, '')) LIKE ${pattern} ESCAPE '\\'
|
|
480
|
+
)
|
|
481
|
+
)
|
|
449
482
|
)
|
|
450
483
|
`;
|
|
451
484
|
|
|
@@ -471,13 +504,19 @@ export const list = async (params: EntityListParams): Promise<{
|
|
|
471
504
|
WHEN u.provider = 'local' THEN u.admin
|
|
472
505
|
ELSE EXISTS(
|
|
473
506
|
SELECT 1
|
|
474
|
-
FROM auth.
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
AND g_admin.provider = 'ipa'
|
|
478
|
-
AND g_admin.name = ANY(${groupsAdminLiteral}::text[])
|
|
507
|
+
FROM auth.ipa_user_effective_groups eg
|
|
508
|
+
WHERE eg.user_id = u.id
|
|
509
|
+
AND eg.group_name = ANY(${groupsAdminLiteral}::text[])
|
|
479
510
|
)
|
|
480
511
|
END AS effective_admin,
|
|
512
|
+
NULL::text AS service_account_kind,
|
|
513
|
+
NULL::text AS status,
|
|
514
|
+
NULL::uuid AS delegated_user_id,
|
|
515
|
+
NULL::text AS app_id,
|
|
516
|
+
NULL::text AS resource_type,
|
|
517
|
+
NULL::text AS resource_id,
|
|
518
|
+
NULL::uuid AS created_by,
|
|
519
|
+
NULL::timestamptz AS created_at,
|
|
481
520
|
LOWER(COALESCE(NULLIF(u.display_name, ''), NULLIF(u.mail, ''), u.uid)) AS sort_label
|
|
482
521
|
${spec.userFrom}
|
|
483
522
|
WHERE ${spec.userWhere}
|
|
@@ -498,14 +537,54 @@ export const list = async (params: EntityListParams): Promise<{
|
|
|
498
537
|
g.description,
|
|
499
538
|
g.gid_number,
|
|
500
539
|
NULL::boolean AS effective_admin,
|
|
540
|
+
NULL::text AS service_account_kind,
|
|
541
|
+
NULL::text AS status,
|
|
542
|
+
NULL::uuid AS delegated_user_id,
|
|
543
|
+
NULL::text AS app_id,
|
|
544
|
+
NULL::text AS resource_type,
|
|
545
|
+
NULL::text AS resource_id,
|
|
546
|
+
NULL::uuid AS created_by,
|
|
547
|
+
NULL::timestamptz AS created_at,
|
|
501
548
|
LOWER(g.name) AS sort_label
|
|
502
549
|
${spec.groupFrom}
|
|
503
550
|
WHERE ${spec.groupWhere}
|
|
504
551
|
),
|
|
552
|
+
service_account_rows AS (
|
|
553
|
+
SELECT
|
|
554
|
+
'service_account'::text AS kind,
|
|
555
|
+
NULL::boolean AS direct,
|
|
556
|
+
sa.id,
|
|
557
|
+
NULL::text AS provider,
|
|
558
|
+
NULL::text AS profile,
|
|
559
|
+
NULL::text AS uid,
|
|
560
|
+
NULL::text AS given_name,
|
|
561
|
+
NULL::text AS sn,
|
|
562
|
+
NULL::text AS display_name,
|
|
563
|
+
NULL::text AS mail,
|
|
564
|
+
sa.name,
|
|
565
|
+
CASE
|
|
566
|
+
WHEN sa.kind = 'user_delegated' THEN 'Personal automation keys'
|
|
567
|
+
ELSE CONCAT_WS(' · ', sa.app_id, sa.resource_type, sa.resource_id)
|
|
568
|
+
END AS description,
|
|
569
|
+
NULL::int AS gid_number,
|
|
570
|
+
NULL::boolean AS effective_admin,
|
|
571
|
+
sa.kind AS service_account_kind,
|
|
572
|
+
sa.status,
|
|
573
|
+
sa.delegated_user_id,
|
|
574
|
+
sa.app_id,
|
|
575
|
+
sa.resource_type,
|
|
576
|
+
sa.resource_id,
|
|
577
|
+
sa.created_by,
|
|
578
|
+
sa.created_at,
|
|
579
|
+
LOWER(sa.name) AS sort_label
|
|
580
|
+
FROM auth.service_accounts sa
|
|
581
|
+
),
|
|
505
582
|
entity_rows AS (
|
|
506
583
|
SELECT * FROM user_rows
|
|
507
584
|
UNION ALL
|
|
508
585
|
SELECT * FROM group_rows
|
|
586
|
+
UNION ALL
|
|
587
|
+
SELECT * FROM service_account_rows
|
|
509
588
|
)
|
|
510
589
|
SELECT *, COUNT(*) OVER() AS total
|
|
511
590
|
FROM entity_rows
|
|
@@ -2,6 +2,7 @@ import { sql } from "bun";
|
|
|
2
2
|
import type { BaseGroup, GroupMember, MutationResult, UserProvider } from "../../contracts/shared";
|
|
3
3
|
import * as localGroups from "./local-groups";
|
|
4
4
|
import { providers } from "../providers";
|
|
5
|
+
import { getServiceIpaSession } from "../ipa/service-account";
|
|
5
6
|
import { freeipa } from "../../server/services";
|
|
6
7
|
import { toPgUuidArray } from "../postgres";
|
|
7
8
|
import { buildBaseGroup } from "./base-group";
|
|
@@ -138,7 +139,6 @@ export const getManagedGroups = async (params: { id: string; provider?: UserProv
|
|
|
138
139
|
};
|
|
139
140
|
|
|
140
141
|
export const create = async (params: {
|
|
141
|
-
ipaSession?: string | null;
|
|
142
142
|
provider: UserProvider;
|
|
143
143
|
name: string;
|
|
144
144
|
description?: string;
|
|
@@ -148,9 +148,10 @@ export const create = async (params: {
|
|
|
148
148
|
if (params.posix) return { ok: false, error: "Local groups do not support POSIX mode", status: 400 };
|
|
149
149
|
return localGroups.create({ name: params.name, description: params.description });
|
|
150
150
|
}
|
|
151
|
-
|
|
151
|
+
const serviceSession = await getServiceIpaSession();
|
|
152
|
+
if (!serviceSession.ok) return serviceSession;
|
|
152
153
|
return providers.ipa.groups.add({
|
|
153
|
-
ipaSession:
|
|
154
|
+
ipaSession: serviceSession.data,
|
|
154
155
|
cn: params.name,
|
|
155
156
|
description: params.description,
|
|
156
157
|
posix: params.posix,
|
|
@@ -158,87 +159,92 @@ export const create = async (params: {
|
|
|
158
159
|
};
|
|
159
160
|
|
|
160
161
|
export const update = async (params: {
|
|
161
|
-
ipaSession?: string | null;
|
|
162
162
|
id: string;
|
|
163
163
|
provider?: UserProvider;
|
|
164
164
|
description: string;
|
|
165
165
|
}): Promise<MutationResult<void>> => {
|
|
166
166
|
const provider = params.provider ?? (await getGroup(params.id))?.provider;
|
|
167
167
|
if (provider === "local") return localGroups.update({ id: params.id, description: params.description });
|
|
168
|
-
|
|
168
|
+
const serviceSession = await getServiceIpaSession();
|
|
169
|
+
if (!serviceSession.ok) return serviceSession;
|
|
169
170
|
return providers.ipa.groups.update({
|
|
170
|
-
ipaSession:
|
|
171
|
+
ipaSession: serviceSession.data,
|
|
171
172
|
id: params.id,
|
|
172
173
|
description: params.description,
|
|
173
174
|
});
|
|
174
175
|
};
|
|
175
176
|
|
|
176
|
-
export const remove = async (params: {
|
|
177
|
+
export const remove = async (params: { id: string; provider?: UserProvider }): Promise<MutationResult<void>> => {
|
|
177
178
|
const provider = params.provider ?? (await getGroup(params.id))?.provider;
|
|
178
179
|
if (provider === "local") return localGroups.remove({ id: params.id });
|
|
179
|
-
|
|
180
|
+
const serviceSession = await getServiceIpaSession();
|
|
181
|
+
if (!serviceSession.ok) return serviceSession;
|
|
180
182
|
return providers.ipa.groups.remove({
|
|
181
|
-
ipaSession:
|
|
183
|
+
ipaSession: serviceSession.data,
|
|
182
184
|
id: params.id,
|
|
183
185
|
});
|
|
184
186
|
};
|
|
185
187
|
|
|
186
188
|
export const makePosix = async (params: {
|
|
187
|
-
ipaSession?: string | null;
|
|
188
189
|
id: string;
|
|
189
190
|
provider?: UserProvider;
|
|
190
191
|
}): Promise<MutationResult<{ gidnumber: number | null }>> => {
|
|
191
192
|
const provider = params.provider ?? (await getGroup(params.id))?.provider;
|
|
192
193
|
if (provider === "local") return { ok: false, error: "Local groups do not support POSIX mode", status: 400 };
|
|
193
|
-
|
|
194
|
+
const serviceSession = await getServiceIpaSession();
|
|
195
|
+
if (!serviceSession.ok) return serviceSession;
|
|
194
196
|
return providers.ipa.groups.makePosix({
|
|
195
|
-
ipaSession:
|
|
197
|
+
ipaSession: serviceSession.data,
|
|
196
198
|
id: params.id,
|
|
197
199
|
});
|
|
198
200
|
};
|
|
199
201
|
|
|
200
|
-
export const addMember = async (params: {
|
|
202
|
+
export const addMember = async (params: { id: string; provider?: UserProvider; user?: string; group?: string }): Promise<MutationResult<void>> => {
|
|
201
203
|
const provider = params.provider ?? (await getGroup(params.id))?.provider;
|
|
202
204
|
if (provider === "local") return localGroups.addMember({ id: params.id, user: params.user, group: params.group });
|
|
203
|
-
|
|
205
|
+
const serviceSession = await getServiceIpaSession();
|
|
206
|
+
if (!serviceSession.ok) return serviceSession;
|
|
204
207
|
return providers.ipa.groups.addMember({
|
|
205
|
-
ipaSession:
|
|
208
|
+
ipaSession: serviceSession.data,
|
|
206
209
|
id: params.id,
|
|
207
210
|
user: params.user,
|
|
208
211
|
group: params.group,
|
|
209
212
|
});
|
|
210
213
|
};
|
|
211
214
|
|
|
212
|
-
export const removeMember = async (params: {
|
|
215
|
+
export const removeMember = async (params: { id: string; provider?: UserProvider; user?: string; group?: string }): Promise<MutationResult<void>> => {
|
|
213
216
|
const provider = params.provider ?? (await getGroup(params.id))?.provider;
|
|
214
217
|
if (provider === "local") return localGroups.removeMember({ id: params.id, user: params.user, group: params.group });
|
|
215
|
-
|
|
218
|
+
const serviceSession = await getServiceIpaSession();
|
|
219
|
+
if (!serviceSession.ok) return serviceSession;
|
|
216
220
|
return providers.ipa.groups.removeMember({
|
|
217
|
-
ipaSession:
|
|
221
|
+
ipaSession: serviceSession.data,
|
|
218
222
|
id: params.id,
|
|
219
223
|
user: params.user,
|
|
220
224
|
group: params.group,
|
|
221
225
|
});
|
|
222
226
|
};
|
|
223
227
|
|
|
224
|
-
export const addManager = async (params: {
|
|
228
|
+
export const addManager = async (params: { id: string; provider?: UserProvider; user?: string; group?: string }): Promise<MutationResult<void>> => {
|
|
225
229
|
const provider = params.provider ?? (await getGroup(params.id))?.provider;
|
|
226
230
|
if (provider === "local") return localGroups.addManager({ id: params.id, user: params.user, group: params.group });
|
|
227
|
-
|
|
231
|
+
const serviceSession = await getServiceIpaSession();
|
|
232
|
+
if (!serviceSession.ok) return serviceSession;
|
|
228
233
|
return providers.ipa.groups.addManager({
|
|
229
|
-
ipaSession:
|
|
234
|
+
ipaSession: serviceSession.data,
|
|
230
235
|
id: params.id,
|
|
231
236
|
user: params.user,
|
|
232
237
|
group: params.group,
|
|
233
238
|
});
|
|
234
239
|
};
|
|
235
240
|
|
|
236
|
-
export const removeManager = async (params: {
|
|
241
|
+
export const removeManager = async (params: { id: string; provider?: UserProvider; user?: string; group?: string }): Promise<MutationResult<void>> => {
|
|
237
242
|
const provider = params.provider ?? (await getGroup(params.id))?.provider;
|
|
238
243
|
if (provider === "local") return localGroups.removeManager({ id: params.id, user: params.user, group: params.group });
|
|
239
|
-
|
|
244
|
+
const serviceSession = await getServiceIpaSession();
|
|
245
|
+
if (!serviceSession.ok) return serviceSession;
|
|
240
246
|
return providers.ipa.groups.removeManager({
|
|
241
|
-
ipaSession:
|
|
247
|
+
ipaSession: serviceSession.data,
|
|
242
248
|
id: params.id,
|
|
243
249
|
user: params.user,
|
|
244
250
|
group: params.group,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
calculateIpaProfileFromGroupNames,
|
|
4
|
+
deriveIpaAdminFromGroupNames,
|
|
5
|
+
parseIpaAccountTransitionPolicy,
|
|
6
|
+
} from "./model";
|
|
7
|
+
|
|
8
|
+
describe("IPA account model helpers", () => {
|
|
9
|
+
test("classifies full IPA users from effective base realm groups", () => {
|
|
10
|
+
expect(calculateIpaProfileFromGroupNames(["base-sync", "base-realm"], ["base-realm"])).toBe("user");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("classifies in-scope IPA users without base realm as guests", () => {
|
|
14
|
+
expect(calculateIpaProfileFromGroupNames(["base-sync"], ["base-realm"])).toBe("guest");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("derives IPA admin from effective groups", () => {
|
|
18
|
+
expect(deriveIpaAdminFromGroupNames(["hidden-admin-transit", "admins"], ["admins"])).toBe(true);
|
|
19
|
+
expect(deriveIpaAdminFromGroupNames(["base-sync"], ["admins"])).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("parses all transition policy settings with safe guest demotion fallback", () => {
|
|
23
|
+
expect(parseIpaAccountTransitionPolicy("delete")).toBe("delete");
|
|
24
|
+
expect(parseIpaAccountTransitionPolicy("demote_to_local")).toBe("demote_to_local");
|
|
25
|
+
expect(parseIpaAccountTransitionPolicy("demote_to_local_user")).toBe("demote_to_local_user");
|
|
26
|
+
expect(parseIpaAccountTransitionPolicy("demote_to_local_guest")).toBe("demote_to_local_guest");
|
|
27
|
+
expect(parseIpaAccountTransitionPolicy("unexpected")).toBe("demote_to_local_guest");
|
|
28
|
+
expect(parseIpaAccountTransitionPolicy(null)).toBe("demote_to_local_guest");
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { resolveIpaTransitionProfile } from "./switching";
|
|
3
|
+
|
|
4
|
+
describe("resolveIpaTransitionProfile", () => {
|
|
5
|
+
test("keeps the current profile for demote_to_local", () => {
|
|
6
|
+
expect(resolveIpaTransitionProfile({ currentProfile: "user", policy: "demote_to_local" })).toBe("user");
|
|
7
|
+
expect(resolveIpaTransitionProfile({ currentProfile: "guest", policy: "demote_to_local" })).toBe("guest");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("supports explicit local user and guest transition policies", () => {
|
|
11
|
+
expect(resolveIpaTransitionProfile({ currentProfile: "guest", policy: "demote_to_local_user" })).toBe("user");
|
|
12
|
+
expect(resolveIpaTransitionProfile({ currentProfile: "user", policy: "demote_to_local_guest" })).toBe("guest");
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -53,12 +53,7 @@ export const resolveIpaTransitionTarget = async (params: {
|
|
|
53
53
|
currentProfile: UserProfile;
|
|
54
54
|
policy: Exclude<IpaAccountTransitionPolicy, "delete">;
|
|
55
55
|
}): Promise<{ targetProfile: UserProfile; accountExpires: Date | null }> => {
|
|
56
|
-
const targetProfile =
|
|
57
|
-
params.policy === "demote_to_local"
|
|
58
|
-
? params.currentProfile
|
|
59
|
-
: params.policy === "demote_to_local_user"
|
|
60
|
-
? "user"
|
|
61
|
-
: "guest";
|
|
56
|
+
const targetProfile = resolveIpaTransitionProfile(params);
|
|
62
57
|
|
|
63
58
|
return {
|
|
64
59
|
targetProfile,
|
|
@@ -66,6 +61,15 @@ export const resolveIpaTransitionTarget = async (params: {
|
|
|
66
61
|
};
|
|
67
62
|
};
|
|
68
63
|
|
|
64
|
+
export const resolveIpaTransitionProfile = (params: {
|
|
65
|
+
currentProfile: UserProfile;
|
|
66
|
+
policy: Exclude<IpaAccountTransitionPolicy, "delete">;
|
|
67
|
+
}): UserProfile => {
|
|
68
|
+
if (params.policy === "demote_to_local") return params.currentProfile;
|
|
69
|
+
if (params.policy === "demote_to_local_user") return "user";
|
|
70
|
+
return "guest";
|
|
71
|
+
};
|
|
72
|
+
|
|
69
73
|
export const transitionIpaUserToLocal = async (params: {
|
|
70
74
|
userId: string;
|
|
71
75
|
targetProfile: UserProfile;
|
|
@@ -88,6 +92,11 @@ export const transitionIpaUserToLocal = async (params: {
|
|
|
88
92
|
WHERE user_id = ${params.userId}::uuid
|
|
89
93
|
`;
|
|
90
94
|
|
|
95
|
+
await db`
|
|
96
|
+
DELETE FROM auth.ipa_user_effective_groups
|
|
97
|
+
WHERE user_id = ${params.userId}::uuid
|
|
98
|
+
`;
|
|
99
|
+
|
|
91
100
|
await clearUserRelationsForProvider({
|
|
92
101
|
userId: params.userId,
|
|
93
102
|
provider: "ipa",
|