@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.
Files changed (173) hide show
  1. package/Models/DatabaseModels/CephCluster.ts +14 -0
  2. package/Models/DatabaseModels/CodeRepository.ts +14 -6
  3. package/Models/DatabaseModels/DockerHost.ts +14 -0
  4. package/Models/DatabaseModels/GlobalOidcProject.ts +18 -0
  5. package/Models/DatabaseModels/GlobalSsoProject.ts +18 -0
  6. package/Models/DatabaseModels/IoTFleet.ts +14 -0
  7. package/Models/DatabaseModels/KubernetesCluster.ts +14 -0
  8. package/Models/DatabaseModels/NetworkDevice.ts +14 -0
  9. package/Models/DatabaseModels/NetworkInterface.ts +18 -0
  10. package/Models/DatabaseModels/Project.ts +16 -2
  11. package/Models/DatabaseModels/ProxmoxCluster.ts +14 -0
  12. package/Models/DatabaseModels/Service.ts +30 -0
  13. package/Models/DatabaseModels/StatusPagePrivateUser.ts +30 -0
  14. package/Models/DatabaseModels/User.ts +30 -0
  15. package/Server/API/AIAgentDataAPI.ts +31 -0
  16. package/Server/API/DashboardAPI.ts +419 -12
  17. package/Server/API/GitHubAPI.ts +119 -283
  18. package/Server/API/UserAPI.ts +263 -1
  19. package/Server/EnvironmentConfig.ts +7 -3
  20. package/Server/Infrastructure/GlobalCache.ts +37 -0
  21. package/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.ts +21 -0
  22. package/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.ts +68 -0
  23. package/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.ts +100 -0
  24. package/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.ts +214 -0
  25. package/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.ts +69 -0
  26. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +10 -0
  27. package/Server/Services/AnalyticsDatabaseService.ts +72 -1
  28. package/Server/Services/CodeRepositoryService.ts +105 -2
  29. package/Server/Services/DatabaseService.ts +185 -2
  30. package/Server/Services/OpenTelemetryIngestService.ts +230 -26
  31. package/Server/Services/UserService.ts +184 -0
  32. package/Server/Utils/AI/Toolbox/CodeTools.ts +45 -4
  33. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +13 -0
  34. package/Server/Utils/CodeRepository/GitHub/GitHub.ts +157 -4
  35. package/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.ts +130 -0
  36. package/Server/Utils/PasswordHash.ts +306 -0
  37. package/Tests/App/Dashboard/OverviewCustomFields.test.tsx +333 -0
  38. package/Tests/App/Dashboard/UserCustomFields.test.tsx +392 -0
  39. package/Tests/Server/API/AIAgentDataRepositoryToken.test.ts +381 -0
  40. package/Tests/Server/API/DashboardPublicAttributeValuesAPI.test.ts +943 -0
  41. package/Tests/Server/API/DashboardPublicMetricsAggregateAPI.test.ts +1296 -0
  42. package/Tests/Server/API/DashboardPublicTemplatePayloads.test.ts +598 -0
  43. package/Tests/Server/API/GitHubAppInstallationBindingAPI.test.ts +539 -0
  44. package/Tests/Server/API/Helpers.ts +6 -1
  45. package/Tests/Server/API/UserProjectsAPI.test.ts +852 -0
  46. package/Tests/Server/Infrastructure/GlobalCache.test.ts +128 -0
  47. package/Tests/Server/Infrastructure/SemaphoreMutex.test.ts +215 -0
  48. package/Tests/Server/Services/AddPerUserPasswordSaltMigration.test.ts +178 -0
  49. package/Tests/Server/Services/AnalyticsDatabasePaginationStability.test.ts +582 -0
  50. package/Tests/Server/Services/AnalyticsDatabaseService.test.ts +5 -0
  51. package/Tests/Server/Services/CodeRepositoryInstallationBinding.test.ts +305 -0
  52. package/Tests/Server/Services/CodeRepositoryResolutionBinding.test.ts +147 -0
  53. package/Tests/Server/Services/DatabaseServicePerUserPasswordSalt.test.ts +870 -0
  54. package/Tests/Server/Services/OpenTelemetryServiceResolutionCache.test.ts +568 -0
  55. package/Tests/Server/Services/RestoreDroppedUniqueIndexesMigration.test.ts +386 -0
  56. package/Tests/Server/Services/RestoreServiceLowerNameIndexMigration.test.ts +240 -0
  57. package/Tests/Server/Services/UserServiceFirstMasterAdminElection.test.ts +885 -0
  58. package/Tests/Server/Services/WidenHashedStringColumnsForScryptMigration.test.ts +220 -0
  59. package/Tests/Server/Utils/AI/CodeTools.test.ts +47 -1
  60. package/Tests/Server/Utils/AI/CodeWriteTools.test.ts +10 -0
  61. package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +120 -0
  62. package/Tests/Server/Utils/CodeRepository/GitHubInstallationBinding.test.ts +292 -0
  63. package/Tests/Server/Utils/GitHubInstallationOwnershipVerification.test.ts +340 -0
  64. package/Tests/Server/Utils/GitHubWebhookAndTreeCacheIsolation.test.ts +250 -0
  65. package/Tests/Server/Utils/PasswordHash.test.ts +555 -0
  66. package/Tests/Types/Database/ColumnLength.test.ts +7 -1
  67. package/Tests/Types/HashedStringPerUserSalt.test.ts +476 -0
  68. package/Tests/Types/Monitor/DockerAlertTemplates.test.ts +293 -0
  69. package/Tests/Types/Monitor/HostAlertTemplates.test.ts +249 -0
  70. package/Tests/Types/Monitor/IotAlertTemplates.test.ts +320 -0
  71. package/Tests/Types/Monitor/PodmanAlertTemplates.test.ts +271 -0
  72. package/Tests/UI/Components/CustomFields/CustomFieldsDetail.test.tsx +696 -0
  73. package/Tests/UI/Utils/UserProjectsModelAPI.test.ts +714 -0
  74. package/Tests/Utils/TeamMembersByProject.test.ts +655 -0
  75. package/Types/Database/ColumnLength.ts +9 -1
  76. package/Types/Database/TableColumn.ts +11 -0
  77. package/Types/Database/UnsynchronizedIndex.ts +48 -0
  78. package/Types/HashedString.ts +139 -4
  79. package/UI/Components/CustomFields/CustomFieldsDetail.tsx +85 -11
  80. package/UI/Components/Navbar/NavBar.tsx +12 -2
  81. package/UI/Components/Navbar/NavBarMenuModal.tsx +19 -7
  82. package/UI/Utils/ModelAPI/UserProjectsModelAPI.ts +257 -0
  83. package/Utils/TeamMembersByProject.ts +237 -0
  84. package/build/dist/Models/DatabaseModels/CephCluster.js +16 -1
  85. package/build/dist/Models/DatabaseModels/CephCluster.js.map +1 -1
  86. package/build/dist/Models/DatabaseModels/CodeRepository.js +14 -6
  87. package/build/dist/Models/DatabaseModels/CodeRepository.js.map +1 -1
  88. package/build/dist/Models/DatabaseModels/DockerHost.js +16 -1
  89. package/build/dist/Models/DatabaseModels/DockerHost.js.map +1 -1
  90. package/build/dist/Models/DatabaseModels/GlobalOidcProject.js +16 -1
  91. package/build/dist/Models/DatabaseModels/GlobalOidcProject.js.map +1 -1
  92. package/build/dist/Models/DatabaseModels/GlobalSsoProject.js +16 -1
  93. package/build/dist/Models/DatabaseModels/GlobalSsoProject.js.map +1 -1
  94. package/build/dist/Models/DatabaseModels/IoTFleet.js +16 -1
  95. package/build/dist/Models/DatabaseModels/IoTFleet.js.map +1 -1
  96. package/build/dist/Models/DatabaseModels/KubernetesCluster.js +16 -1
  97. package/build/dist/Models/DatabaseModels/KubernetesCluster.js.map +1 -1
  98. package/build/dist/Models/DatabaseModels/NetworkDevice.js +16 -1
  99. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  100. package/build/dist/Models/DatabaseModels/NetworkInterface.js +16 -1
  101. package/build/dist/Models/DatabaseModels/NetworkInterface.js.map +1 -1
  102. package/build/dist/Models/DatabaseModels/Project.js +16 -2
  103. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  104. package/build/dist/Models/DatabaseModels/ProxmoxCluster.js +16 -1
  105. package/build/dist/Models/DatabaseModels/ProxmoxCluster.js.map +1 -1
  106. package/build/dist/Models/DatabaseModels/Service.js +28 -1
  107. package/build/dist/Models/DatabaseModels/Service.js.map +1 -1
  108. package/build/dist/Models/DatabaseModels/StatusPagePrivateUser.js +32 -0
  109. package/build/dist/Models/DatabaseModels/StatusPagePrivateUser.js.map +1 -1
  110. package/build/dist/Models/DatabaseModels/User.js +32 -0
  111. package/build/dist/Models/DatabaseModels/User.js.map +1 -1
  112. package/build/dist/Server/API/AIAgentDataAPI.js +21 -2
  113. package/build/dist/Server/API/AIAgentDataAPI.js.map +1 -1
  114. package/build/dist/Server/API/DashboardAPI.js +304 -10
  115. package/build/dist/Server/API/DashboardAPI.js.map +1 -1
  116. package/build/dist/Server/API/GitHubAPI.js +94 -167
  117. package/build/dist/Server/API/GitHubAPI.js.map +1 -1
  118. package/build/dist/Server/API/UserAPI.js +187 -2
  119. package/build/dist/Server/API/UserAPI.js.map +1 -1
  120. package/build/dist/Server/EnvironmentConfig.js +7 -3
  121. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  122. package/build/dist/Server/Infrastructure/GlobalCache.js +29 -0
  123. package/build/dist/Server/Infrastructure/GlobalCache.js.map +1 -1
  124. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.js +14 -0
  125. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.js.map +1 -0
  126. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.js +59 -0
  127. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.js.map +1 -0
  128. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.js +93 -0
  129. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.js.map +1 -0
  130. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.js +101 -0
  131. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.js.map +1 -0
  132. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.js +66 -0
  133. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.js.map +1 -0
  134. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +10 -0
  135. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  136. package/build/dist/Server/Services/AnalyticsDatabaseService.js +57 -1
  137. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  138. package/build/dist/Server/Services/CodeRepositoryService.js +90 -2
  139. package/build/dist/Server/Services/CodeRepositoryService.js.map +1 -1
  140. package/build/dist/Server/Services/DatabaseService.js +160 -2
  141. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  142. package/build/dist/Server/Services/OpenTelemetryIngestService.js +155 -25
  143. package/build/dist/Server/Services/OpenTelemetryIngestService.js.map +1 -1
  144. package/build/dist/Server/Services/UserService.js +165 -0
  145. package/build/dist/Server/Services/UserService.js.map +1 -1
  146. package/build/dist/Server/Utils/AI/Toolbox/CodeTools.js +31 -1
  147. package/build/dist/Server/Utils/AI/Toolbox/CodeTools.js.map +1 -1
  148. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +13 -0
  149. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  150. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHub.js +126 -4
  151. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHub.js.map +1 -1
  152. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.js +136 -0
  153. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.js.map +1 -0
  154. package/build/dist/Server/Utils/PasswordHash.js +237 -0
  155. package/build/dist/Server/Utils/PasswordHash.js.map +1 -0
  156. package/build/dist/Types/Database/ColumnLength.js +9 -1
  157. package/build/dist/Types/Database/ColumnLength.js.map +1 -1
  158. package/build/dist/Types/Database/TableColumn.js.map +1 -1
  159. package/build/dist/Types/Database/UnsynchronizedIndex.js +46 -0
  160. package/build/dist/Types/Database/UnsynchronizedIndex.js.map +1 -0
  161. package/build/dist/Types/HashedString.js +95 -5
  162. package/build/dist/Types/HashedString.js.map +1 -1
  163. package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js +51 -10
  164. package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js.map +1 -1
  165. package/build/dist/UI/Components/Navbar/NavBar.js +3 -1
  166. package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
  167. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js +19 -7
  168. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js.map +1 -1
  169. package/build/dist/UI/Utils/ModelAPI/UserProjectsModelAPI.js +156 -0
  170. package/build/dist/UI/Utils/ModelAPI/UserProjectsModelAPI.js.map +1 -0
  171. package/build/dist/Utils/TeamMembersByProject.js +145 -0
  172. package/build/dist/Utils/TeamMembersByProject.js.map +1 -0
  173. 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
- return repositories.filter((repository: CodeRepository) => {
138
- return (
139
- repository.repositoryHostedAt === CodeRepositoryType.GitHub &&
140
- Boolean(repository.gitHubAppInstallationId)
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
- const cacheKey: string = `${data.organizationName}/${data.repositoryName}@${data.branchName}`;
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.warn(
1420
- "GITHUB_APP_WEBHOOK_SECRET is not set, skipping verification",
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 true;
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
+ }