@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.
- package/Models/AnalyticsModels/ChangeEvent.ts +331 -0
- package/Models/AnalyticsModels/Index.ts +2 -0
- package/Models/DatabaseModels/Index.ts +2 -0
- package/Models/DatabaseModels/UserTwoFactorBackupCode.ts +262 -0
- package/Server/API/UserTwoFactorBackupCodeAPI.ts +258 -0
- package/Server/EnvironmentConfig.ts +1 -8
- package/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.ts +42 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddUserTwoFactorBackupCode.ts +63 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -2
- package/Server/Services/ChangeEventService.ts +11 -0
- package/Server/Services/EnterpriseLicenseService.ts +3 -3
- package/Server/Services/Index.ts +4 -0
- package/Server/Services/UserService.ts +42 -0
- package/Server/Services/UserTwoFactorBackupCodeService.ts +355 -0
- package/Server/Utils/Attribution.ts +11 -11
- package/Server/Utils/Marketing/MarketingEventWebhook.ts +4 -5
- package/Server/Utils/Telemetry/ChangeEventRow.ts +228 -0
- package/Server/Utils/TwoFactorBackupCode.ts +266 -0
- package/Server/Views/Partials/AnalyticsConsent.ejs +3 -95
- package/Tests/App/Dashboard/DashboardChartWidgetZoom.test.tsx +64 -0
- package/Tests/App/Dashboard/EventOverlayHook.test.tsx +249 -0
- package/Tests/App/Dashboard/InvestigationArtifacts.test.tsx +315 -0
- package/Tests/App/Dashboard/InvestigationDrawer.test.tsx +258 -0
- package/Tests/App/Dashboard/MetricChartsCompare.test.tsx +171 -0
- package/Tests/App/Dashboard/MetricViewCompare.test.tsx +233 -0
- package/Tests/Server/API/UserAuthenticationAPI.test.ts +16 -3
- package/Tests/Server/API/UserTwoFactorAuthAdminAPI.test.ts +18 -2
- package/Tests/Server/API/UserTwoFactorBackupCodeAPI.test.ts +1390 -0
- package/Tests/Server/Infrastructure/Postgres/AddUserSlackAndMicrosoftTeamsMigration.test.ts +236 -0
- package/Tests/Server/Services/UserAuthenticationService.test.ts +23 -1
- package/Tests/Server/Services/UserTwoFactorAuthAdmin.test.ts +21 -0
- package/Tests/Server/Services/UserTwoFactorBackupCodeAdminSurface.test.ts +919 -0
- package/Tests/Server/Services/UserTwoFactorBackupCodeService.test.ts +862 -0
- package/Tests/Server/Utils/AI/SRE/Insights/Detectors/ExceptionIdentity.test.ts +224 -0
- package/Tests/Server/Utils/Attribution.test.ts +4 -5
- package/Tests/Server/Utils/Marketing/MarketingEventUtil.test.ts +2 -2
- package/Tests/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.test.ts +300 -0
- package/Tests/Server/Utils/TwoFactorBackupCode.test.ts +475 -0
- package/Tests/UI/Components/Charts/ChartGhostSeries.test.tsx +103 -0
- package/Tests/UI/Components/Charts/TooltipEntries.test.ts +44 -8
- package/Tests/Utils/Telemetry/CrossSignalScope.test.ts +13 -2
- package/Types/AnalyticsDatabase/AnalyticsTableName.ts +7 -0
- package/Types/Email/EmailTemplateType.ts +2 -0
- package/Types/Marketing/Attribution.ts +9 -15
- package/Types/Marketing/MarketingEvent.ts +10 -46
- package/Types/UserAuthenticationStatus.ts +16 -0
- package/UI/Components/Charts/Area/AreaChart.tsx +15 -0
- package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +16 -1
- package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +189 -93
- package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +6 -1
- package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +213 -114
- package/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.ts +42 -27
- package/UI/Components/Charts/Line/LineChart.tsx +15 -0
- package/Utils/Metrics/MetricExplorerUrl.ts +119 -5
- package/Utils/Telemetry/CrossSignalScope.ts +19 -10
- package/build/dist/Models/AnalyticsModels/ChangeEvent.js +298 -0
- package/build/dist/Models/AnalyticsModels/ChangeEvent.js.map +1 -0
- package/build/dist/Models/AnalyticsModels/Index.js +2 -0
- package/build/dist/Models/AnalyticsModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Index.js +2 -0
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserTwoFactorBackupCode.js +277 -0
- package/build/dist/Models/DatabaseModels/UserTwoFactorBackupCode.js.map +1 -0
- package/build/dist/Server/API/UserTwoFactorBackupCodeAPI.js +201 -0
- package/build/dist/Server/API/UserTwoFactorBackupCodeAPI.js.map +1 -0
- package/build/dist/Server/EnvironmentConfig.js +1 -7
- package/build/dist/Server/EnvironmentConfig.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js +39 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddUserTwoFactorBackupCode.js +46 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddUserTwoFactorBackupCode.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -2
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/ChangeEventService.js +9 -0
- package/build/dist/Server/Services/ChangeEventService.js.map +1 -0
- package/build/dist/Server/Services/EnterpriseLicenseService.js +3 -3
- package/build/dist/Server/Services/Index.js +4 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Services/UserService.js +39 -0
- package/build/dist/Server/Services/UserService.js.map +1 -1
- package/build/dist/Server/Services/UserTwoFactorBackupCodeService.js +327 -0
- package/build/dist/Server/Services/UserTwoFactorBackupCodeService.js.map +1 -0
- package/build/dist/Server/Utils/Attribution.js +6 -6
- package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js +4 -5
- package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js +150 -0
- package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js.map +1 -0
- package/build/dist/Server/Utils/TwoFactorBackupCode.js +269 -0
- package/build/dist/Server/Utils/TwoFactorBackupCode.js.map +1 -0
- package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js +7 -0
- package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js.map +1 -1
- package/build/dist/Types/Email/EmailTemplateType.js +2 -0
- package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
- package/build/dist/Types/Marketing/Attribution.js +9 -15
- package/build/dist/Types/Marketing/Attribution.js.map +1 -1
- package/build/dist/Types/Marketing/MarketingEvent.js +2 -33
- package/build/dist/Types/Marketing/MarketingEvent.js.map +1 -1
- package/build/dist/UI/Components/Charts/Area/AreaChart.js +1 -1
- package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +9 -1
- package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +92 -38
- package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +8 -8
- package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +95 -37
- package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js +39 -24
- package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js.map +1 -1
- package/build/dist/UI/Components/Charts/Line/LineChart.js +1 -1
- package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
- package/build/dist/Utils/Metrics/MetricExplorerUrl.js +81 -0
- package/build/dist/Utils/Metrics/MetricExplorerUrl.js.map +1 -1
- package/build/dist/Utils/Telemetry/CrossSignalScope.js +14 -8
- package/build/dist/Utils/Telemetry/CrossSignalScope.js.map +1 -1
- package/package.json +1 -1
- package/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.ts +0 -117
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js +0 -106
- 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;
|