@oneuptime/common 12.0.27 → 12.0.28
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/RumSessionChunk.ts +52 -0
- package/Models/DatabaseModels/CloudResource.ts +17 -1
- package/Models/DatabaseModels/RumApplication.ts +35 -2
- package/Server/API/TelemetryAPI.ts +104 -15
- package/Server/Utils/SessionReplay/SessionReplayGateCache.ts +12 -5
- package/Server/Utils/SessionReplay/SessionReplayIdentity.ts +102 -0
- package/Server/Utils/SessionReplay/SessionReplayReadService.ts +11 -2
- package/Tests/Models/DatabaseModels/SessionReplayModels.test.ts +81 -0
- package/Tests/Server/API/SessionReplayAPI.test.ts +232 -0
- package/Types/Rum/SessionReplay.ts +14 -0
- package/build/dist/Models/AnalyticsModels/RumSessionChunk.js +48 -0
- package/build/dist/Models/AnalyticsModels/RumSessionChunk.js.map +1 -1
- package/build/dist/Models/DatabaseModels/CloudResource.js +19 -2
- package/build/dist/Models/DatabaseModels/CloudResource.js.map +1 -1
- package/build/dist/Models/DatabaseModels/RumApplication.js +37 -3
- package/build/dist/Models/DatabaseModels/RumApplication.js.map +1 -1
- package/build/dist/Server/API/TelemetryAPI.js +67 -14
- package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
- package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js.map +1 -1
- package/build/dist/Server/Utils/SessionReplay/SessionReplayIdentity.js +59 -0
- package/build/dist/Server/Utils/SessionReplay/SessionReplayIdentity.js.map +1 -0
- package/build/dist/Server/Utils/SessionReplay/SessionReplayReadService.js +9 -2
- package/build/dist/Server/Utils/SessionReplay/SessionReplayReadService.js.map +1 -1
- package/package.json +1 -1
|
@@ -450,6 +450,56 @@ export default class RumSessionChunk extends AnalyticsBaseModel {
|
|
|
450
450
|
});
|
|
451
451
|
});
|
|
452
452
|
|
|
453
|
+
/*
|
|
454
|
+
* WHERE the page was while this chunk was open.
|
|
455
|
+
*
|
|
456
|
+
* The chunk table used to carry routeCount but not the routes, so the
|
|
457
|
+
* session header's entryUrl / exitUrl / routes[] could only ever be
|
|
458
|
+
* whatever chunk 0 happened to know - which for a single-page app is the
|
|
459
|
+
* landing page, forever. pageCount said "7 pages" on the same row where
|
|
460
|
+
* routes[] held one element, and the "Exit page URL (exact)" filter
|
|
461
|
+
* could not match a page the user demonstrably reached.
|
|
462
|
+
*
|
|
463
|
+
* Both are already scrubbed on the client AND re-scrubbed at ingest:
|
|
464
|
+
* these render under the wider session-metadata ACL, so an unscrubbed
|
|
465
|
+
* `/reset-password?token=...` here reaches more readers than the payload
|
|
466
|
+
* itself does.
|
|
467
|
+
*/
|
|
468
|
+
const urlColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
|
|
469
|
+
key: "url",
|
|
470
|
+
title: "URL",
|
|
471
|
+
description:
|
|
472
|
+
"Scrubbed URL the page was on when this chunk was flushed. The session's exit URL is the latest of these.",
|
|
473
|
+
/*
|
|
474
|
+
* Required with an empty default rather than nullable. Chunks written
|
|
475
|
+
* before this column existed read back as "", which is what the
|
|
476
|
+
* finalizer's argMinIf/argMaxIf skip over so a pre-migration session
|
|
477
|
+
* falls back to its provisional header instead of losing its URLs.
|
|
478
|
+
*/
|
|
479
|
+
required: true,
|
|
480
|
+
defaultValue: "",
|
|
481
|
+
type: TableColumnType.Text,
|
|
482
|
+
codec: [{ codec: "ZSTD", level: 1 }],
|
|
483
|
+
accessControl: chunkAccessControl,
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
const routesColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
|
|
487
|
+
key: "routes",
|
|
488
|
+
title: "Routes",
|
|
489
|
+
description:
|
|
490
|
+
"Distinct scrubbed URLs visited while this chunk was open, in order. The session's routes[] is the union of these.",
|
|
491
|
+
/*
|
|
492
|
+
* Required, like RumSession.routes. ClickHouse refuses
|
|
493
|
+
* Nullable(Array(String)) outright, so an array column can never be
|
|
494
|
+
* optional - it is empty or it is absent.
|
|
495
|
+
*/
|
|
496
|
+
required: true,
|
|
497
|
+
defaultValue: [],
|
|
498
|
+
type: TableColumnType.ArrayText,
|
|
499
|
+
codec: [{ codec: "ZSTD", level: 1 }],
|
|
500
|
+
accessControl: chunkAccessControl,
|
|
501
|
+
});
|
|
502
|
+
|
|
453
503
|
const retentionDateColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
|
|
454
504
|
key: "retentionDate",
|
|
455
505
|
title: "Retention Date",
|
|
@@ -510,6 +560,8 @@ export default class RumSessionChunk extends AnalyticsBaseModel {
|
|
|
510
560
|
payloadColumn,
|
|
511
561
|
payloadBytesColumn,
|
|
512
562
|
...counterColumns,
|
|
563
|
+
urlColumn,
|
|
564
|
+
routesColumn,
|
|
513
565
|
retentionDateColumn,
|
|
514
566
|
],
|
|
515
567
|
sortKeys: ["projectId", "sessionId", "tabId", "chunkIndex"],
|
|
@@ -283,8 +283,22 @@ export default class CloudResource extends BaseModel {
|
|
|
283
283
|
})
|
|
284
284
|
public description?: string = undefined;
|
|
285
285
|
|
|
286
|
+
/*
|
|
287
|
+
* Creatable, never updatable - see the identical note on
|
|
288
|
+
* RumApplication.appIdentifier. `create: []` on a required column that the
|
|
289
|
+
* Cloud Resources create form declares made manual creation impossible:
|
|
290
|
+
* the field was stripped from the form and the server then rejected the
|
|
291
|
+
* POST for the very value it had just removed.
|
|
292
|
+
*/
|
|
286
293
|
@ColumnAccessControl({
|
|
287
|
-
create: [
|
|
294
|
+
create: [
|
|
295
|
+
Permission.ProjectOwner,
|
|
296
|
+
Permission.ProjectAdmin,
|
|
297
|
+
Permission.ProjectMember,
|
|
298
|
+
Permission.SettingsAdmin,
|
|
299
|
+
Permission.SettingsMember,
|
|
300
|
+
Permission.CreateCloudResource,
|
|
301
|
+
],
|
|
288
302
|
read: [
|
|
289
303
|
Permission.ProjectOwner,
|
|
290
304
|
Permission.ProjectAdmin,
|
|
@@ -305,6 +319,8 @@ export default class CloudResource extends BaseModel {
|
|
|
305
319
|
description:
|
|
306
320
|
"Stable identifier for this managed-compute workload (service.name, falling back to host.name). Identity key for this resource.",
|
|
307
321
|
})
|
|
322
|
+
/* Case-insensitive uniqueness; see RumApplication.appIdentifier. */
|
|
323
|
+
@UniqueColumnBy("projectId")
|
|
308
324
|
@Column({
|
|
309
325
|
nullable: false,
|
|
310
326
|
type: ColumnType.ShortText,
|
|
@@ -286,8 +286,31 @@ export default class RumApplication extends BaseModel {
|
|
|
286
286
|
})
|
|
287
287
|
public description?: string = undefined;
|
|
288
288
|
|
|
289
|
+
/*
|
|
290
|
+
* Creatable, never updatable.
|
|
291
|
+
*
|
|
292
|
+
* `create: []` here made the documented "create an application by hand"
|
|
293
|
+
* flow (docs/rum/applications.md) impossible: ModelForm drops any field
|
|
294
|
+
* the caller has no create permission on, so the Dashboard's required
|
|
295
|
+
* "App Identifier" input never rendered, and the POST that followed was
|
|
296
|
+
* rejected by the server with a raw `appIdentifier is required`. The
|
|
297
|
+
* column is required and has no default, so nothing could supply it.
|
|
298
|
+
*
|
|
299
|
+
* `update` stays empty on purpose. This value is the application's
|
|
300
|
+
* identity - telemetry is filed under it and the (projectId,
|
|
301
|
+
* appIdentifier) index is unique - so re-pointing it after the fact would
|
|
302
|
+
* orphan every session, trace and recording already keyed on the old one.
|
|
303
|
+
* Same shape as ServerlessFunction.functionIdentifier.
|
|
304
|
+
*/
|
|
289
305
|
@ColumnAccessControl({
|
|
290
|
-
create: [
|
|
306
|
+
create: [
|
|
307
|
+
Permission.ProjectOwner,
|
|
308
|
+
Permission.ProjectAdmin,
|
|
309
|
+
Permission.ProjectMember,
|
|
310
|
+
Permission.SettingsAdmin,
|
|
311
|
+
Permission.SettingsMember,
|
|
312
|
+
Permission.CreateRumApplication,
|
|
313
|
+
],
|
|
291
314
|
read: [
|
|
292
315
|
Permission.ProjectOwner,
|
|
293
316
|
Permission.ProjectAdmin,
|
|
@@ -308,6 +331,16 @@ export default class RumApplication extends BaseModel {
|
|
|
308
331
|
description:
|
|
309
332
|
"Stable identifier for this application from the service.name OpenTelemetry resource attribute. Identity key for this RUM application.",
|
|
310
333
|
})
|
|
334
|
+
/*
|
|
335
|
+
* Case-INSENSITIVE uniqueness, which the unique index on
|
|
336
|
+
* (projectId, appIdentifier) does not give: Postgres compares it byte for
|
|
337
|
+
* byte, while RumApplicationService.findOrCreateByAppIdentifier resolves
|
|
338
|
+
* an incoming service.name with QueryHelper.findWithSameText, which
|
|
339
|
+
* lowercases. Without this, someone hand-creating "Storefront-Web" beside
|
|
340
|
+
* an auto-discovered "storefront-web" passes the index and leaves two rows
|
|
341
|
+
* that the case-insensitive lookup resolves arbitrarily.
|
|
342
|
+
*/
|
|
343
|
+
@UniqueColumnBy("projectId")
|
|
311
344
|
@Column({
|
|
312
345
|
nullable: false,
|
|
313
346
|
type: ColumnType.ShortText,
|
|
@@ -1245,7 +1278,7 @@ export default class RumApplication extends BaseModel {
|
|
|
1245
1278
|
type: TableColumnType.Boolean,
|
|
1246
1279
|
title: "Capture Session Replay User Identity",
|
|
1247
1280
|
description:
|
|
1248
|
-
"When enabled, the
|
|
1281
|
+
"When enabled, the end-user reference supplied by the host page is stored alongside the recording - as a one-way per-project HMAC for lookup and erasure, plus the raw reference behind its own narrower column ACL - so a support engineer can find the session a named customer is complaining about. When off, the reference is never attached to a recording and neither column is stored. (It is still sent once on the policy request, which is how targeted capture matches a named user; it is not persisted.) The reference must be supplied at load time - identify() called later reaches the server only on the session's final chunk, which the header is not rebuilt from. On by default. Narrower create/update ACL than the other replay settings: this is the switch that turns a pseudonymous recording into an identified one.",
|
|
1249
1282
|
defaultValue: true,
|
|
1250
1283
|
})
|
|
1251
1284
|
@Column({
|
|
@@ -115,6 +115,7 @@ import RumApplication from "../../Models/DatabaseModels/RumApplication";
|
|
|
115
115
|
import RumApplicationService from "../Services/RumApplicationService";
|
|
116
116
|
import Project from "../../Models/DatabaseModels/Project";
|
|
117
117
|
import ProjectService from "../Services/ProjectService";
|
|
118
|
+
import SessionReplayIdentity from "../Utils/SessionReplay/SessionReplayIdentity";
|
|
118
119
|
import SessionReplayTargeting from "../Utils/SessionReplay/SessionReplayTargeting";
|
|
119
120
|
import SessionReplayUsage from "../Utils/SessionReplay/SessionReplayUsage";
|
|
120
121
|
import RumSessionReplayView from "../../Models/DatabaseModels/RumSessionReplayView";
|
|
@@ -134,6 +135,7 @@ import {
|
|
|
134
135
|
DEFAULT_SESSION_REPLAY_MAX_BYTES_PER_PROJECT_PER_DAY,
|
|
135
136
|
MAX_SESSION_REPLAY_CHUNKS_PER_READ,
|
|
136
137
|
MAX_SESSION_REPLAY_READ_BYTES,
|
|
138
|
+
SESSION_REPLAY_MAX_USER_REF_LENGTH,
|
|
137
139
|
} from "../../Types/Rum/SessionReplay";
|
|
138
140
|
|
|
139
141
|
const router: ExpressRouter = Express.getRouter();
|
|
@@ -3495,6 +3497,14 @@ router.post(
|
|
|
3495
3497
|
/*
|
|
3496
3498
|
* Listing sessions. Mirrors RumSession's table-level read ACL exactly:
|
|
3497
3499
|
* knowing WHICH sessions errored is triage.
|
|
3500
|
+
*
|
|
3501
|
+
* Including the PAYLOAD permission, which that ACL also carries and this
|
|
3502
|
+
* guard used to omit. Watching implies listing: the payload routes authorize
|
|
3503
|
+
* on ReadRumSessionReplayPayload alone, so a role granted only "Watch
|
|
3504
|
+
* Session Replays" could play back any session whose id it was handed while
|
|
3505
|
+
* being 401'd on the list, the manifest and the exception page's replay card
|
|
3506
|
+
* - an incoherent grant rather than a safer one, and exactly what
|
|
3507
|
+
* RumSession's own comment says the ACL exists to prevent.
|
|
3498
3508
|
*/
|
|
3499
3509
|
const requireSessionReplayListAccess: Array<RequestHandler> = [
|
|
3500
3510
|
UserMiddleware.getUserMiddleware,
|
|
@@ -3505,6 +3515,7 @@ const requireSessionReplayListAccess: Array<RequestHandler> = [
|
|
|
3505
3515
|
Permission.ProjectAdmin,
|
|
3506
3516
|
Permission.TelemetryAdmin,
|
|
3507
3517
|
Permission.ReadRumSessionReplay,
|
|
3518
|
+
Permission.ReadRumSessionReplayPayload,
|
|
3508
3519
|
],
|
|
3509
3520
|
}),
|
|
3510
3521
|
];
|
|
@@ -3534,6 +3545,20 @@ const SESSION_REPLAY_LIST_PERMISSIONS: Array<Permission> = [
|
|
|
3534
3545
|
Permission.ProjectAdmin,
|
|
3535
3546
|
Permission.TelemetryAdmin,
|
|
3536
3547
|
Permission.ReadRumSessionReplay,
|
|
3548
|
+
/*
|
|
3549
|
+
* Watching implies listing.
|
|
3550
|
+
*
|
|
3551
|
+
* RumSession's own table read ACL contains this permission for a reason it
|
|
3552
|
+
* states outright: a role granted only the watch permission could fetch
|
|
3553
|
+
* payloads (the payload routes authorize on it alone) while being 401'd on
|
|
3554
|
+
* the manifest and the list - an incoherent grant rather than a safer one.
|
|
3555
|
+
* Leaving it out here meant a support-engineer role built from "Watch
|
|
3556
|
+
* Session Replays" + "Read RUM Application" - the natural pairing, and the
|
|
3557
|
+
* one the permission's own description suggests - got a permission error
|
|
3558
|
+
* on the session list and a silently missing "Watch what the user saw"
|
|
3559
|
+
* card on every exception page.
|
|
3560
|
+
*/
|
|
3561
|
+
Permission.ReadRumSessionReplayPayload,
|
|
3537
3562
|
];
|
|
3538
3563
|
|
|
3539
3564
|
const SESSION_REPLAY_PAYLOAD_PERMISSIONS: Array<Permission> = [
|
|
@@ -4131,8 +4156,51 @@ router.post(
|
|
|
4131
4156
|
? OneUptimeDate.fromString(body["endTime"] as string)
|
|
4132
4157
|
: OneUptimeDate.getCurrentDate();
|
|
4133
4158
|
|
|
4159
|
+
/*
|
|
4160
|
+
* The narrower identity ACL, resolved BEFORE the filters are built.
|
|
4161
|
+
*
|
|
4162
|
+
* It is enforced by simply not naming the column in the SELECT - there
|
|
4163
|
+
* is no ModelPermission on this path to strip it after the fact - and
|
|
4164
|
+
* it is decided against the application already loaded by the access
|
|
4165
|
+
* check, so a caller whose identity grant is label-scoped elsewhere
|
|
4166
|
+
* does not get named end users here.
|
|
4167
|
+
*
|
|
4168
|
+
* It gates the identity FILTER as well as the column. Without that,
|
|
4169
|
+
* a caller deliberately denied the label could still ask "does
|
|
4170
|
+
* jane@example.com have sessions here" and read every other field of
|
|
4171
|
+
* the answer - a dictionary attack that de-anonymises the list one
|
|
4172
|
+
* candidate at a time, and hands back identifiedUserKey as a stable
|
|
4173
|
+
* pseudonym to join against the route filter. The permission sets are
|
|
4174
|
+
* genuinely different: SESSION_REPLAY_IDENTITY_PERMISSIONS excludes
|
|
4175
|
+
* TelemetryAdmin and ReadRumSessionReplay, both of which can list.
|
|
4176
|
+
*/
|
|
4177
|
+
const includeIdentifiedUserLabel: boolean = canReadIdentifiedUserLabel({
|
|
4178
|
+
databaseProps: databaseProps,
|
|
4179
|
+
application: application,
|
|
4180
|
+
});
|
|
4181
|
+
|
|
4134
4182
|
const rawFilters: JSONObject = (body["filters"] as JSONObject) || {};
|
|
4135
4183
|
|
|
4184
|
+
/*
|
|
4185
|
+
* A reference the server cannot hash must be a 400, never a filter
|
|
4186
|
+
* that quietly disappears. Dropping it would return the WHOLE
|
|
4187
|
+
* unfiltered list with a 200 - the caller sees every session in the
|
|
4188
|
+
* project and has no way to tell that the person they asked about was
|
|
4189
|
+
* not the one being answered about.
|
|
4190
|
+
*/
|
|
4191
|
+
if (
|
|
4192
|
+
rawFilters["identifiedUserRef"] !== undefined &&
|
|
4193
|
+
!SessionReplayIdentity.isUsableUserRef(rawFilters["identifiedUserRef"])
|
|
4194
|
+
) {
|
|
4195
|
+
return Response.sendErrorResponse(
|
|
4196
|
+
req,
|
|
4197
|
+
res,
|
|
4198
|
+
new BadDataException(
|
|
4199
|
+
`identifiedUserRef must be a non-empty string of at most ${SESSION_REPLAY_MAX_USER_REF_LENGTH} characters.`,
|
|
4200
|
+
),
|
|
4201
|
+
);
|
|
4202
|
+
}
|
|
4203
|
+
|
|
4136
4204
|
const filters: SessionReplayListFilters = {
|
|
4137
4205
|
...(typeof rawFilters["hasError"] === "boolean" && {
|
|
4138
4206
|
hasError: rawFilters["hasError"],
|
|
@@ -4158,9 +4226,42 @@ router.post(
|
|
|
4158
4226
|
...(readStringArrayFromBody(rawFilters, "countryCodes") && {
|
|
4159
4227
|
countryCodes: readStringArrayFromBody(rawFilters, "countryCodes"),
|
|
4160
4228
|
}),
|
|
4161
|
-
|
|
4162
|
-
|
|
4163
|
-
|
|
4229
|
+
/*
|
|
4230
|
+
* The caller sends the end-user reference their own page supplied -
|
|
4231
|
+
* the value the session list displays - and the server derives the
|
|
4232
|
+
* digest with the same per-project HMAC the ingest used. Hashing
|
|
4233
|
+
* here rather than in the browser is what keeps the derivation (and
|
|
4234
|
+
* the EncryptionSecret it is keyed on) server-side, and it is the
|
|
4235
|
+
* only reason this filter can match anything: the raw key is
|
|
4236
|
+
* displayed nowhere in the product, so a user had no way to obtain
|
|
4237
|
+
* the value the field used to demand.
|
|
4238
|
+
*
|
|
4239
|
+
* Gated on the identity permission, and validated above so an
|
|
4240
|
+
* unusable reference is a 400 rather than a silently unfiltered
|
|
4241
|
+
* list.
|
|
4242
|
+
*/
|
|
4243
|
+
...(includeIdentifiedUserLabel &&
|
|
4244
|
+
SessionReplayIdentity.isUsableUserRef(
|
|
4245
|
+
rawFilters["identifiedUserRef"],
|
|
4246
|
+
) && {
|
|
4247
|
+
identifiedUserKey: SessionReplayIdentity.buildUserKey({
|
|
4248
|
+
projectId: projectId,
|
|
4249
|
+
userRef: rawFilters["identifiedUserRef"] as string,
|
|
4250
|
+
}),
|
|
4251
|
+
}),
|
|
4252
|
+
/*
|
|
4253
|
+
* Still accepted, for API callers that already hold a digest (an
|
|
4254
|
+
* erasure workflow, a saved view). Ignored when a reference was also
|
|
4255
|
+
* sent, since the reference is the one a human typed. The digest is
|
|
4256
|
+
* not guessable and is already returned to every list-capable
|
|
4257
|
+
* caller, so it needs no identity gate of its own.
|
|
4258
|
+
*/
|
|
4259
|
+
...(typeof rawFilters["identifiedUserKey"] === "string" &&
|
|
4260
|
+
!SessionReplayIdentity.isUsableUserRef(
|
|
4261
|
+
rawFilters["identifiedUserRef"],
|
|
4262
|
+
) && {
|
|
4263
|
+
identifiedUserKey: rawFilters["identifiedUserKey"],
|
|
4264
|
+
}),
|
|
4164
4265
|
...(typeof rawFilters["route"] === "string" && {
|
|
4165
4266
|
route: rawFilters["route"],
|
|
4166
4267
|
}),
|
|
@@ -4183,18 +4284,6 @@ router.post(
|
|
|
4183
4284
|
}
|
|
4184
4285
|
: undefined;
|
|
4185
4286
|
|
|
4186
|
-
/*
|
|
4187
|
-
* The narrower identity ACL is enforced by simply not naming the
|
|
4188
|
-
* column in the SELECT. There is no ModelPermission on this path to
|
|
4189
|
-
* strip it after the fact. Decided against the application already
|
|
4190
|
-
* loaded by the access check, so a caller whose identity grant is
|
|
4191
|
-
* label-scoped elsewhere does not get named end users here.
|
|
4192
|
-
*/
|
|
4193
|
-
const includeIdentifiedUserLabel: boolean = canReadIdentifiedUserLabel({
|
|
4194
|
-
databaseProps: databaseProps,
|
|
4195
|
-
application: application,
|
|
4196
|
-
});
|
|
4197
|
-
|
|
4198
4287
|
const result: SessionReplayListResult =
|
|
4199
4288
|
await SessionReplayReadService.listSessions({
|
|
4200
4289
|
projectId: projectId,
|
|
@@ -105,11 +105,18 @@ export interface SessionReplayGatePolicy {
|
|
|
105
105
|
isAppEnabled: boolean;
|
|
106
106
|
|
|
107
107
|
/*
|
|
108
|
-
* Empty means
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
108
|
+
* Empty means ANY ORIGIN, which is the shipped default (the column
|
|
109
|
+
* defaults to '[]') and what isOriginAllowed below actually implements -
|
|
110
|
+
* this doc used to claim the opposite ("empty means REFUSED"), in the one
|
|
111
|
+
* direction where being wrong matters, since a reader would conclude the
|
|
112
|
+
* feature was locked down out of the box.
|
|
113
|
+
*
|
|
114
|
+
* Filling it in is the only anti-forgery control available: a
|
|
115
|
+
* TelemetryIngestionKey has no expiry, no scope and no origin binding, and
|
|
116
|
+
* the docs tell customers to paste it into browser JavaScript, so anyone
|
|
117
|
+
* who scrapes the key can write recordings into the victim's project until
|
|
118
|
+
* this list names the customer's own domains. The installation-test panel
|
|
119
|
+
* flags an empty list as a warning for exactly that reason.
|
|
113
120
|
*/
|
|
114
121
|
allowedOrigins: Array<string>;
|
|
115
122
|
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import { EncryptionSecret } from "../../EnvironmentConfig";
|
|
3
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
4
|
+
import { SESSION_REPLAY_MAX_USER_REF_LENGTH } from "../../../Types/Rum/SessionReplay";
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* "Whose session is this?"
|
|
8
|
+
*
|
|
9
|
+
* A page that has an end-user reference hands it to the recorder
|
|
10
|
+
* (data-oneuptime-user-ref, or the init global). When the application has
|
|
11
|
+
* identity capture switched on, the recorder puts that reference on the
|
|
12
|
+
* chunk envelope's meta, and this module turns it into the two columns the
|
|
13
|
+
* session header carries:
|
|
14
|
+
*
|
|
15
|
+
* identifiedUserKey HMAC(EncryptionSecret, "<projectId>:<userRef>")
|
|
16
|
+
* identifiedUserLabel the reference itself, behind its own column ACL
|
|
17
|
+
*
|
|
18
|
+
* The key is what makes a reference SEARCHABLE and ERASABLE without storing
|
|
19
|
+
* it in a lookup-friendly form: a support engineer filtering by user, and a
|
|
20
|
+
* right-to-erasure request naming one, both resolve to the same digest. The
|
|
21
|
+
* label is what makes a session READABLE - "jane@example.com" rather than a
|
|
22
|
+
* 64-character hash - and is why it sits behind a narrower ACL than the rest
|
|
23
|
+
* of the session metadata.
|
|
24
|
+
*
|
|
25
|
+
* Scoped by project and NOT by application, deliberately. A project-wide
|
|
26
|
+
* erasure request (ProcessSessionErasureRequests filters on projectId with
|
|
27
|
+
* the application clause optional) has to be able to reach every session for
|
|
28
|
+
* that person, including ones recorded by sibling applications. An
|
|
29
|
+
* application-scoped digest would make that request silently under-delete,
|
|
30
|
+
* which is the failure mode with legal consequences.
|
|
31
|
+
*
|
|
32
|
+
* Deliberately NOT reusing SessionReplayTargeting.buildTargetKey: that one
|
|
33
|
+
* is application-scoped and prefixed for a Redis keyspace. Both are HMACs of
|
|
34
|
+
* a user reference, and conflating them would tie an erasure lookup to the
|
|
35
|
+
* shape of a Redis key.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
export interface SessionReplayUserKeyInput {
|
|
39
|
+
projectId: ObjectID;
|
|
40
|
+
userRef: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default class SessionReplayIdentity {
|
|
44
|
+
/*
|
|
45
|
+
* Usable when non-empty and within the shared cap. The cap matters on both
|
|
46
|
+
* sides of the comparison: the recorder slices the reference to the same
|
|
47
|
+
* length before sending it, so anything longer could never match a stored
|
|
48
|
+
* key anyway, and hashing an unbounded string here would be the only
|
|
49
|
+
* unbounded work on this path.
|
|
50
|
+
*/
|
|
51
|
+
public static isUsableUserRef(userRef: unknown): userRef is string {
|
|
52
|
+
return (
|
|
53
|
+
typeof userRef === "string" &&
|
|
54
|
+
userRef.trim().length > 0 &&
|
|
55
|
+
userRef.length <= SESSION_REPLAY_MAX_USER_REF_LENGTH
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/*
|
|
60
|
+
* The project scope lives in the KEY, not in the message.
|
|
61
|
+
*
|
|
62
|
+
* HMAC takes arbitrary key material, so deriving a per-project key from
|
|
63
|
+
* the instance secret and the project id gives real domain separation:
|
|
64
|
+
* two projects cannot produce the same digest for the same person even
|
|
65
|
+
* if one of them learns the other's project id, which prefixing the
|
|
66
|
+
* message only achieves by convention. It is also what makes the
|
|
67
|
+
* "per-project" wording on the identity columns literally true.
|
|
68
|
+
*
|
|
69
|
+
* The reference is trimmed but NOT lower-cased. End-user references are
|
|
70
|
+
* opaque to us - "U-1000" and "u-1000" may well be two different
|
|
71
|
+
* customers in the host application's own database - so folding case here
|
|
72
|
+
* would merge two people's recordings under one key, and an erasure for
|
|
73
|
+
* one would delete the other's. Targeting lower-cases the APPLICATION
|
|
74
|
+
* identifier for the same reason in reverse: that one is ours and is
|
|
75
|
+
* case-insensitive.
|
|
76
|
+
*
|
|
77
|
+
* A slow KDF (bcrypt / scrypt / argon2) is deliberately NOT used and
|
|
78
|
+
* would not help. This is a keyed pseudonym, not a stored password: it
|
|
79
|
+
* has to be DETERMINISTIC so the session filter and a right-to-erasure
|
|
80
|
+
* request months later resolve the same digest, and those algorithms are
|
|
81
|
+
* salted per invocation. What defends the digest is the key - without
|
|
82
|
+
* EncryptionSecret an attacker holding the column cannot test candidate
|
|
83
|
+
* references at all, at any work factor.
|
|
84
|
+
*/
|
|
85
|
+
public static buildUserKey(data: SessionReplayUserKeyInput): string {
|
|
86
|
+
const projectKey: string = `${EncryptionSecret.toString()}:${data.projectId.toString()}`;
|
|
87
|
+
|
|
88
|
+
return crypto
|
|
89
|
+
.createHmac("sha256", projectKey)
|
|
90
|
+
.update(data.userRef.trim())
|
|
91
|
+
.digest("hex");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/*
|
|
95
|
+
* The label as stored. Trimmed and capped to the same length the recorder
|
|
96
|
+
* enforces, so the column can never hold something the wire could not have
|
|
97
|
+
* carried.
|
|
98
|
+
*/
|
|
99
|
+
public static buildUserLabel(userRef: string): string {
|
|
100
|
+
return userRef.trim().slice(0, SESSION_REPLAY_MAX_USER_REF_LENGTH);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -1341,13 +1341,22 @@ export default class SessionReplayReadService {
|
|
|
1341
1341
|
);
|
|
1342
1342
|
}
|
|
1343
1343
|
|
|
1344
|
-
if (filters.hasFrustration
|
|
1344
|
+
if (filters.hasFrustration !== undefined) {
|
|
1345
1345
|
/*
|
|
1346
1346
|
* Over the argMax aliases, like every HAVING predicate here — the
|
|
1347
1347
|
* raw columns would sum across ReplacingMergeTree versions.
|
|
1348
|
+
*
|
|
1349
|
+
* `!== undefined` rather than `=== true`, so `false` means "sessions
|
|
1350
|
+
* with NO frustration signals" instead of being silently dropped. The
|
|
1351
|
+
* route admits any boolean, and hasError / isFinalized beside it both
|
|
1352
|
+
* honour false, so accepting the value and ignoring it returned the
|
|
1353
|
+
* whole unfiltered list with a 200 and no indication why.
|
|
1348
1354
|
*/
|
|
1355
|
+
const total: string =
|
|
1356
|
+
"(aggRageClickCount + aggDeadClickCount + aggErrorClickCount + aggRefreshRageCount)";
|
|
1357
|
+
|
|
1349
1358
|
statement.append(
|
|
1350
|
-
|
|
1359
|
+
filters.hasFrustration ? ` AND ${total} > 0` : ` AND ${total} = 0`,
|
|
1351
1360
|
);
|
|
1352
1361
|
}
|
|
1353
1362
|
|
|
@@ -5,6 +5,7 @@ import AuditLog from "../../../Models/AnalyticsModels/AuditLog";
|
|
|
5
5
|
import BaseModel from "../../../Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
|
|
6
6
|
import { PlanType } from "../../../Types/Billing/SubscriptionPlan";
|
|
7
7
|
import Project from "../../../Models/DatabaseModels/Project";
|
|
8
|
+
import CloudResource from "../../../Models/DatabaseModels/CloudResource";
|
|
8
9
|
import RumApplication from "../../../Models/DatabaseModels/RumApplication";
|
|
9
10
|
import RumSessionErasureRequest from "../../../Models/DatabaseModels/RumSessionErasureRequest";
|
|
10
11
|
import RumSessionPin from "../../../Models/DatabaseModels/RumSessionPin";
|
|
@@ -61,6 +62,86 @@ describe("RumApplication session replay configuration", () => {
|
|
|
61
62
|
return model.getTableColumnMetadata(columnName);
|
|
62
63
|
};
|
|
63
64
|
|
|
65
|
+
/*
|
|
66
|
+
* The identity column has to be CREATABLE and NEVER UPDATABLE.
|
|
67
|
+
*
|
|
68
|
+
* It shipped as `create: []` on a required column with no default, which
|
|
69
|
+
* made the documented "create an application by hand" flow impossible:
|
|
70
|
+
* ModelForm drops any field the caller has no create permission on, so the
|
|
71
|
+
* Dashboard's required "App Identifier" input never rendered, and the POST
|
|
72
|
+
* that followed was rejected by the server with "appIdentifier is
|
|
73
|
+
* required". The Create button was a dead end for every user.
|
|
74
|
+
*/
|
|
75
|
+
it("lets a user supply the app identifier at creation time", () => {
|
|
76
|
+
const accessControl: ColumnAccessControl | null =
|
|
77
|
+
model.getColumnAccessControlFor("appIdentifier");
|
|
78
|
+
|
|
79
|
+
expect(accessControl).toBeDefined();
|
|
80
|
+
expect(accessControl!.create.length).toBeGreaterThan(0);
|
|
81
|
+
expect(accessControl!.create).toContain(Permission.ProjectOwner);
|
|
82
|
+
expect(accessControl!.create).toContain(Permission.CreateRumApplication);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("never lets the app identifier be edited afterwards", () => {
|
|
86
|
+
/*
|
|
87
|
+
* Deliberately immutable. Telemetry, sessions, traces and recordings are
|
|
88
|
+
* all filed under this value and the (projectId, appIdentifier) index is
|
|
89
|
+
* unique, so re-pointing it after the fact would orphan every row that
|
|
90
|
+
* already references it. If this list is ever populated, the orphaning
|
|
91
|
+
* has to be solved first - do not "tidy it up" to match `create`.
|
|
92
|
+
*/
|
|
93
|
+
const accessControl: ColumnAccessControl | null =
|
|
94
|
+
model.getColumnAccessControlFor("appIdentifier");
|
|
95
|
+
|
|
96
|
+
expect(accessControl!.update).toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
/*
|
|
100
|
+
* The create form declares an "App Identifier" field. That field can only
|
|
101
|
+
* render if the column is creatable, and the POST can only succeed if the
|
|
102
|
+
* required column is supplied - so a required column with an empty create
|
|
103
|
+
* list is always a dead form, whatever the page source says.
|
|
104
|
+
*
|
|
105
|
+
* CloudResource.resourceIdentifier is checked alongside because it had the
|
|
106
|
+
* identical defect on the identical page shape: an auto-discovered
|
|
107
|
+
* identity column whose dashboard page also offers a Create button.
|
|
108
|
+
*/
|
|
109
|
+
it.each([
|
|
110
|
+
["RumApplication", new RumApplication() as BaseModel],
|
|
111
|
+
["CloudResource", new CloudResource() as BaseModel],
|
|
112
|
+
])(
|
|
113
|
+
"%s has no required column that the user is forbidden to create",
|
|
114
|
+
(_name: string, subject: BaseModel) => {
|
|
115
|
+
const uncreatable: Array<string> = [];
|
|
116
|
+
|
|
117
|
+
for (const columnName of subject.getTableColumns().columns) {
|
|
118
|
+
const metadata: TableColumnMetadata =
|
|
119
|
+
subject.getTableColumnMetadata(columnName);
|
|
120
|
+
const accessControl: ColumnAccessControl | null =
|
|
121
|
+
subject.getColumnAccessControlFor(columnName);
|
|
122
|
+
|
|
123
|
+
if (!metadata.required || metadata.defaultValue !== undefined) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* System columns nobody submits through a form. */
|
|
128
|
+
if (
|
|
129
|
+
["_id", "createdAt", "updatedAt", "version", "slug"].includes(
|
|
130
|
+
columnName,
|
|
131
|
+
)
|
|
132
|
+
) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (accessControl && accessControl.create.length === 0) {
|
|
137
|
+
uncreatable.push(columnName);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
expect(uncreatable).toEqual([]);
|
|
142
|
+
},
|
|
143
|
+
);
|
|
144
|
+
|
|
64
145
|
it("declares every session replay configuration column", () => {
|
|
65
146
|
const expectedColumns: Array<string> = [
|
|
66
147
|
"isSessionReplayEnabled",
|