@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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { sql } from "bun";
|
|
2
2
|
import { accountLifecycle } from "../account-lifecycle";
|
|
3
|
+
import { audit, type AuditActor, type AuditTarget } from "../audit";
|
|
3
4
|
import { lifecycleJobs } from "../account-lifecycle/scheduler";
|
|
4
5
|
import { logger, logging, type LogEntry } from "../logging";
|
|
5
6
|
import { notifications } from "../notifications";
|
|
@@ -11,6 +12,7 @@ import { providers } from "../providers";
|
|
|
11
12
|
import * as users from "./users";
|
|
12
13
|
import * as groups from "./groups";
|
|
13
14
|
import * as entities from "./entities";
|
|
15
|
+
import { canMutateManagedGroup, hasOnlySelfUpdateFields, isAdminActor, isSelfTarget, type AccountsActor } from "./authz";
|
|
14
16
|
import type {
|
|
15
17
|
BaseGroup,
|
|
16
18
|
BaseUser,
|
|
@@ -23,7 +25,17 @@ import type {
|
|
|
23
25
|
UserProvider,
|
|
24
26
|
} from "../../contracts/shared";
|
|
25
27
|
import { dates } from "../../shared";
|
|
26
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
err,
|
|
30
|
+
fail,
|
|
31
|
+
ok,
|
|
32
|
+
paginate,
|
|
33
|
+
paginateItems,
|
|
34
|
+
type PageParams,
|
|
35
|
+
type Paginated,
|
|
36
|
+
type Result,
|
|
37
|
+
type ServiceError,
|
|
38
|
+
} from "../../server/services";
|
|
27
39
|
|
|
28
40
|
type CreateUserInput =
|
|
29
41
|
| {
|
|
@@ -49,6 +61,7 @@ type CreateUserInput =
|
|
|
49
61
|
|
|
50
62
|
type DbRow = Record<string, unknown>;
|
|
51
63
|
type MutationErrorStatus = Extract<MutationResult, { ok: false }>["status"];
|
|
64
|
+
type CreateUserResult = { id: string; uid: string; accountExpires: string | null; notificationSent: boolean };
|
|
52
65
|
|
|
53
66
|
export type AccountRequestStatus = "pending" | "completed" | "denied";
|
|
54
67
|
export type AccountRequestScope = "open" | "processed" | "all";
|
|
@@ -105,28 +118,6 @@ const ACTIVITY_SOURCES = [
|
|
|
105
118
|
"auth:lifecycle:scheduler",
|
|
106
119
|
] as const;
|
|
107
120
|
|
|
108
|
-
const paginateItems = <T>(items: T[], pagination?: PageParams): Paginated<T> => {
|
|
109
|
-
if (!pagination) {
|
|
110
|
-
return {
|
|
111
|
-
items,
|
|
112
|
-
page: 1,
|
|
113
|
-
perPage: items.length,
|
|
114
|
-
total: items.length,
|
|
115
|
-
hasNext: false,
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const { page, perPage, offset } = paginate(pagination);
|
|
120
|
-
const pagedItems = items.slice(offset, offset + perPage);
|
|
121
|
-
return {
|
|
122
|
-
items: pagedItems,
|
|
123
|
-
page,
|
|
124
|
-
perPage,
|
|
125
|
-
total: items.length,
|
|
126
|
-
hasNext: page * perPage < items.length,
|
|
127
|
-
};
|
|
128
|
-
};
|
|
129
|
-
|
|
130
121
|
const toServiceError = (status: MutationErrorStatus, message: string): ServiceError => {
|
|
131
122
|
if (status === 400) return err.badInput(message);
|
|
132
123
|
if (status === 401) return err.unauthenticated(message);
|
|
@@ -226,6 +217,91 @@ const mapSummary = (row: DbRow): AccountsDashboardSummary => {
|
|
|
226
217
|
|
|
227
218
|
const appLog = logger("accounts:app");
|
|
228
219
|
|
|
220
|
+
const auditActor = (actor: AccountsActor | null | undefined): AuditActor | null =>
|
|
221
|
+
actor
|
|
222
|
+
? {
|
|
223
|
+
userId: actor.userId,
|
|
224
|
+
uid: actor.uid,
|
|
225
|
+
provider: actor.provider,
|
|
226
|
+
roles: actor.roles,
|
|
227
|
+
}
|
|
228
|
+
: null;
|
|
229
|
+
|
|
230
|
+
const userTarget = (user: { id?: string | null; uid?: string | null; provider?: string | null } | null | undefined): AuditTarget => ({
|
|
231
|
+
type: "user",
|
|
232
|
+
id: user?.id ?? null,
|
|
233
|
+
label: user?.uid ?? null,
|
|
234
|
+
provider: user?.provider ?? null,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const groupTarget = (group: { id?: string | null; name?: string | null; provider?: string | null } | null | undefined): AuditTarget => ({
|
|
238
|
+
type: "group",
|
|
239
|
+
id: group?.id ?? null,
|
|
240
|
+
label: group?.name ?? null,
|
|
241
|
+
provider: group?.provider ?? null,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const recordCompletedMutation = <T,>(params: {
|
|
245
|
+
action: string;
|
|
246
|
+
actor?: AuditActor | null;
|
|
247
|
+
target?: AuditTarget | null;
|
|
248
|
+
metadata?: Record<string, unknown> | null;
|
|
249
|
+
result: Result<T>;
|
|
250
|
+
}) => (params.result.ok ? audit.recordResultAfterSideEffect(params) : audit.recordResult(params));
|
|
251
|
+
|
|
252
|
+
const requireAdminActor = async <T,>(params: {
|
|
253
|
+
actor: AccountsActor | null | undefined;
|
|
254
|
+
action: string;
|
|
255
|
+
target?: AuditTarget;
|
|
256
|
+
}): Promise<Result<T> | null> => {
|
|
257
|
+
if (isAdminActor(params.actor)) return null;
|
|
258
|
+
return audit.deny<T>({
|
|
259
|
+
action: params.action,
|
|
260
|
+
actor: auditActor(params.actor),
|
|
261
|
+
target: params.target,
|
|
262
|
+
message: "Admin access required",
|
|
263
|
+
});
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const requireDifferentActor = async <T,>(params: {
|
|
267
|
+
actor: AccountsActor | null | undefined;
|
|
268
|
+
targetUserId: string;
|
|
269
|
+
action: string;
|
|
270
|
+
message: string;
|
|
271
|
+
target?: AuditTarget;
|
|
272
|
+
}): Promise<Result<T> | null> => {
|
|
273
|
+
if (!isSelfTarget({ actor: params.actor, targetUserId: params.targetUserId })) return null;
|
|
274
|
+
return audit.deny<T>({
|
|
275
|
+
action: params.action,
|
|
276
|
+
actor: auditActor(params.actor),
|
|
277
|
+
target: params.target,
|
|
278
|
+
message: params.message,
|
|
279
|
+
});
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const authorizeGroupMutation = async <T,>(params: {
|
|
283
|
+
actor: AccountsActor | null | undefined;
|
|
284
|
+
group: BaseGroup;
|
|
285
|
+
action: string;
|
|
286
|
+
}): Promise<Result<T> | null> => {
|
|
287
|
+
if (canMutateManagedGroup({ actor: params.actor, groupId: params.group.id, managedGroupIds: [] })) return null;
|
|
288
|
+
if (!params.actor) {
|
|
289
|
+
return audit.deny<T>({
|
|
290
|
+
action: params.action,
|
|
291
|
+
target: groupTarget(params.group),
|
|
292
|
+
message: "Access denied",
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
const managedGroupIds = await users.getManagedGroupIds({ id: params.actor.userId, recursive: true });
|
|
296
|
+
if (canMutateManagedGroup({ actor: params.actor, groupId: params.group.id, managedGroupIds })) return null;
|
|
297
|
+
return audit.deny<T>({
|
|
298
|
+
action: params.action,
|
|
299
|
+
actor: auditActor(params.actor),
|
|
300
|
+
target: groupTarget(params.group),
|
|
301
|
+
message: "Access denied",
|
|
302
|
+
});
|
|
303
|
+
};
|
|
304
|
+
|
|
229
305
|
const buildAccountRequestWhereClause = (config: {
|
|
230
306
|
access: { userId: string; isAdmin: boolean };
|
|
231
307
|
filter?: { status?: AccountRequestStatus; scope?: AccountRequestScope };
|
|
@@ -322,20 +398,102 @@ export const accountsAppService = {
|
|
|
322
398
|
recursive: config.recursive,
|
|
323
399
|
}),
|
|
324
400
|
},
|
|
325
|
-
create: async (config: {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
);
|
|
336
|
-
|
|
401
|
+
create: async (config: {
|
|
402
|
+
actor: AccountsActor;
|
|
403
|
+
data: CreateUserInput;
|
|
404
|
+
processedBy: string;
|
|
405
|
+
}): Promise<Result<CreateUserResult>> => {
|
|
406
|
+
const adminError = await requireAdminActor<{ id: string; uid: string; accountExpires: string | null; notificationSent: boolean }>({
|
|
407
|
+
actor: config.actor,
|
|
408
|
+
action: "accounts.user.create",
|
|
409
|
+
target: { type: "user", label: config.data.email, provider: config.data.provider },
|
|
410
|
+
});
|
|
411
|
+
if (adminError) return adminError;
|
|
412
|
+
let requestCompletionFailed = false;
|
|
413
|
+
const createFromRequest = async () => {
|
|
414
|
+
const txResult = await sql.begin(async (tx) => {
|
|
415
|
+
const requestRows: DbRow[] = await tx`
|
|
416
|
+
SELECT r.id
|
|
417
|
+
FROM auth.account_requests r
|
|
418
|
+
JOIN auth.users u ON u.id = r.user_id
|
|
419
|
+
WHERE r.id = ${config.data.requestId}
|
|
420
|
+
AND r.status = 'pending'
|
|
421
|
+
AND lower(u.mail) = lower(${config.data.email})
|
|
422
|
+
LIMIT 1
|
|
423
|
+
FOR UPDATE OF r
|
|
424
|
+
`;
|
|
425
|
+
if (requestRows.length === 0) {
|
|
426
|
+
return {
|
|
427
|
+
completed: false,
|
|
428
|
+
result: fail(err.badInput("Account request not found, already processed, or not owned by the target email.")),
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const result = fromMutationResult(
|
|
433
|
+
await users.create({
|
|
434
|
+
data: {
|
|
435
|
+
...config.data,
|
|
436
|
+
profile: config.data.provider === "ipa" ? "user" : config.data.profile,
|
|
437
|
+
admin: config.data.provider === "local" ? config.data.admin : undefined,
|
|
438
|
+
},
|
|
439
|
+
}),
|
|
440
|
+
);
|
|
441
|
+
if (!result.ok) return { completed: false, result };
|
|
442
|
+
|
|
443
|
+
const completedRows: DbRow[] = await tx`
|
|
444
|
+
UPDATE auth.account_requests
|
|
445
|
+
SET status = 'completed', processed_at = now(), processed_by = ${config.processedBy}
|
|
446
|
+
WHERE id = ${config.data.requestId}
|
|
447
|
+
AND user_id = ${result.data.user.id}::uuid
|
|
448
|
+
AND status = 'pending'
|
|
449
|
+
RETURNING id
|
|
450
|
+
`;
|
|
451
|
+
|
|
452
|
+
return { completed: completedRows.length > 0, result };
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
if (txResult.result.ok && !txResult.completed) {
|
|
456
|
+
requestCompletionFailed = true;
|
|
457
|
+
appLog.warn("Account request completion did not match", {
|
|
458
|
+
requestId: config.data.requestId,
|
|
459
|
+
createdUserId: txResult.result.data.user.id,
|
|
460
|
+
processedBy: config.processedBy,
|
|
461
|
+
});
|
|
462
|
+
await audit.recordResultAfterSideEffect({
|
|
463
|
+
action: "accounts.request.complete",
|
|
464
|
+
actor: auditActor(config.actor),
|
|
465
|
+
target: { type: "account_request", id: config.data.requestId },
|
|
466
|
+
metadata: { createdUserId: txResult.result.data.user.id, provider: config.data.provider },
|
|
467
|
+
result: fail(err.badInput("Account request not found, already processed, or not owned by the created user")),
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return txResult.result;
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
const createResult = config.data.requestId
|
|
475
|
+
? await createFromRequest()
|
|
476
|
+
: fromMutationResult(
|
|
477
|
+
await users.create({
|
|
478
|
+
data: {
|
|
479
|
+
...config.data,
|
|
480
|
+
profile: config.data.provider === "ipa" ? "user" : config.data.profile,
|
|
481
|
+
admin: config.data.provider === "local" ? config.data.admin : undefined,
|
|
482
|
+
},
|
|
483
|
+
}),
|
|
484
|
+
);
|
|
485
|
+
if (!createResult.ok) {
|
|
486
|
+
return audit.recordResult({
|
|
487
|
+
action: "accounts.user.create",
|
|
488
|
+
actor: auditActor(config.actor),
|
|
489
|
+
target: { type: "user", label: config.data.email, provider: config.data.provider },
|
|
490
|
+
metadata: { provider: config.data.provider, requestId: config.data.requestId ?? null },
|
|
491
|
+
result: createResult,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
337
494
|
|
|
338
495
|
const created = createResult.data;
|
|
496
|
+
|
|
339
497
|
const autoSend = config.data.autoSendNotification ?? true;
|
|
340
498
|
if (autoSend && created.user.mail) {
|
|
341
499
|
if (config.data.provider === "ipa" && created.temporaryPassword) {
|
|
@@ -366,54 +524,212 @@ export const accountsAppService = {
|
|
|
366
524
|
}
|
|
367
525
|
}
|
|
368
526
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
527
|
+
return audit.recordResultAfterSideEffect({
|
|
528
|
+
action: "accounts.user.create",
|
|
529
|
+
actor: auditActor(config.actor),
|
|
530
|
+
target: userTarget(created.user),
|
|
531
|
+
metadata: { provider: config.data.provider, requestId: config.data.requestId ?? null, notificationSent: autoSend, requestCompletionFailed },
|
|
532
|
+
result: ok({
|
|
533
|
+
id: created.user.id,
|
|
534
|
+
uid: created.user.uid,
|
|
535
|
+
accountExpires: created.user.accountExpires,
|
|
536
|
+
notificationSent: autoSend,
|
|
537
|
+
}),
|
|
538
|
+
});
|
|
539
|
+
},
|
|
540
|
+
update: async (config: { actor: AccountsActor; id: string; data: Parameters<typeof users.update>[0]["data"] }) => {
|
|
541
|
+
const target = await users.getMinimal({ id: config.id });
|
|
542
|
+
const targetInfo = userTarget(target);
|
|
543
|
+
const selfService = config.actor.userId === config.id;
|
|
544
|
+
if (selfService && !hasOnlySelfUpdateFields(config.data as Record<string, unknown>)) {
|
|
545
|
+
return audit.recordResult({
|
|
546
|
+
action: "accounts.user.update",
|
|
547
|
+
actor: auditActor(config.actor),
|
|
548
|
+
target: targetInfo,
|
|
549
|
+
metadata: { changedFields: Object.keys(config.data), selfService },
|
|
550
|
+
result: fail(err.forbidden("Only admins can update account management fields.")),
|
|
551
|
+
});
|
|
390
552
|
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
553
|
+
if (!selfService) {
|
|
554
|
+
const adminError = await requireAdminActor<void>({ actor: config.actor, action: "accounts.user.update", target: targetInfo });
|
|
555
|
+
if (adminError) return adminError;
|
|
556
|
+
}
|
|
557
|
+
const result = fromMutationResult(await users.update(config));
|
|
558
|
+
return recordCompletedMutation({
|
|
559
|
+
action: "accounts.user.update",
|
|
560
|
+
actor: auditActor(config.actor),
|
|
561
|
+
target: targetInfo,
|
|
562
|
+
metadata: { changedFields: Object.keys(config.data), selfService },
|
|
563
|
+
result,
|
|
564
|
+
});
|
|
565
|
+
},
|
|
566
|
+
resetPassword: async (config: { actor: AccountsActor; id: string }) => {
|
|
567
|
+
const target = await users.getMinimal({ id: config.id });
|
|
568
|
+
const targetInfo = userTarget(target);
|
|
569
|
+
const adminError = await requireAdminActor<{ password: string }>({ actor: config.actor, action: "accounts.user.password_reset", target: targetInfo });
|
|
570
|
+
if (adminError) return adminError;
|
|
571
|
+
const selfError = await requireDifferentActor<{ password: string }>({
|
|
572
|
+
actor: config.actor,
|
|
573
|
+
targetUserId: config.id,
|
|
574
|
+
action: "accounts.user.password_reset",
|
|
575
|
+
target: targetInfo,
|
|
576
|
+
message: "You cannot reset your own password from the admin users API.",
|
|
577
|
+
});
|
|
578
|
+
if (selfError) return selfError;
|
|
579
|
+
const result = fromMutationResult(await users.resetPassword(config));
|
|
580
|
+
return recordCompletedMutation({
|
|
581
|
+
action: "accounts.user.password_reset",
|
|
582
|
+
actor: auditActor(config.actor),
|
|
583
|
+
target: targetInfo,
|
|
584
|
+
result: result.ok ? ok({ password: "[REDACTED]" }) : result,
|
|
585
|
+
}).then(() => result);
|
|
586
|
+
},
|
|
587
|
+
setExpiry: async (config: { actor: AccountsActor; id: string; expiryDate: string | null }) => {
|
|
588
|
+
const target = await users.getMinimal({ id: config.id });
|
|
589
|
+
const targetInfo = userTarget(target);
|
|
590
|
+
const adminError = await requireAdminActor<void>({ actor: config.actor, action: "accounts.user.set_expiry", target: targetInfo });
|
|
591
|
+
if (adminError) return adminError;
|
|
592
|
+
const result = fromMutationResult(await users.setExpiry(config));
|
|
593
|
+
return recordCompletedMutation({
|
|
594
|
+
action: "accounts.user.set_expiry",
|
|
595
|
+
actor: auditActor(config.actor),
|
|
596
|
+
target: targetInfo,
|
|
597
|
+
metadata: { expiryDate: config.expiryDate },
|
|
598
|
+
result,
|
|
599
|
+
});
|
|
600
|
+
},
|
|
601
|
+
setProfile: async (config: { actor: AccountsActor; id: string; profile: UserProfile }) => {
|
|
602
|
+
const target = await users.getMinimal({ id: config.id });
|
|
603
|
+
const targetInfo = userTarget(target);
|
|
604
|
+
const adminError = await requireAdminActor<void>({ actor: config.actor, action: "accounts.user.set_profile", target: targetInfo });
|
|
605
|
+
if (adminError) return adminError;
|
|
606
|
+
const selfError = config.profile === "guest"
|
|
607
|
+
? await requireDifferentActor<void>({
|
|
608
|
+
actor: config.actor,
|
|
609
|
+
targetUserId: config.id,
|
|
610
|
+
action: "accounts.user.set_profile",
|
|
611
|
+
target: targetInfo,
|
|
612
|
+
message: "You cannot demote your own account to guest.",
|
|
613
|
+
})
|
|
614
|
+
: null;
|
|
615
|
+
if (selfError) return selfError;
|
|
616
|
+
const result = fromMutationResult(await users.setProfile(config));
|
|
617
|
+
return recordCompletedMutation({
|
|
618
|
+
action: "accounts.user.set_profile",
|
|
619
|
+
actor: auditActor(config.actor),
|
|
620
|
+
target: targetInfo,
|
|
621
|
+
metadata: { profile: config.profile },
|
|
622
|
+
result,
|
|
623
|
+
});
|
|
624
|
+
},
|
|
625
|
+
setAdmin: async (config: { actor: AccountsActor; id: string; admin: boolean }) => {
|
|
626
|
+
const target = await users.getMinimal({ id: config.id });
|
|
627
|
+
const targetInfo = userTarget(target);
|
|
628
|
+
const adminError = await requireAdminActor<void>({ actor: config.actor, action: "accounts.user.set_admin", target: targetInfo });
|
|
629
|
+
if (adminError) return adminError;
|
|
630
|
+
const result = fromMutationResult(await users.setAdmin(config));
|
|
631
|
+
return recordCompletedMutation({
|
|
632
|
+
action: "accounts.user.set_admin",
|
|
633
|
+
actor: auditActor(config.actor),
|
|
634
|
+
target: targetInfo,
|
|
635
|
+
metadata: { admin: config.admin },
|
|
636
|
+
result,
|
|
637
|
+
});
|
|
638
|
+
},
|
|
639
|
+
switchProvider: async (config: { actor: AccountsActor; id: string; provider: UserProvider }) => {
|
|
640
|
+
const target = await users.getMinimal({ id: config.id });
|
|
641
|
+
const targetInfo = userTarget(target);
|
|
642
|
+
const adminError = await requireAdminActor<void>({ actor: config.actor, action: "accounts.user.switch_provider", target: targetInfo });
|
|
643
|
+
if (adminError) return adminError;
|
|
644
|
+
const selfError = await requireDifferentActor<void>({
|
|
645
|
+
actor: config.actor,
|
|
646
|
+
targetUserId: config.id,
|
|
647
|
+
action: "accounts.user.switch_provider",
|
|
648
|
+
target: targetInfo,
|
|
649
|
+
message: "You cannot switch your own account provider.",
|
|
650
|
+
});
|
|
651
|
+
if (selfError) return selfError;
|
|
652
|
+
const result = fromMutationResult(await users.switchProvider(config));
|
|
653
|
+
return recordCompletedMutation({
|
|
654
|
+
action: "accounts.user.switch_provider",
|
|
655
|
+
actor: auditActor(config.actor),
|
|
656
|
+
target: targetInfo,
|
|
657
|
+
metadata: { provider: config.provider },
|
|
658
|
+
result,
|
|
659
|
+
});
|
|
660
|
+
},
|
|
661
|
+
demoteToGuest: async (config: { actor: AccountsActor; id: string }) => {
|
|
662
|
+
const target = await users.getMinimal({ id: config.id });
|
|
663
|
+
const targetInfo = userTarget(target);
|
|
664
|
+
const adminError = await requireAdminActor<void>({ actor: config.actor, action: "accounts.user.demote_to_guest", target: targetInfo });
|
|
665
|
+
if (adminError) return adminError;
|
|
666
|
+
const selfError = await requireDifferentActor<void>({
|
|
667
|
+
actor: config.actor,
|
|
668
|
+
targetUserId: config.id,
|
|
669
|
+
action: "accounts.user.demote_to_guest",
|
|
670
|
+
target: targetInfo,
|
|
671
|
+
message: "You cannot demote your own account.",
|
|
672
|
+
});
|
|
673
|
+
if (selfError) return selfError;
|
|
674
|
+
const result = fromMutationResult(await users.demoteToGuest(config));
|
|
675
|
+
return recordCompletedMutation({
|
|
676
|
+
action: "accounts.user.demote_to_guest",
|
|
677
|
+
actor: auditActor(config.actor),
|
|
678
|
+
target: targetInfo,
|
|
679
|
+
result,
|
|
680
|
+
});
|
|
681
|
+
},
|
|
682
|
+
sendLoginLink: async (config: { actor: AccountsActor; id: string }) => {
|
|
683
|
+
const target = await users.getMinimal({ id: config.id });
|
|
684
|
+
const adminError = await requireAdminActor<void>({ actor: config.actor, action: "accounts.user.send_login_link", target: userTarget(target) });
|
|
685
|
+
if (adminError) return adminError;
|
|
686
|
+
const result = fromMutationResult(await users.sendLoginLink(config));
|
|
687
|
+
return recordCompletedMutation({
|
|
688
|
+
action: "accounts.user.send_login_link",
|
|
689
|
+
actor: auditActor(config.actor),
|
|
690
|
+
target: userTarget(target),
|
|
691
|
+
result,
|
|
692
|
+
});
|
|
693
|
+
},
|
|
694
|
+
createLoginToken: async (config: { actor: AccountsActor; id: string }) => {
|
|
695
|
+
const target = await users.getMinimal({ id: config.id });
|
|
696
|
+
const targetInfo = userTarget(target);
|
|
697
|
+
const adminError = await requireAdminActor<{ token: string; magicLink: string; expiresInSeconds: number }>({
|
|
698
|
+
actor: config.actor,
|
|
699
|
+
action: "accounts.user.create_login_token",
|
|
700
|
+
target: targetInfo,
|
|
701
|
+
});
|
|
702
|
+
if (adminError) return adminError;
|
|
703
|
+
const result = fromMutationResult(await users.createLoginToken(config));
|
|
704
|
+
await recordCompletedMutation({
|
|
705
|
+
action: "accounts.user.create_login_token",
|
|
706
|
+
actor: auditActor(config.actor),
|
|
707
|
+
target: targetInfo,
|
|
708
|
+
result: result.ok ? ok({ token: "[REDACTED]", magicLink: "[REDACTED]", expiresInSeconds: result.data.expiresInSeconds }) : result,
|
|
709
|
+
});
|
|
710
|
+
return result;
|
|
711
|
+
},
|
|
712
|
+
remove: async (config: { actor: AccountsActor; id: string }) => {
|
|
713
|
+
const target = await users.getMinimal({ id: config.id });
|
|
714
|
+
const targetInfo = userTarget(target);
|
|
715
|
+
const adminError = await requireAdminActor<void>({ actor: config.actor, action: "accounts.user.remove", target: targetInfo });
|
|
716
|
+
if (adminError) return adminError;
|
|
717
|
+
const selfError = await requireDifferentActor<void>({
|
|
718
|
+
actor: config.actor,
|
|
719
|
+
targetUserId: config.id,
|
|
720
|
+
action: "accounts.user.remove",
|
|
721
|
+
target: targetInfo,
|
|
722
|
+
message: "You cannot delete your own account.",
|
|
723
|
+
});
|
|
724
|
+
if (selfError) return selfError;
|
|
725
|
+
const result = fromMutationResult(await users.remove(config));
|
|
726
|
+
return recordCompletedMutation({
|
|
727
|
+
action: "accounts.user.remove",
|
|
728
|
+
actor: auditActor(config.actor),
|
|
729
|
+
target: targetInfo,
|
|
730
|
+
result,
|
|
397
731
|
});
|
|
398
732
|
},
|
|
399
|
-
update: async (config: { ipaSession?: string | null; id: string; data: Parameters<typeof users.update>[0]["data"] }) =>
|
|
400
|
-
fromMutationResult(await users.update(config)),
|
|
401
|
-
resetPassword: async (config: { ipaSession: string; id: string }) =>
|
|
402
|
-
fromMutationResult(await users.resetPassword(config)),
|
|
403
|
-
setExpiry: async (config: { ipaSession?: string | null; id: string; expiryDate: string | null }) =>
|
|
404
|
-
fromMutationResult(await users.setExpiry(config)),
|
|
405
|
-
setProfile: async (config: { id: string; profile: UserProfile }) =>
|
|
406
|
-
fromMutationResult(await users.setProfile(config)),
|
|
407
|
-
setAdmin: async (config: { id: string; admin: boolean }) =>
|
|
408
|
-
fromMutationResult(await users.setAdmin(config)),
|
|
409
|
-
switchProvider: async (config: { ipaSession: string; id: string; provider: UserProvider }) =>
|
|
410
|
-
fromMutationResult(await users.switchProvider(config)),
|
|
411
|
-
demoteToGuest: async (config: { ipaSession: string; id: string; actor: { userId: string; uid: string } }) =>
|
|
412
|
-
fromMutationResult(await users.demoteToGuest(config)),
|
|
413
|
-
sendLoginLink: async (config: { id: string }) => fromMutationResult(await users.sendLoginLink(config)),
|
|
414
|
-
createLoginToken: async (config: { id: string }) => fromMutationResult(await users.createLoginToken(config)),
|
|
415
|
-
remove: async (config: { ipaSession?: string | null; id: string; actor: { userId: string; uid: string } }) =>
|
|
416
|
-
fromMutationResult(await users.remove(config)),
|
|
417
733
|
|
|
418
734
|
/**
|
|
419
735
|
* Change an IPA user's own password. Verifies the current password via
|
|
@@ -422,19 +738,21 @@ export const accountsAppService = {
|
|
|
422
738
|
* admin app is UI only and must not dispatch on provider or speak to
|
|
423
739
|
* FreeIPA directly.
|
|
424
740
|
*/
|
|
425
|
-
changeOwnPassword: async (config: {
|
|
426
|
-
user:
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
741
|
+
changeOwnPassword: async (config: { user: User; currentPassword: string; newPassword: string }): Promise<Result<void>> => {
|
|
742
|
+
const actor = { userId: config.user.id, uid: config.user.uid, roles: config.user.roles, provider: config.user.provider };
|
|
743
|
+
if (!(await getFreeIpaConfig()).enabled) {
|
|
744
|
+
const result = fail(err.badInput("FreeIPA is disabled."));
|
|
745
|
+
return audit.recordResult({ action: "accounts.user.change_own_password", actor: auditActor(actor), target: userTarget(config.user), result });
|
|
746
|
+
}
|
|
431
747
|
if (config.user.provider !== "ipa") {
|
|
432
|
-
|
|
748
|
+
const result = fail(err.badInput("Password change is only available for IPA accounts."));
|
|
749
|
+
return audit.recordResult({ action: "accounts.user.change_own_password", actor: auditActor(actor), target: userTarget(config.user), result });
|
|
433
750
|
}
|
|
434
751
|
|
|
435
752
|
const verify = await providers.ipa.auth.login(config.user.uid, config.currentPassword);
|
|
436
753
|
if (verify.status !== "success") {
|
|
437
|
-
|
|
754
|
+
const result = fail(err.unauthenticated("Current password is incorrect."));
|
|
755
|
+
return audit.recordResult({ action: "accounts.user.change_own_password", actor: auditActor(actor), target: userTarget(config.user), result });
|
|
438
756
|
}
|
|
439
757
|
|
|
440
758
|
const result = await providers.ipa.auth.changePassword({
|
|
@@ -442,8 +760,8 @@ export const accountsAppService = {
|
|
|
442
760
|
uid: config.user.uid,
|
|
443
761
|
newPassword: config.newPassword,
|
|
444
762
|
});
|
|
445
|
-
|
|
446
|
-
return
|
|
763
|
+
const serviceResult = result.ok ? ok(undefined) : fail(toServiceError(result.status, result.error));
|
|
764
|
+
return recordCompletedMutation({ action: "accounts.user.change_own_password", actor: auditActor(actor), target: userTarget(config.user), result: serviceResult });
|
|
447
765
|
},
|
|
448
766
|
|
|
449
767
|
/**
|
|
@@ -451,15 +769,18 @@ export const accountsAppService = {
|
|
|
451
769
|
* callers must enforce that before calling. Dispatches to the correct
|
|
452
770
|
* provider internally — callers should not branch on provider themselves.
|
|
453
771
|
*/
|
|
454
|
-
removeSelf: async (config: { user: User
|
|
455
|
-
const actor = { userId: config.user.id, uid: config.user.uid };
|
|
772
|
+
removeSelf: async (config: { user: User }): Promise<Result<void>> => {
|
|
773
|
+
const actor = { userId: config.user.id, uid: config.user.uid, roles: config.user.roles, provider: config.user.provider };
|
|
774
|
+
if (config.user.profile !== "guest") {
|
|
775
|
+
const result = fail(err.forbidden("Only guest accounts can be self-deleted."));
|
|
776
|
+
return audit.recordResult({ action: "accounts.user.remove_self", actor: auditActor(actor), target: userTarget(config.user), result });
|
|
777
|
+
}
|
|
456
778
|
if (config.user.provider === "ipa") {
|
|
457
|
-
|
|
458
|
-
return
|
|
459
|
-
await providers.ipa.users.remove({ ipaSession: config.ipaSession, id: config.user.id, actor }),
|
|
460
|
-
);
|
|
779
|
+
const result = fromMutationResult(await users.remove({ id: config.user.id, actor }));
|
|
780
|
+
return recordCompletedMutation({ action: "accounts.user.remove_self", actor: auditActor(actor), target: userTarget(config.user), result });
|
|
461
781
|
}
|
|
462
|
-
|
|
782
|
+
const result = fromMutationResult(await providers.local.users.remove({ id: config.user.id, actor }));
|
|
783
|
+
return recordCompletedMutation({ action: "accounts.user.remove_self", actor: auditActor(actor), target: userTarget(config.user), result });
|
|
463
784
|
},
|
|
464
785
|
},
|
|
465
786
|
|
|
@@ -511,26 +832,64 @@ export const accountsAppService = {
|
|
|
511
832
|
});
|
|
512
833
|
return paginateItems(filtered, config.pagination);
|
|
513
834
|
},
|
|
514
|
-
add: async (config: {
|
|
515
|
-
|
|
835
|
+
add: async (config: { actor: AccountsActor; id: string; provider?: UserProvider; userId?: string; groupId?: string }) => {
|
|
836
|
+
const group = await groups.get({ id: config.id });
|
|
837
|
+
if (!group) {
|
|
838
|
+
const result = fail(err.notFound("Group not found"));
|
|
839
|
+
return audit.recordResult({
|
|
840
|
+
action: "accounts.group.member.add",
|
|
841
|
+
actor: auditActor(config.actor),
|
|
842
|
+
target: { type: "group", id: config.id },
|
|
843
|
+
result,
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
const accessError = await authorizeGroupMutation<void>({ actor: config.actor, group, action: "accounts.group.member.add" });
|
|
847
|
+
if (accessError) return accessError;
|
|
848
|
+
const result = fromMutationResult(
|
|
516
849
|
await groups.addMember({
|
|
517
|
-
ipaSession: config.ipaSession,
|
|
518
850
|
id: config.id,
|
|
519
851
|
provider: config.provider,
|
|
520
852
|
user: config.userId,
|
|
521
853
|
group: config.groupId,
|
|
522
854
|
}),
|
|
523
|
-
)
|
|
524
|
-
|
|
525
|
-
|
|
855
|
+
);
|
|
856
|
+
return recordCompletedMutation({
|
|
857
|
+
action: "accounts.group.member.add",
|
|
858
|
+
actor: auditActor(config.actor),
|
|
859
|
+
target: groupTarget(group),
|
|
860
|
+
metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null },
|
|
861
|
+
result,
|
|
862
|
+
});
|
|
863
|
+
},
|
|
864
|
+
remove: async (config: { actor: AccountsActor; id: string; provider?: UserProvider; userId?: string; groupId?: string }) => {
|
|
865
|
+
const group = await groups.get({ id: config.id });
|
|
866
|
+
if (!group) {
|
|
867
|
+
const result = fail(err.notFound("Group not found"));
|
|
868
|
+
return audit.recordResult({
|
|
869
|
+
action: "accounts.group.member.remove",
|
|
870
|
+
actor: auditActor(config.actor),
|
|
871
|
+
target: { type: "group", id: config.id },
|
|
872
|
+
result,
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
const accessError = await authorizeGroupMutation<void>({ actor: config.actor, group, action: "accounts.group.member.remove" });
|
|
876
|
+
if (accessError) return accessError;
|
|
877
|
+
const result = fromMutationResult(
|
|
526
878
|
await groups.removeMember({
|
|
527
|
-
ipaSession: config.ipaSession,
|
|
528
879
|
id: config.id,
|
|
529
880
|
provider: config.provider,
|
|
530
881
|
user: config.userId,
|
|
531
882
|
group: config.groupId,
|
|
532
883
|
}),
|
|
533
|
-
)
|
|
884
|
+
);
|
|
885
|
+
return recordCompletedMutation({
|
|
886
|
+
action: "accounts.group.member.remove",
|
|
887
|
+
actor: auditActor(config.actor),
|
|
888
|
+
target: groupTarget(group),
|
|
889
|
+
metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null },
|
|
890
|
+
result,
|
|
891
|
+
});
|
|
892
|
+
},
|
|
534
893
|
},
|
|
535
894
|
manager: {
|
|
536
895
|
list: async (config: {
|
|
@@ -550,26 +909,64 @@ export const accountsAppService = {
|
|
|
550
909
|
});
|
|
551
910
|
return paginateItems(filtered, config.pagination);
|
|
552
911
|
},
|
|
553
|
-
add: async (config: {
|
|
554
|
-
|
|
912
|
+
add: async (config: { actor: AccountsActor; id: string; provider?: UserProvider; userId?: string; groupId?: string }) => {
|
|
913
|
+
const group = await groups.get({ id: config.id });
|
|
914
|
+
if (!group) {
|
|
915
|
+
const result = fail(err.notFound("Group not found"));
|
|
916
|
+
return audit.recordResult({
|
|
917
|
+
action: "accounts.group.manager.add",
|
|
918
|
+
actor: auditActor(config.actor),
|
|
919
|
+
target: { type: "group", id: config.id },
|
|
920
|
+
result,
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
const accessError = await requireAdminActor<void>({ actor: config.actor, action: "accounts.group.manager.add", target: groupTarget(group) });
|
|
924
|
+
if (accessError) return accessError;
|
|
925
|
+
const result = fromMutationResult(
|
|
555
926
|
await groups.addManager({
|
|
556
|
-
ipaSession: config.ipaSession,
|
|
557
927
|
id: config.id,
|
|
558
928
|
provider: config.provider,
|
|
559
929
|
user: config.userId,
|
|
560
930
|
group: config.groupId,
|
|
561
931
|
}),
|
|
562
|
-
)
|
|
563
|
-
|
|
564
|
-
|
|
932
|
+
);
|
|
933
|
+
return recordCompletedMutation({
|
|
934
|
+
action: "accounts.group.manager.add",
|
|
935
|
+
actor: auditActor(config.actor),
|
|
936
|
+
target: groupTarget(group),
|
|
937
|
+
metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null },
|
|
938
|
+
result,
|
|
939
|
+
});
|
|
940
|
+
},
|
|
941
|
+
remove: async (config: { actor: AccountsActor; id: string; provider?: UserProvider; userId?: string; groupId?: string }) => {
|
|
942
|
+
const group = await groups.get({ id: config.id });
|
|
943
|
+
if (!group) {
|
|
944
|
+
const result = fail(err.notFound("Group not found"));
|
|
945
|
+
return audit.recordResult({
|
|
946
|
+
action: "accounts.group.manager.remove",
|
|
947
|
+
actor: auditActor(config.actor),
|
|
948
|
+
target: { type: "group", id: config.id },
|
|
949
|
+
result,
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
const accessError = await requireAdminActor<void>({ actor: config.actor, action: "accounts.group.manager.remove", target: groupTarget(group) });
|
|
953
|
+
if (accessError) return accessError;
|
|
954
|
+
const result = fromMutationResult(
|
|
565
955
|
await groups.removeManager({
|
|
566
|
-
ipaSession: config.ipaSession,
|
|
567
956
|
id: config.id,
|
|
568
957
|
provider: config.provider,
|
|
569
958
|
user: config.userId,
|
|
570
959
|
group: config.groupId,
|
|
571
960
|
}),
|
|
572
|
-
)
|
|
961
|
+
);
|
|
962
|
+
return recordCompletedMutation({
|
|
963
|
+
action: "accounts.group.manager.remove",
|
|
964
|
+
actor: auditActor(config.actor),
|
|
965
|
+
target: groupTarget(group),
|
|
966
|
+
metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null },
|
|
967
|
+
result,
|
|
968
|
+
});
|
|
969
|
+
},
|
|
573
970
|
},
|
|
574
971
|
parent: {
|
|
575
972
|
list: async (config: {
|
|
@@ -595,14 +992,62 @@ export const accountsAppService = {
|
|
|
595
992
|
return paginateItems(filtered, config.pagination);
|
|
596
993
|
},
|
|
597
994
|
},
|
|
598
|
-
create: async (config: {
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
fromMutationResult(await groups.
|
|
995
|
+
create: async (config: { actor: AccountsActor; provider: UserProvider; name: string; description?: string; posix?: boolean }) => {
|
|
996
|
+
const adminError = await requireAdminActor<BaseGroup>({
|
|
997
|
+
actor: config.actor,
|
|
998
|
+
action: "accounts.group.create",
|
|
999
|
+
target: { type: "group", label: config.name, provider: config.provider },
|
|
1000
|
+
});
|
|
1001
|
+
if (adminError) return adminError;
|
|
1002
|
+
const result = fromMutationResult(await groups.create(config));
|
|
1003
|
+
return recordCompletedMutation({
|
|
1004
|
+
action: "accounts.group.create",
|
|
1005
|
+
actor: auditActor(config.actor),
|
|
1006
|
+
target: result.ok ? groupTarget(result.data) : { type: "group", label: config.name, provider: config.provider },
|
|
1007
|
+
metadata: { posix: config.posix ?? false },
|
|
1008
|
+
result,
|
|
1009
|
+
});
|
|
1010
|
+
},
|
|
1011
|
+
update: async (config: { actor: AccountsActor; id: string; provider?: UserProvider; description: string }) => {
|
|
1012
|
+
const group = await groups.get({ id: config.id });
|
|
1013
|
+
const target = groupTarget(group ?? { id: config.id, name: null, provider: config.provider ?? null });
|
|
1014
|
+
const adminError = await requireAdminActor<void>({ actor: config.actor, action: "accounts.group.update", target });
|
|
1015
|
+
if (adminError) return adminError;
|
|
1016
|
+
const result = fromMutationResult(await groups.update(config));
|
|
1017
|
+
return recordCompletedMutation({
|
|
1018
|
+
action: "accounts.group.update",
|
|
1019
|
+
actor: auditActor(config.actor),
|
|
1020
|
+
target,
|
|
1021
|
+
metadata: { changedFields: ["description"] },
|
|
1022
|
+
result,
|
|
1023
|
+
});
|
|
1024
|
+
},
|
|
1025
|
+
remove: async (config: { actor: AccountsActor; id: string; provider?: UserProvider }) => {
|
|
1026
|
+
const group = await groups.get({ id: config.id });
|
|
1027
|
+
const target = groupTarget(group ?? { id: config.id, name: null, provider: config.provider ?? null });
|
|
1028
|
+
const adminError = await requireAdminActor<void>({ actor: config.actor, action: "accounts.group.remove", target });
|
|
1029
|
+
if (adminError) return adminError;
|
|
1030
|
+
const result = fromMutationResult(await groups.remove(config));
|
|
1031
|
+
return recordCompletedMutation({
|
|
1032
|
+
action: "accounts.group.remove",
|
|
1033
|
+
actor: auditActor(config.actor),
|
|
1034
|
+
target,
|
|
1035
|
+
result,
|
|
1036
|
+
});
|
|
1037
|
+
},
|
|
1038
|
+
makePosix: async (config: { actor: AccountsActor; id: string; provider?: UserProvider }) => {
|
|
1039
|
+
const group = await groups.get({ id: config.id });
|
|
1040
|
+
const target = groupTarget(group ?? { id: config.id, name: null, provider: config.provider ?? null });
|
|
1041
|
+
const adminError = await requireAdminActor<{ gidnumber: number | null }>({ actor: config.actor, action: "accounts.group.make_posix", target });
|
|
1042
|
+
if (adminError) return adminError;
|
|
1043
|
+
const result = fromMutationResult(await groups.makePosix(config));
|
|
1044
|
+
return recordCompletedMutation({
|
|
1045
|
+
action: "accounts.group.make_posix",
|
|
1046
|
+
actor: auditActor(config.actor),
|
|
1047
|
+
target,
|
|
1048
|
+
result,
|
|
1049
|
+
});
|
|
1050
|
+
},
|
|
606
1051
|
},
|
|
607
1052
|
entity: {
|
|
608
1053
|
list: async (config: {
|
|
@@ -613,6 +1058,7 @@ export const accountsAppService = {
|
|
|
613
1058
|
profile?: UserProfile;
|
|
614
1059
|
excludeUserIds?: string[];
|
|
615
1060
|
excludeGroupIds?: string[];
|
|
1061
|
+
excludeServiceAccountIds?: string[];
|
|
616
1062
|
userMemberOfGroupIds?: string[];
|
|
617
1063
|
memberOfGroupId?: string;
|
|
618
1064
|
managerOfGroupId?: string;
|
|
@@ -628,6 +1074,7 @@ export const accountsAppService = {
|
|
|
628
1074
|
profile: config.profile,
|
|
629
1075
|
excludeUserIds: config.excludeUserIds,
|
|
630
1076
|
excludeGroupIds: config.excludeGroupIds,
|
|
1077
|
+
excludeServiceAccountIds: config.excludeServiceAccountIds,
|
|
631
1078
|
userMemberOfGroupIds: config.userMemberOfGroupIds,
|
|
632
1079
|
memberOfGroupId: config.memberOfGroupId,
|
|
633
1080
|
managerOfGroupId: config.managerOfGroupId,
|
|
@@ -714,15 +1161,22 @@ export const accountsAppService = {
|
|
|
714
1161
|
createdAt: rows[0]!.created_at as Date,
|
|
715
1162
|
};
|
|
716
1163
|
},
|
|
717
|
-
create: async (config: {
|
|
1164
|
+
create: async (config: {
|
|
1165
|
+
user: Pick<User, "id" | "uid" | "mail" | "provider" | "roles">;
|
|
1166
|
+
data: { phone?: string; comment?: string; acceptedAgb: true };
|
|
1167
|
+
}) => {
|
|
1168
|
+
const actor = { userId: config.user.id, uid: config.user.uid, roles: config.user.roles, provider: config.user.provider };
|
|
718
1169
|
if (!(await getFreeIpaConfig()).enabled) {
|
|
719
|
-
|
|
1170
|
+
const result = fail(err.badInput("FreeIPA is disabled"));
|
|
1171
|
+
return audit.recordResult({ action: "accounts.request.create", actor: auditActor(actor), target: { type: "account_request", label: config.user.mail }, result });
|
|
720
1172
|
}
|
|
721
1173
|
if (config.user.provider !== "local") {
|
|
722
|
-
|
|
1174
|
+
const result = fail(err.forbidden("Only local accounts can request IPA-backed access"));
|
|
1175
|
+
return audit.recordResult({ action: "accounts.request.create", actor: auditActor(actor), target: { type: "account_request", label: config.user.mail }, result });
|
|
723
1176
|
}
|
|
724
1177
|
if (!config.user.mail) {
|
|
725
|
-
|
|
1178
|
+
const result = fail(err.badInput("Your account has no email address"));
|
|
1179
|
+
return audit.recordResult({ action: "accounts.request.create", actor: auditActor(actor), target: { type: "account_request", id: config.user.id }, result });
|
|
726
1180
|
}
|
|
727
1181
|
|
|
728
1182
|
const existingRows: DbRow[] = await sql`
|
|
@@ -730,11 +1184,12 @@ export const accountsAppService = {
|
|
|
730
1184
|
WHERE user_id = ${config.user.id} AND status = 'pending'
|
|
731
1185
|
`;
|
|
732
1186
|
if (existingRows.length > 0) {
|
|
733
|
-
|
|
1187
|
+
const result = fail({
|
|
734
1188
|
code: "CONFLICT",
|
|
735
1189
|
message: "You already have a pending account request",
|
|
736
1190
|
status: 409,
|
|
737
1191
|
});
|
|
1192
|
+
return audit.recordResult({ action: "accounts.request.create", actor: auditActor(actor), target: { type: "account_request", label: config.user.mail }, result });
|
|
738
1193
|
}
|
|
739
1194
|
|
|
740
1195
|
try {
|
|
@@ -744,55 +1199,100 @@ export const accountsAppService = {
|
|
|
744
1199
|
RETURNING id
|
|
745
1200
|
`;
|
|
746
1201
|
|
|
747
|
-
|
|
748
|
-
|
|
1202
|
+
const requestId = rows[0]!.id as string;
|
|
1203
|
+
const result = ok({
|
|
1204
|
+
id: requestId,
|
|
749
1205
|
message: "FreeIPA account request submitted",
|
|
750
1206
|
});
|
|
1207
|
+
return audit.recordResultAfterSideEffect({
|
|
1208
|
+
action: "accounts.request.create",
|
|
1209
|
+
actor: auditActor(actor),
|
|
1210
|
+
target: { type: "account_request", id: requestId, label: config.user.mail },
|
|
1211
|
+
metadata: { hasPhone: !!config.data.phone, hasComment: !!config.data.comment },
|
|
1212
|
+
result,
|
|
1213
|
+
});
|
|
751
1214
|
} catch (error) {
|
|
752
1215
|
// Belt-and-suspenders: the partial unique index
|
|
753
1216
|
// uq_account_requests_one_pending_per_user closes the race between the
|
|
754
1217
|
// check above and the insert under concurrent submissions.
|
|
755
1218
|
if (isUniqueViolation(error, "uq_account_requests_one_pending_per_user")) {
|
|
756
|
-
|
|
1219
|
+
const result = fail({
|
|
757
1220
|
code: "CONFLICT",
|
|
758
1221
|
message: "You already have a pending account request",
|
|
759
1222
|
status: 409,
|
|
760
1223
|
});
|
|
1224
|
+
return audit.recordResult({ action: "accounts.request.create", actor: auditActor(actor), target: { type: "account_request", label: config.user.mail }, result });
|
|
761
1225
|
}
|
|
762
1226
|
throw error;
|
|
763
1227
|
}
|
|
764
1228
|
},
|
|
765
|
-
withdraw: async (config: { id: string;
|
|
1229
|
+
withdraw: async (config: { id: string; actor: AccountsActor }) => {
|
|
1230
|
+
const deletedRows: DbRow[] = await sql`
|
|
1231
|
+
DELETE FROM auth.account_requests
|
|
1232
|
+
WHERE id = ${config.id}
|
|
1233
|
+
AND user_id = ${config.actor.userId}::uuid
|
|
1234
|
+
AND status = 'pending'
|
|
1235
|
+
RETURNING id
|
|
1236
|
+
`;
|
|
1237
|
+
|
|
1238
|
+
if (deletedRows.length > 0) {
|
|
1239
|
+
return audit.recordResultAfterSideEffect({
|
|
1240
|
+
action: "accounts.request.withdraw",
|
|
1241
|
+
actor: auditActor(config.actor),
|
|
1242
|
+
target: { type: "account_request", id: config.id },
|
|
1243
|
+
result: ok(),
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
|
|
766
1247
|
const rows: DbRow[] = await sql`
|
|
767
1248
|
SELECT id, user_id, status FROM auth.account_requests WHERE id = ${config.id}
|
|
768
1249
|
`;
|
|
769
|
-
|
|
770
|
-
|
|
1250
|
+
if (rows.length === 0) {
|
|
1251
|
+
const result = fail(err.notFound("Request"));
|
|
1252
|
+
return audit.recordResult({ action: "accounts.request.withdraw", actor: auditActor(config.actor), target: { type: "account_request", id: config.id }, result });
|
|
1253
|
+
}
|
|
771
1254
|
const request = rows[0]!;
|
|
772
|
-
if (request.user_id !== config.userId)
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
1255
|
+
if (request.user_id !== config.actor.userId) {
|
|
1256
|
+
const result = fail(err.forbidden("Access denied"));
|
|
1257
|
+
return audit.recordResult({ action: "accounts.request.withdraw", actor: auditActor(config.actor), target: { type: "account_request", id: config.id }, result });
|
|
1258
|
+
}
|
|
1259
|
+
if (request.status !== "pending") {
|
|
1260
|
+
const result = fail(err.forbidden("Only pending requests can be withdrawn"));
|
|
1261
|
+
return audit.recordResult({ action: "accounts.request.withdraw", actor: auditActor(config.actor), target: { type: "account_request", id: config.id }, result });
|
|
1262
|
+
}
|
|
1263
|
+
const result = fail(err.conflict("Account request could not be withdrawn. Please retry."));
|
|
1264
|
+
return audit.recordResult({ action: "accounts.request.withdraw", actor: auditActor(config.actor), target: { type: "account_request", id: config.id }, result });
|
|
777
1265
|
},
|
|
778
|
-
deny: async (config: { id: string; reason?: string;
|
|
1266
|
+
deny: async (config: { id: string; reason?: string; actor: AccountsActor }) => {
|
|
1267
|
+
const adminError = await requireAdminActor<void>({
|
|
1268
|
+
actor: config.actor,
|
|
1269
|
+
action: "accounts.request.deny",
|
|
1270
|
+
target: { type: "account_request", id: config.id },
|
|
1271
|
+
});
|
|
1272
|
+
if (adminError) return adminError;
|
|
779
1273
|
const rows: DbRow[] = await sql`
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
1274
|
+
UPDATE auth.account_requests r
|
|
1275
|
+
SET status = 'denied', denied_reason = ${config.reason ?? null}, processed_at = now(), processed_by = ${config.actor.userId}
|
|
1276
|
+
FROM auth.users u
|
|
783
1277
|
WHERE r.id = ${config.id}
|
|
1278
|
+
AND r.status = 'pending'
|
|
1279
|
+
AND u.id = r.user_id
|
|
1280
|
+
RETURNING r.id, r.user_id, r.status, u.mail AS email, u.given_name AS first_name
|
|
784
1281
|
`;
|
|
785
1282
|
|
|
786
|
-
if (rows.length === 0)
|
|
1283
|
+
if (rows.length === 0) {
|
|
1284
|
+
const currentRows: DbRow[] = await sql`
|
|
1285
|
+
SELECT id, status FROM auth.account_requests WHERE id = ${config.id}
|
|
1286
|
+
`;
|
|
1287
|
+
if (currentRows.length === 0) {
|
|
1288
|
+
const result = fail(err.notFound("Request"));
|
|
1289
|
+
return audit.recordResult({ action: "accounts.request.deny", actor: auditActor(config.actor), target: { type: "account_request", id: config.id }, result });
|
|
1290
|
+
}
|
|
1291
|
+
const result = fail(err.badInput("Only pending requests can be denied"));
|
|
1292
|
+
return audit.recordResult({ action: "accounts.request.deny", actor: auditActor(config.actor), target: { type: "account_request", id: config.id }, result });
|
|
1293
|
+
}
|
|
787
1294
|
|
|
788
1295
|
const request = rows[0]!;
|
|
789
|
-
if (request.status !== "pending") return fail(err.badInput("Only pending requests can be denied"));
|
|
790
|
-
|
|
791
|
-
await sql`
|
|
792
|
-
UPDATE auth.account_requests
|
|
793
|
-
SET status = 'denied', denied_reason = ${config.reason ?? null}, processed_at = now(), processed_by = ${config.processedBy}
|
|
794
|
-
WHERE id = ${config.id}
|
|
795
|
-
`;
|
|
796
1296
|
|
|
797
1297
|
if (config.reason) {
|
|
798
1298
|
const template = await settings.get<string>("mail.account_request_denial");
|
|
@@ -810,11 +1310,17 @@ export const accountsAppService = {
|
|
|
810
1310
|
APP_NAME: appName,
|
|
811
1311
|
}),
|
|
812
1312
|
autoSend: true,
|
|
813
|
-
sentBy: config.
|
|
1313
|
+
sentBy: config.actor.userId,
|
|
814
1314
|
});
|
|
815
1315
|
}
|
|
816
1316
|
|
|
817
|
-
return
|
|
1317
|
+
return audit.recordResultAfterSideEffect({
|
|
1318
|
+
action: "accounts.request.deny",
|
|
1319
|
+
actor: auditActor(config.actor),
|
|
1320
|
+
target: { type: "account_request", id: config.id, label: request.email as string },
|
|
1321
|
+
metadata: { hasReason: !!config.reason },
|
|
1322
|
+
result: ok(),
|
|
1323
|
+
});
|
|
818
1324
|
},
|
|
819
1325
|
},
|
|
820
1326
|
|
|
@@ -924,10 +1430,7 @@ export const accountsAppService = {
|
|
|
924
1430
|
return mapSummary(rows[0] ?? {});
|
|
925
1431
|
},
|
|
926
1432
|
activity: async (): Promise<LogEntry[]> => {
|
|
927
|
-
const result = await logging.list(
|
|
928
|
-
{ page: 1, perPage: 15, offset: 0 },
|
|
929
|
-
{ sources: [...ACTIVITY_SOURCES] },
|
|
930
|
-
);
|
|
1433
|
+
const result = await logging.list({ page: 1, perPage: 15, offset: 0 }, { sources: [...ACTIVITY_SOURCES] });
|
|
931
1434
|
return result.entries;
|
|
932
1435
|
},
|
|
933
1436
|
},
|
|
@@ -944,21 +1447,6 @@ export const accountsAppService = {
|
|
|
944
1447
|
runGuestBackfill: async (): Promise<string> => lifecycleJobs.submitGuestBackfill(),
|
|
945
1448
|
runReminders: async (): Promise<string> => lifecycleJobs.submitReminderRun(),
|
|
946
1449
|
},
|
|
947
|
-
|
|
948
|
-
/**
|
|
949
|
-
* Obtain a privileged FreeIPA service session for operations performed on
|
|
950
|
-
* behalf of the system (e.g. a local group manager mutating an IPA group
|
|
951
|
-
* they don't personally own). Exposed through the facade so admin UIs
|
|
952
|
-
* never import `providers.*` directly.
|
|
953
|
-
*/
|
|
954
|
-
getServiceIpaSession: async (): Promise<Result<string>> => {
|
|
955
|
-
if (!(await getFreeIpaConfig()).enabled) return fail(err.badInput("FreeIPA is disabled."));
|
|
956
|
-
try {
|
|
957
|
-
return ok(await providers.ipa.auth.getServiceSession());
|
|
958
|
-
} catch {
|
|
959
|
-
return fail(err.internal("Internal FreeIPA session unavailable."));
|
|
960
|
-
}
|
|
961
|
-
},
|
|
962
1450
|
} as const;
|
|
963
1451
|
|
|
964
1452
|
export type AccountsAppService = typeof accountsAppService;
|