@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,331 @@
1
+ import AnalyticsBaseModel from "./AnalyticsBaseModel/AnalyticsBaseModel";
2
+ import Route from "../../Types/API/Route";
3
+ import AnalyticsTableEngine from "../../Types/AnalyticsDatabase/AnalyticsTableEngine";
4
+ import AnalyticsTableName from "../../Types/AnalyticsDatabase/AnalyticsTableName";
5
+ import AnalyticsTableColumn, {
6
+ SkipIndexType,
7
+ } from "../../Types/AnalyticsDatabase/TableColumn";
8
+ import TableColumnType from "../../Types/AnalyticsDatabase/TableColumnType";
9
+ import OperationalResource from "../../Types/Database/AccessControl/OperationalResource";
10
+ import OwnedThrough from "../../Types/Database/AccessControl/OwnedThrough";
11
+ import { JSONObject } from "../../Types/JSON";
12
+ import ObjectID from "../../Types/ObjectID";
13
+ import Permission from "../../Types/Permission";
14
+ import Service from "../DatabaseModels/Service";
15
+ import ServiceType from "../../Types/Telemetry/ServiceType";
16
+
17
+ /*
18
+ * Deploys, config changes, scaling events — the "what changed?" signal.
19
+ * Rows are posted by CI/CD pipelines through the change-events ingest API
20
+ * (or created from the product) and rendered as vertical dashed markers
21
+ * on metric charts, so "the spike started 40 seconds after deploy X" is
22
+ * visible without leaving the chart.
23
+ *
24
+ * Peer of LogItemV3 in layout: tenant projectId, per-row retentionDate
25
+ * TTL, Map(String,String) attributes with a bloom-indexed keys sidecar.
26
+ * Deliberately narrow — this is an annotation stream, not a log store.
27
+ */
28
+
29
+ const readPermissions: Array<Permission> = [
30
+ Permission.ProjectOwner,
31
+ Permission.ProjectAdmin,
32
+ Permission.ProjectMember,
33
+ Permission.Viewer,
34
+ Permission.TelemetryAdmin,
35
+ Permission.TelemetryMember,
36
+ Permission.TelemetryViewer,
37
+ ];
38
+
39
+ const createPermissions: Array<Permission> = [
40
+ Permission.ProjectOwner,
41
+ Permission.ProjectAdmin,
42
+ Permission.ProjectMember,
43
+ Permission.TelemetryAdmin,
44
+ Permission.TelemetryMember,
45
+ ];
46
+
47
+ @OperationalResource()
48
+ @OwnedThrough("primaryEntityId", Service, { includeProjectScope: true })
49
+ export default class ChangeEvent extends AnalyticsBaseModel {
50
+ public constructor() {
51
+ const projectIdColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
52
+ key: "projectId",
53
+ title: "Project ID",
54
+ description: "ID of project",
55
+ required: true,
56
+ type: TableColumnType.ObjectID,
57
+ isTenantId: true,
58
+ accessControl: {
59
+ read: readPermissions,
60
+ create: createPermissions,
61
+ update: [],
62
+ },
63
+ });
64
+
65
+ const primaryEntityIdColumn: AnalyticsTableColumn =
66
+ new AnalyticsTableColumn({
67
+ key: "primaryEntityId",
68
+ title: "Service ID",
69
+ description:
70
+ "ID of the telemetry Service this change belongs to (the deployed service). Optional — project-wide changes carry none.",
71
+ required: false,
72
+ type: TableColumnType.ObjectID,
73
+ accessControl: {
74
+ read: readPermissions,
75
+ create: createPermissions,
76
+ update: [],
77
+ },
78
+ });
79
+
80
+ const primaryEntityTypeColumn: AnalyticsTableColumn =
81
+ new AnalyticsTableColumn({
82
+ key: "primaryEntityType",
83
+ isLowCardinality: true,
84
+ title: "Service Type",
85
+ description: "Discriminator for primaryEntityId",
86
+ required: false,
87
+ type: TableColumnType.Text,
88
+ accessControl: {
89
+ read: readPermissions,
90
+ create: createPermissions,
91
+ update: [],
92
+ },
93
+ });
94
+
95
+ const timeColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
96
+ key: "time",
97
+ title: "Time",
98
+ description: "When the change happened",
99
+ required: true,
100
+ type: TableColumnType.DateTime64,
101
+ accessControl: {
102
+ read: readPermissions,
103
+ create: createPermissions,
104
+ update: [],
105
+ },
106
+ });
107
+
108
+ const eventTypeColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
109
+ key: "eventType",
110
+ isLowCardinality: true,
111
+ title: "Event Type",
112
+ description:
113
+ 'Kind of change — e.g. "deployment", "config-change", "scaling", "rollback", "custom"',
114
+ required: true,
115
+ defaultValue: "deployment",
116
+ type: TableColumnType.Text,
117
+ skipIndex: {
118
+ name: "idx_change_event_type",
119
+ type: SkipIndexType.Set,
120
+ params: [16],
121
+ granularity: 4,
122
+ },
123
+ accessControl: {
124
+ read: readPermissions,
125
+ create: createPermissions,
126
+ update: [],
127
+ },
128
+ });
129
+
130
+ const titleColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
131
+ key: "title",
132
+ title: "Title",
133
+ description: 'Short human label, e.g. "Deploy v2.31.0"',
134
+ required: true,
135
+ type: TableColumnType.Text,
136
+ accessControl: {
137
+ read: readPermissions,
138
+ create: createPermissions,
139
+ update: [],
140
+ },
141
+ });
142
+
143
+ const descriptionColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
144
+ key: "description",
145
+ title: "Description",
146
+ description: "Optional detail — commit message, change summary, links",
147
+ required: false,
148
+ type: TableColumnType.Text,
149
+ accessControl: {
150
+ read: readPermissions,
151
+ create: createPermissions,
152
+ update: [],
153
+ },
154
+ });
155
+
156
+ const attributesColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
157
+ key: "attributes",
158
+ codec: { codec: "ZSTD", level: 3 },
159
+ title: "Attributes",
160
+ description:
161
+ "Arbitrary tags — version, commit sha, pipeline url, environment",
162
+ required: true,
163
+ defaultValue: {},
164
+ type: TableColumnType.MapStringString,
165
+ mapKeysColumn: "attributeKeys",
166
+ accessControl: {
167
+ read: readPermissions,
168
+ create: createPermissions,
169
+ update: [],
170
+ },
171
+ });
172
+
173
+ const attributeKeysColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
174
+ key: "attributeKeys",
175
+ codec: { codec: "ZSTD", level: 3 },
176
+ title: "Attribute Keys",
177
+ description: "Attribute keys extracted from attributes",
178
+ required: true,
179
+ defaultValue: [],
180
+ type: TableColumnType.ArrayText,
181
+ skipIndex: {
182
+ name: "idx_attribute_keys",
183
+ type: SkipIndexType.BloomFilter,
184
+ params: [0.01],
185
+ granularity: 1,
186
+ },
187
+ accessControl: {
188
+ read: readPermissions,
189
+ create: createPermissions,
190
+ update: [],
191
+ },
192
+ });
193
+
194
+ const retentionDateColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
195
+ key: "retentionDate",
196
+ codec: [{ codec: "DoubleDelta" }, { codec: "ZSTD", level: 1 }],
197
+ title: "Retention Date",
198
+ description:
199
+ "Date after which this row is eligible for TTL deletion, computed at ingest time",
200
+ required: true,
201
+ type: TableColumnType.Date,
202
+ defaultValue: undefined,
203
+ });
204
+
205
+ super({
206
+ tableName: AnalyticsTableName.ChangeEvent,
207
+ tableEngine: AnalyticsTableEngine.MergeTree,
208
+ singularName: "Change Event",
209
+ accessControl: {
210
+ read: readPermissions,
211
+ create: createPermissions,
212
+ update: [],
213
+ delete: [
214
+ Permission.ProjectOwner,
215
+ Permission.ProjectAdmin,
216
+ Permission.TelemetryAdmin,
217
+ ],
218
+ },
219
+ pluralName: "Change Events",
220
+ crudApiPath: new Route("/change-events"),
221
+ enableMCP: true,
222
+ enableDocumentation: true,
223
+ tableDescription:
224
+ "Change events (deployments, config changes, scaling) posted by CI/CD pipelines. Rendered as markers on metric charts so spikes can be correlated with what changed.",
225
+ tableColumns: [
226
+ projectIdColumn,
227
+ primaryEntityIdColumn,
228
+ primaryEntityTypeColumn,
229
+ timeColumn,
230
+ eventTypeColumn,
231
+ titleColumn,
232
+ descriptionColumn,
233
+ attributesColumn,
234
+ attributeKeysColumn,
235
+ retentionDateColumn,
236
+ ],
237
+ sortKeys: ["projectId", "time"],
238
+ primaryKeys: ["projectId", "time"],
239
+ partitionKey: "toYYYYMMDD(time)",
240
+ /*
241
+ * Tiny volume (a handful of rows per deploy) — shard by the
242
+ * always-present tenant + time pair; no hotspot risk at this rate.
243
+ */
244
+ shardingKey: "cityHash64(projectId, time)",
245
+ tableSettings:
246
+ "ttl_only_drop_parts = 1, non_replicated_deduplication_window = 10000",
247
+ ttlExpression: "retentionDate DELETE",
248
+ defaultSortColumn: "time",
249
+ });
250
+ }
251
+
252
+ public get projectId(): ObjectID | undefined {
253
+ return this.getColumnValue("projectId") as ObjectID | undefined;
254
+ }
255
+
256
+ public set projectId(v: ObjectID | undefined) {
257
+ this.setColumnValue("projectId", v);
258
+ }
259
+
260
+ public get primaryEntityId(): ObjectID | undefined {
261
+ return this.getColumnValue("primaryEntityId") as ObjectID | undefined;
262
+ }
263
+
264
+ public set primaryEntityId(v: ObjectID | undefined) {
265
+ this.setColumnValue("primaryEntityId", v);
266
+ }
267
+
268
+ public get primaryEntityType(): ServiceType | undefined {
269
+ return this.getColumnValue("primaryEntityType") as ServiceType | undefined;
270
+ }
271
+
272
+ public set primaryEntityType(v: ServiceType | undefined) {
273
+ this.setColumnValue("primaryEntityType", v);
274
+ }
275
+
276
+ public get time(): Date | undefined {
277
+ return this.getColumnValue("time") as Date | undefined;
278
+ }
279
+
280
+ public set time(v: Date | undefined) {
281
+ this.setColumnValue("time", v);
282
+ }
283
+
284
+ public get eventType(): string | undefined {
285
+ return this.getColumnValue("eventType") as string | undefined;
286
+ }
287
+
288
+ public set eventType(v: string | undefined) {
289
+ this.setColumnValue("eventType", v);
290
+ }
291
+
292
+ public get title(): string | undefined {
293
+ return this.getColumnValue("title") as string | undefined;
294
+ }
295
+
296
+ public set title(v: string | undefined) {
297
+ this.setColumnValue("title", v);
298
+ }
299
+
300
+ public get description(): string | undefined {
301
+ return this.getColumnValue("description") as string | undefined;
302
+ }
303
+
304
+ public set description(v: string | undefined) {
305
+ this.setColumnValue("description", v);
306
+ }
307
+
308
+ public get attributes(): JSONObject | undefined {
309
+ return this.getColumnValue("attributes") as JSONObject | undefined;
310
+ }
311
+
312
+ public set attributes(v: JSONObject | undefined) {
313
+ this.setColumnValue("attributes", v);
314
+ }
315
+
316
+ public get attributeKeys(): Array<string> | undefined {
317
+ return this.getColumnValue("attributeKeys") as Array<string> | undefined;
318
+ }
319
+
320
+ public set attributeKeys(v: Array<string> | undefined) {
321
+ this.setColumnValue("attributeKeys", v);
322
+ }
323
+
324
+ public get retentionDate(): Date | undefined {
325
+ return this.getColumnValue("retentionDate") as Date | undefined;
326
+ }
327
+
328
+ public set retentionDate(v: Date | undefined) {
329
+ this.setColumnValue("retentionDate", v);
330
+ }
331
+ }
@@ -21,6 +21,7 @@ import ProfileSample from "./ProfileSample";
21
21
  import RumSession from "./RumSession";
22
22
  import RumSessionChunk from "./RumSessionChunk";
23
23
  import AuditLog from "./AuditLog";
24
+ import ChangeEvent from "./ChangeEvent";
24
25
 
25
26
  const AnalyticsModels: Array<{ new (): AnalyticsBaseModel }> = [
26
27
  Log,
@@ -69,6 +70,7 @@ const AnalyticsModels: Array<{ new (): AnalyticsBaseModel }> = [
69
70
  RumSessionChunk,
70
71
  AuditLog,
71
72
  SecurityEvent,
73
+ ChangeEvent,
72
74
  ];
73
75
 
74
76
  const modelTypeMap: { [key: string]: { new (): AnalyticsBaseModel } } = {};
@@ -311,6 +311,7 @@ import AutoRemediationRule from "./AutoRemediationRule";
311
311
  import AutoRemediationSuggestion from "./AutoRemediationSuggestion";
312
312
 
313
313
  import UserTotpAuth from "./UserTotpAuth";
314
+ import UserTwoFactorBackupCode from "./UserTwoFactorBackupCode";
314
315
  import UserWebAuthn from "./UserWebAuthn";
315
316
 
316
317
  import TelemetryIngestionKey from "./TelemetryIngestionKey";
@@ -797,6 +798,7 @@ const AllModelTypes: Array<{
797
798
 
798
799
  UserSession,
799
800
  UserTotpAuth,
801
+ UserTwoFactorBackupCode,
800
802
  UserWebAuthn,
801
803
 
802
804
  TelemetryIngestionKey,
@@ -0,0 +1,262 @@
1
+ import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel";
2
+ import User from "./User";
3
+ import Route from "../../Types/API/Route";
4
+ import AllowAccessIfSubscriptionIsUnpaid from "../../Types/Database/AccessControl/AllowAccessIfSubscriptionIsUnpaid";
5
+ import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl";
6
+ import TableAccessControl from "../../Types/Database/AccessControl/TableAccessControl";
7
+ import AllowUserQueryWithoutTenant from "../../Types/Database/AllowUserQueryWithoutTenant";
8
+ import ColumnLength from "../../Types/Database/ColumnLength";
9
+ import ColumnType from "../../Types/Database/ColumnType";
10
+ import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint";
11
+ import CurrentUserCanAccessRecordBy from "../../Types/Database/CurrentUserCanAccessRecordBy";
12
+ import EnableDocumentation from "../../Types/Database/EnableDocumentation";
13
+ import TableColumn from "../../Types/Database/TableColumn";
14
+ import TableColumnType from "../../Types/Database/TableColumnType";
15
+ import TableMetadata from "../../Types/Database/TableMetadata";
16
+ import IconProp from "../../Types/Icon/IconProp";
17
+ import ObjectID from "../../Types/ObjectID";
18
+ import Permission from "../../Types/Permission";
19
+ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
20
+
21
+ /*
22
+ * One single-use recovery code, for the sign-in a user cannot otherwise
23
+ * complete: the authenticator app went down with the phone, or the security
24
+ * key is gone.
25
+ *
26
+ * ONE ROW PER CODE, NOT ONE ROW PER USER
27
+ *
28
+ * The alternative -- a single row holding a JSON array of digests -- would
29
+ * make consuming a code a read-modify-write of the whole array, and two
30
+ * sign-in attempts arriving together would each write back a copy of the array
31
+ * missing only their own code, so one of the two consumed codes would come
32
+ * back to life. A row per code turns consumption into one conditional UPDATE
33
+ * that Postgres settles for us (see UserTwoFactorBackupCodeService.consumeCode).
34
+ *
35
+ * IT IS NOT A SECOND FACTOR, AND IS DELIBERATELY NOT COUNTED AS ONE
36
+ *
37
+ * UserService.countVerifiedTwoFactorAuthMethods sums TOTP apps and security
38
+ * keys and does NOT look here. That is load-bearing rather than an oversight:
39
+ * an account whose only remaining "method" was a backup code would read as
40
+ * fully configured, so login would send it to the two factor CHALLENGE screen
41
+ * instead of through enrolment -- and the user, having no authenticator left
42
+ * to enrol from, would burn recovery codes one per sign-in until they ran out
43
+ * and then be locked out for good. Backup codes are the way BACK IN to an
44
+ * account that has a factor it cannot currently reach; they are not the
45
+ * factor.
46
+ *
47
+ * NOTHING HERE IS WRITABLE THROUGH THE CRUD API
48
+ *
49
+ * Every table permission except read is empty, so codes can only be minted by
50
+ * the service (which writes as root) -- never by a caller choosing their own.
51
+ * A user who could POST their own code has turned a stolen password into a
52
+ * permanent second factor of their own devising.
53
+ */
54
+ @EnableDocumentation({
55
+ isMasterAdminApiDocs: true,
56
+ })
57
+ @AllowAccessIfSubscriptionIsUnpaid()
58
+ @TableAccessControl({
59
+ /*
60
+ * Minting is POST /user-two-factor-backup-code/generate, which regenerates
61
+ * the whole set at once. There is no meaningful "create one code" operation
62
+ * and, more to the point, no safe one: the code is the credential, so a
63
+ * caller supplying it is choosing their own recovery secret.
64
+ */
65
+ create: [],
66
+
67
+ /*
68
+ * Read is open to the owner so the profile page can count what is left. It
69
+ * exposes nothing: `codeHash` denies read to everybody, so a row read here
70
+ * carries only its id, its `usedAt` and its owner.
71
+ */
72
+ read: [Permission.CurrentUser],
73
+
74
+ /*
75
+ * Consumption is a server-side write on the login path and regeneration
76
+ * replaces the set wholesale. A user-driven update or delete could only
77
+ * make a code stop working, which is the same thing regeneration does more
78
+ * clearly, and would give an attacker holding a session a way to quietly
79
+ * strip the account's recovery options.
80
+ */
81
+ delete: [],
82
+ update: [],
83
+ })
84
+ @CrudApiEndpoint(new Route("/user-two-factor-backup-code"))
85
+ @Entity({
86
+ name: "UserTwoFactorBackupCode",
87
+ })
88
+ @TableMetadata({
89
+ tableName: "UserTwoFactorBackupCode",
90
+ singularName: "Two Factor Backup Code",
91
+ pluralName: "Two Factor Backup Codes",
92
+ icon: IconProp.Key,
93
+ tableDescription:
94
+ "Single-use backup codes that let a user sign in when their two factor authentication device is unavailable",
95
+ })
96
+ @AllowUserQueryWithoutTenant(true)
97
+ @CurrentUserCanAccessRecordBy("userId")
98
+ /*
99
+ * The entire hot path is `WHERE "userId" = ... AND "codeHash" = ...` -- the
100
+ * conditional UPDATE that consumes a code during a sign-in somebody is locked
101
+ * out of. `userId` alone leads the index so the profile page's "how many are
102
+ * left" count uses it too.
103
+ */
104
+ @Index(["userId", "codeHash"])
105
+ class UserTwoFactorBackupCode extends BaseModel {
106
+ /*
107
+ * HMAC-SHA256 of the code, keyed by the instance's EncryptionSecret and
108
+ * domain separated by the owning user -- see
109
+ * Common/Server/Utils/TwoFactorBackupCode.ts for the construction.
110
+ *
111
+ * Read is denied to EVERYONE, including the owner and a master admin. There
112
+ * is no product question this column answers: the plaintext is shown once,
113
+ * at generation, and never again. A master admin bypasses column read
114
+ * permissions on some paths, which is exactly why nothing here is worth
115
+ * shipping to a browser even in digest form.
116
+ *
117
+ * Not UNIQUE. The digest is domain separated by user, so two users cannot
118
+ * collide even if they were issued the same code, and within one user
119
+ * `generateCodeSet` already rules duplicates out -- a unique constraint
120
+ * would add nothing and would turn an astronomically unlikely collision
121
+ * into a failed regeneration for somebody who is already having a bad day.
122
+ */
123
+ @ColumnAccessControl({
124
+ create: [],
125
+ read: [],
126
+ update: [],
127
+ })
128
+ @TableColumn({
129
+ type: TableColumnType.ShortText,
130
+ canReadOnRelationQuery: false,
131
+ title: "Backup Code Hash",
132
+ description: "Keyed hash of this single-use two factor backup code",
133
+ hideColumnInDocumentation: true,
134
+ })
135
+ @Column({
136
+ type: ColumnType.ShortText,
137
+ length: ColumnLength.ShortText,
138
+ nullable: false,
139
+ unique: false,
140
+ })
141
+ public codeHash?: string = undefined;
142
+
143
+ /*
144
+ * When this code was spent, or null while it is still usable.
145
+ *
146
+ * The row is kept rather than deleted on use, for two reasons that both
147
+ * matter to somebody recovering an account: the profile page can say "3 of
148
+ * 10 remaining" instead of silently shrinking a list, and a user who is
149
+ * asking "did somebody else get into my account?" has a timestamp to look
150
+ * at rather than an absence.
151
+ */
152
+ @ColumnAccessControl({
153
+ create: [],
154
+ read: [Permission.CurrentUser],
155
+ update: [],
156
+ })
157
+ @TableColumn({
158
+ type: TableColumnType.Date,
159
+ canReadOnRelationQuery: false,
160
+ title: "Used At",
161
+ description:
162
+ "When this backup code was used to sign in. Null while the code is still unused.",
163
+ })
164
+ @Column({
165
+ type: ColumnType.Date,
166
+ nullable: true,
167
+ unique: false,
168
+ })
169
+ public usedAt?: Date = undefined;
170
+
171
+ @ColumnAccessControl({
172
+ create: [],
173
+ read: [],
174
+ update: [],
175
+ })
176
+ @TableColumn({
177
+ manyToOneRelationColumn: "deletedByUserId",
178
+ type: TableColumnType.Entity,
179
+ title: "Deleted by User",
180
+ modelType: User,
181
+ description:
182
+ "Relation to User who deleted this object (if this object was deleted by a User)",
183
+ })
184
+ @ManyToOne(
185
+ () => {
186
+ return User;
187
+ },
188
+ {
189
+ cascade: false,
190
+ eager: false,
191
+ nullable: true,
192
+ onDelete: "SET NULL",
193
+ orphanedRowAction: "nullify",
194
+ },
195
+ )
196
+ @JoinColumn({ name: "deletedByUserId" })
197
+ public deletedByUser?: User = undefined;
198
+
199
+ @ColumnAccessControl({
200
+ create: [],
201
+ read: [],
202
+ update: [],
203
+ })
204
+ @TableColumn({
205
+ type: TableColumnType.ObjectID,
206
+ title: "Deleted by User ID",
207
+ description:
208
+ "User ID who deleted this object (if this object was deleted by a User)",
209
+ })
210
+ @Column({
211
+ type: ColumnType.ObjectID,
212
+ nullable: true,
213
+ transformer: ObjectID.getDatabaseTransformer(),
214
+ })
215
+ public deletedByUserId?: ObjectID = undefined;
216
+
217
+ @ColumnAccessControl({
218
+ create: [],
219
+ read: [Permission.CurrentUser],
220
+ update: [],
221
+ })
222
+ @TableColumn({
223
+ manyToOneRelationColumn: "userId",
224
+ type: TableColumnType.Entity,
225
+ title: "User",
226
+ modelType: User,
227
+ description: "Relation to User who owns this backup code",
228
+ })
229
+ @ManyToOne(
230
+ () => {
231
+ return User;
232
+ },
233
+ {
234
+ cascade: false,
235
+ eager: false,
236
+ nullable: true,
237
+ onDelete: "CASCADE",
238
+ orphanedRowAction: "nullify",
239
+ },
240
+ )
241
+ @JoinColumn({ name: "userId" })
242
+ public user?: User = undefined;
243
+
244
+ @ColumnAccessControl({
245
+ create: [],
246
+ read: [Permission.CurrentUser],
247
+ update: [],
248
+ })
249
+ @TableColumn({
250
+ type: TableColumnType.ObjectID,
251
+ title: "User ID",
252
+ description: "User ID who owns this backup code",
253
+ })
254
+ @Column({
255
+ type: ColumnType.ObjectID,
256
+ nullable: true,
257
+ transformer: ObjectID.getDatabaseTransformer(),
258
+ })
259
+ public userId?: ObjectID = undefined;
260
+ }
261
+
262
+ export default UserTwoFactorBackupCode;