@valentinkolb/cloud 0.4.0 → 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 -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 +113 -10
- 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 +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,431 @@
|
|
|
1
|
+
import { sql, type SQL } from "bun";
|
|
2
|
+
import type { PaginationParams, UserProvider } from "../../contracts/shared";
|
|
3
|
+
import { err, fail, ok, paginate, type PageParams, type Paginated, type Result, type ServiceError } from "../../server/services";
|
|
4
|
+
import { escapeLikePattern, parsePgJsonRecord, toPgTextArray } from "../postgres";
|
|
5
|
+
import { logger } from "../logging";
|
|
6
|
+
|
|
7
|
+
export type AuditOutcome = "allowed" | "denied" | "failed";
|
|
8
|
+
export type AuditActionGroup = "service_accounts";
|
|
9
|
+
|
|
10
|
+
export type AuditActor = {
|
|
11
|
+
userId?: string | null;
|
|
12
|
+
uid?: string | null;
|
|
13
|
+
provider?: UserProvider | string | null;
|
|
14
|
+
roles?: readonly string[] | null;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type AuditTarget = {
|
|
18
|
+
type?: string | null;
|
|
19
|
+
id?: string | null;
|
|
20
|
+
label?: string | null;
|
|
21
|
+
provider?: UserProvider | string | null;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type AuditEvent = {
|
|
25
|
+
id: number;
|
|
26
|
+
createdAt: string;
|
|
27
|
+
action: string;
|
|
28
|
+
outcome: AuditOutcome;
|
|
29
|
+
actor: {
|
|
30
|
+
userId: string | null;
|
|
31
|
+
uid: string | null;
|
|
32
|
+
provider: string | null;
|
|
33
|
+
roles: string[];
|
|
34
|
+
};
|
|
35
|
+
target: {
|
|
36
|
+
type: string | null;
|
|
37
|
+
id: string | null;
|
|
38
|
+
label: string | null;
|
|
39
|
+
provider: string | null;
|
|
40
|
+
};
|
|
41
|
+
reason: string | null;
|
|
42
|
+
errorCode: string | null;
|
|
43
|
+
errorMessage: string | null;
|
|
44
|
+
requestId: string | null;
|
|
45
|
+
metadata: Record<string, unknown>;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type AuditListFilter = {
|
|
49
|
+
search?: string;
|
|
50
|
+
actor?: string;
|
|
51
|
+
target?: string;
|
|
52
|
+
action?: string;
|
|
53
|
+
actionGroup?: AuditActionGroup;
|
|
54
|
+
serviceAccountId?: string;
|
|
55
|
+
outcome?: AuditOutcome;
|
|
56
|
+
provider?: UserProvider | string;
|
|
57
|
+
days?: number;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type SelfServiceAuditActivity = {
|
|
61
|
+
id: number;
|
|
62
|
+
createdAt: string;
|
|
63
|
+
action: string;
|
|
64
|
+
label: string;
|
|
65
|
+
outcome: AuditOutcome;
|
|
66
|
+
context: string | null;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export type AuditRecordParams = {
|
|
70
|
+
action: string;
|
|
71
|
+
outcome: AuditOutcome;
|
|
72
|
+
actor?: AuditActor | null;
|
|
73
|
+
target?: AuditTarget | null;
|
|
74
|
+
reason?: string | null;
|
|
75
|
+
error?: Pick<ServiceError, "code" | "message"> | { code?: string | null; message?: string | null } | null;
|
|
76
|
+
requestId?: string | null;
|
|
77
|
+
metadata?: Record<string, unknown> | null;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
type AuditDb = typeof sql;
|
|
81
|
+
|
|
82
|
+
type DbAuditRow = {
|
|
83
|
+
id: number;
|
|
84
|
+
created_at: Date | string;
|
|
85
|
+
action: string;
|
|
86
|
+
outcome: string;
|
|
87
|
+
actor_user_id: string | null;
|
|
88
|
+
actor_uid: string | null;
|
|
89
|
+
actor_provider: string | null;
|
|
90
|
+
actor_roles: string[] | null;
|
|
91
|
+
target_type: string | null;
|
|
92
|
+
target_id: string | null;
|
|
93
|
+
target_label: string | null;
|
|
94
|
+
target_provider: string | null;
|
|
95
|
+
reason: string | null;
|
|
96
|
+
error_code: string | null;
|
|
97
|
+
error_message: string | null;
|
|
98
|
+
request_id: string | null;
|
|
99
|
+
metadata: Record<string, unknown> | string | null;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const log = logger("audit");
|
|
103
|
+
const SENSITIVE_KEY_PATTERN = /(password|secret|token|cookie|authorization|api[_-]?key|private[_-]?key|session|ipa[_-]?session)/i;
|
|
104
|
+
const REDACTED = "[REDACTED]";
|
|
105
|
+
const MAX_STRING_LENGTH = 500;
|
|
106
|
+
const MAX_ARRAY_LENGTH = 50;
|
|
107
|
+
const MAX_DEPTH = 8;
|
|
108
|
+
const SELF_SERVICE_ACTION_LABELS = {
|
|
109
|
+
"accounts.user.change_own_password": "Password changed",
|
|
110
|
+
"accounts.user.remove_self": "Account deleted",
|
|
111
|
+
"accounts.user.update": "Profile updated",
|
|
112
|
+
"accounts.request.create": "Account request submitted",
|
|
113
|
+
"accounts.request.withdraw": "Account request withdrawn",
|
|
114
|
+
"accounts.user.extend_account": "Account extended",
|
|
115
|
+
"service_account_credential.create": "API key created",
|
|
116
|
+
"service_account_credential.revoke": "API key revoked",
|
|
117
|
+
"service_account_credential.authenticate": "API key used",
|
|
118
|
+
"webauthn_credential.create": "Passkey added",
|
|
119
|
+
"webauthn_credential.delete": "Passkey removed",
|
|
120
|
+
"webauthn_credential.authenticate": "Passkey used",
|
|
121
|
+
} as const satisfies Record<string, string>;
|
|
122
|
+
const SELF_SERVICE_ACTIONS = Object.keys(SELF_SERVICE_ACTION_LABELS);
|
|
123
|
+
|
|
124
|
+
const asString = (value: string | null | undefined): string | null => {
|
|
125
|
+
const trimmed = value?.trim();
|
|
126
|
+
return trimmed ? trimmed : null;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const asRoleArray = (roles: readonly string[] | null | undefined): string[] => [...new Set((roles ?? []).map((role) => role.trim()).filter(Boolean))];
|
|
130
|
+
|
|
131
|
+
export const sanitizeAuditMetadata = (value: unknown, depth = 0): unknown => {
|
|
132
|
+
if (value == null) return null;
|
|
133
|
+
if (value instanceof Date) return value.toISOString();
|
|
134
|
+
if (typeof value === "string") return value.length > MAX_STRING_LENGTH ? `${value.slice(0, MAX_STRING_LENGTH)}...` : value;
|
|
135
|
+
if (typeof value === "number" || typeof value === "boolean") return value;
|
|
136
|
+
if (typeof value === "bigint") return value.toString();
|
|
137
|
+
if (typeof value !== "object") return null;
|
|
138
|
+
if (depth >= MAX_DEPTH) return "[MAX_DEPTH]";
|
|
139
|
+
|
|
140
|
+
if (Array.isArray(value)) {
|
|
141
|
+
const items = value.slice(0, MAX_ARRAY_LENGTH).map((item) => sanitizeAuditMetadata(item, depth + 1));
|
|
142
|
+
if (value.length > MAX_ARRAY_LENGTH) items.push(`[${value.length - MAX_ARRAY_LENGTH} more]`);
|
|
143
|
+
return items;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const out: Record<string, unknown> = {};
|
|
147
|
+
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
|
148
|
+
out[key] = SENSITIVE_KEY_PATTERN.test(key) ? REDACTED : sanitizeAuditMetadata(child, depth + 1);
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export const sanitizeAuditText = (value: string | null | undefined): string | null => {
|
|
154
|
+
const text = asString(value);
|
|
155
|
+
if (!text) return null;
|
|
156
|
+
if (SENSITIVE_KEY_PATTERN.test(text)) return REDACTED;
|
|
157
|
+
return text.length > MAX_STRING_LENGTH ? `${text.slice(0, MAX_STRING_LENGTH)}...` : text;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const mapRow = (row: DbAuditRow): AuditEvent => ({
|
|
161
|
+
id: Number(row.id),
|
|
162
|
+
createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at,
|
|
163
|
+
action: row.action,
|
|
164
|
+
outcome: row.outcome as AuditOutcome,
|
|
165
|
+
actor: {
|
|
166
|
+
userId: row.actor_user_id,
|
|
167
|
+
uid: row.actor_uid,
|
|
168
|
+
provider: row.actor_provider,
|
|
169
|
+
roles: row.actor_roles ?? [],
|
|
170
|
+
},
|
|
171
|
+
target: {
|
|
172
|
+
type: row.target_type,
|
|
173
|
+
id: row.target_id,
|
|
174
|
+
label: row.target_label,
|
|
175
|
+
provider: row.target_provider,
|
|
176
|
+
},
|
|
177
|
+
reason: row.reason,
|
|
178
|
+
errorCode: row.error_code,
|
|
179
|
+
errorMessage: row.error_message,
|
|
180
|
+
requestId: row.request_id,
|
|
181
|
+
metadata: parsePgJsonRecord(row.metadata) ?? {},
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const mapSelfServiceActivityRow = (row: DbAuditRow): SelfServiceAuditActivity => ({
|
|
185
|
+
id: Number(row.id),
|
|
186
|
+
createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at,
|
|
187
|
+
action: row.action,
|
|
188
|
+
label: SELF_SERVICE_ACTION_LABELS[row.action as keyof typeof SELF_SERVICE_ACTION_LABELS] ?? row.action,
|
|
189
|
+
outcome: row.outcome as AuditOutcome,
|
|
190
|
+
context: row.target_label ?? null,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const outcomeForError = (error: Pick<ServiceError, "status"> | null | undefined): AuditOutcome => {
|
|
194
|
+
if (!error) return "allowed";
|
|
195
|
+
return error.status === 401 || error.status === 403 ? "denied" : "failed";
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const record = async (params: AuditRecordParams, db: AuditDb = sql): Promise<void> => {
|
|
199
|
+
try {
|
|
200
|
+
const actorRoles = asRoleArray(params.actor?.roles);
|
|
201
|
+
const metadata = (sanitizeAuditMetadata(params.metadata ?? {}) as Record<string, unknown>) ?? {};
|
|
202
|
+
await db`
|
|
203
|
+
INSERT INTO audit.events (
|
|
204
|
+
action,
|
|
205
|
+
outcome,
|
|
206
|
+
actor_user_id,
|
|
207
|
+
actor_uid,
|
|
208
|
+
actor_provider,
|
|
209
|
+
actor_roles,
|
|
210
|
+
target_type,
|
|
211
|
+
target_id,
|
|
212
|
+
target_label,
|
|
213
|
+
target_provider,
|
|
214
|
+
reason,
|
|
215
|
+
error_code,
|
|
216
|
+
error_message,
|
|
217
|
+
request_id,
|
|
218
|
+
metadata
|
|
219
|
+
)
|
|
220
|
+
VALUES (
|
|
221
|
+
${params.action},
|
|
222
|
+
${params.outcome},
|
|
223
|
+
${asString(params.actor?.userId)}::uuid,
|
|
224
|
+
${asString(params.actor?.uid)},
|
|
225
|
+
${asString(params.actor?.provider)},
|
|
226
|
+
${toPgTextArray(actorRoles)}::text[],
|
|
227
|
+
${asString(params.target?.type)},
|
|
228
|
+
${asString(params.target?.id)},
|
|
229
|
+
${asString(params.target?.label)},
|
|
230
|
+
${asString(params.target?.provider)},
|
|
231
|
+
${sanitizeAuditText(params.reason)},
|
|
232
|
+
${asString(params.error?.code ?? null)},
|
|
233
|
+
${sanitizeAuditText(params.error?.message ?? null)},
|
|
234
|
+
${asString(params.requestId)},
|
|
235
|
+
${JSON.stringify(metadata)}::jsonb
|
|
236
|
+
)
|
|
237
|
+
`;
|
|
238
|
+
} catch (error) {
|
|
239
|
+
log.error("Audit write failed", {
|
|
240
|
+
action: params.action,
|
|
241
|
+
outcome: params.outcome,
|
|
242
|
+
error: error instanceof Error ? error.message : String(error),
|
|
243
|
+
});
|
|
244
|
+
throw error;
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const recordResult = async <T,>(params: {
|
|
249
|
+
action: string;
|
|
250
|
+
actor?: AuditActor | null;
|
|
251
|
+
target?: AuditTarget | null;
|
|
252
|
+
metadata?: Record<string, unknown> | null;
|
|
253
|
+
result: Result<T>;
|
|
254
|
+
db?: AuditDb;
|
|
255
|
+
}): Promise<Result<T>> => {
|
|
256
|
+
await record({
|
|
257
|
+
action: params.action,
|
|
258
|
+
actor: params.actor,
|
|
259
|
+
target: params.target,
|
|
260
|
+
metadata: params.metadata,
|
|
261
|
+
outcome: params.result.ok ? "allowed" : outcomeForError(params.result.error),
|
|
262
|
+
reason: params.result.ok ? null : params.result.error.message,
|
|
263
|
+
error: params.result.ok ? null : params.result.error,
|
|
264
|
+
}, params.db);
|
|
265
|
+
return params.result;
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Use only after an irreversible side effect already happened. Authorization
|
|
270
|
+
* checks and pre-mutation validation must keep using `record`/`recordResult`
|
|
271
|
+
* so missing audit storage can still fail closed before anything changes.
|
|
272
|
+
*/
|
|
273
|
+
const recordResultAfterSideEffect = async <T,>(params: {
|
|
274
|
+
action: string;
|
|
275
|
+
actor?: AuditActor | null;
|
|
276
|
+
target?: AuditTarget | null;
|
|
277
|
+
metadata?: Record<string, unknown> | null;
|
|
278
|
+
result: Result<T>;
|
|
279
|
+
}): Promise<Result<T>> => {
|
|
280
|
+
try {
|
|
281
|
+
await recordResult(params);
|
|
282
|
+
} catch (error) {
|
|
283
|
+
log.error("Post-side-effect audit write failed", {
|
|
284
|
+
action: params.action,
|
|
285
|
+
outcome: params.result.ok ? "allowed" : outcomeForError(params.result.error),
|
|
286
|
+
error: error instanceof Error ? error.message : String(error),
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
return params.result;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const deny = async <T,>(params: {
|
|
293
|
+
action: string;
|
|
294
|
+
actor?: AuditActor | null;
|
|
295
|
+
target?: AuditTarget | null;
|
|
296
|
+
message?: string;
|
|
297
|
+
metadata?: Record<string, unknown> | null;
|
|
298
|
+
}): Promise<Result<T>> => {
|
|
299
|
+
const error = err.forbidden(params.message ?? "Access denied");
|
|
300
|
+
await record({
|
|
301
|
+
action: params.action,
|
|
302
|
+
actor: params.actor,
|
|
303
|
+
target: params.target,
|
|
304
|
+
metadata: params.metadata,
|
|
305
|
+
outcome: "denied",
|
|
306
|
+
reason: error.message,
|
|
307
|
+
error,
|
|
308
|
+
});
|
|
309
|
+
return fail(error);
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const buildWhere = (filter: AuditListFilter = {}) => {
|
|
313
|
+
const conditions: SQL.Query<unknown>[] = [sql`TRUE`];
|
|
314
|
+
const search = filter.search?.trim();
|
|
315
|
+
const actor = filter.actor?.trim();
|
|
316
|
+
const target = filter.target?.trim();
|
|
317
|
+
const provider = filter.provider?.trim();
|
|
318
|
+
const days = filter.days && Number.isFinite(filter.days) && filter.days > 0 ? Math.min(Math.floor(filter.days), 3650) : null;
|
|
319
|
+
|
|
320
|
+
if (search) {
|
|
321
|
+
const pattern = `%${escapeLikePattern(search)}%`;
|
|
322
|
+
conditions.push(sql`(
|
|
323
|
+
action ILIKE ${pattern} ESCAPE '\\'
|
|
324
|
+
OR COALESCE(actor_user_id::text, '') ILIKE ${pattern} ESCAPE '\\'
|
|
325
|
+
OR COALESCE(actor_uid, '') ILIKE ${pattern} ESCAPE '\\'
|
|
326
|
+
OR COALESCE(target_label, '') ILIKE ${pattern} ESCAPE '\\'
|
|
327
|
+
OR COALESCE(target_id, '') ILIKE ${pattern} ESCAPE '\\'
|
|
328
|
+
OR COALESCE(reason, '') ILIKE ${pattern} ESCAPE '\\'
|
|
329
|
+
OR COALESCE(error_message, '') ILIKE ${pattern} ESCAPE '\\'
|
|
330
|
+
OR metadata::text ILIKE ${pattern} ESCAPE '\\'
|
|
331
|
+
)`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (actor) {
|
|
335
|
+
const pattern = `%${escapeLikePattern(actor)}%`;
|
|
336
|
+
conditions.push(sql`(actor_user_id::text = ${actor} OR COALESCE(actor_uid, '') ILIKE ${pattern} ESCAPE '\\')`);
|
|
337
|
+
}
|
|
338
|
+
if (target) {
|
|
339
|
+
const pattern = `%${escapeLikePattern(target)}%`;
|
|
340
|
+
conditions.push(sql`(target_id = ${target} OR COALESCE(target_label, '') ILIKE ${pattern} ESCAPE '\\')`);
|
|
341
|
+
}
|
|
342
|
+
if (filter.action?.trim()) conditions.push(sql`action = ${filter.action.trim()}`);
|
|
343
|
+
if (filter.actionGroup === "service_accounts") {
|
|
344
|
+
conditions.push(sql`(
|
|
345
|
+
action LIKE 'service_account%'
|
|
346
|
+
OR target_type IN ('service_account', 'service_account_credential')
|
|
347
|
+
OR metadata ? 'serviceAccountId'
|
|
348
|
+
)`);
|
|
349
|
+
}
|
|
350
|
+
if (filter.serviceAccountId?.trim()) {
|
|
351
|
+
const serviceAccountId = filter.serviceAccountId.trim();
|
|
352
|
+
conditions.push(sql`(
|
|
353
|
+
(target_type = 'service_account' AND target_id = ${serviceAccountId})
|
|
354
|
+
OR metadata->>'serviceAccountId' = ${serviceAccountId}
|
|
355
|
+
)`);
|
|
356
|
+
}
|
|
357
|
+
if (filter.outcome) conditions.push(sql`outcome = ${filter.outcome}`);
|
|
358
|
+
if (provider) conditions.push(sql`(actor_provider = ${provider} OR target_provider = ${provider})`);
|
|
359
|
+
if (days) conditions.push(sql`created_at >= now() - ${`${days} days`}::interval`);
|
|
360
|
+
|
|
361
|
+
return conditions.reduce((acc, condition) => sql`${acc} AND ${condition}`);
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const list = async (config: {
|
|
365
|
+
pagination?: PageParams;
|
|
366
|
+
filter?: AuditListFilter;
|
|
367
|
+
}): Promise<Paginated<AuditEvent>> => {
|
|
368
|
+
const { page, perPage, offset } = paginate(config.pagination);
|
|
369
|
+
const where = buildWhere(config.filter);
|
|
370
|
+
const [countRows, rows] = await Promise.all([
|
|
371
|
+
sql<{ count: number }[]>`SELECT COUNT(*)::int AS count FROM audit.events WHERE ${where}`,
|
|
372
|
+
sql<DbAuditRow[]>`
|
|
373
|
+
SELECT *
|
|
374
|
+
FROM audit.events
|
|
375
|
+
WHERE ${where}
|
|
376
|
+
ORDER BY created_at DESC, id DESC
|
|
377
|
+
LIMIT ${perPage}
|
|
378
|
+
OFFSET ${offset}
|
|
379
|
+
`,
|
|
380
|
+
]);
|
|
381
|
+
const total = countRows[0]?.count ?? 0;
|
|
382
|
+
return {
|
|
383
|
+
items: rows.map(mapRow),
|
|
384
|
+
page,
|
|
385
|
+
perPage,
|
|
386
|
+
total,
|
|
387
|
+
hasNext: page * perPage < total,
|
|
388
|
+
};
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const listSelfServiceActivity = async (config: {
|
|
392
|
+
userId: string;
|
|
393
|
+
pagination?: PageParams;
|
|
394
|
+
days?: number;
|
|
395
|
+
}): Promise<Paginated<SelfServiceAuditActivity>> => {
|
|
396
|
+
const { page, perPage, offset } = paginate(config.pagination);
|
|
397
|
+
const days = config.days && Number.isFinite(config.days) && config.days > 0 ? Math.min(Math.floor(config.days), 365) : 30;
|
|
398
|
+
const where = sql`
|
|
399
|
+
actor_user_id = ${config.userId}::uuid
|
|
400
|
+
AND action = ANY(${toPgTextArray(SELF_SERVICE_ACTIONS)}::text[])
|
|
401
|
+
AND created_at >= now() - ${`${days} days`}::interval
|
|
402
|
+
`;
|
|
403
|
+
const [countRows, rows] = await Promise.all([
|
|
404
|
+
sql<{ count: number }[]>`SELECT COUNT(*)::int AS count FROM audit.events WHERE ${where}`,
|
|
405
|
+
sql<DbAuditRow[]>`
|
|
406
|
+
SELECT *
|
|
407
|
+
FROM audit.events
|
|
408
|
+
WHERE ${where}
|
|
409
|
+
ORDER BY created_at DESC, id DESC
|
|
410
|
+
LIMIT ${perPage}
|
|
411
|
+
OFFSET ${offset}
|
|
412
|
+
`,
|
|
413
|
+
]);
|
|
414
|
+
const total = countRows[0]?.count ?? 0;
|
|
415
|
+
return {
|
|
416
|
+
items: rows.map(mapSelfServiceActivityRow),
|
|
417
|
+
page,
|
|
418
|
+
perPage,
|
|
419
|
+
total,
|
|
420
|
+
hasNext: page * perPage < total,
|
|
421
|
+
};
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
export const audit = {
|
|
425
|
+
record,
|
|
426
|
+
recordResult,
|
|
427
|
+
recordResultAfterSideEffect,
|
|
428
|
+
deny,
|
|
429
|
+
list,
|
|
430
|
+
listSelfServiceActivity,
|
|
431
|
+
};
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import * as ipa from "./ipa";
|
|
2
2
|
import * as magicLink from "./magic-link";
|
|
3
|
+
import * as passwordReset from "./password-reset";
|
|
4
|
+
import * as proxyReturn from "./proxy-return";
|
|
3
5
|
|
|
4
|
-
export { ipa, magicLink };
|
|
6
|
+
export { ipa, magicLink, passwordReset, proxyReturn };
|
|
5
7
|
|
|
6
|
-
export const authFlows = {
|
|
8
|
+
export const authFlows = {
|
|
9
|
+
ipa,
|
|
10
|
+
magicLink,
|
|
11
|
+
passwordReset,
|
|
12
|
+
proxyReturn,
|
|
13
|
+
} as const;
|
|
@@ -14,7 +14,6 @@ type IpaLoginFailure =
|
|
|
14
14
|
|
|
15
15
|
type IpaLoginSuccess = {
|
|
16
16
|
ok: true;
|
|
17
|
-
ipaSession: string;
|
|
18
17
|
userId: string;
|
|
19
18
|
user: User;
|
|
20
19
|
};
|
|
@@ -103,7 +102,6 @@ export const login = async (params: { username: string; password: string }): Pro
|
|
|
103
102
|
|
|
104
103
|
return {
|
|
105
104
|
ok: true,
|
|
106
|
-
ipaSession: loginResult.session,
|
|
107
105
|
userId: userResult.userId,
|
|
108
106
|
user: userResult.user,
|
|
109
107
|
};
|
|
@@ -5,8 +5,9 @@ import { providers } from "../providers";
|
|
|
5
5
|
import * as settings from "../settings";
|
|
6
6
|
import { renderTemplate } from "../settings/templates";
|
|
7
7
|
import type { User } from "../../contracts/shared";
|
|
8
|
+
import { createAuthLoginUrl } from "../../shared/redirect";
|
|
8
9
|
|
|
9
|
-
export const request = async (params: { email: string }): Promise<
|
|
10
|
+
export const request = async (params: { email: string; redirectTo?: string }): Promise<
|
|
10
11
|
| { ok: true }
|
|
11
12
|
| { ok: false; status: 400; message: string }
|
|
12
13
|
> => {
|
|
@@ -32,7 +33,7 @@ export const request = async (params: { email: string }): Promise<
|
|
|
32
33
|
const token = await providers.local.auth.createMagicLinkToken({ email: params.email, ttlSeconds: 300 });
|
|
33
34
|
const rawAppUrl = await settings.get<string>("app.url");
|
|
34
35
|
const appUrl = rawAppUrl.startsWith("http") ? rawAppUrl : `https://${rawAppUrl}`;
|
|
35
|
-
const magicLink =
|
|
36
|
+
const magicLink = createAuthLoginUrl(appUrl, { token, redirectTo: params.redirectTo });
|
|
36
37
|
|
|
37
38
|
const appName = await settings.get<string>("app.name");
|
|
38
39
|
const template = await settings.get<string>("mail.magic_link_login");
|