@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
|
@@ -24,6 +24,9 @@ import URL from "../../Types/API/URL";
|
|
|
24
24
|
import DatabaseCommonInteractionProps from "../../Types/BaseDatabase/DatabaseCommonInteractionProps";
|
|
25
25
|
import LIMIT_MAX from "../../Types/Database/LimitMax";
|
|
26
26
|
import OneUptimeDate from "../../Types/Date";
|
|
27
|
+
import PositiveNumber from "../../Types/PositiveNumber";
|
|
28
|
+
import ProjectService from "./ProjectService";
|
|
29
|
+
import Semaphore, { SemaphoreMutex } from "../Infrastructure/Semaphore";
|
|
27
30
|
import Email from "../../Types/Email";
|
|
28
31
|
import EmailTemplateType from "../../Types/Email/EmailTemplateType";
|
|
29
32
|
import HashedString from "../../Types/HashedString";
|
|
@@ -45,6 +48,37 @@ import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
|
|
45
48
|
import Timezone from "../../Types/Timezone";
|
|
46
49
|
import InMemoryTTLCache from "../Infrastructure/InMemoryTTLCache";
|
|
47
50
|
|
|
51
|
+
/*
|
|
52
|
+
* Names the Redis mutex that serializes the first-Master-Admin election across
|
|
53
|
+
* every process on this instance. Namespaced the same way as the other
|
|
54
|
+
* cross-process mutexes in this codebase (see ProjectService's counter locks)
|
|
55
|
+
* so the key is self-documenting in Redis and cannot collide with another lock.
|
|
56
|
+
*/
|
|
57
|
+
const FIRST_MASTER_ADMIN_ELECTION_LOCK_NAMESPACE: string =
|
|
58
|
+
"UserService.firstMasterAdminElection";
|
|
59
|
+
const FIRST_MASTER_ADMIN_ELECTION_LOCK_KEY: string = "instance";
|
|
60
|
+
|
|
61
|
+
/*
|
|
62
|
+
* How long the mutex survives without being refreshed. This is a crash bound,
|
|
63
|
+
* NOT a work budget: redis-semaphore refreshes an held lock every
|
|
64
|
+
* 0.8 * lockTimeout for as long as the holder is alive, so a slow COUNT+INSERT
|
|
65
|
+
* does not expire the lock out from under itself. It only matters when a holder
|
|
66
|
+
* dies mid-election (SIGKILL, eviction) — the lock then frees itself after this
|
|
67
|
+
* long instead of wedging every subsequent signup on the instance forever.
|
|
68
|
+
*/
|
|
69
|
+
const FIRST_MASTER_ADMIN_ELECTION_LOCK_TIMEOUT_MS: number = 10_000;
|
|
70
|
+
|
|
71
|
+
/*
|
|
72
|
+
* How long a signup waits for the lock before giving up. Signup is a
|
|
73
|
+
* user-facing request, so the wait has to be bounded rather than indefinite —
|
|
74
|
+
* this replaces the bound that Postgres `lock_timeout` used to give us.
|
|
75
|
+
*
|
|
76
|
+
* Failing the signup is the correct outcome when it fires: refusing to serve
|
|
77
|
+
* one request is safe, serving it unserialized is exactly the bug this lock
|
|
78
|
+
* exists to prevent (GHSA-3qqq-hprx-g2jw).
|
|
79
|
+
*/
|
|
80
|
+
const FIRST_MASTER_ADMIN_ELECTION_ACQUIRE_TIMEOUT_MS: number = 5_000;
|
|
81
|
+
|
|
48
82
|
export class Service extends DatabaseService<Model> {
|
|
49
83
|
/*
|
|
50
84
|
* Suppresses repeated `lastActive` UPDATEs from a single API node. 60s of
|
|
@@ -499,6 +533,156 @@ export class Service extends DatabaseService<Model> {
|
|
|
499
533
|
return onUpdate;
|
|
500
534
|
}
|
|
501
535
|
|
|
536
|
+
/**
|
|
537
|
+
* Creates a user who is signing themselves up, and decides — atomically —
|
|
538
|
+
* whether they are the instance's very first Master Admin.
|
|
539
|
+
*
|
|
540
|
+
* Every signup MUST come through here rather than calling `create` directly,
|
|
541
|
+
* because this method is the only place that is allowed to set
|
|
542
|
+
* `isMasterAdmin` on a self-service signup. It always assigns the column, so
|
|
543
|
+
* an `isMasterAdmin: true` smuggled into the request body is overwritten
|
|
544
|
+
* rather than honoured (signup creates with `isRoot: true`, which bypasses
|
|
545
|
+
* the column's empty `create: []` access control).
|
|
546
|
+
*/
|
|
547
|
+
@CaptureSpan()
|
|
548
|
+
public async createUserOnSignup(data: {
|
|
549
|
+
user: Model;
|
|
550
|
+
props: DatabaseCommonInteractionProps;
|
|
551
|
+
}): Promise<Model> {
|
|
552
|
+
// Never inherited from the caller. Decided below, or not at all.
|
|
553
|
+
data.user.isMasterAdmin = false;
|
|
554
|
+
|
|
555
|
+
if (IsBillingEnabled) {
|
|
556
|
+
/*
|
|
557
|
+
* On the hosted service there is no first-user bootstrap: master admins
|
|
558
|
+
* are provisioned out of band, so there is nothing to elect and no reason
|
|
559
|
+
* to take a lock.
|
|
560
|
+
*/
|
|
561
|
+
return await this.create({
|
|
562
|
+
data: data.user,
|
|
563
|
+
props: data.props,
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/*
|
|
568
|
+
* Self-hosted: the first user to sign up bootstraps the instance as Master
|
|
569
|
+
* Admin. The eligibility check reads the User table and the create writes to
|
|
570
|
+
* it, which is a read-then-write and therefore not atomic on its own — two
|
|
571
|
+
* signups landing together both counted zero users and both became Master
|
|
572
|
+
* Admin (GHSA-3qqq-hprx-g2jw). Serialize the check and the insert
|
|
573
|
+
* instance-wide so the second request observes the first one's row.
|
|
574
|
+
*
|
|
575
|
+
* The mutex is the Redis one every other cross-process critical section in
|
|
576
|
+
* this codebase uses (Semaphore), not a Postgres advisory lock. It holds no
|
|
577
|
+
* database connection and no open transaction, so serializing signups
|
|
578
|
+
* cannot pin a pooled backend `idle in transaction`, and it is unaffected
|
|
579
|
+
* by how PgBouncer is pooling.
|
|
580
|
+
*/
|
|
581
|
+
let mutex: SemaphoreMutex;
|
|
582
|
+
|
|
583
|
+
try {
|
|
584
|
+
mutex = await Semaphore.lock({
|
|
585
|
+
key: FIRST_MASTER_ADMIN_ELECTION_LOCK_KEY,
|
|
586
|
+
namespace: FIRST_MASTER_ADMIN_ELECTION_LOCK_NAMESPACE,
|
|
587
|
+
lockTimeout: FIRST_MASTER_ADMIN_ELECTION_LOCK_TIMEOUT_MS,
|
|
588
|
+
acquireTimeout: FIRST_MASTER_ADMIN_ELECTION_ACQUIRE_TIMEOUT_MS,
|
|
589
|
+
});
|
|
590
|
+
} catch (err) {
|
|
591
|
+
/*
|
|
592
|
+
* Redis unreachable, or the lock stayed contended past the acquire
|
|
593
|
+
* timeout. Fail the signup rather than fall back to an unserialized
|
|
594
|
+
* election — the fallback is the vulnerability.
|
|
595
|
+
*/
|
|
596
|
+
logger.error(
|
|
597
|
+
"Refusing to create a signup: could not acquire the first-Master-Admin election lock.",
|
|
598
|
+
);
|
|
599
|
+
logger.error(err);
|
|
600
|
+
|
|
601
|
+
throw err;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/*
|
|
605
|
+
* The critical section deliberately covers the INSERT, not just the count:
|
|
606
|
+
* releasing after the count would let the next holder count a table the
|
|
607
|
+
* winner had not written to yet, which is the same race with extra steps.
|
|
608
|
+
*
|
|
609
|
+
* `finally` so a failed create still frees the lock immediately instead of
|
|
610
|
+
* blocking every other signup until lockTimeout elapses.
|
|
611
|
+
*/
|
|
612
|
+
try {
|
|
613
|
+
data.user.isMasterAdmin = await this.isInstanceAwaitingFirstUser();
|
|
614
|
+
|
|
615
|
+
return await this.create({
|
|
616
|
+
data: data.user,
|
|
617
|
+
props: data.props,
|
|
618
|
+
});
|
|
619
|
+
} finally {
|
|
620
|
+
try {
|
|
621
|
+
await Semaphore.release(mutex);
|
|
622
|
+
} catch (err) {
|
|
623
|
+
/*
|
|
624
|
+
* Best-effort and non-masking: an error here must not replace the
|
|
625
|
+
* outcome of the signup, and an unreleased lock still expires on its
|
|
626
|
+
* own after lockTimeout.
|
|
627
|
+
*/
|
|
628
|
+
logger.error(
|
|
629
|
+
"Failed to release the first-Master-Admin election lock; it will expire on its own.",
|
|
630
|
+
);
|
|
631
|
+
logger.error(err);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Is this a brand-new instance that nobody has ever signed up to — i.e. may
|
|
638
|
+
* the next user through the door take Master Admin?
|
|
639
|
+
*
|
|
640
|
+
* Only ever called while holding the first-Master-Admin election mutex.
|
|
641
|
+
*/
|
|
642
|
+
private async isInstanceAwaitingFirstUser(): Promise<boolean> {
|
|
643
|
+
const userCount: PositiveNumber = await this.countBy({
|
|
644
|
+
props: {
|
|
645
|
+
isRoot: true,
|
|
646
|
+
},
|
|
647
|
+
query: {},
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
if (!userCount.isZero()) {
|
|
651
|
+
return false;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/*
|
|
655
|
+
* Zero users, but projects exist: this is not a fresh install, it is an
|
|
656
|
+
* instance whose User table was emptied out from under it — a bad restore, a
|
|
657
|
+
* cleanup script, a cascade. Handing Master Admin to whoever signs up next
|
|
658
|
+
* would be a free promotion for a stranger, so refuse and make the operator
|
|
659
|
+
* grant it deliberately.
|
|
660
|
+
*
|
|
661
|
+
* This cannot fire on any supported path: a user who belongs to a project
|
|
662
|
+
* cannot be deleted (see onBeforeDelete), so "projects with no users at all"
|
|
663
|
+
* is only reachable by writing to the database directly. It does not get in
|
|
664
|
+
* the way of the ordinary recovery either — someone who signs up, has no
|
|
665
|
+
* projects yet, deletes their account and signs up again still bootstraps
|
|
666
|
+
* normally.
|
|
667
|
+
*/
|
|
668
|
+
const projectCount: PositiveNumber = await ProjectService.countBy({
|
|
669
|
+
props: {
|
|
670
|
+
isRoot: true,
|
|
671
|
+
},
|
|
672
|
+
query: {},
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
if (!projectCount.isZero()) {
|
|
676
|
+
logger.warn(
|
|
677
|
+
"Refusing to grant Master Admin to a new signup: the User table is empty but this instance already has projects. Grant Master Admin explicitly instead.",
|
|
678
|
+
);
|
|
679
|
+
|
|
680
|
+
return false;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
return true;
|
|
684
|
+
}
|
|
685
|
+
|
|
502
686
|
@CaptureSpan()
|
|
503
687
|
public async createByEmail(data: {
|
|
504
688
|
email: Email;
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
RepoResolution,
|
|
18
18
|
} from "../../CodeRepository/StackTraceRepoResolver";
|
|
19
19
|
import logger from "../../Logger";
|
|
20
|
+
import GitHubInstallationBinding from "../../CodeRepository/GitHub/GitHubInstallationBinding";
|
|
20
21
|
import StackTraceParser, {
|
|
21
22
|
ParsedStackTrace,
|
|
22
23
|
StackFrame,
|
|
@@ -121,6 +122,7 @@ export async function findReadableRepositories(
|
|
|
121
122
|
query: query as never,
|
|
122
123
|
select: {
|
|
123
124
|
_id: true,
|
|
125
|
+
projectId: true,
|
|
124
126
|
name: true,
|
|
125
127
|
organizationName: true,
|
|
126
128
|
repositoryName: true,
|
|
@@ -134,11 +136,50 @@ export async function findReadableRepositories(
|
|
|
134
136
|
props: ctx.props,
|
|
135
137
|
});
|
|
136
138
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
139
|
+
const gitHubRepositories: Array<CodeRepository> = repositories.filter(
|
|
140
|
+
(repository: CodeRepository) => {
|
|
141
|
+
return (
|
|
142
|
+
repository.repositoryHostedAt === CodeRepositoryType.GitHub &&
|
|
143
|
+
Boolean(repository.gitHubAppInstallationId)
|
|
144
|
+
);
|
|
145
|
+
},
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
/*
|
|
149
|
+
* Every code tool — read and write — resolves its target through here, so
|
|
150
|
+
* this is the one place that has to prove the row's installation really
|
|
151
|
+
* belongs to the row's project (GHSA-xx95-gmcf-7q86). "The row is in your
|
|
152
|
+
* project" is not that proof: before the fix a caller could put any
|
|
153
|
+
* installation ID on a row in a project they owned, and these tools would
|
|
154
|
+
* then read source, commit and open pull requests in another tenant's
|
|
155
|
+
* private repositories.
|
|
156
|
+
*
|
|
157
|
+
* Writing an unbound installation ID is closed off now and the quarantine
|
|
158
|
+
* migration clears the rows that already existed, so this filter should
|
|
159
|
+
* never drop anything on a healthy database. It stays because the cost of
|
|
160
|
+
* being wrong here is another tenant's source code.
|
|
161
|
+
*/
|
|
162
|
+
if (gitHubRepositories.length === 0) {
|
|
163
|
+
return gitHubRepositories;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Every row here is in ctx.projectId, so one lookup answers for all of them.
|
|
167
|
+
const boundInstallationId: string | null =
|
|
168
|
+
await GitHubInstallationBinding.getBoundInstallationId(ctx.projectId);
|
|
169
|
+
|
|
170
|
+
return gitHubRepositories.filter((repository: CodeRepository) => {
|
|
171
|
+
if (
|
|
172
|
+
boundInstallationId &&
|
|
173
|
+
repository.gitHubAppInstallationId === boundInstallationId
|
|
174
|
+
) {
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
logger.error(
|
|
179
|
+
`Excluding code repository ${repository.id?.toString()} from AI code tools: its GitHub App installation is not bound to project ${ctx.projectId.toString()}.`,
|
|
141
180
|
);
|
|
181
|
+
|
|
182
|
+
return false;
|
|
142
183
|
});
|
|
143
184
|
}
|
|
144
185
|
|
|
@@ -1169,11 +1169,24 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
|
|
|
1169
1169
|
public toSortStatement(sort: Sort<TBaseModel>): Statement {
|
|
1170
1170
|
const sortStatement: Statement = new Statement();
|
|
1171
1171
|
|
|
1172
|
+
/*
|
|
1173
|
+
* Keys must be comma separated. Without the separator a multi-key sort
|
|
1174
|
+
* concatenates into a single malformed term (`time DESCseverity ASC`),
|
|
1175
|
+
* so every ORDER BY beyond the first key was unusable. Mirrors
|
|
1176
|
+
* toGroupByStatement above.
|
|
1177
|
+
*/
|
|
1178
|
+
let first: boolean = true;
|
|
1172
1179
|
for (const key in sort) {
|
|
1173
1180
|
if (!this.model.getTableColumn(key)) {
|
|
1174
1181
|
throw new BadDataException(`Unknown column: ${key}`);
|
|
1175
1182
|
}
|
|
1176
1183
|
|
|
1184
|
+
if (first) {
|
|
1185
|
+
first = false;
|
|
1186
|
+
} else {
|
|
1187
|
+
sortStatement.append(SQL`, `);
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1177
1190
|
const value: SortOrder = sort[key]!;
|
|
1178
1191
|
sortStatement.append(SQL`${key} `).append(
|
|
1179
1192
|
{
|
|
@@ -16,6 +16,8 @@ import { JSONArray, JSONObject } from "../../../../Types/JSON";
|
|
|
16
16
|
import API from "../../../../Utils/API";
|
|
17
17
|
import CaptureSpan from "../../Telemetry/CaptureSpan";
|
|
18
18
|
import {
|
|
19
|
+
GitHubAppClientId,
|
|
20
|
+
GitHubAppClientSecret,
|
|
19
21
|
GitHubAppId,
|
|
20
22
|
GitHubAppPrivateKey,
|
|
21
23
|
GitHubAppWebhookSecret,
|
|
@@ -686,6 +688,140 @@ export default class GitHubUtil extends HostedCodeRepository {
|
|
|
686
688
|
return GitHubUtil.summarizeCheckRuns(allCheckRuns);
|
|
687
689
|
}
|
|
688
690
|
|
|
691
|
+
/*
|
|
692
|
+
* Exchanges the short-lived OAuth `code` GitHub appends to the install
|
|
693
|
+
* redirect for a user-to-server access token.
|
|
694
|
+
*
|
|
695
|
+
* This is the ONLY thing that ties a redirect back to the human who
|
|
696
|
+
* actually performed the install: `installation_id` is just a number in a
|
|
697
|
+
* query string that anyone can type, but `code` is minted by GitHub for one
|
|
698
|
+
* specific GitHub user and is single-use. Pair it with
|
|
699
|
+
* listInstallationIdsForUserAccessToken below to answer "does this person
|
|
700
|
+
* really control this installation?".
|
|
701
|
+
*/
|
|
702
|
+
@CaptureSpan()
|
|
703
|
+
public static async exchangeOAuthCodeForUserAccessToken(
|
|
704
|
+
code: string,
|
|
705
|
+
): Promise<string> {
|
|
706
|
+
if (!GitHubAppClientId || !GitHubAppClientSecret) {
|
|
707
|
+
throw new BadDataException(
|
|
708
|
+
'GitHub App OAuth is not configured on this OneUptime instance. Set GITHUB_APP_CLIENT_ID and GITHUB_APP_CLIENT_SECRET, and enable "Request user authorization (OAuth) during installation" in the GitHub App settings.',
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const result: HTTPErrorResponse | HTTPResponse<JSONObject> = await API.post(
|
|
713
|
+
{
|
|
714
|
+
url: URL.fromString("https://github.com/login/oauth/access_token"),
|
|
715
|
+
data: {
|
|
716
|
+
client_id: GitHubAppClientId,
|
|
717
|
+
client_secret: GitHubAppClientSecret,
|
|
718
|
+
code: code,
|
|
719
|
+
},
|
|
720
|
+
headers: {
|
|
721
|
+
Accept: "application/json",
|
|
722
|
+
},
|
|
723
|
+
},
|
|
724
|
+
);
|
|
725
|
+
|
|
726
|
+
if (result instanceof HTTPErrorResponse) {
|
|
727
|
+
throw new BadDataException(
|
|
728
|
+
"Could not verify the GitHub App installation with GitHub. Please restart the installation from OneUptime.",
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const accessToken: string | undefined =
|
|
733
|
+
result.data["access_token"]?.toString();
|
|
734
|
+
|
|
735
|
+
/*
|
|
736
|
+
* GitHub answers a bad / expired / already-redeemed code with HTTP 200 and
|
|
737
|
+
* an `error` body, so a non-error status is not enough on its own.
|
|
738
|
+
*/
|
|
739
|
+
if (!accessToken) {
|
|
740
|
+
throw new BadDataException(
|
|
741
|
+
"The GitHub authorization code is invalid or has already been used. Please restart the installation from OneUptime.",
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
return accessToken;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/*
|
|
749
|
+
* Lists the IDs of every installation of THIS GitHub App that the user
|
|
750
|
+
* behind `userAccessToken` can administer. GitHub scopes this endpoint to
|
|
751
|
+
* the app the token was issued for, so a token minted by another app cannot
|
|
752
|
+
* widen it.
|
|
753
|
+
*/
|
|
754
|
+
@CaptureSpan()
|
|
755
|
+
public static async listInstallationIdsForUserAccessToken(
|
|
756
|
+
userAccessToken: string,
|
|
757
|
+
): Promise<Array<string>> {
|
|
758
|
+
const installationIds: Array<string> = [];
|
|
759
|
+
let page: number = 1;
|
|
760
|
+
let hasMore: boolean = true;
|
|
761
|
+
|
|
762
|
+
while (hasMore) {
|
|
763
|
+
const result: HTTPErrorResponse | HTTPResponse<JSONObject> =
|
|
764
|
+
await API.get({
|
|
765
|
+
url: URL.fromString(
|
|
766
|
+
`https://api.github.com/user/installations?per_page=100&page=${page}`,
|
|
767
|
+
),
|
|
768
|
+
headers: {
|
|
769
|
+
Authorization: `Bearer ${userAccessToken}`,
|
|
770
|
+
Accept: "application/vnd.github+json",
|
|
771
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
772
|
+
},
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
if (result instanceof HTTPErrorResponse) {
|
|
776
|
+
throw new BadDataException(
|
|
777
|
+
"Could not read your GitHub App installations from GitHub. Please restart the installation from OneUptime.",
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const installations: JSONArray =
|
|
782
|
+
(result.data["installations"] as JSONArray) || [];
|
|
783
|
+
|
|
784
|
+
for (const installation of installations) {
|
|
785
|
+
const installationId: string | undefined = (
|
|
786
|
+
installation as JSONObject
|
|
787
|
+
)?.["id"]?.toString();
|
|
788
|
+
|
|
789
|
+
if (installationId) {
|
|
790
|
+
installationIds.push(installationId);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
hasMore = installations.length === 100;
|
|
795
|
+
page++;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
return installationIds;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/*
|
|
802
|
+
* Proves that the person completing the install redirect actually controls
|
|
803
|
+
* the installation they claim, by round-tripping the OAuth code through
|
|
804
|
+
* GitHub. Throws (never returns false) so no caller can accidentally treat
|
|
805
|
+
* a verification failure as a pass.
|
|
806
|
+
*/
|
|
807
|
+
@CaptureSpan()
|
|
808
|
+
public static async assertUserControlsInstallation(data: {
|
|
809
|
+
oauthCode: string;
|
|
810
|
+
installationId: string;
|
|
811
|
+
}): Promise<void> {
|
|
812
|
+
const userAccessToken: string =
|
|
813
|
+
await GitHubUtil.exchangeOAuthCodeForUserAccessToken(data.oauthCode);
|
|
814
|
+
|
|
815
|
+
const installationIds: Array<string> =
|
|
816
|
+
await GitHubUtil.listInstallationIdsForUserAccessToken(userAccessToken);
|
|
817
|
+
|
|
818
|
+
if (!installationIds.includes(data.installationId.toString())) {
|
|
819
|
+
throw new BadDataException(
|
|
820
|
+
"The GitHub account you authorized does not have access to this GitHub App installation. Please restart the installation from OneUptime.",
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
689
825
|
/**
|
|
690
826
|
* Lists repositories accessible to a GitHub App installation
|
|
691
827
|
* @param installationId - The GitHub App installation ID
|
|
@@ -767,7 +903,15 @@ export default class GitHubUtil extends HostedCodeRepository {
|
|
|
767
903
|
branchName: string;
|
|
768
904
|
}): Promise<Array<string>> {
|
|
769
905
|
const cacheNamespace: string = "github-repo-tree";
|
|
770
|
-
|
|
906
|
+
|
|
907
|
+
/*
|
|
908
|
+
* The installation is part of the key because this cache is read BEFORE a
|
|
909
|
+
* token is minted — so the key is doing the access control for a cache
|
|
910
|
+
* hit. Keyed on org/repo/branch alone, any caller who could name someone
|
|
911
|
+
* else's repository would be served that repository's cached private file
|
|
912
|
+
* listing without ever proving access to it.
|
|
913
|
+
*/
|
|
914
|
+
const cacheKey: string = `${data.installationId}:${data.organizationName}/${data.repositoryName}@${data.branchName}`;
|
|
771
915
|
|
|
772
916
|
try {
|
|
773
917
|
const cachedPaths: Array<string> | null =
|
|
@@ -1415,11 +1559,20 @@ export default class GitHubUtil extends HostedCodeRepository {
|
|
|
1415
1559
|
payload: string,
|
|
1416
1560
|
signature: string,
|
|
1417
1561
|
): boolean {
|
|
1562
|
+
/*
|
|
1563
|
+
* Fail closed. This used to return true when the secret was unset, which
|
|
1564
|
+
* made the webhook endpoint fully unauthenticated on any instance that had
|
|
1565
|
+
* not configured one — and the handler acts on what it is sent: the
|
|
1566
|
+
* `installation.deleted` branch clears installation IDs across every
|
|
1567
|
+
* tenant, and `installation_repositories` triggers repository imports. An
|
|
1568
|
+
* unconfigured secret is a misconfiguration to surface, not a check to
|
|
1569
|
+
* skip.
|
|
1570
|
+
*/
|
|
1418
1571
|
if (!GitHubAppWebhookSecret) {
|
|
1419
|
-
logger.
|
|
1420
|
-
"GITHUB_APP_WEBHOOK_SECRET is not set,
|
|
1572
|
+
logger.error(
|
|
1573
|
+
"Rejecting GitHub webhook: GITHUB_APP_WEBHOOK_SECRET is not set, so the payload's signature cannot be verified. Set it to the webhook secret configured in the GitHub App settings.",
|
|
1421
1574
|
);
|
|
1422
|
-
return
|
|
1575
|
+
return false;
|
|
1423
1576
|
}
|
|
1424
1577
|
|
|
1425
1578
|
const expectedSignature: string = `sha256=${crypto
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import ProjectService from "../../../Services/ProjectService";
|
|
2
|
+
import Project from "../../../../Models/DatabaseModels/Project";
|
|
3
|
+
import ObjectID from "../../../../Types/ObjectID";
|
|
4
|
+
import BadDataException from "../../../../Types/Exception/BadDataException";
|
|
5
|
+
import CaptureSpan from "../../Telemetry/CaptureSpan";
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* A GitHub App installation ID is a small, guessable integer, and the single
|
|
9
|
+
* GitHub App JWT this instance holds will mint a `ghs_` token for ANY of them
|
|
10
|
+
* (GitHubUtil.getInstallationAccessToken does not — and cannot — know which
|
|
11
|
+
* tenant asked). So the installation ID alone is not a capability: on its own
|
|
12
|
+
* it is just an attacker-supplied number.
|
|
13
|
+
*
|
|
14
|
+
* Project.gitHubAppInstallationId is the ONE authoritative record of which
|
|
15
|
+
* project an installation belongs to. It is written only by the GitHub App
|
|
16
|
+
* install callback, and only after GitHub itself has confirmed — via the
|
|
17
|
+
* OAuth user-to-server round trip — that the person completing the install
|
|
18
|
+
* controls that installation. Both `create` and `update` on that column are
|
|
19
|
+
* closed to API callers, so nothing outside that callback can assert a
|
|
20
|
+
* binding.
|
|
21
|
+
*
|
|
22
|
+
* Every path that turns an installation ID into GitHub access must therefore
|
|
23
|
+
* re-derive the binding from here rather than trusting the ID it was handed.
|
|
24
|
+
* Checking that a CodeRepository row is in the caller's project is NOT enough
|
|
25
|
+
* on its own: the attacker owns their own project, so they control both sides
|
|
26
|
+
* of that comparison.
|
|
27
|
+
*/
|
|
28
|
+
export default class GitHubInstallationBinding {
|
|
29
|
+
/*
|
|
30
|
+
* The installation this project owns, or null if it has none. Callers
|
|
31
|
+
* filtering a whole project's repositories should read it once and compare
|
|
32
|
+
* in memory rather than asking per row — every row in such a list shares
|
|
33
|
+
* the same project, so the answer cannot differ between them.
|
|
34
|
+
*/
|
|
35
|
+
@CaptureSpan()
|
|
36
|
+
public static async getBoundInstallationId(
|
|
37
|
+
projectId: ObjectID,
|
|
38
|
+
): Promise<string | null> {
|
|
39
|
+
const project: Project | null = await ProjectService.findOneBy({
|
|
40
|
+
query: {
|
|
41
|
+
_id: projectId.toString(),
|
|
42
|
+
},
|
|
43
|
+
select: {
|
|
44
|
+
_id: true,
|
|
45
|
+
gitHubAppInstallationId: true,
|
|
46
|
+
},
|
|
47
|
+
props: {
|
|
48
|
+
isRoot: true,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return project?.gitHubAppInstallationId || null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// True only if `installationId` is the installation bound to `projectId`.
|
|
56
|
+
@CaptureSpan()
|
|
57
|
+
public static async isInstallationBoundToProject(data: {
|
|
58
|
+
projectId: ObjectID;
|
|
59
|
+
installationId: string;
|
|
60
|
+
}): Promise<boolean> {
|
|
61
|
+
if (!data.installationId) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const project: Project | null = await ProjectService.findOneBy({
|
|
66
|
+
query: {
|
|
67
|
+
_id: data.projectId.toString(),
|
|
68
|
+
gitHubAppInstallationId: data.installationId.toString(),
|
|
69
|
+
},
|
|
70
|
+
select: {
|
|
71
|
+
_id: true,
|
|
72
|
+
},
|
|
73
|
+
props: {
|
|
74
|
+
isRoot: true,
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return Boolean(project);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/*
|
|
82
|
+
* Convenience for the many callers that hold a CodeRepository row and are
|
|
83
|
+
* about to use its installation ID against GitHub. Returns false for a row
|
|
84
|
+
* that carries no project or no installation, so a half-populated row can
|
|
85
|
+
* never be mistaken for a permitted one.
|
|
86
|
+
*
|
|
87
|
+
* Row-level checks like this stay necessary even with the write boundary
|
|
88
|
+
* closed, because a database that was exploited before the fix can still
|
|
89
|
+
* hold rows whose installation ID never belonged to their project.
|
|
90
|
+
*/
|
|
91
|
+
@CaptureSpan()
|
|
92
|
+
public static async isRepositoryInstallationBound(repository: {
|
|
93
|
+
projectId?: ObjectID | undefined;
|
|
94
|
+
gitHubAppInstallationId?: string | undefined;
|
|
95
|
+
}): Promise<boolean> {
|
|
96
|
+
if (!repository.projectId || !repository.gitHubAppInstallationId) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return GitHubInstallationBinding.isInstallationBoundToProject({
|
|
101
|
+
projectId: repository.projectId,
|
|
102
|
+
installationId: repository.gitHubAppInstallationId,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/*
|
|
107
|
+
* Fail-closed variant for the places where continuing without a binding
|
|
108
|
+
* would hand out cross-tenant access.
|
|
109
|
+
*/
|
|
110
|
+
@CaptureSpan()
|
|
111
|
+
public static async assertInstallationBoundToProject(data: {
|
|
112
|
+
projectId: ObjectID;
|
|
113
|
+
installationId: string;
|
|
114
|
+
}): Promise<void> {
|
|
115
|
+
const isBound: boolean =
|
|
116
|
+
await GitHubInstallationBinding.isInstallationBoundToProject(data);
|
|
117
|
+
|
|
118
|
+
if (!isBound) {
|
|
119
|
+
/*
|
|
120
|
+
* Deliberately vague: a probing caller must not be able to tell
|
|
121
|
+
* "this installation belongs to someone else" from "no such
|
|
122
|
+
* installation", or the error itself becomes the enumeration oracle
|
|
123
|
+
* this check exists to close.
|
|
124
|
+
*/
|
|
125
|
+
throw new BadDataException(
|
|
126
|
+
"This GitHub App installation is not connected to this project. Please connect the repository from Project Settings using the GitHub App.",
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|