@valentinkolb/cloud 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +18 -6
- package/scripts/preload.ts +78 -23
- package/src/_internal/define-app.ts +53 -46
- package/src/api/accounts-entities.ts +4 -0
- package/src/api/admin-core-settings.ts +98 -0
- package/src/api/announcements.ts +131 -0
- package/src/api/auth/schemas.ts +24 -0
- package/src/api/auth.ts +116 -13
- package/src/api/index.ts +7 -2
- package/src/api/me.ts +203 -14
- package/src/api/search/schemas.ts +1 -0
- package/src/api/search.ts +62 -8
- package/src/config/ssr.ts +2 -9
- package/src/contracts/announcements.test.ts +37 -0
- package/src/contracts/announcements.ts +121 -0
- package/src/contracts/app.ts +2 -0
- package/src/contracts/index.ts +3 -2
- package/src/contracts/registry.ts +2 -0
- package/src/contracts/shared.ts +108 -1
- package/src/desktop/index.ts +704 -0
- package/src/desktop/solid.tsx +938 -0
- package/src/server/api/index.ts +1 -1
- package/src/server/api/respond.ts +50 -10
- package/src/server/index.ts +44 -38
- package/src/server/middleware/auth.ts +98 -9
- package/src/server/middleware/index.ts +2 -1
- package/src/server/middleware/settings.ts +26 -0
- package/src/server/services/access.test.ts +197 -0
- package/src/server/services/access.ts +254 -6
- package/src/server/services/index.ts +14 -11
- package/src/server/services/pagination.ts +22 -0
- package/src/server/time.ts +45 -0
- package/src/services/account-lifecycle/index.ts +142 -18
- package/src/services/accounts/app.ts +658 -170
- package/src/services/accounts/authz.test.ts +77 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/entities.ts +84 -5
- package/src/services/accounts/groups.ts +30 -24
- package/src/services/accounts/model.test.ts +30 -0
- package/src/services/accounts/switching.test.ts +14 -0
- package/src/services/accounts/switching.ts +15 -6
- package/src/services/accounts/users.ts +75 -52
- package/src/services/announcements/index.test.ts +32 -0
- package/src/services/announcements/index.ts +224 -0
- package/src/services/audit/index.test.ts +84 -0
- package/src/services/audit/index.ts +431 -0
- package/src/services/auth-flows/index.ts +9 -2
- package/src/services/auth-flows/ipa.ts +47 -7
- package/src/services/auth-flows/magic-link.ts +92 -20
- package/src/services/auth-flows/password-reset.ts +284 -0
- package/src/services/auth-flows/proxy-return.test.ts +24 -0
- package/src/services/auth-flows/proxy-return.ts +49 -0
- package/src/services/gateway.ts +162 -0
- package/src/services/index.ts +44 -2
- package/src/services/ipa/effective-groups.test.ts +33 -0
- package/src/services/ipa/effective-groups.ts +70 -0
- package/src/services/ipa/profile.ts +45 -3
- package/src/services/ipa/search.ts +3 -5
- package/src/services/ipa/service-account.ts +15 -0
- package/src/services/ipa/sync-planning.test.ts +32 -0
- package/src/services/ipa/sync-planning.ts +22 -0
- package/src/services/ipa/sync.ts +110 -38
- package/src/services/notifications/index.ts +82 -11
- package/src/services/oauth-tokens.ts +104 -0
- package/src/services/postgres.ts +21 -6
- package/src/services/providers/local/auth.test.ts +22 -0
- package/src/services/providers/local/auth.ts +46 -3
- package/src/services/secrets.ts +10 -0
- package/src/services/service-account-credentials.test.ts +210 -0
- package/src/services/service-account-credentials.ts +715 -0
- package/src/services/service-accounts.ts +188 -0
- package/src/services/session/index.ts +7 -8
- package/src/services/settings/app.ts +4 -20
- package/src/services/settings/defaults.ts +79 -22
- package/src/services/settings/store.ts +47 -0
- package/src/services/weather/forecast.ts +40 -7
- package/src/services/webauthn.test.ts +36 -0
- package/src/services/webauthn.ts +384 -0
- package/src/shared/icons.ts +391 -100
- package/src/shared/index.ts +7 -0
- package/src/shared/markdown/extensions/code.ts +38 -1
- package/src/shared/markdown/extensions/images.ts +39 -3
- package/src/shared/markdown/extensions/info-blocks.ts +5 -5
- package/src/shared/markdown/extensions/mark.ts +48 -0
- package/src/shared/markdown/extensions/sub-sup.ts +60 -0
- package/src/shared/markdown/extensions/tables.ts +79 -58
- package/src/shared/markdown/formula.test.ts +1089 -0
- package/src/shared/markdown/formula.ts +1187 -0
- package/src/shared/markdown/index.ts +76 -2
- package/src/shared/mock-cover.ts +130 -0
- package/src/shared/redirect.test.ts +58 -0
- package/src/shared/redirect.ts +56 -0
- package/src/shared/theme.test.ts +24 -0
- package/src/shared/theme.ts +68 -0
- package/src/shared/time.ts +13 -0
- package/src/ssr/AdminLayout.tsx +7 -3
- package/src/ssr/AdminSidebar.tsx +115 -49
- package/src/ssr/AppLaunchpad.island.tsx +176 -0
- package/src/ssr/Footer.island.tsx +3 -8
- package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
- package/src/ssr/GlobalSearchDialog.tsx +545 -117
- package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
- package/src/ssr/Layout.tsx +74 -66
- package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
- package/src/ssr/LayoutHelp.tsx +266 -0
- package/src/ssr/NavMenu.island.tsx +0 -39
- package/src/ssr/ThemeToggleRail.island.tsx +3 -3
- package/src/ssr/TimezoneCookie.island.tsx +23 -0
- package/src/ssr/islands/index.ts +13 -0
- package/src/styles/base-popover.css +5 -2
- package/src/styles/effects.css +87 -6
- package/src/styles/global.css +146 -9
- package/src/styles/input.css +3 -1
- package/src/styles/utilities-buttons.css +133 -27
- package/src/styles/utilities-code-display.css +67 -0
- package/src/styles/utilities-completion.css +223 -0
- package/src/styles/utilities-detail.css +73 -0
- package/src/styles/utilities-feedback.css +16 -15
- package/src/styles/utilities-layout.css +42 -2
- package/src/styles/utilities-markdown-editor.css +472 -0
- package/src/styles/utilities-navigation.css +63 -8
- package/src/styles/utilities-script.css +84 -0
- package/src/styles/utilities-table-tile.css +229 -0
- package/src/types/ambient.d.ts +9 -0
- package/src/ui/completion/behaviors.test.ts +95 -0
- package/src/ui/completion/behaviors.ts +205 -0
- package/src/ui/completion/engine.ts +368 -0
- package/src/ui/completion/index.ts +40 -0
- package/src/ui/completion/overlay.ts +92 -0
- package/src/ui/dialog-core.ts +173 -45
- package/src/ui/filter/FilterChip.tsx +42 -40
- package/src/ui/index.ts +11 -12
- package/src/ui/input/AutocompleteEditor.tsx +656 -0
- package/src/ui/input/CheckboxCard.tsx +91 -0
- package/src/ui/input/Combobox.tsx +375 -0
- package/src/ui/input/DatePicker.tsx +846 -0
- package/src/ui/input/DateTimeInput.tsx +29 -4
- package/src/ui/input/FileDropzone.tsx +116 -0
- package/src/ui/input/IconInput.tsx +116 -0
- package/src/ui/input/ImageInput.tsx +19 -2
- package/src/ui/input/MultiSelectInput.tsx +448 -0
- package/src/ui/input/NumberInput.tsx +417 -61
- package/src/ui/input/SegmentedControl.tsx +2 -2
- package/src/ui/input/Select.tsx +172 -10
- package/src/ui/input/Slider.tsx +3 -4
- package/src/ui/input/Switch.tsx +3 -2
- package/src/ui/input/TemplateEditor.tsx +212 -0
- package/src/ui/input/TextInput.tsx +144 -13
- package/src/ui/input/index.ts +53 -8
- package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
- package/src/ui/input/markdown/Toolbar.tsx +90 -0
- package/src/ui/input/markdown/actions.ts +233 -0
- package/src/ui/input/markdown/active-formats.ts +94 -0
- package/src/ui/input/markdown/behaviors.ts +193 -0
- package/src/ui/input/markdown/code-zone.ts +23 -0
- package/src/ui/input/markdown/highlight.ts +316 -0
- package/src/ui/layout.ts +22 -0
- package/src/ui/misc/AppOverview.tsx +105 -0
- package/src/ui/misc/AppWorkspace.tsx +607 -0
- package/src/ui/misc/Calendar.tsx +1291 -0
- package/src/ui/misc/Chart.tsx +162 -0
- package/src/ui/misc/CodeDisplay.tsx +54 -0
- package/src/ui/misc/ContextMenu.tsx +2 -2
- package/src/ui/misc/DataTable.tsx +269 -0
- package/src/ui/misc/DockWorkspace.tsx +425 -0
- package/src/ui/misc/Docs.tsx +153 -0
- package/src/ui/misc/Dropdown.tsx +2 -2
- package/src/ui/misc/EntitySearch.tsx +260 -129
- package/src/ui/misc/LinkCard.tsx +14 -2
- package/src/ui/misc/LogEntriesTable.tsx +34 -31
- package/src/ui/misc/Pagination.tsx +31 -12
- package/src/ui/misc/PanelDialog.tsx +109 -0
- package/src/ui/misc/Panes.tsx +873 -0
- package/src/ui/misc/PermissionEditor.tsx +358 -262
- package/src/ui/misc/Placeholder.tsx +40 -0
- package/src/ui/misc/ProgressBar.tsx +1 -1
- package/src/ui/misc/ResourceApiKeys.tsx +260 -0
- package/src/ui/misc/SettingsModal.tsx +150 -0
- package/src/ui/misc/StatCell.tsx +182 -40
- package/src/ui/misc/StatGrid.tsx +149 -0
- package/src/ui/misc/StructuredDataPreview.tsx +107 -0
- package/src/ui/misc/code-highlight.ts +213 -0
- package/src/ui/misc/index.ts +93 -12
- package/src/ui/prompts.tsx +362 -312
- package/src/ui/toast.ts +384 -0
- package/src/ui/widgets/Widget.tsx +12 -4
- package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
- package/src/ui/ipa/GroupView.tsx +0 -36
- package/src/ui/ipa/LoginBtn.tsx +0 -16
- package/src/ui/ipa/UserView.tsx +0 -58
- package/src/ui/ipa/index.ts +0 -4
- package/src/ui/navigation.ts +0 -32
- package/src/ui/sidebar.tsx +0 -468
- /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
|
@@ -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;
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { sql } from "bun";
|
|
2
2
|
import { accounts } from "../accounts";
|
|
3
|
+
import { logger } from "../logging";
|
|
3
4
|
import { providers } from "../providers";
|
|
4
5
|
import type { User } from "../../contracts/shared";
|
|
5
6
|
|
|
6
7
|
type IpaLoginFailure =
|
|
7
|
-
| { ok: false; status: 401; reason: "password_expired"; message: string }
|
|
8
|
+
| { ok: false; status: 401; reason: "password_expired"; message: string; uid: string }
|
|
8
9
|
| { ok: false; status: 401; reason: "invalid_credentials"; message: string }
|
|
9
10
|
| { ok: false; status: 400; reason: "user_not_synced"; message: string }
|
|
10
11
|
| { ok: false; status: 400; reason: "user_not_found"; message: string }
|
|
@@ -14,13 +15,48 @@ type IpaLoginFailure =
|
|
|
14
15
|
|
|
15
16
|
type IpaLoginSuccess = {
|
|
16
17
|
ok: true;
|
|
17
|
-
ipaSession: string;
|
|
18
18
|
userId: string;
|
|
19
19
|
user: User;
|
|
20
20
|
};
|
|
21
21
|
|
|
22
22
|
export type IpaLoginFlowResult = IpaLoginSuccess | IpaLoginFailure;
|
|
23
23
|
|
|
24
|
+
const log = logger("auth:ipa");
|
|
25
|
+
const DUMMY_LOGIN_UID = "__cloud_invalid_ipa_email_login__";
|
|
26
|
+
|
|
27
|
+
const normalizeEmail = (value: string): string => value.trim().toLowerCase();
|
|
28
|
+
|
|
29
|
+
const resolveIpaLoginUid = async (identifier: string): Promise<string | null> => {
|
|
30
|
+
const trimmed = identifier.trim();
|
|
31
|
+
if (!trimmed) return null;
|
|
32
|
+
if (!trimmed.includes("@")) return trimmed;
|
|
33
|
+
|
|
34
|
+
const rows = await sql<{ uid: string }[]>`
|
|
35
|
+
SELECT uid
|
|
36
|
+
FROM auth.users
|
|
37
|
+
WHERE provider = 'ipa'
|
|
38
|
+
AND lower(btrim(mail)) = ${normalizeEmail(trimmed)}
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
if (rows.length !== 1) {
|
|
42
|
+
if (rows.length > 1) {
|
|
43
|
+
log.warn("FreeIPA email login skipped: ambiguous email", {
|
|
44
|
+
email: normalizeEmail(trimmed),
|
|
45
|
+
matches: rows.length,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
return rows[0]!.uid;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const failInvalidCredentials = async (params: { identifier: string; password: string }): Promise<IpaLoginFailure> => {
|
|
54
|
+
if (params.identifier.trim().includes("@")) {
|
|
55
|
+
await providers.ipa.auth.login(DUMMY_LOGIN_UID, params.password).catch(() => undefined);
|
|
56
|
+
}
|
|
57
|
+
return { ok: false, status: 401, reason: "invalid_credentials", message: "Invalid username or password" };
|
|
58
|
+
};
|
|
59
|
+
|
|
24
60
|
const loadSyncedIpaUser = async (uid: string): Promise<{ ok: true; userId: string; user: User } | IpaLoginFailure> => {
|
|
25
61
|
const userRows = await sql`
|
|
26
62
|
SELECT id FROM auth.users
|
|
@@ -51,9 +87,14 @@ const loadSyncedIpaUser = async (uid: string): Promise<{ ok: true; userId: strin
|
|
|
51
87
|
};
|
|
52
88
|
|
|
53
89
|
export const login = async (params: { username: string; password: string }): Promise<IpaLoginFlowResult> => {
|
|
54
|
-
const
|
|
90
|
+
const uid = await resolveIpaLoginUid(params.username);
|
|
91
|
+
if (!uid) {
|
|
92
|
+
return failInvalidCredentials({ identifier: params.username, password: params.password });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const loginResult = await providers.ipa.auth.login(uid, params.password);
|
|
55
96
|
if (loginResult.status === "password_expired") {
|
|
56
|
-
return { ok: false, status: 401, reason: "password_expired", message: "Password expired" };
|
|
97
|
+
return { ok: false, status: 401, reason: "password_expired", message: "Password expired", uid };
|
|
57
98
|
}
|
|
58
99
|
if (loginResult.status !== "success") {
|
|
59
100
|
return { ok: false, status: 401, reason: "invalid_credentials", message: "Invalid username or password" };
|
|
@@ -62,7 +103,7 @@ export const login = async (params: { username: string; password: string }): Pro
|
|
|
62
103
|
// Must reach a "synced" outcome before granting a session. Stale mirror rows
|
|
63
104
|
// (expired remotely, dropped from sync scope, or fetch failures) must never
|
|
64
105
|
// grant a fresh local session on the back of successful FreeIPA credentials.
|
|
65
|
-
const syncOutcome = await providers.ipa.sync.user(
|
|
106
|
+
const syncOutcome = await providers.ipa.sync.user(uid);
|
|
66
107
|
switch (syncOutcome.status) {
|
|
67
108
|
case "synced":
|
|
68
109
|
break;
|
|
@@ -98,12 +139,11 @@ export const login = async (params: { username: string; password: string }): Pro
|
|
|
98
139
|
};
|
|
99
140
|
}
|
|
100
141
|
|
|
101
|
-
const userResult = await loadSyncedIpaUser(
|
|
142
|
+
const userResult = await loadSyncedIpaUser(uid);
|
|
102
143
|
if (!userResult.ok) return userResult;
|
|
103
144
|
|
|
104
145
|
return {
|
|
105
146
|
ok: true,
|
|
106
|
-
ipaSession: loginResult.session,
|
|
107
147
|
userId: userResult.userId,
|
|
108
148
|
user: userResult.user,
|
|
109
149
|
};
|