@oneuptime/common 12.0.20 → 12.0.22

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.
Files changed (119) hide show
  1. package/Models/AnalyticsModels/ChangeEvent.ts +331 -0
  2. package/Models/AnalyticsModels/Index.ts +2 -0
  3. package/Models/DatabaseModels/Index.ts +2 -0
  4. package/Models/DatabaseModels/UserTwoFactorBackupCode.ts +262 -0
  5. package/Server/API/UserTwoFactorBackupCodeAPI.ts +258 -0
  6. package/Server/EnvironmentConfig.ts +1 -8
  7. package/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.ts +42 -0
  8. package/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddUserTwoFactorBackupCode.ts +63 -0
  9. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -2
  10. package/Server/Services/ChangeEventService.ts +11 -0
  11. package/Server/Services/EnterpriseLicenseService.ts +3 -3
  12. package/Server/Services/Index.ts +4 -0
  13. package/Server/Services/UserService.ts +42 -0
  14. package/Server/Services/UserTwoFactorBackupCodeService.ts +355 -0
  15. package/Server/Utils/Attribution.ts +11 -11
  16. package/Server/Utils/Marketing/MarketingEventWebhook.ts +4 -5
  17. package/Server/Utils/Telemetry/ChangeEventRow.ts +228 -0
  18. package/Server/Utils/TwoFactorBackupCode.ts +266 -0
  19. package/Server/Views/Partials/AnalyticsConsent.ejs +3 -95
  20. package/Tests/App/Dashboard/DashboardChartWidgetZoom.test.tsx +64 -0
  21. package/Tests/App/Dashboard/EventOverlayHook.test.tsx +249 -0
  22. package/Tests/App/Dashboard/InvestigationArtifacts.test.tsx +315 -0
  23. package/Tests/App/Dashboard/InvestigationDrawer.test.tsx +258 -0
  24. package/Tests/App/Dashboard/MetricChartsCompare.test.tsx +171 -0
  25. package/Tests/App/Dashboard/MetricViewCompare.test.tsx +233 -0
  26. package/Tests/Server/API/UserAuthenticationAPI.test.ts +16 -3
  27. package/Tests/Server/API/UserTwoFactorAuthAdminAPI.test.ts +18 -2
  28. package/Tests/Server/API/UserTwoFactorBackupCodeAPI.test.ts +1390 -0
  29. package/Tests/Server/Infrastructure/Postgres/AddUserSlackAndMicrosoftTeamsMigration.test.ts +236 -0
  30. package/Tests/Server/Services/UserAuthenticationService.test.ts +23 -1
  31. package/Tests/Server/Services/UserTwoFactorAuthAdmin.test.ts +21 -0
  32. package/Tests/Server/Services/UserTwoFactorBackupCodeAdminSurface.test.ts +919 -0
  33. package/Tests/Server/Services/UserTwoFactorBackupCodeService.test.ts +862 -0
  34. package/Tests/Server/Utils/AI/SRE/Insights/Detectors/ExceptionIdentity.test.ts +224 -0
  35. package/Tests/Server/Utils/Attribution.test.ts +4 -5
  36. package/Tests/Server/Utils/Marketing/MarketingEventUtil.test.ts +2 -2
  37. package/Tests/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.test.ts +300 -0
  38. package/Tests/Server/Utils/TwoFactorBackupCode.test.ts +475 -0
  39. package/Tests/UI/Components/Charts/ChartGhostSeries.test.tsx +103 -0
  40. package/Tests/UI/Components/Charts/TooltipEntries.test.ts +44 -8
  41. package/Tests/Utils/Telemetry/CrossSignalScope.test.ts +13 -2
  42. package/Types/AnalyticsDatabase/AnalyticsTableName.ts +7 -0
  43. package/Types/Email/EmailTemplateType.ts +2 -0
  44. package/Types/Marketing/Attribution.ts +9 -15
  45. package/Types/Marketing/MarketingEvent.ts +10 -46
  46. package/Types/UserAuthenticationStatus.ts +16 -0
  47. package/UI/Components/Charts/Area/AreaChart.tsx +15 -0
  48. package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +16 -1
  49. package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +189 -93
  50. package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +6 -1
  51. package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +213 -114
  52. package/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.ts +42 -27
  53. package/UI/Components/Charts/Line/LineChart.tsx +15 -0
  54. package/Utils/Metrics/MetricExplorerUrl.ts +119 -5
  55. package/Utils/Telemetry/CrossSignalScope.ts +19 -10
  56. package/build/dist/Models/AnalyticsModels/ChangeEvent.js +298 -0
  57. package/build/dist/Models/AnalyticsModels/ChangeEvent.js.map +1 -0
  58. package/build/dist/Models/AnalyticsModels/Index.js +2 -0
  59. package/build/dist/Models/AnalyticsModels/Index.js.map +1 -1
  60. package/build/dist/Models/DatabaseModels/Index.js +2 -0
  61. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  62. package/build/dist/Models/DatabaseModels/UserTwoFactorBackupCode.js +277 -0
  63. package/build/dist/Models/DatabaseModels/UserTwoFactorBackupCode.js.map +1 -0
  64. package/build/dist/Server/API/UserTwoFactorBackupCodeAPI.js +201 -0
  65. package/build/dist/Server/API/UserTwoFactorBackupCodeAPI.js.map +1 -0
  66. package/build/dist/Server/EnvironmentConfig.js +1 -7
  67. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  68. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js +39 -0
  69. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js.map +1 -1
  70. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddUserTwoFactorBackupCode.js +46 -0
  71. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddUserTwoFactorBackupCode.js.map +1 -0
  72. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -2
  73. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  74. package/build/dist/Server/Services/ChangeEventService.js +9 -0
  75. package/build/dist/Server/Services/ChangeEventService.js.map +1 -0
  76. package/build/dist/Server/Services/EnterpriseLicenseService.js +3 -3
  77. package/build/dist/Server/Services/Index.js +4 -0
  78. package/build/dist/Server/Services/Index.js.map +1 -1
  79. package/build/dist/Server/Services/UserService.js +39 -0
  80. package/build/dist/Server/Services/UserService.js.map +1 -1
  81. package/build/dist/Server/Services/UserTwoFactorBackupCodeService.js +327 -0
  82. package/build/dist/Server/Services/UserTwoFactorBackupCodeService.js.map +1 -0
  83. package/build/dist/Server/Utils/Attribution.js +6 -6
  84. package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js +4 -5
  85. package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js.map +1 -1
  86. package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js +150 -0
  87. package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js.map +1 -0
  88. package/build/dist/Server/Utils/TwoFactorBackupCode.js +269 -0
  89. package/build/dist/Server/Utils/TwoFactorBackupCode.js.map +1 -0
  90. package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js +7 -0
  91. package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js.map +1 -1
  92. package/build/dist/Types/Email/EmailTemplateType.js +2 -0
  93. package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
  94. package/build/dist/Types/Marketing/Attribution.js +9 -15
  95. package/build/dist/Types/Marketing/Attribution.js.map +1 -1
  96. package/build/dist/Types/Marketing/MarketingEvent.js +2 -33
  97. package/build/dist/Types/Marketing/MarketingEvent.js.map +1 -1
  98. package/build/dist/UI/Components/Charts/Area/AreaChart.js +1 -1
  99. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  100. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +9 -1
  101. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
  102. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +92 -38
  103. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
  104. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +8 -8
  105. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
  106. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +95 -37
  107. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
  108. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js +39 -24
  109. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js.map +1 -1
  110. package/build/dist/UI/Components/Charts/Line/LineChart.js +1 -1
  111. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  112. package/build/dist/Utils/Metrics/MetricExplorerUrl.js +81 -0
  113. package/build/dist/Utils/Metrics/MetricExplorerUrl.js.map +1 -1
  114. package/build/dist/Utils/Telemetry/CrossSignalScope.js +14 -8
  115. package/build/dist/Utils/Telemetry/CrossSignalScope.js.map +1 -1
  116. package/package.json +1 -1
  117. package/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.ts +0 -117
  118. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js +0 -106
  119. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js.map +0 -1
@@ -0,0 +1,258 @@
1
+ import ObjectID from "../../Types/ObjectID";
2
+ import Hostname from "../../Types/API/Hostname";
3
+ import Protocol from "../../Types/API/Protocol";
4
+ import Route from "../../Types/API/Route";
5
+ import URL from "../../Types/API/URL";
6
+ import OneUptimeDate from "../../Types/Date";
7
+ import EmailTemplateType from "../../Types/Email/EmailTemplateType";
8
+ import { DashboardRoute } from "../../ServiceRoute";
9
+ import DatabaseConfig from "../DatabaseConfig";
10
+ import MailService from "../Services/MailService";
11
+ import UserService from "../Services/UserService";
12
+ import User from "../../Models/DatabaseModels/User";
13
+ import UserMiddleware from "../Middleware/UserAuthorization";
14
+ import UserTwoFactorBackupCodeService, {
15
+ Service as UserTwoFactorBackupCodeServiceType,
16
+ TwoFactorBackupCodeStatus,
17
+ } from "../Services/UserTwoFactorBackupCodeService";
18
+ import TwoFactorBackupCode from "../Utils/TwoFactorBackupCode";
19
+ import {
20
+ ExpressRequest,
21
+ ExpressResponse,
22
+ NextFunction,
23
+ OneUptimeRequest,
24
+ } from "../Utils/Express";
25
+ import BaseAPI from "./BaseAPI";
26
+ import UserTwoFactorBackupCode from "../../Models/DatabaseModels/UserTwoFactorBackupCode";
27
+ import NotAuthenticatedException from "../../Types/Exception/NotAuthenticatedException";
28
+ import Response from "../Utils/Response";
29
+ import logger from "../Utils/Logger";
30
+
31
+ /**
32
+ * The two routes a signed-in user needs to manage their recovery codes.
33
+ *
34
+ * Both act on the CALLER's own codes and nobody else's. There is deliberately
35
+ * no `:userId` parameter anywhere here: the owner is read from the validated
36
+ * access token, so there is no id for a caller to substitute and no ownership
37
+ * check to get wrong. `UserMiddleware.getUserMiddleware` is what puts it
38
+ * there.
39
+ */
40
+ export default class UserTwoFactorBackupCodeAPI extends BaseAPI<
41
+ UserTwoFactorBackupCode,
42
+ UserTwoFactorBackupCodeServiceType
43
+ > {
44
+ public constructor() {
45
+ super(UserTwoFactorBackupCode, UserTwoFactorBackupCodeService);
46
+
47
+ const crudApiPath: string =
48
+ new this.entityType().getCrudApiPath()?.toString() || "";
49
+
50
+ /*
51
+ * Mint a fresh set and return the plaintext codes.
52
+ *
53
+ * THIS RESPONSE IS THE ONLY COPY. Nothing is stored but keyed digests, so
54
+ * if the user closes the tab the codes are gone -- not recoverable by
55
+ * them, by an operator, or by anybody with the database. The UI is
56
+ * expected to make that unmissable, and the count of a previous set is
57
+ * returned alongside so the page can say what was just invalidated.
58
+ *
59
+ * It is a POST because it is destructive: calling it throws away every
60
+ * code the user was previously holding. A GET that silently voided a
61
+ * printed list -- on a browser prefetch, say -- would be a lockout with no
62
+ * user action behind it.
63
+ *
64
+ * NOT gated on `enableTwoFactorAuth`. Generating codes before turning two
65
+ * factor auth on is the sensible order to do it in, and refusing here
66
+ * would push users into the exact sequence -- enable first, find recovery
67
+ * later -- that produces the lost-device support ticket this whole feature
68
+ * exists to prevent.
69
+ */
70
+ this.router.post(
71
+ `${crudApiPath}/generate`,
72
+ UserMiddleware.getUserMiddleware,
73
+ async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
74
+ try {
75
+ const userId: ObjectID =
76
+ UserTwoFactorBackupCodeAPI.getCurrentUserId(req);
77
+
78
+ const previousStatus: TwoFactorBackupCodeStatus =
79
+ await UserTwoFactorBackupCodeService.getStatusForUser({
80
+ userId: userId,
81
+ });
82
+
83
+ const codes: Array<string> =
84
+ await UserTwoFactorBackupCodeService.regenerateForUser({
85
+ userId: userId,
86
+ });
87
+
88
+ /*
89
+ * The codes themselves are NEVER logged -- they are sign-in
90
+ * credentials, and logger output reaches stdout, the recent-log
91
+ * buffer and telemetry at once. The fact that a set was minted is
92
+ * worth recording: it is how an operator later reconstructs "who
93
+ * invalidated the codes this user says they had".
94
+ */
95
+ logger.info(
96
+ `Two factor backup codes regenerated for user: ${userId.toString()}`,
97
+ );
98
+
99
+ /*
100
+ * Tell the account holder, at their address rather than in this
101
+ * browser.
102
+ *
103
+ * This route is the destructive one and it is the one with the least
104
+ * standing in front of it: a session alone -- no password, no second
105
+ * factor -- silently voids every recovery code the user is holding.
106
+ * Somebody who has stolen a session can therefore remove the owner's
107
+ * way back in and leave no trace the owner would ever look at, and
108
+ * the owner finds out at the next sign-in they cannot complete.
109
+ *
110
+ * The mail does not prevent that; it makes it visible, which is the
111
+ * same bargain `sendTwoFactorBackupCodeUsedEmail` strikes on the
112
+ * login path. Re-prompting for the password here would be stronger
113
+ * still, and is the obvious next step -- it needs a re-authentication
114
+ * mechanism this codebase does not have yet, so it is deliberately
115
+ * not invented on this route alone.
116
+ *
117
+ * Detached and never awaited: an unreachable mail server must not
118
+ * fail a regeneration that has already replaced the user's codes,
119
+ * because the response carries the only copy of the new ones.
120
+ */
121
+ UserTwoFactorBackupCodeAPI.notifyCodesRegenerated({
122
+ userId: userId,
123
+ newCodeCount: codes.length,
124
+ }).catch((err: Error) => {
125
+ logger.error(err);
126
+ });
127
+
128
+ return Response.sendJsonObjectResponse(req, res, {
129
+ /*
130
+ * Hyphenated for the page to render as-is. The verify route
131
+ * normalizes whatever comes back, so a user may type them with
132
+ * the hyphen, without it, or with whatever spacing their password
133
+ * manager pasted in.
134
+ */
135
+ codes: codes.map((code: string) => {
136
+ return TwoFactorBackupCode.formatForDisplay(code);
137
+ }),
138
+ replacedCodeCount: previousStatus.total,
139
+ });
140
+ } catch (err) {
141
+ return next(err);
142
+ }
143
+ },
144
+ );
145
+
146
+ /*
147
+ * How many codes the caller has left.
148
+ *
149
+ * Carries no code material of any kind -- three numbers and a date -- so
150
+ * it is safe for the profile page to poll and safe to render anywhere a
151
+ * session already exists.
152
+ */
153
+ this.router.get(
154
+ `${crudApiPath}/status`,
155
+ UserMiddleware.getUserMiddleware,
156
+ async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
157
+ try {
158
+ const userId: ObjectID =
159
+ UserTwoFactorBackupCodeAPI.getCurrentUserId(req);
160
+
161
+ const status: TwoFactorBackupCodeStatus =
162
+ await UserTwoFactorBackupCodeService.getStatusForUser({
163
+ userId: userId,
164
+ });
165
+
166
+ /*
167
+ * A stale "you have 8 codes left" served out of a browser cache
168
+ * after the user has just regenerated is a user who thinks the
169
+ * button did nothing.
170
+ */
171
+ Response.setNoCacheHeaders(res);
172
+
173
+ return Response.sendJsonObjectResponse(req, res, {
174
+ total: status.total,
175
+ unused: status.unused,
176
+ generatedAt: status.generatedAt
177
+ ? status.generatedAt.toISOString()
178
+ : null,
179
+ });
180
+ } catch (err) {
181
+ return next(err);
182
+ }
183
+ },
184
+ );
185
+ }
186
+
187
+ /**
188
+ * Mail the owner that their backup codes were replaced.
189
+ *
190
+ * Separate from the handler so the handler stays readable, and `private
191
+ * static` because nothing outside this route should be announcing a
192
+ * regeneration that did not happen here.
193
+ *
194
+ * Reads the address itself rather than taking it from the request: the only
195
+ * thing the caller proved is which user they are, and the address this goes
196
+ * to has to be the one on the account, not one they supplied.
197
+ */
198
+ private static async notifyCodesRegenerated(data: {
199
+ userId: ObjectID;
200
+ newCodeCount: number;
201
+ }): Promise<void> {
202
+ const user: User | null = await UserService.findOneById({
203
+ id: data.userId,
204
+ select: {
205
+ email: true,
206
+ },
207
+ props: {
208
+ isRoot: true,
209
+ },
210
+ });
211
+
212
+ if (!user || !user.email) {
213
+ return;
214
+ }
215
+
216
+ const host: Hostname = await DatabaseConfig.getHost();
217
+ const httpProtocol: Protocol = await DatabaseConfig.getHttpProtocol();
218
+
219
+ await MailService.sendMail({
220
+ toEmail: user.email,
221
+ subject: "Your OneUptime two factor backup codes were replaced",
222
+ templateType: EmailTemplateType.TwoFactorBackupCodesRegenerated,
223
+ vars: {
224
+ generatedAt: OneUptimeDate.getCurrentDateAsFormattedString(),
225
+ newCodeCount: data.newCodeCount.toString(),
226
+ twoFactorAuthUrl: new URL(
227
+ httpProtocol,
228
+ host,
229
+ new Route(DashboardRoute.toString()).addRoute(
230
+ "/user-profile/two-factor-auth",
231
+ ),
232
+ ).toString(),
233
+ homeUrl: new URL(httpProtocol, host).toString(),
234
+ },
235
+ });
236
+ }
237
+
238
+ /**
239
+ * The signed-in caller, from the token the middleware validated.
240
+ *
241
+ * Throws rather than returning null: every route in this file acts on the
242
+ * caller's own credentials, so "no caller" is not a case any of them can
243
+ * meaningfully proceed with. The middleware ahead of them should already
244
+ * have refused, which makes this a second lock rather than the first.
245
+ */
246
+ private static getCurrentUserId(req: ExpressRequest): ObjectID {
247
+ const userId: ObjectID | undefined = (req as OneUptimeRequest)
248
+ .userAuthorization?.userId;
249
+
250
+ if (!userId) {
251
+ throw new NotAuthenticatedException(
252
+ "You are not authenticated. Please sign in.",
253
+ );
254
+ }
255
+
256
+ return userId;
257
+ }
258
+ }
@@ -385,17 +385,10 @@ export const AnalyticsHost: string = process.env["ANALYTICS_HOST"] || "";
385
385
  export const GoogleTagManagerEnabled: boolean =
386
386
  IsBillingEnabled && process.env["GOOGLE_TAG_MANAGER_ENABLED"] !== "false";
387
387
 
388
- /*
389
- * Shared secret Cal.com signs booking webhooks with (App/API/CalWebhook.ts).
390
- * Server-only — must never be added to FRONTEND_ENV_ALLOW_LIST. Empty means
391
- * the webhook endpoint is disabled and answers 503.
392
- */
393
- export const CalWebhookSecret: string = process.env["CAL_WEBHOOK_SECRET"] || "";
394
-
395
388
  /*
396
389
  * Outbound marketing conversion webhooks.
397
390
  *
398
- * OneUptime does not store conversions. Signups, booked meetings and plan
391
+ * OneUptime does not store conversions. Signups and plan
399
392
  * upgrades/downgrades are POSTed to this endpoint as they happen and kept
400
393
  * nowhere afterwards, so an unset URL means those moments are simply not
401
394
  * measured — nothing accumulates waiting for one to be configured.
@@ -14,6 +14,10 @@ export class AddUserSlackAndMicrosoftTeams1789000000000
14
14
  public name = "AddUserSlackAndMicrosoftTeams1789000000000";
15
15
 
16
16
  public async up(queryRunner: QueryRunner): Promise<void> {
17
+ if (await this.isAlreadyApplied(queryRunner)) {
18
+ return;
19
+ }
20
+
17
21
  await queryRunner.query(
18
22
  `CREATE TABLE "UserSlack" ("_id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP WITH TIME ZONE, "version" integer NOT NULL, "projectId" uuid NOT NULL, "slackUserId" character varying(100), "slackUserName" character varying(100), "userId" uuid, "createdByUserId" uuid, "deletedByUserId" uuid, "isVerified" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_86de4cd76b41a08811fc403e23a" PRIMARY KEY ("_id"))`,
19
23
  );
@@ -106,6 +110,44 @@ export class AddUserSlackAndMicrosoftTeams1789000000000
106
110
  );
107
111
  }
108
112
 
113
+ /*
114
+ * Did everything in `up` already run, under this migration's OLD name?
115
+ *
116
+ * This shipped as AddUserSlackAndMicrosoftTeams1788800000000 and was
117
+ * renumbered to 1789000000000 to clear a timestamp collision with
118
+ * DropMarketingConversionAddEnterpriseLicenseEmail. TypeORM identifies a
119
+ * migration by class NAME rather than by what it does, so every database
120
+ * that had already recorded the old name — anything tracking master between
121
+ * the two commits, which is every test and staging cluster — sees the
122
+ * renamed class as pending and runs it a second time, where
123
+ * `CREATE TABLE "UserSlack"` fails on the table it created itself. The
124
+ * runner stops at the first failure, so that single error holds back this
125
+ * migration and every migration added after it, on every boot.
126
+ *
127
+ * The probe is the table because the earlier run was all-or-nothing:
128
+ * migrations run one transaction each (DataSourceOptions sets
129
+ * migrationsTransactionMode: "each") and Postgres DDL is transactional, so
130
+ * "UserSlack" existing means every statement in `up` already committed.
131
+ * Nothing is left to do but let TypeORM record the new name.
132
+ *
133
+ * The superseded row in "migrations" is deliberately left alone. Deleting it
134
+ * would make this migration pending again for anyone who rolls the image
135
+ * BACK to a build that still registers the old name — the same failure, in
136
+ * the other direction. An unrecognised name in that table is inert.
137
+ */
138
+ private async isAlreadyApplied(queryRunner: QueryRunner): Promise<boolean> {
139
+ const userSlackTableExists: Array<{ exists: boolean }> =
140
+ await queryRunner.query(`
141
+ SELECT EXISTS (
142
+ SELECT FROM information_schema.tables
143
+ WHERE table_schema = 'public'
144
+ AND table_name = 'UserSlack'
145
+ )
146
+ `);
147
+
148
+ return Boolean(userSlackTableExists[0]?.exists);
149
+ }
150
+
109
151
  public async down(queryRunner: QueryRunner): Promise<void> {
110
152
  await queryRunner.query(
111
153
  `ALTER TABLE "UserOnCallLogTimeline" DROP CONSTRAINT "FK_a7206c19df5cdfe02cf3215a64c"`,
@@ -0,0 +1,63 @@
1
+ import { MigrationInterface, QueryRunner } from "typeorm";
2
+
3
+ /*
4
+ * Single-use two factor backup codes, so losing an authenticator app or a
5
+ * security key stops being an account lockout that only a master admin can
6
+ * undo.
7
+ *
8
+ * One row per code rather than an array column on User: consuming a code is
9
+ * then one conditional UPDATE that Postgres serialises for us, instead of a
10
+ * read-modify-write of an array where two simultaneous sign-ins would each
11
+ * write back a copy missing only their own code.
12
+ *
13
+ * `codeHash` holds an HMAC-SHA256 keyed by the instance EncryptionSecret and
14
+ * domain separated by the owning user -- never a code in the clear. It is a
15
+ * plain varchar rather than a `hashed: true` column because the write path's
16
+ * hashing lanes do neither of those things: the salted lane is scrypt (right
17
+ * for a guessable password, pure cost for a 2^50 random code) and the unsalted
18
+ * lane is a bare SHA-256 with no per-user binding. See
19
+ * Common/Server/Utils/TwoFactorBackupCode.ts.
20
+ *
21
+ * The (userId, codeHash) index is the sign-in lookup, which happens while
22
+ * somebody is locked out and waiting. `usedAt` is deliberately not in it: a
23
+ * user holds ten rows, so Postgres filters those in memory for nothing.
24
+ */
25
+ export class AddUserTwoFactorBackupCode1789100000000
26
+ implements MigrationInterface
27
+ {
28
+ public name: string = "AddUserTwoFactorBackupCode1789100000000";
29
+
30
+ public async up(queryRunner: QueryRunner): Promise<void> {
31
+ await queryRunner.query(
32
+ `CREATE TABLE "UserTwoFactorBackupCode" ("_id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP WITH TIME ZONE, "version" integer NOT NULL, "codeHash" character varying(100) NOT NULL, "usedAt" TIMESTAMP WITH TIME ZONE, "deletedByUserId" uuid, "userId" uuid, CONSTRAINT "PK_8007aa681bcad47b88b906319ed" PRIMARY KEY ("_id"))`,
33
+ );
34
+ await queryRunner.query(
35
+ `CREATE INDEX "IDX_19a5937a4f6bd993383b31f3a2" ON "UserTwoFactorBackupCode" ("userId", "codeHash") `,
36
+ );
37
+ await queryRunner.query(
38
+ `ALTER TABLE "UserTwoFactorBackupCode" ADD CONSTRAINT "FK_b6f9bfb29ab554076a2d8dde717" FOREIGN KEY ("deletedByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
39
+ );
40
+ /*
41
+ * CASCADE, not SET NULL. An orphaned row would be a live sign-in
42
+ * credential for an account that no longer exists -- and, with `userId`
43
+ * null, invisible to every query in the service, which all key on the
44
+ * owner.
45
+ */
46
+ await queryRunner.query(
47
+ `ALTER TABLE "UserTwoFactorBackupCode" ADD CONSTRAINT "FK_dd1f93f94cd3a4f39435b56d944" FOREIGN KEY ("userId") REFERENCES "User"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
48
+ );
49
+ }
50
+
51
+ public async down(queryRunner: QueryRunner): Promise<void> {
52
+ await queryRunner.query(
53
+ `ALTER TABLE "UserTwoFactorBackupCode" DROP CONSTRAINT "FK_dd1f93f94cd3a4f39435b56d944"`,
54
+ );
55
+ await queryRunner.query(
56
+ `ALTER TABLE "UserTwoFactorBackupCode" DROP CONSTRAINT "FK_b6f9bfb29ab554076a2d8dde717"`,
57
+ );
58
+ await queryRunner.query(
59
+ `DROP INDEX "public"."IDX_19a5937a4f6bd993383b31f3a2"`,
60
+ );
61
+ await queryRunner.query(`DROP TABLE "UserTwoFactorBackupCode"`);
62
+ }
63
+ }
@@ -542,8 +542,8 @@ import { RemoveLlmCostBudgetAlertColumns1788500000000 } from "./1788500000000-Re
542
542
  import { AddDetectionRuleIncidentColumns1788600000000 } from "./1788600000000-AddDetectionRuleIncidentColumns";
543
543
  import { RemoveMarketingConversionUploadState1788700000000 } from "./1788700000000-RemoveMarketingConversionUploadState";
544
544
  import { DropMarketingConversionAddEnterpriseLicenseEmail1788800000000 } from "./1788800000000-DropMarketingConversionAddEnterpriseLicenseEmail";
545
- import { RedactStoredMonitorIngestSecrets1788900000000 } from "./1788900000000-RedactStoredMonitorIngestSecrets";
546
545
  import { AddUserSlackAndMicrosoftTeams1789000000000 } from "./1789000000000-AddUserSlackAndMicrosoftTeams";
546
+ import { AddUserTwoFactorBackupCode1789100000000 } from "./1789100000000-AddUserTwoFactorBackupCode";
547
547
  import { MigrationName1787142779538 } from "./1787142779538-MigrationName";
548
548
  import { MigrationName1787156982416 } from "./1787156982416-MigrationName";
549
549
 
@@ -1094,6 +1094,6 @@ export default [
1094
1094
  AddDetectionRuleIncidentColumns1788600000000,
1095
1095
  RemoveMarketingConversionUploadState1788700000000,
1096
1096
  DropMarketingConversionAddEnterpriseLicenseEmail1788800000000,
1097
- RedactStoredMonitorIngestSecrets1788900000000,
1098
1097
  AddUserSlackAndMicrosoftTeams1789000000000,
1098
+ AddUserTwoFactorBackupCode1789100000000,
1099
1099
  ];
@@ -0,0 +1,11 @@
1
+ import ClickhouseDatabase from "../Infrastructure/ClickhouseDatabase";
2
+ import AnalyticsDatabaseService from "./AnalyticsDatabaseService";
3
+ import ChangeEvent from "../../Models/AnalyticsModels/ChangeEvent";
4
+
5
+ export class ChangeEventService extends AnalyticsDatabaseService<ChangeEvent> {
6
+ public constructor(clickhouseDatabase?: ClickhouseDatabase | undefined) {
7
+ super({ modelType: ChangeEvent, database: clickhouseDatabase });
8
+ }
9
+ }
10
+
11
+ export default new ChangeEventService();
@@ -37,9 +37,9 @@ export class Service extends DatabaseService<EnterpriseLicense> {
37
37
  email: createdItem.email?.toString(),
38
38
  /*
39
39
  * No attribution source. A licence row is typed in by a human and
40
- * carries no session, so its campaign is whatever the meeting_booked
41
- * sharing its email carried — the join the receiver makes on
42
- * emailHash, not something OneUptime can restate here.
40
+ * carries no session, so its campaign is whatever the earlier
41
+ * conversion sharing its email carried — the join the receiver makes
42
+ * on emailHash, not something OneUptime can restate here.
43
43
  */
44
44
  data: {
45
45
  enterpriseLicenseId: createdItem.id?.toString() || "",
@@ -77,6 +77,7 @@ import DataSourceService from "./DataSourceService";
77
77
  import AuditLogService from "./AuditLogService";
78
78
  import LogService from "./LogService";
79
79
  import SecurityEventService from "./SecurityEventService";
80
+ import ChangeEventService from "./ChangeEventService";
80
81
  import MailService from "./MailService";
81
82
  import MetricService from "./MetricService";
82
83
  import MetricItemAggMV1mService from "./MetricItemAggMV1mService";
@@ -207,6 +208,7 @@ import UserOnCallLogTimelineService from "./UserOnCallLogTimelineService";
207
208
  import UserService from "./UserService";
208
209
  import UserSessionService from "./UserSessionService";
209
210
  import UserTotpAuthService from "./UserTotpAuthService";
211
+ import UserTwoFactorBackupCodeService from "./UserTwoFactorBackupCodeService";
210
212
  import UserWebAuthnService from "./UserWebAuthnService";
211
213
  import UserSmsService from "./UserSmsService";
212
214
  import UserIncomingCallNumberService from "./UserIncomingCallNumberService";
@@ -494,6 +496,7 @@ const services: Array<BaseService> = [
494
496
  UserSlackService,
495
497
  UserMicrosoftTeamsService,
496
498
  UserTotpAuthService,
499
+ UserTwoFactorBackupCodeService,
497
500
  UserWebAuthnService,
498
501
 
499
502
  WorkflowLogService,
@@ -641,6 +644,7 @@ export const AnalyticsServices: Array<
641
644
  RumSessionChunkService,
642
645
  AuditLogService,
643
646
  SecurityEventService,
647
+ ChangeEventService,
644
648
  ];
645
649
 
646
650
  export default services;
@@ -45,6 +45,7 @@ import ProductAnalytics from "../Utils/ProductAnalytics";
45
45
  import MarketingEventUtil from "../Utils/Marketing/MarketingEventUtil";
46
46
  import { MarketingEventType } from "../../Types/Marketing/MarketingEvent";
47
47
  import UserTotpAuthService from "./UserTotpAuthService";
48
+ import UserTwoFactorBackupCodeService from "./UserTwoFactorBackupCodeService";
48
49
  import UserWebAuthnService from "./UserWebAuthnService";
49
50
  import BadDataException from "../../Types/Exception/BadDataException";
50
51
  import NotFoundException from "../../Types/Exception/NotFoundException";
@@ -819,6 +820,18 @@ export class Service extends DatabaseService<Model> {
819
820
  const verifiedTwoFactorAuthMethodCount: number =
820
821
  await this.countVerifiedTwoFactorAuthMethods(userId);
821
822
 
823
+ /*
824
+ * Read separately from the method count above, and NOT added to it. See
825
+ * the note on `deriveTwoFactorAuthStatus`: folding recovery codes into the
826
+ * configured-method count would report an account whose authenticator is
827
+ * gone as fully set up, and login would keep sending it to the challenge
828
+ * screen until the last code was spent.
829
+ */
830
+ const unusedTwoFactorBackupCodeCount: number =
831
+ await UserTwoFactorBackupCodeService.countUnusedForUser({
832
+ userId: userId,
833
+ });
834
+
822
835
  return {
823
836
  hasPassword: Boolean(user.password),
824
837
  isEmailVerified: Boolean(user.isEmailVerified),
@@ -828,6 +841,7 @@ export class Service extends DatabaseService<Model> {
828
841
  verifiedMethodCount: verifiedTwoFactorAuthMethodCount,
829
842
  }),
830
843
  verifiedTwoFactorAuthMethodCount: verifiedTwoFactorAuthMethodCount,
844
+ unusedTwoFactorBackupCodeCount: unusedTwoFactorBackupCodeCount,
831
845
  hasPendingPasswordResetLink: hasPendingPasswordResetLink,
832
846
  };
833
847
  }
@@ -845,6 +859,17 @@ export class Service extends DatabaseService<Model> {
845
859
  * Counting unverified rows would therefore report a user who has never once
846
860
  * typed a code as fully configured -- and that user is precisely the one an
847
861
  * operator is looking at the page to help.
862
+ *
863
+ * BACKUP CODES ARE NOT COUNTED HERE, for a related reason one step further
864
+ * on. This number decides `deriveTwoFactorAuthStatus`, which decides whether
865
+ * login shows the two factor CHALLENGE or sends the account through
866
+ * enrolment. An account whose only remaining material was recovery codes
867
+ * would, if they counted, be shown a challenge it cannot answer except by
868
+ * spending a code -- one per sign-in, with no way to enrol a new
869
+ * authenticator, until the last one is gone. Recovery codes are the way back
870
+ * in to an account with a factor it cannot reach; they are not a factor.
871
+ * `UserAuthenticationStatus.unusedTwoFactorBackupCodeCount` reports them
872
+ * separately.
848
873
  */
849
874
  @CaptureSpan()
850
875
  private async countVerifiedTwoFactorAuthMethods(
@@ -1036,6 +1061,23 @@ export class Service extends DatabaseService<Model> {
1036
1061
  },
1037
1062
  });
1038
1063
 
1064
+ /*
1065
+ * The recovery codes go with the authenticators, and this is not
1066
+ * bookkeeping. A backup code signs somebody in on its own, so a reset that
1067
+ * cleared the TOTP secret but left a printed list of codes alive would
1068
+ * have revoked nothing that an attacker who took the phone -- and the
1069
+ * paper next to it -- still holds. "Reset two factor auth" has to mean all
1070
+ * of the second-factor material, not the part that is easiest to see.
1071
+ *
1072
+ * It also matters in the harmless direction: the user is about to be sent
1073
+ * through enrolment at their next sign-in, and codes minted against an
1074
+ * authenticator that no longer exists are dead weight nobody would think
1075
+ * to clear.
1076
+ */
1077
+ await UserTwoFactorBackupCodeService.deleteAllForUser({
1078
+ userId: user.id!,
1079
+ });
1080
+
1039
1081
  /*
1040
1082
  * Ordered after the deletes, not before. A session that survives a reset
1041
1083
  * is a session that never has to prove the second factor again, which