@oneuptime/common 12.0.3 → 12.0.4
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/DatabaseModels/CephCluster.ts +14 -0
- package/Models/DatabaseModels/CodeRepository.ts +14 -6
- package/Models/DatabaseModels/DockerHost.ts +14 -0
- package/Models/DatabaseModels/GlobalOidcProject.ts +18 -0
- package/Models/DatabaseModels/GlobalSsoProject.ts +18 -0
- package/Models/DatabaseModels/IoTFleet.ts +14 -0
- package/Models/DatabaseModels/KubernetesCluster.ts +14 -0
- package/Models/DatabaseModels/NetworkDevice.ts +14 -0
- package/Models/DatabaseModels/NetworkInterface.ts +18 -0
- package/Models/DatabaseModels/Project.ts +16 -2
- package/Models/DatabaseModels/ProxmoxCluster.ts +14 -0
- package/Models/DatabaseModels/Service.ts +30 -0
- package/Models/DatabaseModels/StatusPagePrivateUser.ts +30 -0
- package/Models/DatabaseModels/User.ts +30 -0
- package/Server/API/AIAgentDataAPI.ts +31 -0
- package/Server/API/DashboardAPI.ts +419 -12
- package/Server/API/GitHubAPI.ts +119 -283
- package/Server/API/UserAPI.ts +263 -1
- package/Server/EnvironmentConfig.ts +7 -3
- package/Server/Infrastructure/GlobalCache.ts +37 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.ts +21 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.ts +68 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.ts +100 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.ts +214 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.ts +69 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +10 -0
- package/Server/Services/AnalyticsDatabaseService.ts +72 -1
- package/Server/Services/CodeRepositoryService.ts +105 -2
- package/Server/Services/DatabaseService.ts +185 -2
- package/Server/Services/OpenTelemetryIngestService.ts +230 -26
- package/Server/Services/UserService.ts +184 -0
- package/Server/Utils/AI/Toolbox/CodeTools.ts +45 -4
- package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +13 -0
- package/Server/Utils/CodeRepository/GitHub/GitHub.ts +157 -4
- package/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.ts +130 -0
- package/Server/Utils/PasswordHash.ts +306 -0
- package/Tests/App/Dashboard/OverviewCustomFields.test.tsx +333 -0
- package/Tests/App/Dashboard/UserCustomFields.test.tsx +392 -0
- package/Tests/Server/API/AIAgentDataRepositoryToken.test.ts +381 -0
- package/Tests/Server/API/DashboardPublicAttributeValuesAPI.test.ts +943 -0
- package/Tests/Server/API/DashboardPublicMetricsAggregateAPI.test.ts +1296 -0
- package/Tests/Server/API/DashboardPublicTemplatePayloads.test.ts +598 -0
- package/Tests/Server/API/GitHubAppInstallationBindingAPI.test.ts +539 -0
- package/Tests/Server/API/Helpers.ts +6 -1
- package/Tests/Server/API/UserProjectsAPI.test.ts +852 -0
- package/Tests/Server/Infrastructure/GlobalCache.test.ts +128 -0
- package/Tests/Server/Infrastructure/SemaphoreMutex.test.ts +215 -0
- package/Tests/Server/Services/AddPerUserPasswordSaltMigration.test.ts +178 -0
- package/Tests/Server/Services/AnalyticsDatabasePaginationStability.test.ts +582 -0
- package/Tests/Server/Services/AnalyticsDatabaseService.test.ts +5 -0
- package/Tests/Server/Services/CodeRepositoryInstallationBinding.test.ts +305 -0
- package/Tests/Server/Services/CodeRepositoryResolutionBinding.test.ts +147 -0
- package/Tests/Server/Services/DatabaseServicePerUserPasswordSalt.test.ts +870 -0
- package/Tests/Server/Services/OpenTelemetryServiceResolutionCache.test.ts +568 -0
- package/Tests/Server/Services/RestoreDroppedUniqueIndexesMigration.test.ts +386 -0
- package/Tests/Server/Services/RestoreServiceLowerNameIndexMigration.test.ts +240 -0
- package/Tests/Server/Services/UserServiceFirstMasterAdminElection.test.ts +885 -0
- package/Tests/Server/Services/WidenHashedStringColumnsForScryptMigration.test.ts +220 -0
- package/Tests/Server/Utils/AI/CodeTools.test.ts +47 -1
- package/Tests/Server/Utils/AI/CodeWriteTools.test.ts +10 -0
- package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +120 -0
- package/Tests/Server/Utils/CodeRepository/GitHubInstallationBinding.test.ts +292 -0
- package/Tests/Server/Utils/GitHubInstallationOwnershipVerification.test.ts +340 -0
- package/Tests/Server/Utils/GitHubWebhookAndTreeCacheIsolation.test.ts +250 -0
- package/Tests/Server/Utils/PasswordHash.test.ts +555 -0
- package/Tests/Types/Database/ColumnLength.test.ts +7 -1
- package/Tests/Types/HashedStringPerUserSalt.test.ts +476 -0
- package/Tests/Types/Monitor/DockerAlertTemplates.test.ts +293 -0
- package/Tests/Types/Monitor/HostAlertTemplates.test.ts +249 -0
- package/Tests/Types/Monitor/IotAlertTemplates.test.ts +320 -0
- package/Tests/Types/Monitor/PodmanAlertTemplates.test.ts +271 -0
- package/Tests/UI/Components/CustomFields/CustomFieldsDetail.test.tsx +696 -0
- package/Tests/UI/Utils/UserProjectsModelAPI.test.ts +714 -0
- package/Tests/Utils/TeamMembersByProject.test.ts +655 -0
- package/Types/Database/ColumnLength.ts +9 -1
- package/Types/Database/TableColumn.ts +11 -0
- package/Types/Database/UnsynchronizedIndex.ts +48 -0
- package/Types/HashedString.ts +139 -4
- package/UI/Components/CustomFields/CustomFieldsDetail.tsx +85 -11
- package/UI/Components/Navbar/NavBar.tsx +12 -2
- package/UI/Components/Navbar/NavBarMenuModal.tsx +19 -7
- package/UI/Utils/ModelAPI/UserProjectsModelAPI.ts +257 -0
- package/Utils/TeamMembersByProject.ts +237 -0
- package/build/dist/Models/DatabaseModels/CephCluster.js +16 -1
- package/build/dist/Models/DatabaseModels/CephCluster.js.map +1 -1
- package/build/dist/Models/DatabaseModels/CodeRepository.js +14 -6
- package/build/dist/Models/DatabaseModels/CodeRepository.js.map +1 -1
- package/build/dist/Models/DatabaseModels/DockerHost.js +16 -1
- package/build/dist/Models/DatabaseModels/DockerHost.js.map +1 -1
- package/build/dist/Models/DatabaseModels/GlobalOidcProject.js +16 -1
- package/build/dist/Models/DatabaseModels/GlobalOidcProject.js.map +1 -1
- package/build/dist/Models/DatabaseModels/GlobalSsoProject.js +16 -1
- package/build/dist/Models/DatabaseModels/GlobalSsoProject.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IoTFleet.js +16 -1
- package/build/dist/Models/DatabaseModels/IoTFleet.js.map +1 -1
- package/build/dist/Models/DatabaseModels/KubernetesCluster.js +16 -1
- package/build/dist/Models/DatabaseModels/KubernetesCluster.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkDevice.js +16 -1
- package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkInterface.js +16 -1
- package/build/dist/Models/DatabaseModels/NetworkInterface.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Project.js +16 -2
- package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ProxmoxCluster.js +16 -1
- package/build/dist/Models/DatabaseModels/ProxmoxCluster.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Service.js +28 -1
- package/build/dist/Models/DatabaseModels/Service.js.map +1 -1
- package/build/dist/Models/DatabaseModels/StatusPagePrivateUser.js +32 -0
- package/build/dist/Models/DatabaseModels/StatusPagePrivateUser.js.map +1 -1
- package/build/dist/Models/DatabaseModels/User.js +32 -0
- package/build/dist/Models/DatabaseModels/User.js.map +1 -1
- package/build/dist/Server/API/AIAgentDataAPI.js +21 -2
- package/build/dist/Server/API/AIAgentDataAPI.js.map +1 -1
- package/build/dist/Server/API/DashboardAPI.js +304 -10
- package/build/dist/Server/API/DashboardAPI.js.map +1 -1
- package/build/dist/Server/API/GitHubAPI.js +94 -167
- package/build/dist/Server/API/GitHubAPI.js.map +1 -1
- package/build/dist/Server/API/UserAPI.js +187 -2
- package/build/dist/Server/API/UserAPI.js.map +1 -1
- package/build/dist/Server/EnvironmentConfig.js +7 -3
- package/build/dist/Server/EnvironmentConfig.js.map +1 -1
- package/build/dist/Server/Infrastructure/GlobalCache.js +29 -0
- package/build/dist/Server/Infrastructure/GlobalCache.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.js +14 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.js +59 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.js +93 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.js +101 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.js +66 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +10 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/AnalyticsDatabaseService.js +57 -1
- package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
- package/build/dist/Server/Services/CodeRepositoryService.js +90 -2
- package/build/dist/Server/Services/CodeRepositoryService.js.map +1 -1
- package/build/dist/Server/Services/DatabaseService.js +160 -2
- package/build/dist/Server/Services/DatabaseService.js.map +1 -1
- package/build/dist/Server/Services/OpenTelemetryIngestService.js +155 -25
- package/build/dist/Server/Services/OpenTelemetryIngestService.js.map +1 -1
- package/build/dist/Server/Services/UserService.js +165 -0
- package/build/dist/Server/Services/UserService.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/CodeTools.js +31 -1
- package/build/dist/Server/Utils/AI/Toolbox/CodeTools.js.map +1 -1
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +13 -0
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
- package/build/dist/Server/Utils/CodeRepository/GitHub/GitHub.js +126 -4
- package/build/dist/Server/Utils/CodeRepository/GitHub/GitHub.js.map +1 -1
- package/build/dist/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.js +136 -0
- package/build/dist/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.js.map +1 -0
- package/build/dist/Server/Utils/PasswordHash.js +237 -0
- package/build/dist/Server/Utils/PasswordHash.js.map +1 -0
- package/build/dist/Types/Database/ColumnLength.js +9 -1
- package/build/dist/Types/Database/ColumnLength.js.map +1 -1
- package/build/dist/Types/Database/TableColumn.js.map +1 -1
- package/build/dist/Types/Database/UnsynchronizedIndex.js +46 -0
- package/build/dist/Types/Database/UnsynchronizedIndex.js.map +1 -0
- package/build/dist/Types/HashedString.js +95 -5
- package/build/dist/Types/HashedString.js.map +1 -1
- package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js +51 -10
- package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js.map +1 -1
- package/build/dist/UI/Components/Navbar/NavBar.js +3 -1
- package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
- package/build/dist/UI/Components/Navbar/NavBarMenuModal.js +19 -7
- package/build/dist/UI/Components/Navbar/NavBarMenuModal.js.map +1 -1
- package/build/dist/UI/Utils/ModelAPI/UserProjectsModelAPI.js +156 -0
- package/build/dist/UI/Utils/ModelAPI/UserProjectsModelAPI.js.map +1 -0
- package/build/dist/Utils/TeamMembersByProject.js +145 -0
- package/build/dist/Utils/TeamMembersByProject.js.map +1 -0
- package/package.json +1 -1
|
@@ -30,6 +30,7 @@ import UpdateByID from "../Types/Database/UpdateByID";
|
|
|
30
30
|
import UpdateByIDAndFetch from "../Types/Database/UpdateByIDAndFetch";
|
|
31
31
|
import UpdateOneBy from "../Types/Database/UpdateOneBy";
|
|
32
32
|
import Encryption from "../Utils/Encryption";
|
|
33
|
+
import PasswordHash from "../Utils/PasswordHash";
|
|
33
34
|
import PostgresErrorTranslator from "../Utils/Database/PostgresErrorTranslator";
|
|
34
35
|
import logger, { LogAttributes } from "../Utils/Logger";
|
|
35
36
|
import BaseService from "./BaseService";
|
|
@@ -321,6 +322,55 @@ class DatabaseService<TBaseModel extends BaseModel> extends BaseService {
|
|
|
321
322
|
return data;
|
|
322
323
|
}
|
|
323
324
|
|
|
325
|
+
/*
|
|
326
|
+
* Hash one HashedString column in place.
|
|
327
|
+
*
|
|
328
|
+
* Declaring `hashSaltColumn` on a column is what marks it a CREDENTIAL —
|
|
329
|
+
* a low-entropy, human-chosen secret. Those go through scrypt (see
|
|
330
|
+
* PasswordHash): deliberately slow and memory-hard, with a fresh per-record
|
|
331
|
+
* salt minted into the sibling column named by the metadata.
|
|
332
|
+
*
|
|
333
|
+
* Columns without a salt column are high-entropy values the server itself
|
|
334
|
+
* generated — session refresh tokens and the like. They keep the fast
|
|
335
|
+
* SHA-256 hash, which is the right tool for them: there is nothing to guess,
|
|
336
|
+
* and they have to stay searchable by hash because that is how they are
|
|
337
|
+
* looked up.
|
|
338
|
+
*
|
|
339
|
+
* The salt is written onto the SAME payload as the hash, so the pair can
|
|
340
|
+
* never be persisted out of step with each other.
|
|
341
|
+
*
|
|
342
|
+
* The salt column grants nobody create or update rights, so it has to get
|
|
343
|
+
* past the column-permission check some other way on each path. On update
|
|
344
|
+
* it does so by timing — sanitizeCreateOrUpdate runs after the check. On
|
|
345
|
+
* create the check runs after `hash()`, so the salt column relies on being
|
|
346
|
+
* declared `computed: true`, which checkDataColumnPermissions skips on
|
|
347
|
+
* create. A salt column that forgets that flag breaks every non-root
|
|
348
|
+
* create of a row carrying a password.
|
|
349
|
+
*/
|
|
350
|
+
private async hashColumnValue(data: {
|
|
351
|
+
payload: TBaseModel | PartialEntity<TBaseModel>;
|
|
352
|
+
columnName: string;
|
|
353
|
+
columnValue: HashedString;
|
|
354
|
+
}): Promise<void> {
|
|
355
|
+
const saltColumnName: string | undefined =
|
|
356
|
+
this.model.getTableColumnMetadata(data.columnName)?.hashSaltColumn;
|
|
357
|
+
|
|
358
|
+
if (!saltColumnName) {
|
|
359
|
+
await data.columnValue.hashValue(EncryptionSecret);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const salt: string = PasswordHash.generateSalt();
|
|
364
|
+
(data.payload as any)[saltColumnName] = salt;
|
|
365
|
+
|
|
366
|
+
data.columnValue.setHashedValue(
|
|
367
|
+
await PasswordHash.hash({
|
|
368
|
+
plainValue: data.columnValue.toString(),
|
|
369
|
+
salt: salt,
|
|
370
|
+
}),
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
324
374
|
protected async hash(data: TBaseModel): Promise<TBaseModel> {
|
|
325
375
|
const columns: Columns = data.getHashedColumns();
|
|
326
376
|
|
|
@@ -329,13 +379,110 @@ class DatabaseService<TBaseModel extends BaseModel> extends BaseService {
|
|
|
329
379
|
data.hasValue(key) &&
|
|
330
380
|
!(data.getValue(key) as HashedString).isValueHashed()
|
|
331
381
|
) {
|
|
332
|
-
await
|
|
382
|
+
await this.hashColumnValue({
|
|
383
|
+
payload: data,
|
|
384
|
+
columnName: key,
|
|
385
|
+
columnValue: (data as any)[key] as HashedString,
|
|
386
|
+
});
|
|
333
387
|
}
|
|
334
388
|
}
|
|
335
389
|
|
|
336
390
|
return data;
|
|
337
391
|
}
|
|
338
392
|
|
|
393
|
+
/*
|
|
394
|
+
* Check a plaintext secret (a password) against a hashed column on a row
|
|
395
|
+
* that has already been read out of the database, and re-hash the stored
|
|
396
|
+
* value in place when it was not written by today's scheme.
|
|
397
|
+
*
|
|
398
|
+
* The row must have been selected with BOTH the hashed column and its salt
|
|
399
|
+
* column. Without the salt a scrypt hash cannot be checked at all, and an
|
|
400
|
+
* older SHA-256 one would be checked against the wrong scheme — either way
|
|
401
|
+
* a perfectly good password gets rejected. PasswordHash.verify throws
|
|
402
|
+
* rather than silently returning false for the scrypt case, because a
|
|
403
|
+
* missing SELECT is a bug and must not look like a bad password.
|
|
404
|
+
*
|
|
405
|
+
* Why the caller cannot just query by the hash any more: with a per-user
|
|
406
|
+
* salt the hash is not computable until the row's own salt is known, so
|
|
407
|
+
* `WHERE password = <hash>` has nothing to bind. Read the row by its
|
|
408
|
+
* identity (email, id) and verify here.
|
|
409
|
+
*
|
|
410
|
+
* THE UPGRADE. Anything that is not exactly what PasswordHash writes today
|
|
411
|
+
* is re-hashed once the password is known to be correct: the two SHA-256
|
|
412
|
+
* schemes that preceded scrypt, and any scrypt hash whose cost parameters
|
|
413
|
+
* have since been raised. That is what makes raising the cost a one-line
|
|
414
|
+
* change — users migrate as they log in, with no reset and no migration.
|
|
415
|
+
*
|
|
416
|
+
* The write is deliberately hook-free and leaves updatedAt alone: the user
|
|
417
|
+
* did not change their password, the server changed how it stores it.
|
|
418
|
+
* Firing the password-change hooks would revoke every session and mail the
|
|
419
|
+
* user about a change they did not make. A failed upgrade is logged and
|
|
420
|
+
* swallowed — a correct password must not be rejected because a
|
|
421
|
+
* bookkeeping write lost a race.
|
|
422
|
+
*/
|
|
423
|
+
@CaptureSpan()
|
|
424
|
+
public async verifyHashedColumnValue(data: {
|
|
425
|
+
item: TBaseModel;
|
|
426
|
+
columnName: string;
|
|
427
|
+
plainValue: string;
|
|
428
|
+
}): Promise<boolean> {
|
|
429
|
+
const storedHash: string | undefined = (data.item as any)[
|
|
430
|
+
data.columnName
|
|
431
|
+
]?.toString();
|
|
432
|
+
|
|
433
|
+
if (!storedHash || !data.plainValue) {
|
|
434
|
+
return false;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const saltColumnName: string | undefined =
|
|
438
|
+
this.model.getTableColumnMetadata(data.columnName)?.hashSaltColumn;
|
|
439
|
+
|
|
440
|
+
const salt: string | null = saltColumnName
|
|
441
|
+
? ((data.item as any)[saltColumnName] as string | undefined) || null
|
|
442
|
+
: null;
|
|
443
|
+
|
|
444
|
+
const isValid: boolean = await PasswordHash.verify({
|
|
445
|
+
plainValue: data.plainValue,
|
|
446
|
+
storedValue: storedHash,
|
|
447
|
+
salt: salt,
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
if (!isValid) {
|
|
451
|
+
return false;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (
|
|
455
|
+
saltColumnName &&
|
|
456
|
+
data.item.id &&
|
|
457
|
+
PasswordHash.needsUpgrade(storedHash)
|
|
458
|
+
) {
|
|
459
|
+
try {
|
|
460
|
+
const newSalt: string = PasswordHash.generateSalt();
|
|
461
|
+
|
|
462
|
+
await this.updateColumnsByIdWithoutHooks({
|
|
463
|
+
id: data.item.id,
|
|
464
|
+
data: {
|
|
465
|
+
[data.columnName]: await PasswordHash.hash({
|
|
466
|
+
plainValue: data.plainValue,
|
|
467
|
+
salt: newSalt,
|
|
468
|
+
}),
|
|
469
|
+
[saltColumnName]: newSalt,
|
|
470
|
+
} as unknown as PartialEntity<TBaseModel>,
|
|
471
|
+
skipUpdateDateColumn: true,
|
|
472
|
+
});
|
|
473
|
+
} catch (err) {
|
|
474
|
+
/*
|
|
475
|
+
* `data.item` is deliberately left holding the old hash and old salt,
|
|
476
|
+
* so it stays an internally consistent pair whether or not the write
|
|
477
|
+
* above landed.
|
|
478
|
+
*/
|
|
479
|
+
logger.error(err);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return true;
|
|
484
|
+
}
|
|
485
|
+
|
|
339
486
|
protected async decrypt(data: TBaseModel): Promise<TBaseModel> {
|
|
340
487
|
for (const key of data.getEncryptedColumns().columns) {
|
|
341
488
|
if (!data.hasValue(key)) {
|
|
@@ -552,6 +699,26 @@ class DatabaseService<TBaseModel extends BaseModel> extends BaseService {
|
|
|
552
699
|
if (this.model.isHashedStringColumn(columnName)) {
|
|
553
700
|
const columnValue: JSONValue = (data as any)[columnName];
|
|
554
701
|
|
|
702
|
+
/*
|
|
703
|
+
* A salted column's hash and its salt are only ever correct as a
|
|
704
|
+
* pair. A write that supplies the column as a bare string skips the
|
|
705
|
+
* hashing branch below, leaving the row's existing salt attached to
|
|
706
|
+
* a value it was not computed from — which locks the account out
|
|
707
|
+
* silently, at the next login, far from whatever wrote it. Refuse
|
|
708
|
+
* instead. Callers pass a HashedString; the write path hashes it.
|
|
709
|
+
*/
|
|
710
|
+
if (
|
|
711
|
+
data &&
|
|
712
|
+
columnName &&
|
|
713
|
+
columnValue &&
|
|
714
|
+
!(columnValue instanceof HashedString) &&
|
|
715
|
+
this.model.getTableColumnMetadata(columnName)?.hashSaltColumn
|
|
716
|
+
) {
|
|
717
|
+
throw new BadDataException(
|
|
718
|
+
`${columnName} is a salted column and must be supplied as a HashedString, not a pre-hashed value.`,
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
|
|
555
722
|
if (
|
|
556
723
|
data &&
|
|
557
724
|
columnName &&
|
|
@@ -559,7 +726,23 @@ class DatabaseService<TBaseModel extends BaseModel> extends BaseService {
|
|
|
559
726
|
columnValue instanceof HashedString
|
|
560
727
|
) {
|
|
561
728
|
if (!columnValue.isValueHashed()) {
|
|
562
|
-
|
|
729
|
+
/*
|
|
730
|
+
* The update path never runs `hash()`, so this is where an
|
|
731
|
+
* updated password gets both hashed and salted. The salt lands
|
|
732
|
+
* on `data`, which the caller turns into the SET list, so the
|
|
733
|
+
* new hash and the salt it was computed with are written by the
|
|
734
|
+
* same statement.
|
|
735
|
+
*
|
|
736
|
+
* An update matching several rows writes the same salt to all of
|
|
737
|
+
* them, which is correct: they are all being set to the same
|
|
738
|
+
* value by one statement. Per-row salts come from per-row
|
|
739
|
+
* writes.
|
|
740
|
+
*/
|
|
741
|
+
await this.hashColumnValue({
|
|
742
|
+
payload: data,
|
|
743
|
+
columnName: columnName,
|
|
744
|
+
columnValue: columnValue,
|
|
745
|
+
});
|
|
563
746
|
}
|
|
564
747
|
|
|
565
748
|
(data as any)[columnName] = columnValue.toString();
|
|
@@ -168,6 +168,66 @@ const PROJECT_RETENTION_CACHE_TTL_SECONDS: number = 5 * 60;
|
|
|
168
168
|
const projectRetentionInProcessCache: Map<string, CachedRetentionContext> =
|
|
169
169
|
new Map();
|
|
170
170
|
|
|
171
|
+
/*
|
|
172
|
+
* Everything `findOrCreateTelemetryService` needs from the Service row. Cached
|
|
173
|
+
* instead of the model itself so the payload is a flat, JSON-round-trippable
|
|
174
|
+
* shape with no TypeORM entity semantics riding along.
|
|
175
|
+
*/
|
|
176
|
+
interface ServiceResolution {
|
|
177
|
+
serviceId: string;
|
|
178
|
+
retainTelemetryDataForDays: number | null;
|
|
179
|
+
telemetryRetentionConfig: TelemetryRetentionConfig | null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
interface CachedServiceResolution {
|
|
183
|
+
resolution: ServiceResolution;
|
|
184
|
+
expiresAtMs: number;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/*
|
|
188
|
+
* Per-process memo for `service.name` -> Service resolution, backed by the same
|
|
189
|
+
* L1-Map + L2-Redis pair as the project retention context above.
|
|
190
|
+
*
|
|
191
|
+
* This is the hot read on the whole ingest path and it was the one ungated
|
|
192
|
+
* operation in the function. `resolveTelemetryResource` calls it once per OTLP
|
|
193
|
+
* *resource*, not per request — the shipped host-metrics collector config
|
|
194
|
+
* produces hundreds per batch — and again for every signal type a collector
|
|
195
|
+
* exports, from every ingest pod. The per-batch `serviceDictionary` in the four
|
|
196
|
+
* Otel*IngestService files cannot help: it is written AFTER this resolves and
|
|
197
|
+
* never read before it.
|
|
198
|
+
*
|
|
199
|
+
* The uncached read was also the most expensive shape it could have been.
|
|
200
|
+
* `QueryHelper.findWithSameText` emits `LOWER("Service"."name") = $2`, and the
|
|
201
|
+
* live indexes on Service are all over the RAW name column
|
|
202
|
+
* (IDX_80cd6d06b0ab8fbce9fea6d3bc on ("projectId","name")), so Postgres can use
|
|
203
|
+
* only the `projectId` prefix as an index qual and must then heap-fetch and
|
|
204
|
+
* filter every service in the project. Cost per call is O(services in project),
|
|
205
|
+
* not O(log n). The matching expression index existed once —
|
|
206
|
+
* `("projectId", LOWER("name")) WHERE "deletedAt" IS NULL` in migration
|
|
207
|
+
* 1774559064921 — and was dropped as collateral damage by autogenerated
|
|
208
|
+
* migration 1775735059360. Restoring it is a separate change; this cache is
|
|
209
|
+
* what removes the calls in the first place.
|
|
210
|
+
*/
|
|
211
|
+
const SERVICE_RESOLUTION_CACHE_NAMESPACE: string = "service-resolution";
|
|
212
|
+
|
|
213
|
+
/*
|
|
214
|
+
* Matches the project-retention TTL deliberately: the two are read together on
|
|
215
|
+
* every resolution and there is no value in them expiring at different times.
|
|
216
|
+
*
|
|
217
|
+
* Staleness is bounded rather than invalidated, exactly as it is for project
|
|
218
|
+
* retention. A retention change made in the UI takes up to this long to affect
|
|
219
|
+
* newly ingested rows, and a service deleted in the UI keeps resolving for up
|
|
220
|
+
* to this long — which stamps `primaryEntityId` values into ClickHouse that no
|
|
221
|
+
* longer have a Postgres parent. Those rows are orphaned, not corrupt (the
|
|
222
|
+
* analytics side has no FK to Service) and they age out via retention; this is
|
|
223
|
+
* the same trade-off the dedupe migration already documents for its own
|
|
224
|
+
* ClickHouse rows. Cross-process invalidation would need pub/sub, which
|
|
225
|
+
* GlobalCache has no primitive for.
|
|
226
|
+
*/
|
|
227
|
+
const SERVICE_RESOLUTION_CACHE_TTL_SECONDS: number = 5 * 60;
|
|
228
|
+
const serviceResolutionInProcessCache: Map<string, CachedServiceResolution> =
|
|
229
|
+
new Map();
|
|
230
|
+
|
|
171
231
|
export default class OTelIngestService {
|
|
172
232
|
/*
|
|
173
233
|
* Read a single string-valued OTel resource attribute out of the raw
|
|
@@ -459,6 +519,114 @@ export default class OTelIngestService {
|
|
|
459
519
|
return result;
|
|
460
520
|
}
|
|
461
521
|
|
|
522
|
+
/*
|
|
523
|
+
* Normalises exactly as `QueryHelper.findWithSameText` does
|
|
524
|
+
* (`toLowerCase().trim()`). This has to stay in lockstep with that helper:
|
|
525
|
+
* if the key kept casing or surrounding whitespace that the SQL predicate
|
|
526
|
+
* folds away, two spellings of one `service.name` would occupy two cache
|
|
527
|
+
* entries pointing at the same row, and a batch could miss the entry the
|
|
528
|
+
* previous batch just wrote.
|
|
529
|
+
*/
|
|
530
|
+
private static getServiceResolutionCacheKey(
|
|
531
|
+
projectId: ObjectID,
|
|
532
|
+
serviceName: string,
|
|
533
|
+
): string {
|
|
534
|
+
return `${projectId.toString()}:${serviceName.toLowerCase().trim()}`;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
private static async getCachedServiceResolution(
|
|
538
|
+
cacheKey: string,
|
|
539
|
+
): Promise<ServiceResolution | null> {
|
|
540
|
+
const now: number = Date.now();
|
|
541
|
+
|
|
542
|
+
// L1: in-process memo. Zero network cost.
|
|
543
|
+
const memoed: CachedServiceResolution | undefined =
|
|
544
|
+
serviceResolutionInProcessCache.get(cacheKey);
|
|
545
|
+
if (memoed && memoed.expiresAtMs > now) {
|
|
546
|
+
return memoed.resolution;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// L2: Redis. Single round-trip; shared across workers.
|
|
550
|
+
try {
|
|
551
|
+
const cached: JSONObject | null = await GlobalCache.getJSONObject(
|
|
552
|
+
SERVICE_RESOLUTION_CACHE_NAMESPACE,
|
|
553
|
+
cacheKey,
|
|
554
|
+
);
|
|
555
|
+
|
|
556
|
+
/*
|
|
557
|
+
* `serviceId` is the load-bearing field — a cached entry without one
|
|
558
|
+
* cannot produce a `primaryEntityId`, so treat it as a miss and let the
|
|
559
|
+
* caller re-resolve rather than stamping rows with a broken id.
|
|
560
|
+
*/
|
|
561
|
+
if (cached && cached["serviceId"]) {
|
|
562
|
+
const resolution: ServiceResolution = {
|
|
563
|
+
serviceId: String(cached["serviceId"]),
|
|
564
|
+
retainTelemetryDataForDays:
|
|
565
|
+
(cached["retainTelemetryDataForDays"] as number | null) ?? null,
|
|
566
|
+
telemetryRetentionConfig:
|
|
567
|
+
(cached[
|
|
568
|
+
"telemetryRetentionConfig"
|
|
569
|
+
] as TelemetryRetentionConfig | null) ?? null,
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
serviceResolutionInProcessCache.set(cacheKey, {
|
|
573
|
+
resolution,
|
|
574
|
+
expiresAtMs: now + SERVICE_RESOLUTION_CACHE_TTL_SECONDS * 1000,
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
return resolution;
|
|
578
|
+
}
|
|
579
|
+
} catch (err) {
|
|
580
|
+
// Cache outage must never fail ingest. Fall through to Postgres.
|
|
581
|
+
logger.warn(
|
|
582
|
+
`Service resolution cache read failed for "${cacheKey}"; falling back to Postgres: ${
|
|
583
|
+
err instanceof Error ? err.message : String(err)
|
|
584
|
+
}`,
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
private static async cacheServiceResolution(
|
|
592
|
+
cacheKey: string,
|
|
593
|
+
resolution: ServiceResolution,
|
|
594
|
+
): Promise<void> {
|
|
595
|
+
serviceResolutionInProcessCache.set(cacheKey, {
|
|
596
|
+
resolution,
|
|
597
|
+
expiresAtMs: Date.now() + SERVICE_RESOLUTION_CACHE_TTL_SECONDS * 1000,
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
try {
|
|
601
|
+
await GlobalCache.setJSON(
|
|
602
|
+
SERVICE_RESOLUTION_CACHE_NAMESPACE,
|
|
603
|
+
cacheKey,
|
|
604
|
+
{
|
|
605
|
+
serviceId: resolution.serviceId,
|
|
606
|
+
retainTelemetryDataForDays: resolution.retainTelemetryDataForDays,
|
|
607
|
+
telemetryRetentionConfig: (resolution.telemetryRetentionConfig ??
|
|
608
|
+
null) as unknown as JSONObject,
|
|
609
|
+
},
|
|
610
|
+
{ expiresInSeconds: SERVICE_RESOLUTION_CACHE_TTL_SECONDS },
|
|
611
|
+
);
|
|
612
|
+
} catch (err) {
|
|
613
|
+
// Best-effort warm. Don't fail the request.
|
|
614
|
+
logger.warn(
|
|
615
|
+
`Service resolution cache write failed for "${cacheKey}": ${
|
|
616
|
+
err instanceof Error ? err.message : String(err)
|
|
617
|
+
}`,
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
private static toServiceResolution(service: Service): ServiceResolution {
|
|
623
|
+
return {
|
|
624
|
+
serviceId: service.id!.toString(),
|
|
625
|
+
retainTelemetryDataForDays: service.retainTelemetryDataForDays ?? null,
|
|
626
|
+
telemetryRetentionConfig: service.telemetryRetentionConfig ?? null,
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
462
630
|
@CaptureSpan()
|
|
463
631
|
private static async findOrCreateTelemetryService(data: {
|
|
464
632
|
serviceName: string;
|
|
@@ -481,23 +649,38 @@ export default class OTelIngestService {
|
|
|
481
649
|
* Stored casing is preserved: service-detail pages key telemetry by the
|
|
482
650
|
* stable primaryEntityId, and the name is user-facing.
|
|
483
651
|
*/
|
|
484
|
-
const
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
652
|
+
const resolutionCacheKey: string = this.getServiceResolutionCacheKey(
|
|
653
|
+
data.projectId,
|
|
654
|
+
data.serviceName,
|
|
655
|
+
);
|
|
656
|
+
|
|
657
|
+
let resolution: ServiceResolution | null =
|
|
658
|
+
await this.getCachedServiceResolution(resolutionCacheKey);
|
|
659
|
+
|
|
660
|
+
if (!resolution) {
|
|
661
|
+
const service: Service | null = await ServiceService.findOneBy({
|
|
662
|
+
query: {
|
|
663
|
+
projectId: data.projectId,
|
|
664
|
+
name: QueryHelper.findWithSameText(data.serviceName),
|
|
665
|
+
},
|
|
666
|
+
select: {
|
|
667
|
+
_id: true,
|
|
668
|
+
retainTelemetryDataForDays: true,
|
|
669
|
+
telemetryRetentionConfig: true,
|
|
670
|
+
},
|
|
671
|
+
sort: {
|
|
672
|
+
createdAt: SortOrder.Ascending,
|
|
673
|
+
},
|
|
674
|
+
props: {
|
|
675
|
+
isRoot: true,
|
|
676
|
+
},
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
if (service) {
|
|
680
|
+
resolution = this.toServiceResolution(service);
|
|
681
|
+
await this.cacheServiceResolution(resolutionCacheKey, resolution);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
501
684
|
|
|
502
685
|
const projectContext: ProjectRetentionContext =
|
|
503
686
|
await this.getProjectRetentionContext(data.projectId);
|
|
@@ -513,14 +696,16 @@ export default class OTelIngestService {
|
|
|
513
696
|
data.serviceName,
|
|
514
697
|
);
|
|
515
698
|
|
|
516
|
-
const buildMetadata: (
|
|
517
|
-
|
|
699
|
+
const buildMetadata: (
|
|
700
|
+
resolved: ServiceResolution,
|
|
701
|
+
) => TelemetryServiceMetadata = (
|
|
702
|
+
resolved: ServiceResolution,
|
|
518
703
|
): TelemetryServiceMetadata => {
|
|
519
704
|
const serviceLevelRetention: number | null =
|
|
520
|
-
|
|
705
|
+
resolved.retainTelemetryDataForDays ?? null;
|
|
521
706
|
return {
|
|
522
707
|
serviceName: data.serviceName,
|
|
523
|
-
primaryEntityId:
|
|
708
|
+
primaryEntityId: new ObjectID(resolved.serviceId),
|
|
524
709
|
primaryEntityType: ServiceType.OpenTelemetry,
|
|
525
710
|
entityKeys: [serviceEntityKey],
|
|
526
711
|
/*
|
|
@@ -534,14 +719,14 @@ export default class OTelIngestService {
|
|
|
534
719
|
},
|
|
535
720
|
dataRententionInDays:
|
|
536
721
|
serviceLevelRetention || projectContext.projectRetentionInDays,
|
|
537
|
-
serviceRetentionConfig:
|
|
722
|
+
serviceRetentionConfig: resolved.telemetryRetentionConfig ?? null,
|
|
538
723
|
serviceRetentionInDays: serviceLevelRetention,
|
|
539
724
|
projectRetentionConfig: projectContext.projectRetentionConfig,
|
|
540
725
|
projectRetentionInDays: projectContext.projectRetentionInDays,
|
|
541
726
|
};
|
|
542
727
|
};
|
|
543
728
|
|
|
544
|
-
if (!
|
|
729
|
+
if (!resolution) {
|
|
545
730
|
try {
|
|
546
731
|
const newService: Service = new Service();
|
|
547
732
|
newService.projectId = data.projectId;
|
|
@@ -555,11 +740,23 @@ export default class OTelIngestService {
|
|
|
555
740
|
},
|
|
556
741
|
});
|
|
557
742
|
|
|
558
|
-
|
|
743
|
+
const createdResolution: ServiceResolution =
|
|
744
|
+
this.toServiceResolution(createdService);
|
|
745
|
+
await this.cacheServiceResolution(
|
|
746
|
+
resolutionCacheKey,
|
|
747
|
+
createdResolution,
|
|
748
|
+
);
|
|
749
|
+
|
|
750
|
+
return buildMetadata(createdResolution);
|
|
559
751
|
} catch {
|
|
560
752
|
/*
|
|
561
753
|
* Race condition: another request created the service concurrently.
|
|
562
754
|
* Re-fetch the existing service (oldest wins, see sort above).
|
|
755
|
+
*
|
|
756
|
+
* This refetch emits SQL byte-identical to the lookup above, so it is
|
|
757
|
+
* indistinguishable from it in pg_stat_activity — caching the result
|
|
758
|
+
* here matters as much as caching it there. A first-contact stampede
|
|
759
|
+
* on a brand-new service.name is exactly when this branch runs hot.
|
|
563
760
|
*/
|
|
564
761
|
const existingService: Service | null = await ServiceService.findOneBy({
|
|
565
762
|
query: {
|
|
@@ -580,7 +777,14 @@ export default class OTelIngestService {
|
|
|
580
777
|
});
|
|
581
778
|
|
|
582
779
|
if (existingService) {
|
|
583
|
-
|
|
780
|
+
const existingResolution: ServiceResolution =
|
|
781
|
+
this.toServiceResolution(existingService);
|
|
782
|
+
await this.cacheServiceResolution(
|
|
783
|
+
resolutionCacheKey,
|
|
784
|
+
existingResolution,
|
|
785
|
+
);
|
|
786
|
+
|
|
787
|
+
return buildMetadata(existingResolution);
|
|
584
788
|
}
|
|
585
789
|
|
|
586
790
|
throw new Error(
|
|
@@ -589,7 +793,7 @@ export default class OTelIngestService {
|
|
|
589
793
|
}
|
|
590
794
|
}
|
|
591
795
|
|
|
592
|
-
return buildMetadata(
|
|
796
|
+
return buildMetadata(resolution);
|
|
593
797
|
}
|
|
594
798
|
|
|
595
799
|
/*
|