@valentinkolb/cloud 0.1.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 +69 -0
- package/public/logo.svg +1 -0
- package/scripts/build.ts +113 -0
- package/scripts/preload.ts +73 -0
- package/src/_internal/define-app.ts +399 -0
- package/src/_internal/heartbeat.ts +33 -0
- package/src/_internal/registry.ts +100 -0
- package/src/_internal/runtime-context.ts +38 -0
- package/src/api/accounts-entities.ts +134 -0
- package/src/api/admin-lifecycle.ts +210 -0
- package/src/api/auth/schemas.ts +28 -0
- package/src/api/auth.ts +230 -0
- package/src/api/index.ts +66 -0
- package/src/api/me.ts +206 -0
- package/src/api/search/schemas.ts +43 -0
- package/src/api/search.ts +130 -0
- package/src/clients/core.ts +19 -0
- package/src/config/env.ts +23 -0
- package/src/config/index.ts +6 -0
- package/src/config/ssr.ts +58 -0
- package/src/contracts/app.ts +140 -0
- package/src/contracts/index.ts +5 -0
- package/src/contracts/profile.ts +67 -0
- package/src/contracts/registry.ts +50 -0
- package/src/contracts/settings-types.ts +84 -0
- package/src/contracts/shared.ts +258 -0
- package/src/contracts/widgets.ts +121 -0
- package/src/index.ts +6 -0
- package/src/server/api/index.ts +1 -0
- package/src/server/api/respond.ts +55 -0
- package/src/server/api-client.ts +54 -0
- package/src/server/app-context.ts +39 -0
- package/src/server/index.ts +62 -0
- package/src/server/middleware/auth.ts +168 -0
- package/src/server/middleware/index.ts +7 -0
- package/src/server/middleware/middleware.ts +47 -0
- package/src/server/middleware/openapi.ts +126 -0
- package/src/server/middleware/rate-limit.ts +126 -0
- package/src/server/middleware/request-logger.ts +41 -0
- package/src/server/middleware/validator.ts +35 -0
- package/src/server/services/access.ts +294 -0
- package/src/server/services/freeipa/client.ts +100 -0
- package/src/server/services/freeipa/index.ts +9 -0
- package/src/server/services/freeipa/session.ts +78 -0
- package/src/server/services/freeipa/tls.ts +48 -0
- package/src/server/services/freeipa/util.ts +60 -0
- package/src/server/services/geo.ts +154 -0
- package/src/server/services/index.ts +28 -0
- package/src/server/services/services.ts +13 -0
- package/src/services/account-lifecycle/audit.ts +41 -0
- package/src/services/account-lifecycle/index.ts +907 -0
- package/src/services/account-lifecycle/scheduler.ts +347 -0
- package/src/services/account-model.ts +21 -0
- package/src/services/accounts/app.ts +966 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/base-group.ts +11 -0
- package/src/services/accounts/base-user.ts +45 -0
- package/src/services/accounts/entities.ts +529 -0
- package/src/services/accounts/group-sql.ts +106 -0
- package/src/services/accounts/groups.ts +246 -0
- package/src/services/accounts/index.ts +14 -0
- package/src/services/accounts/ipa-data.ts +64 -0
- package/src/services/accounts/lifecycle.ts +2 -0
- package/src/services/accounts/local-groups.ts +491 -0
- package/src/services/accounts/model.ts +135 -0
- package/src/services/accounts/switching.ts +117 -0
- package/src/services/accounts/users.ts +714 -0
- package/src/services/auth-flows/index.ts +6 -0
- package/src/services/auth-flows/ipa.ts +128 -0
- package/src/services/auth-flows/magic-link.ts +119 -0
- package/src/services/freeipa-config.ts +89 -0
- package/src/services/index.ts +46 -0
- package/src/services/ipa/auth.ts +122 -0
- package/src/services/ipa/groups.ts +684 -0
- package/src/services/ipa/guard.ts +17 -0
- package/src/services/ipa/index.ts +17 -0
- package/src/services/ipa/profile.ts +90 -0
- package/src/services/ipa/search.ts +154 -0
- package/src/services/ipa/sync.ts +740 -0
- package/src/services/ipa/users.ts +794 -0
- package/src/services/logging/index.ts +294 -0
- package/src/services/notifications/email.ts +123 -0
- package/src/services/notifications/index.ts +413 -0
- package/src/services/postgres.ts +51 -0
- package/src/services/providers/index.ts +27 -0
- package/src/services/providers/local/auth.ts +13 -0
- package/src/services/providers/local/index.ts +4 -0
- package/src/services/providers/local/users.ts +255 -0
- package/src/services/session/index.ts +137 -0
- package/src/services/settings/api.ts +61 -0
- package/src/services/settings/app.ts +101 -0
- package/src/services/settings/crypto.ts +69 -0
- package/src/services/settings/defaults.ts +824 -0
- package/src/services/settings/index.ts +203 -0
- package/src/services/settings/namespace.ts +9 -0
- package/src/services/settings/snapshot.ts +49 -0
- package/src/services/settings/store.ts +179 -0
- package/src/services/settings/templates.ts +10 -0
- package/src/services/weather/forecast.ts +287 -0
- package/src/services/weather/geo.ts +110 -0
- package/src/services/weather/index.ts +99 -0
- package/src/services/weather/location.ts +24 -0
- package/src/services/weather/locations.ts +125 -0
- package/src/services/weather/migrate.ts +22 -0
- package/src/services/weather/types.ts +61 -0
- package/src/services/weather/ui.ts +50 -0
- package/src/shared/account-display.ts +17 -0
- package/src/shared/account-session.ts +15 -0
- package/src/shared/icons.ts +109 -0
- package/src/shared/index.ts +10 -0
- package/src/shared/markdown/client.ts +130 -0
- package/src/shared/markdown/extensions/code.ts +58 -0
- package/src/shared/markdown/extensions/images.ts +43 -0
- package/src/shared/markdown/extensions/info-blocks.ts +93 -0
- package/src/shared/markdown/extensions/katex.ts +120 -0
- package/src/shared/markdown/extensions/links.ts +34 -0
- package/src/shared/markdown/extensions/tables.ts +88 -0
- package/src/shared/markdown/extensions/task-list.ts +53 -0
- package/src/shared/markdown/index.ts +97 -0
- package/src/shared/markdown/shared.ts +36 -0
- package/src/ssr/AdminLayout.tsx +42 -0
- package/src/ssr/AdminSidebar.tsx +95 -0
- package/src/ssr/Footer.island.tsx +62 -0
- package/src/ssr/GlobalSearchDialog.tsx +389 -0
- package/src/ssr/GlobalSearchHelpDialog.tsx +106 -0
- package/src/ssr/GlobalSearchTrigger.island.tsx +42 -0
- package/src/ssr/HotkeysHelpRail.island.tsx +99 -0
- package/src/ssr/Layout.tsx +326 -0
- package/src/ssr/MoreAppsDropdown.island.tsx +61 -0
- package/src/ssr/NavMenu.island.tsx +108 -0
- package/src/ssr/ThemeToggleRail.island.tsx +27 -0
- package/src/ssr/index.ts +5 -0
- package/src/ssr/islands/SearchBar.island.tsx +77 -0
- package/src/ssr/islands/index.ts +1 -0
- package/src/ssr/runtime.ts +22 -0
- package/src/styles/base-popover.css +28 -0
- package/src/styles/effects.css +65 -0
- package/src/styles/global.css +133 -0
- package/src/styles/input.css +54 -0
- package/src/styles/tokens.css +35 -0
- package/src/styles/utilities-buttons.css +125 -0
- package/src/styles/utilities-feedback.css +65 -0
- package/src/styles/utilities-layout.css +122 -0
- package/src/styles/utilities-navigation.css +196 -0
- package/src/types/ambient.d.ts +8 -0
- package/src/ui/admin-settings.tsx +148 -0
- package/src/ui/dialog-core.ts +146 -0
- package/src/ui/filter/FilterChip.tsx +196 -0
- package/src/ui/filter/index.ts +2 -0
- package/src/ui/index.ts +19 -0
- package/src/ui/input/Checkbox.tsx +55 -0
- package/src/ui/input/ColorInput.tsx +122 -0
- package/src/ui/input/DateTimeInput.tsx +86 -0
- package/src/ui/input/ImageInput.tsx +170 -0
- package/src/ui/input/NumberInput.tsx +113 -0
- package/src/ui/input/PinInput.tsx +169 -0
- package/src/ui/input/SegmentedControl.tsx +99 -0
- package/src/ui/input/Select.tsx +288 -0
- package/src/ui/input/SelectChip.tsx +61 -0
- package/src/ui/input/Slider.tsx +118 -0
- package/src/ui/input/Switch.tsx +62 -0
- package/src/ui/input/TagsInput.tsx +115 -0
- package/src/ui/input/TextInput.tsx +160 -0
- package/src/ui/input/index.ts +13 -0
- package/src/ui/input/types.ts +42 -0
- package/src/ui/input/util.tsx +105 -0
- package/src/ui/ipa/Avatar.tsx +28 -0
- package/src/ui/ipa/GroupView.tsx +36 -0
- package/src/ui/ipa/LoginBtn.tsx +16 -0
- package/src/ui/ipa/UserView.tsx +58 -0
- package/src/ui/ipa/index.ts +4 -0
- package/src/ui/misc/ContextMenu.tsx +211 -0
- package/src/ui/misc/CopyButton.tsx +28 -0
- package/src/ui/misc/Dropdown.tsx +194 -0
- package/src/ui/misc/EntitySearch.tsx +213 -0
- package/src/ui/misc/Lightbox.tsx +194 -0
- package/src/ui/misc/LinkCard.tsx +34 -0
- package/src/ui/misc/LogEntriesTable.tsx +61 -0
- package/src/ui/misc/MarkdownView.tsx +65 -0
- package/src/ui/misc/Pagination.tsx +51 -0
- package/src/ui/misc/PermissionEditor.tsx +379 -0
- package/src/ui/misc/ProgressBar.tsx +47 -0
- package/src/ui/misc/RemoveBtn.tsx +27 -0
- package/src/ui/misc/StatCell.tsx +90 -0
- package/src/ui/misc/index.ts +18 -0
- package/src/ui/navigation.ts +32 -0
- package/src/ui/prompts.tsx +854 -0
- package/src/ui/sidebar.tsx +468 -0
- package/src/ui/widgets/Widget.tsx +62 -0
- package/src/ui/widgets/WidgetCard.tsx +19 -0
- package/src/ui/widgets/WidgetHero.tsx +39 -0
- package/src/ui/widgets/WidgetList.tsx +84 -0
- package/src/ui/widgets/WidgetPills.tsx +68 -0
- package/src/ui/widgets/WidgetStat.tsx +67 -0
- package/src/ui/widgets/WidgetStatus.tsx +62 -0
- package/src/ui/widgets/index.ts +9 -0
|
@@ -0,0 +1,907 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
2
|
+
import type { User } from "../../contracts/shared";
|
|
3
|
+
import { logger } from "../logging";
|
|
4
|
+
import { notifications } from "../notifications";
|
|
5
|
+
import { applyIpaAccountTransitionPolicy } from "../accounts/switching";
|
|
6
|
+
import { get as getSetting } from "../settings";
|
|
7
|
+
import { renderTemplate } from "../settings/templates";
|
|
8
|
+
import { session } from "../session";
|
|
9
|
+
import { getConfiguredExpiryDays, parseIpaAccountTransitionPolicy } from "../account-model";
|
|
10
|
+
import { getFreeIpaConfig } from "../freeipa-config";
|
|
11
|
+
import { parsePgJsonRecord } from "../postgres";
|
|
12
|
+
import { dates } from "../../shared";
|
|
13
|
+
import { freeipa } from "../../server/services";
|
|
14
|
+
import { writeDeletedAccountAudit } from "./audit";
|
|
15
|
+
import { getIpaUrl } from "../ipa/guard";
|
|
16
|
+
|
|
17
|
+
const log = logger("auth:lifecycle");
|
|
18
|
+
|
|
19
|
+
type DbRow = Record<string, unknown>;
|
|
20
|
+
|
|
21
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
22
|
+
|
|
23
|
+
type ReminderKind = "account_expiry";
|
|
24
|
+
|
|
25
|
+
type ReminderCandidate = {
|
|
26
|
+
userId: string;
|
|
27
|
+
uid: string;
|
|
28
|
+
mail: string;
|
|
29
|
+
givenName: string;
|
|
30
|
+
displayName: string;
|
|
31
|
+
expiresAt: Date;
|
|
32
|
+
kind: ReminderKind;
|
|
33
|
+
accountKind: "ipa" | "local-user" | "local-guest";
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type LifecycleSummary = {
|
|
37
|
+
scanned: number;
|
|
38
|
+
changed: number;
|
|
39
|
+
skipped: number;
|
|
40
|
+
failed: number;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const settingInt = async (key: string, fallback: number): Promise<number> => {
|
|
44
|
+
const raw = await getSetting<number | string | null>(key);
|
|
45
|
+
const value = typeof raw === "number" ? raw : Number(raw);
|
|
46
|
+
return Number.isFinite(value) ? value : fallback;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const getIpaExpiresDays = async (): Promise<number> => getConfiguredExpiryDays("ipa", "user");
|
|
50
|
+
|
|
51
|
+
const getLocalUserExpiresDays = async (): Promise<number> => getConfiguredExpiryDays("local", "user");
|
|
52
|
+
const getGuestExpiresDays = async (): Promise<number> => {
|
|
53
|
+
return getConfiguredExpiryDays("local", "guest");
|
|
54
|
+
};
|
|
55
|
+
const getDeletedAccountsRetentionDays = async (): Promise<number> => settingInt("user.account.deleted_accounts_retention_days", 365);
|
|
56
|
+
const getReminderHistoryRetentionDays = async (): Promise<number> => settingInt("user.account.reminder_history_retention_days", 365);
|
|
57
|
+
|
|
58
|
+
const parseReminderDays = async (): Promise<number[]> => {
|
|
59
|
+
const raw = await getSetting<number[]>("user.account.reminder_days");
|
|
60
|
+
const parsed = Array.isArray(raw) ? raw.filter((entry) => Number.isInteger(entry) && entry > 0) : [];
|
|
61
|
+
return [...new Set(parsed)].sort((a, b) => b - a);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const upsertReminderAttempt = async (config: {
|
|
65
|
+
userId: string;
|
|
66
|
+
uid: string;
|
|
67
|
+
mail: string;
|
|
68
|
+
displayName: string;
|
|
69
|
+
kind: ReminderKind;
|
|
70
|
+
thresholdDays: number;
|
|
71
|
+
targetExpiryAt: Date;
|
|
72
|
+
}): Promise<{ id: string; status: "pending" | "sent" | "error" }> => {
|
|
73
|
+
const rows = await sql<DbRow[]>`
|
|
74
|
+
INSERT INTO auth.account_lifecycle_reminders (
|
|
75
|
+
user_id, uid, mail, display_name, kind, threshold_days, target_expiry_at, status, attempt_count, created_at
|
|
76
|
+
)
|
|
77
|
+
VALUES (
|
|
78
|
+
${config.userId}::uuid, ${config.uid}, ${config.mail}, ${config.displayName}, ${config.kind}, ${config.thresholdDays}, ${config.targetExpiryAt}, 'pending', 0, now()
|
|
79
|
+
)
|
|
80
|
+
ON CONFLICT (user_id, kind, threshold_days, target_expiry_at) WHERE user_id IS NOT NULL DO UPDATE
|
|
81
|
+
SET uid = EXCLUDED.uid,
|
|
82
|
+
mail = EXCLUDED.mail,
|
|
83
|
+
display_name = EXCLUDED.display_name,
|
|
84
|
+
status = CASE WHEN auth.account_lifecycle_reminders.status = 'sent' THEN 'sent' ELSE 'pending' END
|
|
85
|
+
RETURNING id, status
|
|
86
|
+
`;
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
id: rows[0]!.id as string,
|
|
90
|
+
status: rows[0]!.status as "pending" | "sent" | "error",
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const markReminderSuccess = async (id: string): Promise<void> => {
|
|
95
|
+
await sql`
|
|
96
|
+
UPDATE auth.account_lifecycle_reminders
|
|
97
|
+
SET status = 'sent',
|
|
98
|
+
attempt_count = attempt_count + 1,
|
|
99
|
+
last_attempt_at = now(),
|
|
100
|
+
sent_at = now(),
|
|
101
|
+
last_error = NULL
|
|
102
|
+
WHERE id = ${id}::uuid
|
|
103
|
+
`;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const markReminderError = async (id: string, error: string): Promise<void> => {
|
|
107
|
+
await sql`
|
|
108
|
+
UPDATE auth.account_lifecycle_reminders
|
|
109
|
+
SET status = 'error',
|
|
110
|
+
attempt_count = attempt_count + 1,
|
|
111
|
+
last_attempt_at = now(),
|
|
112
|
+
last_error = ${error}
|
|
113
|
+
WHERE id = ${id}::uuid
|
|
114
|
+
`;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const deleteFromFreeIpa = async (ipaSession: string, uid: string): Promise<{ ok: true } | { ok: false; error: string }> => {
|
|
118
|
+
const response = await freeipa.client.call({ url: await getIpaUrl(), ipaSession, method: "user_del", args: [uid], options: {} });
|
|
119
|
+
if (!response.error) return { ok: true };
|
|
120
|
+
|
|
121
|
+
const message = (response.error.message ?? "").toLowerCase();
|
|
122
|
+
const isNotFound = message.includes("not found") || message.includes("does not exist");
|
|
123
|
+
if (isNotFound) return { ok: true };
|
|
124
|
+
|
|
125
|
+
return { ok: false, error: response.error.message };
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const resolveExtendUrl = async (): Promise<string> => {
|
|
129
|
+
const appUrl = await getSetting<string>("app.url");
|
|
130
|
+
const base = appUrl && appUrl.length > 0 ? appUrl : "";
|
|
131
|
+
if (base.startsWith("http://") || base.startsWith("https://")) return `${base.replace(/\/+$/, "")}/auth/extend`;
|
|
132
|
+
if (base.length > 0) return `https://${base.replace(/\/+$/, "")}/auth/extend`;
|
|
133
|
+
return "/auth/extend";
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const listReminderCandidates = async (thresholdDays: number): Promise<ReminderCandidate[]> => {
|
|
137
|
+
const rows = await sql<DbRow[]>`
|
|
138
|
+
SELECT id,
|
|
139
|
+
uid,
|
|
140
|
+
mail,
|
|
141
|
+
given_name,
|
|
142
|
+
display_name,
|
|
143
|
+
account_expires AS expires_at,
|
|
144
|
+
'account_expiry'::text AS kind,
|
|
145
|
+
'ipa'::text AS account_kind
|
|
146
|
+
FROM auth.users
|
|
147
|
+
WHERE provider = 'ipa'
|
|
148
|
+
AND mail IS NOT NULL
|
|
149
|
+
AND account_expires IS NOT NULL
|
|
150
|
+
AND now() >= account_expires - (${thresholdDays} * interval '1 day')
|
|
151
|
+
AND now() < account_expires
|
|
152
|
+
|
|
153
|
+
UNION ALL
|
|
154
|
+
|
|
155
|
+
SELECT id,
|
|
156
|
+
uid,
|
|
157
|
+
mail,
|
|
158
|
+
given_name,
|
|
159
|
+
display_name,
|
|
160
|
+
account_expires AS expires_at,
|
|
161
|
+
'account_expiry'::text AS kind,
|
|
162
|
+
'local-guest'::text AS account_kind
|
|
163
|
+
FROM auth.users
|
|
164
|
+
WHERE provider = 'local'
|
|
165
|
+
AND profile = 'guest'
|
|
166
|
+
AND mail IS NOT NULL
|
|
167
|
+
AND account_expires IS NOT NULL
|
|
168
|
+
AND now() >= account_expires - (${thresholdDays} * interval '1 day')
|
|
169
|
+
AND now() < account_expires
|
|
170
|
+
|
|
171
|
+
UNION ALL
|
|
172
|
+
|
|
173
|
+
SELECT id,
|
|
174
|
+
uid,
|
|
175
|
+
mail,
|
|
176
|
+
given_name,
|
|
177
|
+
display_name,
|
|
178
|
+
account_expires AS expires_at,
|
|
179
|
+
'account_expiry'::text AS kind,
|
|
180
|
+
'local-user'::text AS account_kind
|
|
181
|
+
FROM auth.users
|
|
182
|
+
WHERE provider = 'local'
|
|
183
|
+
AND profile = 'user'
|
|
184
|
+
AND mail IS NOT NULL
|
|
185
|
+
AND account_expires IS NOT NULL
|
|
186
|
+
AND now() >= account_expires - (${thresholdDays} * interval '1 day')
|
|
187
|
+
AND now() < account_expires
|
|
188
|
+
`;
|
|
189
|
+
|
|
190
|
+
return rows.map(
|
|
191
|
+
(row): ReminderCandidate => ({
|
|
192
|
+
userId: row.id as string,
|
|
193
|
+
uid: row.uid as string,
|
|
194
|
+
mail: row.mail as string,
|
|
195
|
+
givenName: ((row.given_name as string) || "").trim(),
|
|
196
|
+
displayName: ((row.display_name as string) || "").trim(),
|
|
197
|
+
expiresAt: row.expires_at as Date,
|
|
198
|
+
kind: row.kind as ReminderKind,
|
|
199
|
+
accountKind: row.account_kind as ReminderCandidate["accountKind"],
|
|
200
|
+
}),
|
|
201
|
+
);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
export const accountLifecycle = {
|
|
205
|
+
demoteExpiredIpaUsers: async (): Promise<LifecycleSummary> => {
|
|
206
|
+
const freeIpaConfig = (await getFreeIpaConfig());
|
|
207
|
+
if (!freeIpaConfig.enabled) {
|
|
208
|
+
log.info("Expired IPA demotion skipped", { reason: "freeipa_disabled" });
|
|
209
|
+
return { scanned: 0, changed: 0, skipped: 0, failed: 0 };
|
|
210
|
+
}
|
|
211
|
+
if (!freeIpaConfig.configured) {
|
|
212
|
+
throw new Error("FreeIPA is enabled but not fully configured.");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const rows = await sql<DbRow[]>`
|
|
216
|
+
SELECT id, uid, mail, display_name, profile, account_expires
|
|
217
|
+
FROM auth.users
|
|
218
|
+
WHERE provider = 'ipa'
|
|
219
|
+
AND account_expires IS NOT NULL
|
|
220
|
+
AND account_expires <= now()
|
|
221
|
+
ORDER BY account_expires ASC
|
|
222
|
+
`;
|
|
223
|
+
|
|
224
|
+
const transitionPolicy = parseIpaAccountTransitionPolicy(
|
|
225
|
+
await getSetting<string | null>("freeipa.account_transition_policy"),
|
|
226
|
+
);
|
|
227
|
+
const ipaSession = await freeipa.session.getServiceSession({
|
|
228
|
+
url: freeIpaConfig.url,
|
|
229
|
+
serviceUser: freeIpaConfig.serviceUser,
|
|
230
|
+
servicePassword: freeIpaConfig.servicePassword,
|
|
231
|
+
});
|
|
232
|
+
const summary: LifecycleSummary = {
|
|
233
|
+
scanned: rows.length,
|
|
234
|
+
changed: 0,
|
|
235
|
+
skipped: 0,
|
|
236
|
+
failed: 0,
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
for (const row of rows) {
|
|
240
|
+
const userId = row.id as string;
|
|
241
|
+
const uid = row.uid as string;
|
|
242
|
+
const previousProfile = (row.profile as User["profile"] | null) ?? "guest";
|
|
243
|
+
const ipaDelete = await deleteFromFreeIpa(ipaSession, uid);
|
|
244
|
+
if (!ipaDelete.ok) {
|
|
245
|
+
summary.failed += 1;
|
|
246
|
+
log.error("Failed to delete expired IPA account", { uid, userId, error: ipaDelete.error });
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
if (transitionPolicy === "delete") {
|
|
252
|
+
await sql.begin(async (tx) => {
|
|
253
|
+
await writeDeletedAccountAudit({
|
|
254
|
+
db: tx,
|
|
255
|
+
userId,
|
|
256
|
+
uid,
|
|
257
|
+
mail: (row.mail as string) ?? null,
|
|
258
|
+
displayName: (row.display_name as string) ?? null,
|
|
259
|
+
previousProvider: "ipa",
|
|
260
|
+
previousProfile,
|
|
261
|
+
reason: "ipa_expired_deleted",
|
|
262
|
+
meta: {
|
|
263
|
+
reason: "ipa_account_expired",
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
await tx`DELETE FROM auth.users WHERE id = ${userId}::uuid`;
|
|
267
|
+
});
|
|
268
|
+
} else {
|
|
269
|
+
await sql.begin(async (tx) => {
|
|
270
|
+
const target = await applyIpaAccountTransitionPolicy({
|
|
271
|
+
userId,
|
|
272
|
+
currentProfile: previousProfile,
|
|
273
|
+
policy: transitionPolicy,
|
|
274
|
+
db: tx,
|
|
275
|
+
});
|
|
276
|
+
await writeDeletedAccountAudit({
|
|
277
|
+
db: tx,
|
|
278
|
+
userId,
|
|
279
|
+
uid,
|
|
280
|
+
mail: (row.mail as string) ?? null,
|
|
281
|
+
displayName: (row.display_name as string) ?? null,
|
|
282
|
+
previousProvider: "ipa",
|
|
283
|
+
previousProfile,
|
|
284
|
+
reason: "ipa_expired_demoted",
|
|
285
|
+
meta: {
|
|
286
|
+
accountExpiresAt: target.accountExpires?.toISOString() ?? null,
|
|
287
|
+
targetProfile: target.targetProfile,
|
|
288
|
+
policy: transitionPolicy,
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
await session.revokeAllForUser(userId);
|
|
294
|
+
summary.changed += 1;
|
|
295
|
+
} catch (error) {
|
|
296
|
+
summary.failed += 1;
|
|
297
|
+
log.error("Failed to demote expired IPA account", {
|
|
298
|
+
uid,
|
|
299
|
+
userId,
|
|
300
|
+
error: error instanceof Error ? error.message : String(error),
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return summary;
|
|
306
|
+
},
|
|
307
|
+
|
|
308
|
+
cleanupExpiredGuests: async (): Promise<LifecycleSummary> => {
|
|
309
|
+
const rows = await sql<DbRow[]>`
|
|
310
|
+
SELECT id, uid, mail, display_name
|
|
311
|
+
FROM auth.users
|
|
312
|
+
WHERE provider = 'local'
|
|
313
|
+
AND profile = 'guest'
|
|
314
|
+
AND account_expires IS NOT NULL
|
|
315
|
+
AND account_expires <= now()
|
|
316
|
+
ORDER BY account_expires ASC
|
|
317
|
+
`;
|
|
318
|
+
|
|
319
|
+
const summary: LifecycleSummary = {
|
|
320
|
+
scanned: rows.length,
|
|
321
|
+
changed: 0,
|
|
322
|
+
skipped: 0,
|
|
323
|
+
failed: 0,
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
for (const row of rows) {
|
|
327
|
+
const userId = row.id as string;
|
|
328
|
+
const uid = row.uid as string;
|
|
329
|
+
try {
|
|
330
|
+
await sql.begin(async (tx) => {
|
|
331
|
+
await writeDeletedAccountAudit({
|
|
332
|
+
db: tx,
|
|
333
|
+
userId,
|
|
334
|
+
uid,
|
|
335
|
+
mail: (row.mail as string) ?? null,
|
|
336
|
+
displayName: (row.display_name as string) ?? null,
|
|
337
|
+
previousProvider: "local",
|
|
338
|
+
previousProfile: "guest",
|
|
339
|
+
reason: "guest_expired_deleted",
|
|
340
|
+
});
|
|
341
|
+
await tx`DELETE FROM auth.users WHERE id = ${userId}::uuid`;
|
|
342
|
+
});
|
|
343
|
+
await session.revokeAllForUser(userId);
|
|
344
|
+
summary.changed += 1;
|
|
345
|
+
} catch (error) {
|
|
346
|
+
summary.failed += 1;
|
|
347
|
+
log.error("Failed to delete expired guest account", {
|
|
348
|
+
uid,
|
|
349
|
+
userId,
|
|
350
|
+
error: error instanceof Error ? error.message : String(error),
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return summary;
|
|
356
|
+
},
|
|
357
|
+
|
|
358
|
+
cleanupExpiredLocalUsers: async (): Promise<LifecycleSummary> => {
|
|
359
|
+
const rows = await sql<DbRow[]>`
|
|
360
|
+
SELECT id, uid, mail, display_name
|
|
361
|
+
FROM auth.users
|
|
362
|
+
WHERE provider = 'local'
|
|
363
|
+
AND profile = 'user'
|
|
364
|
+
AND account_expires IS NOT NULL
|
|
365
|
+
AND account_expires <= now()
|
|
366
|
+
ORDER BY account_expires ASC
|
|
367
|
+
`;
|
|
368
|
+
|
|
369
|
+
const summary: LifecycleSummary = {
|
|
370
|
+
scanned: rows.length,
|
|
371
|
+
changed: 0,
|
|
372
|
+
skipped: 0,
|
|
373
|
+
failed: 0,
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
for (const row of rows) {
|
|
377
|
+
const userId = row.id as string;
|
|
378
|
+
const uid = row.uid as string;
|
|
379
|
+
try {
|
|
380
|
+
await sql.begin(async (tx) => {
|
|
381
|
+
await writeDeletedAccountAudit({
|
|
382
|
+
db: tx,
|
|
383
|
+
userId,
|
|
384
|
+
uid,
|
|
385
|
+
mail: (row.mail as string) ?? null,
|
|
386
|
+
displayName: (row.display_name as string) ?? null,
|
|
387
|
+
previousProvider: "local",
|
|
388
|
+
previousProfile: "user",
|
|
389
|
+
reason: "local_user_expired_deleted",
|
|
390
|
+
});
|
|
391
|
+
await tx`DELETE FROM auth.users WHERE id = ${userId}::uuid`;
|
|
392
|
+
});
|
|
393
|
+
await session.revokeAllForUser(userId);
|
|
394
|
+
summary.changed += 1;
|
|
395
|
+
} catch (error) {
|
|
396
|
+
summary.failed += 1;
|
|
397
|
+
log.error("Failed to delete expired local user account", {
|
|
398
|
+
uid,
|
|
399
|
+
userId,
|
|
400
|
+
error: error instanceof Error ? error.message : String(error),
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return summary;
|
|
406
|
+
},
|
|
407
|
+
|
|
408
|
+
sendExpiryReminders: async (): Promise<LifecycleSummary> => {
|
|
409
|
+
const days = await parseReminderDays();
|
|
410
|
+
if (days.length === 0) {
|
|
411
|
+
return { scanned: 0, changed: 0, skipped: 0, failed: 0 };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const template = await getSetting<string>("mail.account_expiry_reminder");
|
|
415
|
+
const appName = (await getSetting<string>("app.name")) || "Cloud";
|
|
416
|
+
const contactEmail = (await getSetting<string>("app.contact_email")) || "";
|
|
417
|
+
const extendUrl = await resolveExtendUrl();
|
|
418
|
+
|
|
419
|
+
let scanned = 0;
|
|
420
|
+
let changed = 0;
|
|
421
|
+
let skipped = 0;
|
|
422
|
+
let failed = 0;
|
|
423
|
+
|
|
424
|
+
for (const thresholdDays of days) {
|
|
425
|
+
const candidates = await listReminderCandidates(thresholdDays);
|
|
426
|
+
scanned += candidates.length;
|
|
427
|
+
|
|
428
|
+
for (const candidate of candidates) {
|
|
429
|
+
const attempt = await upsertReminderAttempt({
|
|
430
|
+
userId: candidate.userId,
|
|
431
|
+
uid: candidate.uid,
|
|
432
|
+
mail: candidate.mail,
|
|
433
|
+
displayName: candidate.displayName,
|
|
434
|
+
kind: candidate.kind,
|
|
435
|
+
thresholdDays,
|
|
436
|
+
targetExpiryAt: candidate.expiresAt,
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
if (attempt.status === "sent") {
|
|
440
|
+
skipped += 1;
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const expiryText = dates.formatDate(candidate.expiresAt);
|
|
445
|
+
|
|
446
|
+
const subject = `${appName} account expires soon`;
|
|
447
|
+
const html = renderTemplate(template, {
|
|
448
|
+
FIRST_NAME: candidate.givenName || candidate.displayName || candidate.uid,
|
|
449
|
+
DISPLAY_NAME: candidate.displayName || candidate.uid,
|
|
450
|
+
EXPIRY: expiryText,
|
|
451
|
+
EXTEND_URL: extendUrl,
|
|
452
|
+
APP_NAME: appName,
|
|
453
|
+
CONTACT_EMAIL: contactEmail,
|
|
454
|
+
ACCOUNT_KIND: candidate.accountKind,
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
try {
|
|
458
|
+
const notification = await notifications.send({
|
|
459
|
+
type: "email",
|
|
460
|
+
recipient: candidate.mail,
|
|
461
|
+
subject,
|
|
462
|
+
rawHtml: html,
|
|
463
|
+
autoSend: true,
|
|
464
|
+
});
|
|
465
|
+
if (notification.status === "error") {
|
|
466
|
+
await markReminderError(attempt.id, notification.error ?? "Notification delivery failed");
|
|
467
|
+
failed += 1;
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
await markReminderSuccess(attempt.id);
|
|
472
|
+
changed += 1;
|
|
473
|
+
} catch (error) {
|
|
474
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
475
|
+
await markReminderError(attempt.id, message);
|
|
476
|
+
failed += 1;
|
|
477
|
+
log.error("Failed to send expiry reminder", {
|
|
478
|
+
userId: candidate.userId,
|
|
479
|
+
uid: candidate.uid,
|
|
480
|
+
kind: candidate.kind,
|
|
481
|
+
thresholdDays,
|
|
482
|
+
error: message,
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return { scanned, changed, skipped, failed };
|
|
489
|
+
},
|
|
490
|
+
|
|
491
|
+
cleanupLifecycleAudit: async (): Promise<LifecycleSummary> => {
|
|
492
|
+
const [deletedAccountsRetentionDays, reminderHistoryRetentionDays] = await Promise.all([
|
|
493
|
+
getDeletedAccountsRetentionDays(),
|
|
494
|
+
getReminderHistoryRetentionDays(),
|
|
495
|
+
]);
|
|
496
|
+
|
|
497
|
+
const deletedRows =
|
|
498
|
+
deletedAccountsRetentionDays > 0
|
|
499
|
+
? await sql<DbRow[]>`
|
|
500
|
+
DELETE FROM auth.deleted_accounts
|
|
501
|
+
WHERE deleted_at < now() - (${deletedAccountsRetentionDays} * interval '1 day')
|
|
502
|
+
RETURNING id
|
|
503
|
+
`
|
|
504
|
+
: [];
|
|
505
|
+
const reminderRows =
|
|
506
|
+
reminderHistoryRetentionDays > 0
|
|
507
|
+
? await sql<DbRow[]>`
|
|
508
|
+
DELETE FROM auth.account_lifecycle_reminders
|
|
509
|
+
WHERE created_at < now() - (${reminderHistoryRetentionDays} * interval '1 day')
|
|
510
|
+
RETURNING id
|
|
511
|
+
`
|
|
512
|
+
: [];
|
|
513
|
+
|
|
514
|
+
return {
|
|
515
|
+
scanned: deletedRows.length + reminderRows.length,
|
|
516
|
+
changed: deletedRows.length + reminderRows.length,
|
|
517
|
+
skipped: 0,
|
|
518
|
+
failed: 0,
|
|
519
|
+
};
|
|
520
|
+
},
|
|
521
|
+
|
|
522
|
+
runIpaBackfill: async (): Promise<LifecycleSummary> => {
|
|
523
|
+
const freeIpaConfig = (await getFreeIpaConfig());
|
|
524
|
+
if (!freeIpaConfig.enabled) {
|
|
525
|
+
log.info("IPA backfill skipped", { reason: "freeipa_disabled" });
|
|
526
|
+
return { scanned: 0, changed: 0, skipped: 0, failed: 0 };
|
|
527
|
+
}
|
|
528
|
+
if (!freeIpaConfig.configured) {
|
|
529
|
+
throw new Error("FreeIPA is enabled but not fully configured.");
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const configuredDays = await getIpaExpiresDays();
|
|
533
|
+
if (configuredDays <= 0) {
|
|
534
|
+
return { scanned: 0, changed: 0, skipped: 0, failed: 0 };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const days = Math.max(configuredDays, 7);
|
|
538
|
+
const minimumExpiry = new Date(Date.now() + days * DAY_MS);
|
|
539
|
+
minimumExpiry.setUTCHours(23, 59, 59, 0);
|
|
540
|
+
const ipaExpiry = freeipa.util.toGeneralizedTime(minimumExpiry);
|
|
541
|
+
const ipaSession = await freeipa.session.getServiceSession({
|
|
542
|
+
url: freeIpaConfig.url,
|
|
543
|
+
serviceUser: freeIpaConfig.serviceUser,
|
|
544
|
+
servicePassword: freeIpaConfig.servicePassword,
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
const rows = await sql<DbRow[]>`
|
|
548
|
+
SELECT id, uid, account_expires
|
|
549
|
+
FROM auth.users
|
|
550
|
+
WHERE provider = 'ipa'
|
|
551
|
+
AND (account_expires IS NULL OR account_expires < ${minimumExpiry})
|
|
552
|
+
ORDER BY uid
|
|
553
|
+
`;
|
|
554
|
+
|
|
555
|
+
const summary: LifecycleSummary = {
|
|
556
|
+
scanned: rows.length,
|
|
557
|
+
changed: 0,
|
|
558
|
+
skipped: 0,
|
|
559
|
+
failed: 0,
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
for (const row of rows) {
|
|
563
|
+
const userId = row.id as string;
|
|
564
|
+
const uid = row.uid as string;
|
|
565
|
+
const remote = await freeipa.client.call({
|
|
566
|
+
url: freeIpaConfig.url,
|
|
567
|
+
ipaSession,
|
|
568
|
+
method: "user_show",
|
|
569
|
+
args: [uid],
|
|
570
|
+
options: { all: true },
|
|
571
|
+
});
|
|
572
|
+
if (remote.error) {
|
|
573
|
+
summary.failed += 1;
|
|
574
|
+
log.error("IPA backfill read failed", { uid, userId, error: remote.error.message });
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const remoteResult = remote.result?.result as Record<string, unknown> | undefined;
|
|
579
|
+
const remoteExpiry = freeipa.util.parseGeneralizedTime(remoteResult?.krbprincipalexpiration);
|
|
580
|
+
if (remoteExpiry && remoteExpiry >= minimumExpiry) {
|
|
581
|
+
await sql`
|
|
582
|
+
UPDATE auth.users
|
|
583
|
+
SET account_expires = ${remoteExpiry}
|
|
584
|
+
WHERE id = ${userId}::uuid
|
|
585
|
+
`;
|
|
586
|
+
await sql`
|
|
587
|
+
INSERT INTO auth.user_ipa_data (user_id, synced_at)
|
|
588
|
+
VALUES (${userId}::uuid, now())
|
|
589
|
+
ON CONFLICT (user_id) DO UPDATE SET synced_at = EXCLUDED.synced_at
|
|
590
|
+
`;
|
|
591
|
+
summary.skipped += 1;
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const response = await freeipa.client.call({
|
|
596
|
+
url: freeIpaConfig.url,
|
|
597
|
+
ipaSession,
|
|
598
|
+
method: "user_mod",
|
|
599
|
+
args: [uid],
|
|
600
|
+
options: { krbprincipalexpiration: ipaExpiry },
|
|
601
|
+
});
|
|
602
|
+
if (response.error) {
|
|
603
|
+
summary.failed += 1;
|
|
604
|
+
log.error("IPA backfill failed", { uid, userId, error: response.error.message });
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
await sql`
|
|
609
|
+
UPDATE auth.users
|
|
610
|
+
SET account_expires = ${minimumExpiry}
|
|
611
|
+
WHERE id = ${userId}::uuid
|
|
612
|
+
`;
|
|
613
|
+
await sql`
|
|
614
|
+
INSERT INTO auth.user_ipa_data (user_id, synced_at)
|
|
615
|
+
VALUES (${userId}::uuid, now())
|
|
616
|
+
ON CONFLICT (user_id) DO UPDATE SET synced_at = EXCLUDED.synced_at
|
|
617
|
+
`;
|
|
618
|
+
summary.changed += 1;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
return summary;
|
|
622
|
+
},
|
|
623
|
+
|
|
624
|
+
runLocalUserBackfill: async (): Promise<LifecycleSummary> => {
|
|
625
|
+
const configuredDays = await getLocalUserExpiresDays();
|
|
626
|
+
if (configuredDays <= 0) {
|
|
627
|
+
return { scanned: 0, changed: 0, skipped: 0, failed: 0 };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const days = Math.max(configuredDays, 7);
|
|
631
|
+
const target = new Date(Date.now() + days * DAY_MS);
|
|
632
|
+
|
|
633
|
+
const rows = await sql<DbRow[]>`
|
|
634
|
+
UPDATE auth.users
|
|
635
|
+
SET account_expires = ${target}
|
|
636
|
+
WHERE provider = 'local'
|
|
637
|
+
AND profile = 'user'
|
|
638
|
+
AND (account_expires IS NULL OR account_expires < ${target})
|
|
639
|
+
RETURNING id
|
|
640
|
+
`;
|
|
641
|
+
|
|
642
|
+
return {
|
|
643
|
+
scanned: rows.length,
|
|
644
|
+
changed: rows.length,
|
|
645
|
+
skipped: 0,
|
|
646
|
+
failed: 0,
|
|
647
|
+
};
|
|
648
|
+
},
|
|
649
|
+
|
|
650
|
+
runGuestBackfill: async (): Promise<LifecycleSummary> => {
|
|
651
|
+
const configuredDays = await getGuestExpiresDays();
|
|
652
|
+
if (configuredDays <= 0) {
|
|
653
|
+
return { scanned: 0, changed: 0, skipped: 0, failed: 0 };
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const days = Math.max(configuredDays, 7);
|
|
657
|
+
const target = new Date(Date.now() + days * DAY_MS);
|
|
658
|
+
|
|
659
|
+
const rows = await sql<DbRow[]>`
|
|
660
|
+
UPDATE auth.users
|
|
661
|
+
SET account_expires = ${target}
|
|
662
|
+
WHERE provider = 'local'
|
|
663
|
+
AND profile = 'guest'
|
|
664
|
+
AND (account_expires IS NULL OR account_expires < ${target})
|
|
665
|
+
RETURNING id
|
|
666
|
+
`;
|
|
667
|
+
|
|
668
|
+
return {
|
|
669
|
+
scanned: rows.length,
|
|
670
|
+
changed: rows.length,
|
|
671
|
+
skipped: 0,
|
|
672
|
+
failed: 0,
|
|
673
|
+
};
|
|
674
|
+
},
|
|
675
|
+
|
|
676
|
+
extendCurrentUserAccount: async (config: {
|
|
677
|
+
user: User;
|
|
678
|
+
ipaSession?: string | null;
|
|
679
|
+
}): Promise<{ message: string; newExpiry?: string }> => {
|
|
680
|
+
if (config.user.provider === "ipa") {
|
|
681
|
+
const freeIpaConfig = (await getFreeIpaConfig());
|
|
682
|
+
if (!freeIpaConfig.enabled) {
|
|
683
|
+
return { message: "FreeIPA is disabled." };
|
|
684
|
+
}
|
|
685
|
+
const configuredDays = await getIpaExpiresDays();
|
|
686
|
+
if (configuredDays <= 0) {
|
|
687
|
+
return { message: "Automatic account expiry is disabled for IPA accounts." };
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
if (!config.ipaSession) {
|
|
691
|
+
throw new Error("IPA session required to extend an IPA-backed account.");
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const expiresAt = new Date(Date.now() + configuredDays * DAY_MS);
|
|
695
|
+
expiresAt.setUTCHours(23, 59, 59, 0);
|
|
696
|
+
const ipaExpiry = freeipa.util.toGeneralizedTime(expiresAt);
|
|
697
|
+
|
|
698
|
+
const response = await freeipa.client.call({
|
|
699
|
+
url: freeIpaConfig.url,
|
|
700
|
+
ipaSession: config.ipaSession,
|
|
701
|
+
method: "user_mod",
|
|
702
|
+
args: [config.user.uid],
|
|
703
|
+
options: { krbprincipalexpiration: ipaExpiry },
|
|
704
|
+
});
|
|
705
|
+
if (response.error) {
|
|
706
|
+
throw new Error(response.error.message || "Failed to extend IPA account.");
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
await sql`
|
|
710
|
+
UPDATE auth.users
|
|
711
|
+
SET account_expires = ${expiresAt}
|
|
712
|
+
WHERE id = ${config.user.id}::uuid
|
|
713
|
+
`;
|
|
714
|
+
await sql`
|
|
715
|
+
INSERT INTO auth.user_ipa_data (user_id, synced_at)
|
|
716
|
+
VALUES (${config.user.id}::uuid, now())
|
|
717
|
+
ON CONFLICT (user_id) DO UPDATE SET synced_at = EXCLUDED.synced_at
|
|
718
|
+
`;
|
|
719
|
+
|
|
720
|
+
return {
|
|
721
|
+
message: `Account extended until ${dates.formatDate(expiresAt)}.`,
|
|
722
|
+
newExpiry: expiresAt.toISOString(),
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if (config.user.provider === "local" && config.user.profile === "guest") {
|
|
727
|
+
const guestDays = await getGuestExpiresDays();
|
|
728
|
+
if (guestDays <= 0) {
|
|
729
|
+
await sql`
|
|
730
|
+
UPDATE auth.users
|
|
731
|
+
SET account_expires = NULL
|
|
732
|
+
WHERE id = ${config.user.id}::uuid
|
|
733
|
+
`;
|
|
734
|
+
return { message: "Guest account expiry is disabled." };
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const expiresAt = new Date(Date.now() + guestDays * DAY_MS);
|
|
738
|
+
await sql`
|
|
739
|
+
UPDATE auth.users
|
|
740
|
+
SET account_expires = ${expiresAt}
|
|
741
|
+
WHERE id = ${config.user.id}::uuid
|
|
742
|
+
`;
|
|
743
|
+
|
|
744
|
+
return {
|
|
745
|
+
message: `Guest account extended until ${dates.formatDate(expiresAt)}.`,
|
|
746
|
+
newExpiry: expiresAt.toISOString(),
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
if (config.user.provider === "local" && config.user.profile === "user") {
|
|
751
|
+
const localUserDays = await getLocalUserExpiresDays();
|
|
752
|
+
if (localUserDays <= 0) {
|
|
753
|
+
await sql`
|
|
754
|
+
UPDATE auth.users
|
|
755
|
+
SET account_expires = NULL
|
|
756
|
+
WHERE id = ${config.user.id}::uuid
|
|
757
|
+
`;
|
|
758
|
+
return { message: "Local user account expiry is disabled." };
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const expiresAt = new Date(Date.now() + localUserDays * DAY_MS);
|
|
762
|
+
await sql`
|
|
763
|
+
UPDATE auth.users
|
|
764
|
+
SET account_expires = ${expiresAt}
|
|
765
|
+
WHERE id = ${config.user.id}::uuid
|
|
766
|
+
`;
|
|
767
|
+
|
|
768
|
+
return {
|
|
769
|
+
message: `Account extended until ${dates.formatDate(expiresAt)}.`,
|
|
770
|
+
newExpiry: expiresAt.toISOString(),
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
return { message: "Your account does not support extension." };
|
|
775
|
+
},
|
|
776
|
+
|
|
777
|
+
listDeletedAccounts: async (config: { page: number; perPage: number; reason?: string; search?: string }) => {
|
|
778
|
+
const offset = (config.page - 1) * config.perPage;
|
|
779
|
+
const reason = config.reason?.trim() || null;
|
|
780
|
+
const search = config.search?.trim().toLowerCase() || null;
|
|
781
|
+
const pattern = search ? `%${freeipa.util.escapeLike(search)}%` : null;
|
|
782
|
+
|
|
783
|
+
const countRows = await sql<DbRow[]>`
|
|
784
|
+
SELECT COUNT(*)::int AS total
|
|
785
|
+
FROM auth.deleted_accounts
|
|
786
|
+
WHERE (${reason}::text IS NULL OR reason = ${reason})
|
|
787
|
+
AND (
|
|
788
|
+
${pattern}::text IS NULL
|
|
789
|
+
OR LOWER(uid) LIKE ${pattern} ESCAPE '\\'
|
|
790
|
+
OR LOWER(COALESCE(display_name, '')) LIKE ${pattern} ESCAPE '\\'
|
|
791
|
+
OR LOWER(COALESCE(mail, '')) LIKE ${pattern} ESCAPE '\\'
|
|
792
|
+
)
|
|
793
|
+
`;
|
|
794
|
+
|
|
795
|
+
const rows = await sql<DbRow[]>`
|
|
796
|
+
SELECT id, deleted_user_id, uid, mail, display_name, previous_provider, previous_profile, reason, deleted_at, meta
|
|
797
|
+
FROM auth.deleted_accounts
|
|
798
|
+
WHERE (${reason}::text IS NULL OR reason = ${reason})
|
|
799
|
+
AND (
|
|
800
|
+
${pattern}::text IS NULL
|
|
801
|
+
OR LOWER(uid) LIKE ${pattern} ESCAPE '\\'
|
|
802
|
+
OR LOWER(COALESCE(display_name, '')) LIKE ${pattern} ESCAPE '\\'
|
|
803
|
+
OR LOWER(COALESCE(mail, '')) LIKE ${pattern} ESCAPE '\\'
|
|
804
|
+
)
|
|
805
|
+
ORDER BY deleted_at DESC
|
|
806
|
+
LIMIT ${config.perPage}
|
|
807
|
+
OFFSET ${offset}
|
|
808
|
+
`;
|
|
809
|
+
|
|
810
|
+
return {
|
|
811
|
+
items: rows.map((row) => ({
|
|
812
|
+
id: row.id as string,
|
|
813
|
+
deletedUserId: row.deleted_user_id as string,
|
|
814
|
+
uid: row.uid as string,
|
|
815
|
+
mail: (row.mail as string) ?? null,
|
|
816
|
+
displayName: (row.display_name as string) ?? null,
|
|
817
|
+
previousProvider: (row.previous_provider as string) ?? null,
|
|
818
|
+
previousProfile: (row.previous_profile as string) ?? null,
|
|
819
|
+
reason: row.reason as string,
|
|
820
|
+
deletedAt: (row.deleted_at as Date).toISOString(),
|
|
821
|
+
meta: parsePgJsonRecord(row.meta) ?? {},
|
|
822
|
+
})),
|
|
823
|
+
total: Number(countRows[0]?.total ?? 0),
|
|
824
|
+
page: config.page,
|
|
825
|
+
perPage: config.perPage,
|
|
826
|
+
};
|
|
827
|
+
},
|
|
828
|
+
|
|
829
|
+
listReminderAudit: async (config: { page: number; perPage: number; status?: string; kind?: ReminderKind; search?: string }) => {
|
|
830
|
+
const offset = (config.page - 1) * config.perPage;
|
|
831
|
+
const status = config.status?.trim() || null;
|
|
832
|
+
const kind = config.kind ?? null;
|
|
833
|
+
const search = config.search?.trim().toLowerCase() || null;
|
|
834
|
+
const pattern = search ? `%${freeipa.util.escapeLike(search)}%` : null;
|
|
835
|
+
|
|
836
|
+
const countRows = await sql<DbRow[]>`
|
|
837
|
+
SELECT COUNT(*)::int AS total
|
|
838
|
+
FROM auth.account_lifecycle_reminders r
|
|
839
|
+
LEFT JOIN auth.users u ON u.id = r.user_id
|
|
840
|
+
WHERE (${status}::text IS NULL OR r.status = ${status})
|
|
841
|
+
AND (${kind}::text IS NULL OR r.kind = ${kind})
|
|
842
|
+
AND (
|
|
843
|
+
${pattern}::text IS NULL
|
|
844
|
+
OR LOWER(COALESCE(r.uid, u.uid, '')) LIKE ${pattern} ESCAPE '\\'
|
|
845
|
+
OR LOWER(COALESCE(r.mail, u.mail, '')) LIKE ${pattern} ESCAPE '\\'
|
|
846
|
+
OR LOWER(COALESCE(r.display_name, u.display_name, '')) LIKE ${pattern} ESCAPE '\\'
|
|
847
|
+
)
|
|
848
|
+
`;
|
|
849
|
+
|
|
850
|
+
const rows = await sql<DbRow[]>`
|
|
851
|
+
SELECT r.id,
|
|
852
|
+
r.user_id,
|
|
853
|
+
r.uid AS reminder_uid,
|
|
854
|
+
r.mail AS reminder_mail,
|
|
855
|
+
r.display_name AS reminder_display_name,
|
|
856
|
+
r.kind,
|
|
857
|
+
r.threshold_days,
|
|
858
|
+
r.target_expiry_at,
|
|
859
|
+
r.status,
|
|
860
|
+
r.attempt_count,
|
|
861
|
+
r.last_attempt_at,
|
|
862
|
+
r.sent_at,
|
|
863
|
+
r.last_error,
|
|
864
|
+
r.created_at,
|
|
865
|
+
u.uid AS live_uid,
|
|
866
|
+
u.mail AS live_mail,
|
|
867
|
+
u.display_name AS live_display_name
|
|
868
|
+
FROM auth.account_lifecycle_reminders r
|
|
869
|
+
LEFT JOIN auth.users u ON u.id = r.user_id
|
|
870
|
+
WHERE (${status}::text IS NULL OR r.status = ${status})
|
|
871
|
+
AND (${kind}::text IS NULL OR r.kind = ${kind})
|
|
872
|
+
AND (
|
|
873
|
+
${pattern}::text IS NULL
|
|
874
|
+
OR LOWER(COALESCE(r.uid, u.uid, '')) LIKE ${pattern} ESCAPE '\\'
|
|
875
|
+
OR LOWER(COALESCE(r.mail, u.mail, '')) LIKE ${pattern} ESCAPE '\\'
|
|
876
|
+
OR LOWER(COALESCE(r.display_name, u.display_name, '')) LIKE ${pattern} ESCAPE '\\'
|
|
877
|
+
)
|
|
878
|
+
ORDER BY r.created_at DESC
|
|
879
|
+
LIMIT ${config.perPage}
|
|
880
|
+
OFFSET ${offset}
|
|
881
|
+
`;
|
|
882
|
+
|
|
883
|
+
return {
|
|
884
|
+
items: rows.map((row) => ({
|
|
885
|
+
id: row.id as string,
|
|
886
|
+
userId: (row.user_id as string) ?? null,
|
|
887
|
+
uid: ((row.reminder_uid as string) ?? (row.live_uid as string) ?? null),
|
|
888
|
+
mail: ((row.reminder_mail as string) ?? (row.live_mail as string) ?? null),
|
|
889
|
+
displayName: ((row.reminder_display_name as string) ?? (row.live_display_name as string) ?? null),
|
|
890
|
+
kind: row.kind as string,
|
|
891
|
+
thresholdDays: Number(row.threshold_days),
|
|
892
|
+
targetExpiryAt: (row.target_expiry_at as Date).toISOString(),
|
|
893
|
+
status: row.status as string,
|
|
894
|
+
attemptCount: Number(row.attempt_count),
|
|
895
|
+
lastAttemptAt: row.last_attempt_at ? (row.last_attempt_at as Date).toISOString() : null,
|
|
896
|
+
sentAt: row.sent_at ? (row.sent_at as Date).toISOString() : null,
|
|
897
|
+
lastError: (row.last_error as string) ?? null,
|
|
898
|
+
createdAt: (row.created_at as Date).toISOString(),
|
|
899
|
+
})),
|
|
900
|
+
total: Number(countRows[0]?.total ?? 0),
|
|
901
|
+
page: config.page,
|
|
902
|
+
perPage: config.perPage,
|
|
903
|
+
};
|
|
904
|
+
},
|
|
905
|
+
};
|
|
906
|
+
|
|
907
|
+
export type AccountLifecycleService = typeof accountLifecycle;
|