@oneuptime/common 12.0.26 → 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.
Files changed (50) hide show
  1. package/Models/AnalyticsModels/RumSessionChunk.ts +52 -0
  2. package/Models/DatabaseModels/CloudResource.ts +17 -1
  3. package/Models/DatabaseModels/RumApplication.ts +35 -2
  4. package/Server/API/TelemetryAPI.ts +104 -15
  5. package/Server/Utils/SessionReplay/SessionReplayGateCache.ts +12 -5
  6. package/Server/Utils/SessionReplay/SessionReplayIdentity.ts +102 -0
  7. package/Server/Utils/SessionReplay/SessionReplayReadService.ts +11 -2
  8. package/Tests/Models/DatabaseModels/SessionReplayModels.test.ts +81 -0
  9. package/Tests/Server/API/SessionReplayAPI.test.ts +232 -0
  10. package/Tests/Types/API/HostnameAuthorityParsing.test.ts +1 -0
  11. package/Tests/Types/API/URLMailtoQueryString.test.ts +439 -0
  12. package/Tests/Types/API/URLQueryString.test.ts +33 -24
  13. package/Tests/Types/API/URLRouteQuerySeparation.test.ts +491 -0
  14. package/Tests/UI/Components/LogsHistogramDragTooltip.test.tsx +393 -0
  15. package/Tests/UI/Components/LogsViewerHistogramZoom.test.tsx +248 -0
  16. package/Tests/UI/Components/TelemetryHistogramDragTooltip.test.tsx +205 -0
  17. package/Tests/UI/Components/UseHistogramZoom.test.tsx +262 -0
  18. package/Types/API/URL.ts +55 -18
  19. package/Types/Rum/SessionReplay.ts +14 -0
  20. package/UI/Components/Charts/Utils/useHistogramZoom.ts +101 -0
  21. package/UI/Components/LogsViewer/LogsViewer.tsx +19 -3
  22. package/UI/Components/LogsViewer/components/LogsHistogram.tsx +52 -1
  23. package/UI/Components/TelemetryViewer/TelemetryViewer.tsx +18 -2
  24. package/UI/Components/TelemetryViewer/components/TelemetryHistogram.tsx +52 -1
  25. package/build/dist/Models/AnalyticsModels/RumSessionChunk.js +48 -0
  26. package/build/dist/Models/AnalyticsModels/RumSessionChunk.js.map +1 -1
  27. package/build/dist/Models/DatabaseModels/CloudResource.js +19 -2
  28. package/build/dist/Models/DatabaseModels/CloudResource.js.map +1 -1
  29. package/build/dist/Models/DatabaseModels/RumApplication.js +37 -3
  30. package/build/dist/Models/DatabaseModels/RumApplication.js.map +1 -1
  31. package/build/dist/Server/API/TelemetryAPI.js +67 -14
  32. package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
  33. package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js.map +1 -1
  34. package/build/dist/Server/Utils/SessionReplay/SessionReplayIdentity.js +59 -0
  35. package/build/dist/Server/Utils/SessionReplay/SessionReplayIdentity.js.map +1 -0
  36. package/build/dist/Server/Utils/SessionReplay/SessionReplayReadService.js +9 -2
  37. package/build/dist/Server/Utils/SessionReplay/SessionReplayReadService.js.map +1 -1
  38. package/build/dist/Types/API/URL.js +48 -17
  39. package/build/dist/Types/API/URL.js.map +1 -1
  40. package/build/dist/UI/Components/Charts/Utils/useHistogramZoom.js +47 -0
  41. package/build/dist/UI/Components/Charts/Utils/useHistogramZoom.js.map +1 -0
  42. package/build/dist/UI/Components/LogsViewer/LogsViewer.js +15 -3
  43. package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
  44. package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js +33 -5
  45. package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js.map +1 -1
  46. package/build/dist/UI/Components/TelemetryViewer/TelemetryViewer.js +14 -2
  47. package/build/dist/UI/Components/TelemetryViewer/TelemetryViewer.js.map +1 -1
  48. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js +33 -5
  49. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js.map +1 -1
  50. 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 raw end-user reference supplied by the host page is stored alongside the recording, so a support engineer can find the session a named customer is complaining about. When off, only a one-way per-project HMAC of it is stored. 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.",
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
- ...(typeof rawFilters["identifiedUserKey"] === "string" && {
4162
- identifiedUserKey: rawFilters["identifiedUserKey"],
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 REFUSED, not allow-all. This is the only anti-forgery
109
- * control available: a TelemetryIngestionKey has no expiry, no scope and
110
- * no origin binding, and the docs tell customers to paste it into
111
- * browser JavaScript, so anyone who scrapes the key could otherwise
112
- * write recordings into the victim's project.
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 === true) {
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
- " AND (aggRageClickCount + aggDeadClickCount + aggErrorClickCount + aggRefreshRageCount) > 0",
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",