@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
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { EncryptionSecret } from "../EnvironmentConfig";
|
|
2
|
+
import BadDataException from "../../Types/Exception/BadDataException";
|
|
3
|
+
import HashedString from "../../Types/HashedString";
|
|
4
|
+
import crypto from "crypto";
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* Password hashing for user credentials.
|
|
8
|
+
*
|
|
9
|
+
* WHY THIS IS NOT A PLAIN HASH
|
|
10
|
+
*
|
|
11
|
+
* SHA-256 is designed to be fast, and that is exactly wrong for a password.
|
|
12
|
+
* A commodity GPU computes billions of SHA-256 digests per second, so a
|
|
13
|
+
* leaked User table is a dictionary attack that finishes over lunch — a
|
|
14
|
+
* per-user salt stops the attacker amortising that work across accounts, but
|
|
15
|
+
* it does nothing to slow down any single guess.
|
|
16
|
+
*
|
|
17
|
+
* scrypt is deliberately expensive in BOTH time and memory. The memory cost
|
|
18
|
+
* is the important half: it is what stops an attacker buying a thousandfold
|
|
19
|
+
* speedup with GPUs or custom silicon, because memory bandwidth is the one
|
|
20
|
+
* thing that hardware cannot cheaply parallelise.
|
|
21
|
+
*
|
|
22
|
+
* THE CONSTRUCTION
|
|
23
|
+
*
|
|
24
|
+
* input = HMAC-SHA256(key = EncryptionSecret, message = password)
|
|
25
|
+
* derived = scrypt(input, salt, 32 bytes, { N, r, p })
|
|
26
|
+
*
|
|
27
|
+
* The HMAC step carries the instance-wide EncryptionSecret as a "pepper".
|
|
28
|
+
* The pepper lives in configuration, not in the database, so a dump of
|
|
29
|
+
* Postgres alone is not enough to start guessing — the attacker also needs
|
|
30
|
+
* the deployment's secret. It also normalises the password to a fixed 32
|
|
31
|
+
* bytes before scrypt sees it, which sidesteps every question about very
|
|
32
|
+
* long or oddly-encoded inputs.
|
|
33
|
+
*
|
|
34
|
+
* THE STORED FORMAT
|
|
35
|
+
*
|
|
36
|
+
* scrypt$N=16384,r=8,p=1$<64 hex characters>
|
|
37
|
+
*
|
|
38
|
+
* The hash carries the algorithm and the cost parameters it was produced
|
|
39
|
+
* with. That is what makes raising the cost later a non-event: old hashes
|
|
40
|
+
* keep verifying against the parameters recorded in them, and each user's
|
|
41
|
+
* hash is re-derived at their next successful login. Nothing about a cost
|
|
42
|
+
* bump requires a migration or a forced password reset.
|
|
43
|
+
*
|
|
44
|
+
* The salt is NOT in this string — it lives in the row's own salt column
|
|
45
|
+
* (see TableColumn.hashSaltColumn), which is where the previous scheme put
|
|
46
|
+
* it and where the write path keeps it in step with the hash.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/*
|
|
50
|
+
* N is the CPU/memory cost. Memory used is 128 * N * r bytes, so the values
|
|
51
|
+
* below cost 16 MiB and roughly 50ms per hash on current server hardware.
|
|
52
|
+
*
|
|
53
|
+
* That is a deliberate middle: enough to make offline guessing expensive,
|
|
54
|
+
* cheap enough that a burst of logins does not exhaust the libuv thread pool
|
|
55
|
+
* or the box's memory. Raising N is a one-line change — bump it here, and
|
|
56
|
+
* every user is migrated to the new cost as they log in.
|
|
57
|
+
*/
|
|
58
|
+
const CURRENT_PARAMS: ScryptParams = {
|
|
59
|
+
N: 16384,
|
|
60
|
+
r: 8,
|
|
61
|
+
p: 1,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Bytes of derived key. 32 bytes = the 64 hex characters stored. */
|
|
65
|
+
const KEY_LENGTH_IN_BYTES: number = 32;
|
|
66
|
+
|
|
67
|
+
const SCHEME_NAME: string = "scrypt";
|
|
68
|
+
|
|
69
|
+
/*
|
|
70
|
+
* Refuse absurd parameters read back out of the database. Stored values are
|
|
71
|
+
* ours, not an attacker's, but a corrupted row must fail the login rather
|
|
72
|
+
* than ask Node to allocate gigabytes.
|
|
73
|
+
*/
|
|
74
|
+
const MAX_N: number = 1048576; // 2^20
|
|
75
|
+
const MAX_R: number = 32;
|
|
76
|
+
const MAX_P: number = 16;
|
|
77
|
+
|
|
78
|
+
export interface ScryptParams {
|
|
79
|
+
N: number;
|
|
80
|
+
r: number;
|
|
81
|
+
p: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface ParsedPasswordHash {
|
|
85
|
+
params: ScryptParams;
|
|
86
|
+
derivedKey: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export default class PasswordHash {
|
|
90
|
+
/**
|
|
91
|
+
* A fresh, cryptographically random salt: 32 bytes as 64 hex characters.
|
|
92
|
+
*
|
|
93
|
+
* A salt is not a secret — its one job is to be UNIQUE per user, so that no
|
|
94
|
+
* precomputed table and no cracked password is ever worth anything against
|
|
95
|
+
* a second account.
|
|
96
|
+
*/
|
|
97
|
+
public static generateSalt(): string {
|
|
98
|
+
return crypto.randomBytes(32).toString("hex");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The `algorithm$params$` prefix that hashes written today carry. */
|
|
102
|
+
public static getCurrentPrefix(): string {
|
|
103
|
+
return `${SCHEME_NAME}$${PasswordHash.encodeParams(CURRENT_PARAMS)}$`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
public static getCurrentParams(): ScryptParams {
|
|
107
|
+
return { ...CURRENT_PARAMS };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private static encodeParams(params: ScryptParams): string {
|
|
111
|
+
return `N=${params.N},r=${params.r},p=${params.p}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Pull the parameters and derived key back out of a stored value.
|
|
116
|
+
*
|
|
117
|
+
* Returns null for anything that is not one of our scrypt hashes — which
|
|
118
|
+
* includes every password written before this scheme existed. Callers use
|
|
119
|
+
* null to mean "fall back to the legacy comparison", so this must never
|
|
120
|
+
* throw on unrecognised input.
|
|
121
|
+
*/
|
|
122
|
+
private static parse(storedValue: string): ParsedPasswordHash | null {
|
|
123
|
+
const parts: Array<string> = storedValue.split("$");
|
|
124
|
+
|
|
125
|
+
if (parts.length !== 3 || parts[0] !== SCHEME_NAME) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const match: RegExpMatchArray | null = (parts[1] as string).match(
|
|
130
|
+
/^N=(\d+),r=(\d+),p=(\d+)$/,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
if (!match) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const params: ScryptParams = {
|
|
138
|
+
N: Number(match[1]),
|
|
139
|
+
r: Number(match[2]),
|
|
140
|
+
p: Number(match[3]),
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const isSane: boolean =
|
|
144
|
+
params.N > 1 &&
|
|
145
|
+
params.N <= MAX_N &&
|
|
146
|
+
(params.N & (params.N - 1)) === 0 && // scrypt requires a power of two
|
|
147
|
+
params.r > 0 &&
|
|
148
|
+
params.r <= MAX_R &&
|
|
149
|
+
params.p > 0 &&
|
|
150
|
+
params.p <= MAX_P;
|
|
151
|
+
|
|
152
|
+
if (!isSane || !parts[2]) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { params: params, derivedKey: parts[2] };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The pepper step. Keyed with the instance's EncryptionSecret, so the
|
|
161
|
+
* database alone cannot be attacked offline.
|
|
162
|
+
*/
|
|
163
|
+
private static applyPepper(plainValue: string): Buffer {
|
|
164
|
+
return crypto
|
|
165
|
+
.createHmac("sha256", EncryptionSecret.toString())
|
|
166
|
+
.update(plainValue, "utf8")
|
|
167
|
+
.digest();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private static derive(data: {
|
|
171
|
+
plainValue: string;
|
|
172
|
+
salt: string;
|
|
173
|
+
params: ScryptParams;
|
|
174
|
+
}): Promise<string> {
|
|
175
|
+
const { N, r, p } = data.params;
|
|
176
|
+
|
|
177
|
+
return new Promise<string>(
|
|
178
|
+
(resolve: (value: string) => void, reject: (error: Error) => void) => {
|
|
179
|
+
crypto.scrypt(
|
|
180
|
+
PasswordHash.applyPepper(data.plainValue),
|
|
181
|
+
data.salt,
|
|
182
|
+
KEY_LENGTH_IN_BYTES,
|
|
183
|
+
{
|
|
184
|
+
N: N,
|
|
185
|
+
r: r,
|
|
186
|
+
p: p,
|
|
187
|
+
/*
|
|
188
|
+
* Node rejects the call outright when 128 * N * r exceeds
|
|
189
|
+
* maxmem, and its default is a flat 32 MiB. Scale the ceiling
|
|
190
|
+
* with the parameters instead, so raising N does not silently
|
|
191
|
+
* become "Invalid scrypt params" at runtime.
|
|
192
|
+
*/
|
|
193
|
+
maxmem: 128 * N * r * 2,
|
|
194
|
+
},
|
|
195
|
+
(error: Error | null, derivedKey: Buffer): void => {
|
|
196
|
+
if (error) {
|
|
197
|
+
return reject(error);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return resolve(derivedKey.toString("hex"));
|
|
201
|
+
},
|
|
202
|
+
);
|
|
203
|
+
},
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Hash a password for storage, at today's cost parameters.
|
|
209
|
+
*
|
|
210
|
+
* The returned string records the algorithm and the parameters used, so it
|
|
211
|
+
* stays verifiable after those parameters change.
|
|
212
|
+
*/
|
|
213
|
+
public static async hash(data: {
|
|
214
|
+
plainValue: string;
|
|
215
|
+
salt: string;
|
|
216
|
+
}): Promise<string> {
|
|
217
|
+
if (!data.plainValue) {
|
|
218
|
+
return "";
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (!data.salt) {
|
|
222
|
+
throw new BadDataException("A salt is required to hash a password.");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const derivedKey: string = await PasswordHash.derive({
|
|
226
|
+
plainValue: data.plainValue,
|
|
227
|
+
salt: data.salt,
|
|
228
|
+
params: CURRENT_PARAMS,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
return `${PasswordHash.getCurrentPrefix()}${derivedKey}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Check a password against a stored value, whichever scheme wrote it.
|
|
236
|
+
*
|
|
237
|
+
* Three schemes are in circulation and all three have to keep working, or
|
|
238
|
+
* the change that introduced the next one locks people out:
|
|
239
|
+
*
|
|
240
|
+
* scrypt$... this one. Verified at the parameters recorded in the hash.
|
|
241
|
+
* salted SHA `SHA256("v2:" + salt + ":" + secret + ":" + password)`.
|
|
242
|
+
* bare SHA `SHA256(secret + password)`, from before salts existed.
|
|
243
|
+
*
|
|
244
|
+
* The last two are told apart by whether the row has a salt, which is the
|
|
245
|
+
* same rule they were written under. Anything not carrying a recognised
|
|
246
|
+
* scheme prefix is one of them.
|
|
247
|
+
*/
|
|
248
|
+
public static async verify(data: {
|
|
249
|
+
plainValue: string;
|
|
250
|
+
storedValue: string;
|
|
251
|
+
salt?: string | null | undefined;
|
|
252
|
+
}): Promise<boolean> {
|
|
253
|
+
if (!data.plainValue || !data.storedValue) {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const parsed: ParsedPasswordHash | null = PasswordHash.parse(
|
|
258
|
+
data.storedValue,
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
if (!parsed) {
|
|
262
|
+
return await HashedString.verifyValue({
|
|
263
|
+
plainValue: data.plainValue,
|
|
264
|
+
hashedValue: data.storedValue,
|
|
265
|
+
encryptionSecret: EncryptionSecret,
|
|
266
|
+
salt: data.salt || null,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/*
|
|
271
|
+
* A scrypt hash is meaningless without the salt it was derived with.
|
|
272
|
+
* Reaching here without one means the caller did not select the salt
|
|
273
|
+
* column, which would otherwise look exactly like a wrong password.
|
|
274
|
+
*/
|
|
275
|
+
if (!data.salt) {
|
|
276
|
+
throw new BadDataException(
|
|
277
|
+
"Cannot verify a salted password hash without its salt. Select the salt column alongside the hash.",
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const derivedKey: string = await PasswordHash.derive({
|
|
282
|
+
plainValue: data.plainValue,
|
|
283
|
+
salt: data.salt,
|
|
284
|
+
params: parsed.params,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
return HashedString.isEqual(derivedKey, parsed.derivedKey);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Should this stored value be re-hashed the next time the password is
|
|
292
|
+
* known to be correct?
|
|
293
|
+
*
|
|
294
|
+
* True for every hash that is not exactly what `hash()` writes today: the
|
|
295
|
+
* two SHA-256 schemes that came before, and any scrypt hash carrying
|
|
296
|
+
* parameters that are no longer current. This is the whole upgrade
|
|
297
|
+
* mechanism — raising the cost above is enough to start migrating users.
|
|
298
|
+
*/
|
|
299
|
+
public static needsUpgrade(storedValue: string): boolean {
|
|
300
|
+
if (!storedValue) {
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return !storedValue.startsWith(PasswordHash.getCurrentPrefix());
|
|
305
|
+
}
|
|
306
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { afterEach, describe, expect, jest, test } from "@jest/globals";
|
|
2
|
+
import "@testing-library/jest-dom";
|
|
3
|
+
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import getJestMockFunction, { MockFunction } from "../../MockType";
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* The card that puts a project's custom fields on a resource's OVERVIEW page.
|
|
9
|
+
*
|
|
10
|
+
* Every resource already had a dedicated "Custom Fields" page behind a side
|
|
11
|
+
* menu item, which is where the values went to be ignored. What this wrapper
|
|
12
|
+
* owns is the two things that make the same card safe to mount on a page
|
|
13
|
+
* nobody opened for custom fields: it disappears entirely for the projects
|
|
14
|
+
* that never defined one, and it names itself the same way on every resource.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const getListMock: MockFunction = getJestMockFunction();
|
|
18
|
+
const getItemMock: MockFunction = getJestMockFunction();
|
|
19
|
+
const getCurrentProjectIdMock: MockFunction = getJestMockFunction();
|
|
20
|
+
|
|
21
|
+
/*
|
|
22
|
+
* The arrow wrappers are load bearing: jest.mock is hoisted above the compiled
|
|
23
|
+
* requires, so naming the mocks directly would capture them before their
|
|
24
|
+
* initializers have run.
|
|
25
|
+
*/
|
|
26
|
+
jest.mock("../../../UI/Utils/ModelAPI/ModelAPI", () => {
|
|
27
|
+
return {
|
|
28
|
+
__esModule: true,
|
|
29
|
+
default: {
|
|
30
|
+
getList: (...args: Array<any>) => {
|
|
31
|
+
return getListMock(...args);
|
|
32
|
+
},
|
|
33
|
+
getItem: (...args: Array<any>) => {
|
|
34
|
+
return getItemMock(...args);
|
|
35
|
+
},
|
|
36
|
+
updateById: () => {
|
|
37
|
+
return Promise.resolve();
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
jest.mock("../../../UI/Utils/Project", () => {
|
|
44
|
+
return {
|
|
45
|
+
__esModule: true,
|
|
46
|
+
default: {
|
|
47
|
+
getCurrentProjectId: (...args: Array<any>) => {
|
|
48
|
+
return getCurrentProjectIdMock(...args);
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
import OverviewCustomFields from "../../../../App/FeatureSet/Dashboard/src/Components/CustomFields/OverviewCustomFields";
|
|
55
|
+
import BaseModel from "../../../Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
|
|
56
|
+
import Incident from "../../../Models/DatabaseModels/Incident";
|
|
57
|
+
import IncidentCustomField from "../../../Models/DatabaseModels/IncidentCustomField";
|
|
58
|
+
import StatusPage from "../../../Models/DatabaseModels/StatusPage";
|
|
59
|
+
import StatusPageCustomField from "../../../Models/DatabaseModels/StatusPageCustomField";
|
|
60
|
+
import CustomFieldType from "../../../Types/CustomField/CustomFieldType";
|
|
61
|
+
import { JSONObject } from "../../../Types/JSON";
|
|
62
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
63
|
+
|
|
64
|
+
const PROJECT_ID: ObjectID = new ObjectID(
|
|
65
|
+
"11111111-1111-4111-8111-111111111111",
|
|
66
|
+
);
|
|
67
|
+
const INCIDENT_ID: ObjectID = new ObjectID(
|
|
68
|
+
"22222222-2222-4222-8222-222222222222",
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
type BuildSchemaFunction = (names: Array<string>) => Array<IncidentCustomField>;
|
|
72
|
+
|
|
73
|
+
const buildSchema: BuildSchemaFunction = (
|
|
74
|
+
names: Array<string>,
|
|
75
|
+
): Array<IncidentCustomField> => {
|
|
76
|
+
return names.map((name: string) => {
|
|
77
|
+
const schemaItem: IncidentCustomField = new IncidentCustomField();
|
|
78
|
+
schemaItem.name = name;
|
|
79
|
+
schemaItem.customFieldType = CustomFieldType.Text;
|
|
80
|
+
return schemaItem;
|
|
81
|
+
});
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
type ResolveListFunction = (data: Array<BaseModel>) => void;
|
|
85
|
+
|
|
86
|
+
const resolveListWith: ResolveListFunction = (data: Array<BaseModel>): void => {
|
|
87
|
+
getListMock.mockResolvedValue({
|
|
88
|
+
data: data,
|
|
89
|
+
count: data.length,
|
|
90
|
+
skip: 0,
|
|
91
|
+
limit: data.length,
|
|
92
|
+
} as never);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
type BuildIncidentFunction = (customFields: JSONObject) => Incident;
|
|
96
|
+
|
|
97
|
+
const buildIncident: BuildIncidentFunction = (
|
|
98
|
+
customFields: JSONObject,
|
|
99
|
+
): Incident => {
|
|
100
|
+
const incident: Incident = new Incident();
|
|
101
|
+
incident.id = INCIDENT_ID;
|
|
102
|
+
incident.customFields = customFields;
|
|
103
|
+
return incident;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
afterEach(() => {
|
|
107
|
+
cleanup();
|
|
108
|
+
getListMock.mockReset();
|
|
109
|
+
getItemMock.mockReset();
|
|
110
|
+
getCurrentProjectIdMock.mockReset();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("OverviewCustomFields", () => {
|
|
114
|
+
test("shows the project's fields and this record's values", async () => {
|
|
115
|
+
getCurrentProjectIdMock.mockReturnValue(PROJECT_ID);
|
|
116
|
+
resolveListWith(buildSchema(["Customer", "Escalated To"]));
|
|
117
|
+
getItemMock.mockResolvedValue(
|
|
118
|
+
buildIncident({
|
|
119
|
+
Customer: "Acme Corp",
|
|
120
|
+
"Escalated To": "Tier 2",
|
|
121
|
+
}) as never,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
render(
|
|
125
|
+
<OverviewCustomFields
|
|
126
|
+
modelId={INCIDENT_ID}
|
|
127
|
+
modelType={Incident}
|
|
128
|
+
customFieldType={IncidentCustomField}
|
|
129
|
+
resourceName="Incident"
|
|
130
|
+
/>,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
expect(await screen.findByText("Acme Corp")).toBeInTheDocument();
|
|
134
|
+
expect(screen.getByText("Tier 2")).toBeInTheDocument();
|
|
135
|
+
expect(screen.getByText("Customer")).toBeInTheDocument();
|
|
136
|
+
expect(screen.getByText("Escalated To")).toBeInTheDocument();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("titles the card the same way regardless of resource", async () => {
|
|
140
|
+
getCurrentProjectIdMock.mockReturnValue(PROJECT_ID);
|
|
141
|
+
resolveListWith(buildSchema(["Customer"]));
|
|
142
|
+
getItemMock.mockResolvedValue(
|
|
143
|
+
buildIncident({ Customer: "Acme Corp" }) as never,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
render(
|
|
147
|
+
<OverviewCustomFields
|
|
148
|
+
modelId={INCIDENT_ID}
|
|
149
|
+
modelType={Incident}
|
|
150
|
+
customFieldType={IncidentCustomField}
|
|
151
|
+
resourceName="Incident"
|
|
152
|
+
/>,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
await screen.findByText("Acme Corp");
|
|
156
|
+
|
|
157
|
+
expect(screen.getByText("Custom Fields")).toBeInTheDocument();
|
|
158
|
+
expect(
|
|
159
|
+
screen.getByText("Custom fields for this incident."),
|
|
160
|
+
).toBeInTheDocument();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("lowercases a multi-word resource name in the description", async () => {
|
|
164
|
+
getCurrentProjectIdMock.mockReturnValue(PROJECT_ID);
|
|
165
|
+
resolveListWith(buildSchema(["Owner"]));
|
|
166
|
+
getItemMock.mockResolvedValue(
|
|
167
|
+
buildIncident({ Owner: "Platform" }) as never,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
render(
|
|
171
|
+
<OverviewCustomFields
|
|
172
|
+
modelId={INCIDENT_ID}
|
|
173
|
+
modelType={StatusPage}
|
|
174
|
+
customFieldType={StatusPageCustomField}
|
|
175
|
+
resourceName="Status Page"
|
|
176
|
+
/>,
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
await screen.findByText("Platform");
|
|
180
|
+
|
|
181
|
+
expect(
|
|
182
|
+
screen.getByText("Custom fields for this status page."),
|
|
183
|
+
).toBeInTheDocument();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("gives the Detail block an id derived from the resource", async () => {
|
|
187
|
+
getCurrentProjectIdMock.mockReturnValue(PROJECT_ID);
|
|
188
|
+
resolveListWith(buildSchema(["Customer"]));
|
|
189
|
+
getItemMock.mockResolvedValue(
|
|
190
|
+
buildIncident({ Customer: "Acme Corp" }) as never,
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const { container } = render(
|
|
194
|
+
<OverviewCustomFields
|
|
195
|
+
modelId={INCIDENT_ID}
|
|
196
|
+
modelType={Incident}
|
|
197
|
+
customFieldType={IncidentCustomField}
|
|
198
|
+
resourceName="Incident"
|
|
199
|
+
/>,
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
await screen.findByText("Acme Corp");
|
|
203
|
+
|
|
204
|
+
expect(
|
|
205
|
+
container.querySelector('[id="Incident Custom Fields"]'),
|
|
206
|
+
).not.toBeNull();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
/*
|
|
210
|
+
* The whole reason the overview can mount this unconditionally. Most
|
|
211
|
+
* projects never define a custom field, and they must not be shown an empty
|
|
212
|
+
* card explaining that.
|
|
213
|
+
*/
|
|
214
|
+
test("renders nothing when the project defined no fields", async () => {
|
|
215
|
+
getCurrentProjectIdMock.mockReturnValue(PROJECT_ID);
|
|
216
|
+
resolveListWith([]);
|
|
217
|
+
getItemMock.mockResolvedValue(buildIncident({}) as never);
|
|
218
|
+
|
|
219
|
+
const { container } = render(
|
|
220
|
+
<OverviewCustomFields
|
|
221
|
+
modelId={INCIDENT_ID}
|
|
222
|
+
modelType={Incident}
|
|
223
|
+
customFieldType={IncidentCustomField}
|
|
224
|
+
resourceName="Incident"
|
|
225
|
+
/>,
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
await waitFor(() => {
|
|
229
|
+
expect(getItemMock).toHaveBeenCalled();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
expect(container).toBeEmptyDOMElement();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("renders nothing when reading the fields fails", async () => {
|
|
236
|
+
getCurrentProjectIdMock.mockReturnValue(PROJECT_ID);
|
|
237
|
+
getListMock.mockRejectedValue(new Error("Upgrade your plan"));
|
|
238
|
+
getItemMock.mockResolvedValue(buildIncident({}) as never);
|
|
239
|
+
|
|
240
|
+
const { container } = render(
|
|
241
|
+
<OverviewCustomFields
|
|
242
|
+
modelId={INCIDENT_ID}
|
|
243
|
+
modelType={Incident}
|
|
244
|
+
customFieldType={IncidentCustomField}
|
|
245
|
+
resourceName="Incident"
|
|
246
|
+
/>,
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
await waitFor(() => {
|
|
250
|
+
expect(getListMock).toHaveBeenCalled();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
expect(screen.queryByText(/Upgrade your plan/i)).not.toBeInTheDocument();
|
|
254
|
+
expect(container).toBeEmptyDOMElement();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("scopes the field lookup to the current project and the given record", async () => {
|
|
258
|
+
getCurrentProjectIdMock.mockReturnValue(PROJECT_ID);
|
|
259
|
+
resolveListWith(buildSchema(["Customer"]));
|
|
260
|
+
getItemMock.mockResolvedValue(
|
|
261
|
+
buildIncident({ Customer: "Acme Corp" }) as never,
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
render(
|
|
265
|
+
<OverviewCustomFields
|
|
266
|
+
modelId={INCIDENT_ID}
|
|
267
|
+
modelType={Incident}
|
|
268
|
+
customFieldType={IncidentCustomField}
|
|
269
|
+
resourceName="Incident"
|
|
270
|
+
/>,
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
await screen.findByText("Acme Corp");
|
|
274
|
+
|
|
275
|
+
const listArgs: JSONObject = getListMock.mock.calls[0]![0] as JSONObject;
|
|
276
|
+
expect(listArgs["modelType"]).toBe(IncidentCustomField);
|
|
277
|
+
expect((listArgs["query"] as JSONObject)["projectId"]).toBe(PROJECT_ID);
|
|
278
|
+
|
|
279
|
+
const itemArgs: JSONObject = getItemMock.mock.calls[0]![0] as JSONObject;
|
|
280
|
+
expect(itemArgs["modelType"]).toBe(Incident);
|
|
281
|
+
expect(itemArgs["id"]).toBe(INCIDENT_ID);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("renders nothing, and asks for nothing, without a current project", () => {
|
|
285
|
+
getCurrentProjectIdMock.mockReturnValue(null);
|
|
286
|
+
|
|
287
|
+
const { container } = render(
|
|
288
|
+
<OverviewCustomFields
|
|
289
|
+
modelId={INCIDENT_ID}
|
|
290
|
+
modelType={Incident}
|
|
291
|
+
customFieldType={IncidentCustomField}
|
|
292
|
+
resourceName="Incident"
|
|
293
|
+
/>,
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
expect(container).toBeEmptyDOMElement();
|
|
297
|
+
expect(getListMock).not.toHaveBeenCalled();
|
|
298
|
+
expect(getItemMock).not.toHaveBeenCalled();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("is editable by default and read only when asked", async () => {
|
|
302
|
+
getCurrentProjectIdMock.mockReturnValue(PROJECT_ID);
|
|
303
|
+
resolveListWith(buildSchema(["Customer"]));
|
|
304
|
+
getItemMock.mockResolvedValue(
|
|
305
|
+
buildIncident({ Customer: "Acme Corp" }) as never,
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
const { unmount } = render(
|
|
309
|
+
<OverviewCustomFields
|
|
310
|
+
modelId={INCIDENT_ID}
|
|
311
|
+
modelType={Incident}
|
|
312
|
+
customFieldType={IncidentCustomField}
|
|
313
|
+
resourceName="Incident"
|
|
314
|
+
/>,
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
expect(await screen.findByText("Edit Fields")).toBeInTheDocument();
|
|
318
|
+
unmount();
|
|
319
|
+
|
|
320
|
+
render(
|
|
321
|
+
<OverviewCustomFields
|
|
322
|
+
modelId={INCIDENT_ID}
|
|
323
|
+
modelType={Incident}
|
|
324
|
+
customFieldType={IncidentCustomField}
|
|
325
|
+
resourceName="Incident"
|
|
326
|
+
isEditable={false}
|
|
327
|
+
/>,
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
expect(await screen.findByText("Acme Corp")).toBeInTheDocument();
|
|
331
|
+
expect(screen.queryByText("Edit Fields")).not.toBeInTheDocument();
|
|
332
|
+
});
|
|
333
|
+
});
|