@oneuptime/common 11.0.3 → 11.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 (167) hide show
  1. package/Models/DatabaseModels/GlobalConfig.ts +19 -0
  2. package/Models/DatabaseModels/GlobalOidc.ts +351 -0
  3. package/Models/DatabaseModels/GlobalOidcProject.ts +265 -0
  4. package/Models/DatabaseModels/GlobalSso.ts +312 -0
  5. package/Models/DatabaseModels/GlobalSsoProject.ts +268 -0
  6. package/Models/DatabaseModels/Index.ts +8 -0
  7. package/Models/DatabaseModels/Project.ts +31 -0
  8. package/Models/DatabaseModels/StatusPage.ts +82 -0
  9. package/Server/API/StatusPageAPI.ts +2 -0
  10. package/Server/Infrastructure/Postgres/SchemaMigrations/{1781587937032-MigrationName.ts → 1781750000000-MigrationName.ts} +2 -2
  11. package/Server/Infrastructure/Postgres/SchemaMigrations/1782000000000-AddGlobalSsoAndOidc.ts +176 -0
  12. package/Server/Infrastructure/Postgres/SchemaMigrations/1782100000000-AddStatusPageImageAltText.ts +25 -0
  13. package/Server/Infrastructure/Postgres/SchemaMigrations/1782200000000-AddRequireSsoForLoginToGlobalProviders.ts +25 -0
  14. package/Server/Infrastructure/Postgres/SchemaMigrations/1782300000000-MoveRequireSsoForLoginToGlobalConfig.ts +38 -0
  15. package/Server/Infrastructure/Postgres/SchemaMigrations/1782310000000-MigrationName.ts +299 -0
  16. package/Server/Infrastructure/Postgres/SchemaMigrations/1782400000000-RemoveIsTestedFromGlobalSsoAndOidc.ts +21 -0
  17. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +14 -2
  18. package/Server/Middleware/UserAuthorization.ts +113 -42
  19. package/Server/Services/GlobalConfigService.ts +50 -0
  20. package/Server/Services/GlobalOidcProjectService.ts +85 -0
  21. package/Server/Services/GlobalOidcService.ts +10 -0
  22. package/Server/Services/GlobalSsoProjectService.ts +85 -0
  23. package/Server/Services/GlobalSsoService.ts +10 -0
  24. package/Server/Services/Index.ts +8 -0
  25. package/Server/Services/ProjectService.ts +44 -1
  26. package/Server/Utils/Cookie.ts +39 -5
  27. package/Server/Utils/JsonWebToken.ts +7 -0
  28. package/Server/Utils/ValidateGlobalProviderProjectTeams.ts +119 -0
  29. package/Tests/Server/Middleware/UserAuthorization.test.ts +51 -13
  30. package/Tests/Server/Middleware/UserAuthorizationSSOProvider.test.ts +163 -0
  31. package/Tests/Server/Utils/CookieSSOToken.test.ts +130 -0
  32. package/Types/JsonWebTokenData.ts +3 -0
  33. package/Types/SSO/SsoProviderType.ts +8 -0
  34. package/UI/Components/Accordion/Accordion.tsx +5 -1
  35. package/UI/Components/CardSelect/CardSelect.tsx +6 -1
  36. package/UI/Components/CategoryCheckbox/Index.tsx +2 -1
  37. package/UI/Components/CodeEditor/CodeEditor.tsx +2 -0
  38. package/UI/Components/CollapsibleSection/CollapsibleSection.tsx +8 -1
  39. package/UI/Components/Dropdown/Dropdown.tsx +2 -0
  40. package/UI/Components/EntityDropdown/EntityDropdown.tsx +3 -0
  41. package/UI/Components/FilePicker/FilePicker.tsx +2 -0
  42. package/UI/Components/Forms/Fields/ColorPicker.tsx +2 -0
  43. package/UI/Components/Forms/Fields/FieldLabel.tsx +4 -0
  44. package/UI/Components/Forms/Fields/FormField.tsx +72 -15
  45. package/UI/Components/Forms/Fields/IconPicker.tsx +2 -0
  46. package/UI/Components/Forms/Validation.ts +107 -23
  47. package/UI/Components/Input/Input.tsx +4 -0
  48. package/UI/Components/Link/Link.tsx +23 -0
  49. package/UI/Components/Markdown.tsx/MarkdownConverters.ts +0 -0
  50. package/UI/Components/Markdown.tsx/MarkdownEditor.tsx +3 -0
  51. package/UI/Components/Markdown.tsx/MarkdownViewer.tsx +63 -2
  52. package/UI/Components/Radio/Radio.tsx +2 -0
  53. package/UI/Components/RadioButtons/GroupRadioButtons.tsx +6 -1
  54. package/UI/Components/Tabs/Tabs.tsx +63 -0
  55. package/UI/Components/TextArea/TextArea.tsx +2 -0
  56. package/UI/Components/TimePicker/TimePicker.tsx +2 -0
  57. package/UI/Components/Toggle/Toggle.tsx +2 -1
  58. package/UI/Components/Tooltip/Tooltip.tsx +6 -1
  59. package/build/dist/Models/DatabaseModels/GlobalConfig.js +20 -0
  60. package/build/dist/Models/DatabaseModels/GlobalConfig.js.map +1 -1
  61. package/build/dist/Models/DatabaseModels/GlobalOidc.js +379 -0
  62. package/build/dist/Models/DatabaseModels/GlobalOidc.js.map +1 -0
  63. package/build/dist/Models/DatabaseModels/GlobalOidcProject.js +276 -0
  64. package/build/dist/Models/DatabaseModels/GlobalOidcProject.js.map +1 -0
  65. package/build/dist/Models/DatabaseModels/GlobalSso.js +341 -0
  66. package/build/dist/Models/DatabaseModels/GlobalSso.js.map +1 -0
  67. package/build/dist/Models/DatabaseModels/GlobalSsoProject.js +279 -0
  68. package/build/dist/Models/DatabaseModels/GlobalSsoProject.js.map +1 -0
  69. package/build/dist/Models/DatabaseModels/Index.js +8 -0
  70. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  71. package/build/dist/Models/DatabaseModels/Project.js +32 -0
  72. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  73. package/build/dist/Models/DatabaseModels/StatusPage.js +84 -0
  74. package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
  75. package/build/dist/Server/API/StatusPageAPI.js +2 -0
  76. package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
  77. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/{1781587937032-MigrationName.js → 1781750000000-MigrationName.js} +3 -3
  78. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/{1781587937032-MigrationName.js.map → 1781750000000-MigrationName.js.map} +1 -1
  79. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1782000000000-AddGlobalSsoAndOidc.js +73 -0
  80. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1782000000000-AddGlobalSsoAndOidc.js.map +1 -0
  81. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1782100000000-AddStatusPageImageAltText.js +14 -0
  82. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1782100000000-AddStatusPageImageAltText.js.map +1 -0
  83. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1782200000000-AddRequireSsoForLoginToGlobalProviders.js +14 -0
  84. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1782200000000-AddRequireSsoForLoginToGlobalProviders.js.map +1 -0
  85. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1782300000000-MoveRequireSsoForLoginToGlobalConfig.js +23 -0
  86. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1782300000000-MoveRequireSsoForLoginToGlobalConfig.js.map +1 -0
  87. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1782310000000-MigrationName.js +106 -0
  88. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1782310000000-MigrationName.js.map +1 -0
  89. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1782400000000-RemoveIsTestedFromGlobalSsoAndOidc.js +14 -0
  90. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1782400000000-RemoveIsTestedFromGlobalSsoAndOidc.js.map +1 -0
  91. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +14 -2
  92. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  93. package/build/dist/Server/Middleware/UserAuthorization.js +77 -34
  94. package/build/dist/Server/Middleware/UserAuthorization.js.map +1 -1
  95. package/build/dist/Server/Services/GlobalConfigService.js +55 -0
  96. package/build/dist/Server/Services/GlobalConfigService.js.map +1 -1
  97. package/build/dist/Server/Services/GlobalOidcProjectService.js +80 -0
  98. package/build/dist/Server/Services/GlobalOidcProjectService.js.map +1 -0
  99. package/build/dist/Server/Services/GlobalOidcService.js +9 -0
  100. package/build/dist/Server/Services/GlobalOidcService.js.map +1 -0
  101. package/build/dist/Server/Services/GlobalSsoProjectService.js +80 -0
  102. package/build/dist/Server/Services/GlobalSsoProjectService.js.map +1 -0
  103. package/build/dist/Server/Services/GlobalSsoService.js +9 -0
  104. package/build/dist/Server/Services/GlobalSsoService.js.map +1 -0
  105. package/build/dist/Server/Services/Index.js +8 -0
  106. package/build/dist/Server/Services/Index.js.map +1 -1
  107. package/build/dist/Server/Services/ProjectService.js +36 -1
  108. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  109. package/build/dist/Server/Utils/Cookie.js +32 -3
  110. package/build/dist/Server/Utils/Cookie.js.map +1 -1
  111. package/build/dist/Server/Utils/JsonWebToken.js +6 -0
  112. package/build/dist/Server/Utils/JsonWebToken.js.map +1 -1
  113. package/build/dist/Server/Utils/ValidateGlobalProviderProjectTeams.js +66 -0
  114. package/build/dist/Server/Utils/ValidateGlobalProviderProjectTeams.js.map +1 -0
  115. package/build/dist/Types/SSO/SsoProviderType.js +9 -0
  116. package/build/dist/Types/SSO/SsoProviderType.js.map +1 -0
  117. package/build/dist/UI/Components/Accordion/Accordion.js +5 -3
  118. package/build/dist/UI/Components/Accordion/Accordion.js.map +1 -1
  119. package/build/dist/UI/Components/CardSelect/CardSelect.js +1 -1
  120. package/build/dist/UI/Components/CardSelect/CardSelect.js.map +1 -1
  121. package/build/dist/UI/Components/CategoryCheckbox/Index.js +1 -1
  122. package/build/dist/UI/Components/CategoryCheckbox/Index.js.map +1 -1
  123. package/build/dist/UI/Components/CodeEditor/CodeEditor.js +1 -1
  124. package/build/dist/UI/Components/CodeEditor/CodeEditor.js.map +1 -1
  125. package/build/dist/UI/Components/CollapsibleSection/CollapsibleSection.js +4 -2
  126. package/build/dist/UI/Components/CollapsibleSection/CollapsibleSection.js.map +1 -1
  127. package/build/dist/UI/Components/Dropdown/Dropdown.js +1 -1
  128. package/build/dist/UI/Components/Dropdown/Dropdown.js.map +1 -1
  129. package/build/dist/UI/Components/EntityDropdown/EntityDropdown.js +2 -2
  130. package/build/dist/UI/Components/EntityDropdown/EntityDropdown.js.map +1 -1
  131. package/build/dist/UI/Components/FilePicker/FilePicker.js +1 -1
  132. package/build/dist/UI/Components/FilePicker/FilePicker.js.map +1 -1
  133. package/build/dist/UI/Components/Forms/Fields/ColorPicker.js +1 -1
  134. package/build/dist/UI/Components/Forms/Fields/ColorPicker.js.map +1 -1
  135. package/build/dist/UI/Components/Forms/Fields/FieldLabel.js +1 -1
  136. package/build/dist/UI/Components/Forms/Fields/FieldLabel.js.map +1 -1
  137. package/build/dist/UI/Components/Forms/Fields/FormField.js +58 -22
  138. package/build/dist/UI/Components/Forms/Fields/FormField.js.map +1 -1
  139. package/build/dist/UI/Components/Forms/Fields/IconPicker.js +1 -1
  140. package/build/dist/UI/Components/Forms/Fields/IconPicker.js.map +1 -1
  141. package/build/dist/UI/Components/Forms/Validation.js +64 -15
  142. package/build/dist/UI/Components/Forms/Validation.js.map +1 -1
  143. package/build/dist/UI/Components/Input/Input.js +1 -1
  144. package/build/dist/UI/Components/Input/Input.js.map +1 -1
  145. package/build/dist/UI/Components/Link/Link.js +22 -1
  146. package/build/dist/UI/Components/Link/Link.js.map +1 -1
  147. package/build/dist/UI/Components/Markdown.tsx/MarkdownConverters.js +0 -0
  148. package/build/dist/UI/Components/Markdown.tsx/MarkdownConverters.js.map +1 -1
  149. package/build/dist/UI/Components/Markdown.tsx/MarkdownEditor.js +2 -2
  150. package/build/dist/UI/Components/Markdown.tsx/MarkdownEditor.js.map +1 -1
  151. package/build/dist/UI/Components/Markdown.tsx/MarkdownViewer.js +46 -2
  152. package/build/dist/UI/Components/Markdown.tsx/MarkdownViewer.js.map +1 -1
  153. package/build/dist/UI/Components/Radio/Radio.js +1 -1
  154. package/build/dist/UI/Components/Radio/Radio.js.map +1 -1
  155. package/build/dist/UI/Components/RadioButtons/GroupRadioButtons.js +1 -1
  156. package/build/dist/UI/Components/RadioButtons/GroupRadioButtons.js.map +1 -1
  157. package/build/dist/UI/Components/Tabs/Tabs.js +50 -1
  158. package/build/dist/UI/Components/Tabs/Tabs.js.map +1 -1
  159. package/build/dist/UI/Components/TextArea/TextArea.js +1 -1
  160. package/build/dist/UI/Components/TextArea/TextArea.js.map +1 -1
  161. package/build/dist/UI/Components/TimePicker/TimePicker.js +1 -1
  162. package/build/dist/UI/Components/TimePicker/TimePicker.js.map +1 -1
  163. package/build/dist/UI/Components/Toggle/Toggle.js +1 -1
  164. package/build/dist/UI/Components/Toggle/Toggle.js.map +1 -1
  165. package/build/dist/UI/Components/Tooltip/Tooltip.js +6 -1
  166. package/build/dist/UI/Components/Tooltip/Tooltip.js.map +1 -1
  167. package/package.json +1 -1
@@ -0,0 +1,85 @@
1
+ import DatabaseService from "./DatabaseService";
2
+ import Model from "../../Models/DatabaseModels/GlobalOidcProject";
3
+ import Team from "../../Models/DatabaseModels/Team";
4
+ import { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax";
5
+ import ObjectID from "../../Types/ObjectID";
6
+ import CreateBy from "../Types/Database/CreateBy";
7
+ import { OnCreate, OnUpdate } from "../Types/Database/Hooks";
8
+ import UpdateBy from "../Types/Database/UpdateBy";
9
+ import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
10
+ import validateGlobalProviderProjectTeams, {
11
+ resolveAttachmentProjectId,
12
+ } from "../Utils/ValidateGlobalProviderProjectTeams";
13
+
14
+ export class Service extends DatabaseService<Model> {
15
+ public constructor() {
16
+ super(Model);
17
+ }
18
+
19
+ @CaptureSpan()
20
+ protected override async onBeforeCreate(
21
+ createBy: CreateBy<Model>,
22
+ ): Promise<OnCreate<Model>> {
23
+ /*
24
+ * The attach form submits the project via the `project` relation, so the
25
+ * `projectId` FK is not set yet. Resolve it and persist it (the column is
26
+ * required / NOT NULL) before validating the default teams against it.
27
+ */
28
+ const projectId: ObjectID | undefined = resolveAttachmentProjectId(
29
+ createBy.data,
30
+ );
31
+
32
+ if (projectId) {
33
+ createBy.data.projectId = projectId;
34
+ }
35
+
36
+ await validateGlobalProviderProjectTeams({
37
+ teams: createBy.data.teams,
38
+ projectId,
39
+ });
40
+
41
+ return { createBy, carryForward: null };
42
+ }
43
+
44
+ @CaptureSpan()
45
+ protected override async onBeforeUpdate(
46
+ updateBy: UpdateBy<Model>,
47
+ ): Promise<OnUpdate<Model>> {
48
+ // `updateBy.data` is a partial-entity shape; narrow the relation/id here.
49
+ const teams: Array<Team> | undefined = updateBy.data.teams as unknown as
50
+ | Array<Team>
51
+ | undefined;
52
+
53
+ if (teams && teams.length > 0) {
54
+ const explicitProjectId: ObjectID | undefined = updateBy.data
55
+ .projectId as unknown as ObjectID | undefined;
56
+
57
+ if (explicitProjectId) {
58
+ await validateGlobalProviderProjectTeams({
59
+ teams,
60
+ projectId: explicitProjectId,
61
+ });
62
+ } else {
63
+ // projectId is immutable here; resolve it from the row(s) being updated.
64
+ const rows: Array<Model> = await this.findBy({
65
+ query: updateBy.query,
66
+ select: { _id: true, projectId: true },
67
+ limit: LIMIT_PER_PROJECT,
68
+ skip: 0,
69
+ props: { isRoot: true },
70
+ });
71
+
72
+ for (const row of rows) {
73
+ await validateGlobalProviderProjectTeams({
74
+ teams,
75
+ projectId: row.projectId,
76
+ });
77
+ }
78
+ }
79
+ }
80
+
81
+ return { updateBy, carryForward: null };
82
+ }
83
+ }
84
+
85
+ export default new Service();
@@ -0,0 +1,10 @@
1
+ import DatabaseService from "./DatabaseService";
2
+ import Model from "../../Models/DatabaseModels/GlobalOidc";
3
+
4
+ export class Service extends DatabaseService<Model> {
5
+ public constructor() {
6
+ super(Model);
7
+ }
8
+ }
9
+
10
+ export default new Service();
@@ -0,0 +1,85 @@
1
+ import DatabaseService from "./DatabaseService";
2
+ import Model from "../../Models/DatabaseModels/GlobalSsoProject";
3
+ import Team from "../../Models/DatabaseModels/Team";
4
+ import { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax";
5
+ import ObjectID from "../../Types/ObjectID";
6
+ import CreateBy from "../Types/Database/CreateBy";
7
+ import { OnCreate, OnUpdate } from "../Types/Database/Hooks";
8
+ import UpdateBy from "../Types/Database/UpdateBy";
9
+ import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
10
+ import validateGlobalProviderProjectTeams, {
11
+ resolveAttachmentProjectId,
12
+ } from "../Utils/ValidateGlobalProviderProjectTeams";
13
+
14
+ export class Service extends DatabaseService<Model> {
15
+ public constructor() {
16
+ super(Model);
17
+ }
18
+
19
+ @CaptureSpan()
20
+ protected override async onBeforeCreate(
21
+ createBy: CreateBy<Model>,
22
+ ): Promise<OnCreate<Model>> {
23
+ /*
24
+ * The attach form submits the project via the `project` relation, so the
25
+ * `projectId` FK is not set yet. Resolve it and persist it (the column is
26
+ * required / NOT NULL) before validating the default teams against it.
27
+ */
28
+ const projectId: ObjectID | undefined = resolveAttachmentProjectId(
29
+ createBy.data,
30
+ );
31
+
32
+ if (projectId) {
33
+ createBy.data.projectId = projectId;
34
+ }
35
+
36
+ await validateGlobalProviderProjectTeams({
37
+ teams: createBy.data.teams,
38
+ projectId,
39
+ });
40
+
41
+ return { createBy, carryForward: null };
42
+ }
43
+
44
+ @CaptureSpan()
45
+ protected override async onBeforeUpdate(
46
+ updateBy: UpdateBy<Model>,
47
+ ): Promise<OnUpdate<Model>> {
48
+ // `updateBy.data` is a partial-entity shape; narrow the relation/id here.
49
+ const teams: Array<Team> | undefined = updateBy.data.teams as unknown as
50
+ | Array<Team>
51
+ | undefined;
52
+
53
+ if (teams && teams.length > 0) {
54
+ const explicitProjectId: ObjectID | undefined = updateBy.data
55
+ .projectId as unknown as ObjectID | undefined;
56
+
57
+ if (explicitProjectId) {
58
+ await validateGlobalProviderProjectTeams({
59
+ teams,
60
+ projectId: explicitProjectId,
61
+ });
62
+ } else {
63
+ // projectId is immutable here; resolve it from the row(s) being updated.
64
+ const rows: Array<Model> = await this.findBy({
65
+ query: updateBy.query,
66
+ select: { _id: true, projectId: true },
67
+ limit: LIMIT_PER_PROJECT,
68
+ skip: 0,
69
+ props: { isRoot: true },
70
+ });
71
+
72
+ for (const row of rows) {
73
+ await validateGlobalProviderProjectTeams({
74
+ teams,
75
+ projectId: row.projectId,
76
+ });
77
+ }
78
+ }
79
+ }
80
+
81
+ return { updateBy, carryForward: null };
82
+ }
83
+ }
84
+
85
+ export default new Service();
@@ -0,0 +1,10 @@
1
+ import DatabaseService from "./DatabaseService";
2
+ import Model from "../../Models/DatabaseModels/GlobalSso";
3
+
4
+ export class Service extends DatabaseService<Model> {
5
+ public constructor() {
6
+ super(Model);
7
+ }
8
+ }
9
+
10
+ export default new Service();
@@ -106,6 +106,10 @@ import ProfileSampleService from "./ProfileSampleService";
106
106
  import ProjectSmtpConfigService from "./ProjectSmtpConfigService";
107
107
  import ProjectSsoService from "./ProjectSsoService";
108
108
  import ProjectOidcService from "./ProjectOidcService";
109
+ import GlobalSsoService from "./GlobalSsoService";
110
+ import GlobalOidcService from "./GlobalOidcService";
111
+ import GlobalSsoProjectService from "./GlobalSsoProjectService";
112
+ import GlobalOidcProjectService from "./GlobalOidcProjectService";
109
113
  import PromoCodeService from "./PromoCodeService";
110
114
  import EnterpriseLicenseService from "./EnterpriseLicenseService";
111
115
  import OpenSourceDeploymentService from "./OpenSourceDeploymentService";
@@ -343,6 +347,10 @@ const services: Array<BaseService> = [
343
347
  AIAgentTaskPullRequestService,
344
348
  ProjectSsoService,
345
349
  ProjectOidcService,
350
+ GlobalSsoService,
351
+ GlobalOidcService,
352
+ GlobalSsoProjectService,
353
+ GlobalOidcProjectService,
346
354
 
347
355
  ScheduledMaintenanceCustomFieldService,
348
356
  ScheduledMaintenanceInternalNoteService,
@@ -103,6 +103,14 @@ export class ProjectService extends DatabaseService<Model> {
103
103
  */
104
104
  private requireSsoForLoginCache: InMemoryTTLCache<boolean> =
105
105
  new InMemoryTTLCache(10_000);
106
+ /*
107
+ * Caches the `requireSsoWithSsoProviderId` discriminator per project so the
108
+ * enforce-SSO middleware can require that the SSO token was issued by a
109
+ * specific provider. Stored as a string id (or null when unset). Populated
110
+ * alongside `getRequireSsoForLogin` so the common path stays a single query.
111
+ */
112
+ private requireSsoWithSsoProviderIdCache: InMemoryTTLCache<string | null> =
113
+ new InMemoryTTLCache(10_000);
106
114
  /*
107
115
  * Caches the current billing plan per project. `getCurrentPlan` is hit
108
116
  * by `CommonAPI.getDatabaseCommonInteractionProps` on every
@@ -351,6 +359,10 @@ export class ProjectService extends DatabaseService<Model> {
351
359
  this.requireSsoForLoginCache.clear();
352
360
  }
353
361
 
362
+ if (updateBy.data.requireSsoWithSsoProviderId !== undefined) {
363
+ this.requireSsoWithSsoProviderIdCache.clear();
364
+ }
365
+
354
366
  if (IsBillingEnabled) {
355
367
  if (
356
368
  updateBy.data.businessDetails ||
@@ -1338,7 +1350,7 @@ export class ProjectService extends DatabaseService<Model> {
1338
1350
 
1339
1351
  const project: Model | null = await this.findOneById({
1340
1352
  id: projectId,
1341
- select: { requireSsoForLogin: true },
1353
+ select: { requireSsoForLogin: true, requireSsoWithSsoProviderId: true },
1342
1354
  props: { isRoot: true },
1343
1355
  });
1344
1356
 
@@ -1349,9 +1361,40 @@ export class ProjectService extends DatabaseService<Model> {
1349
1361
 
1350
1362
  const value: boolean = Boolean(project.requireSsoForLogin);
1351
1363
  this.requireSsoForLoginCache.set(key, value, 60_000);
1364
+ this.requireSsoWithSsoProviderIdCache.set(
1365
+ key,
1366
+ project.requireSsoWithSsoProviderId
1367
+ ? project.requireSsoWithSsoProviderId.toString()
1368
+ : null,
1369
+ 60_000,
1370
+ );
1352
1371
  return value;
1353
1372
  }
1354
1373
 
1374
+ /**
1375
+ * Returns the specific SSO provider id a project requires for SSO-enforced
1376
+ * login, or null when any trusted provider is acceptable. Cached for 60s and
1377
+ * populated by `getRequireSsoForLogin`, so the enforce path stays one query.
1378
+ */
1379
+ @CaptureSpan()
1380
+ public async getRequireSsoWithSsoProviderId(
1381
+ projectId: ObjectID,
1382
+ ): Promise<ObjectID | null> {
1383
+ const key: string = projectId.toString();
1384
+ const cached: string | null | undefined =
1385
+ this.requireSsoWithSsoProviderIdCache.get(key);
1386
+ if (cached !== undefined) {
1387
+ return cached ? new ObjectID(cached) : null;
1388
+ }
1389
+
1390
+ // Populate both caches via the existing single-query path.
1391
+ await this.getRequireSsoForLogin(projectId);
1392
+
1393
+ const populated: string | null | undefined =
1394
+ this.requireSsoWithSsoProviderIdCache.get(key);
1395
+ return populated ? new ObjectID(populated) : null;
1396
+ }
1397
+
1355
1398
  @CaptureSpan()
1356
1399
  public async getOwners(projectId: ObjectID): Promise<Array<User>> {
1357
1400
  if (!projectId) {
@@ -8,6 +8,7 @@ import StatusPagePrivateUser from "../../Models/DatabaseModels/StatusPagePrivate
8
8
  import OneUptimeDate from "../../Types/Date";
9
9
  import PositiveNumber from "../../Types/PositiveNumber";
10
10
  import CookieName from "../../Types/CookieName";
11
+ import SsoProviderType from "../../Types/SSO/SsoProviderType";
11
12
  import {
12
13
  MASTER_PASSWORD_COOKIE_IDENTIFIER,
13
14
  MASTER_PASSWORD_COOKIE_MAX_AGE_IN_DAYS,
@@ -38,15 +39,24 @@ export default class CookieUtil {
38
39
  return cookies;
39
40
  }
40
41
 
42
+ /*
43
+ * Builds a per-project SSO token. The optional `ssoProviderId` /
44
+ * `ssoProviderType` discriminator records WHICH identity provider issued the
45
+ * token, so a project that enforces SSO can require a specific provider
46
+ * (e.g. its own Project SSO, or an instance-wide Global SSO). Used by both
47
+ * the cookie flow (web) and the deep-link flow (mobile) so the token shape
48
+ * stays identical.
49
+ */
41
50
  @CaptureSpan()
42
- public static setSSOCookie(data: {
51
+ public static getSSOToken(data: {
43
52
  user: User;
44
53
  projectId: ObjectID;
45
- expressResponse: ExpressResponse;
46
- }): void {
47
- const { user, projectId, expressResponse: res } = data;
54
+ ssoProviderId?: ObjectID | undefined;
55
+ ssoProviderType?: SsoProviderType | undefined;
56
+ }): string {
57
+ const { user, projectId } = data;
48
58
 
49
- const ssoToken: string = JSONWebToken.sign({
59
+ return JSONWebToken.sign({
50
60
  data: {
51
61
  userId: user.id!,
52
62
  projectId: projectId,
@@ -54,9 +64,33 @@ export default class CookieUtil {
54
64
  email: user.email,
55
65
  isMasterAdmin: false,
56
66
  isGeneralLogin: false,
67
+ ssoProviderId: data.ssoProviderId
68
+ ? data.ssoProviderId.toString()
69
+ : undefined,
70
+ ssoProviderType: data.ssoProviderType
71
+ ? data.ssoProviderType.toString()
72
+ : undefined,
57
73
  },
58
74
  expiresInSeconds: OneUptimeDate.getSecondsInDays(new PositiveNumber(30)),
59
75
  });
76
+ }
77
+
78
+ @CaptureSpan()
79
+ public static setSSOCookie(data: {
80
+ user: User;
81
+ projectId: ObjectID;
82
+ expressResponse: ExpressResponse;
83
+ ssoProviderId?: ObjectID | undefined;
84
+ ssoProviderType?: SsoProviderType | undefined;
85
+ }): void {
86
+ const { projectId, expressResponse: res } = data;
87
+
88
+ const ssoToken: string = CookieUtil.getSSOToken({
89
+ user: data.user,
90
+ projectId: projectId,
91
+ ssoProviderId: data.ssoProviderId,
92
+ ssoProviderType: data.ssoProviderType,
93
+ });
60
94
 
61
95
  CookieUtil.setCookie(res, CookieUtil.getUserSSOKey(projectId), ssoToken, {
62
96
  maxAge: OneUptimeDate.getMillisecondsInDays(new PositiveNumber(30)),
@@ -6,6 +6,7 @@ import JSONFunctions from "../../Types/JSONFunctions";
6
6
  import JSONWebTokenData from "../../Types/JsonWebTokenData";
7
7
  import Name from "../../Types/Name";
8
8
  import ObjectID from "../../Types/ObjectID";
9
+ import SsoProviderType from "../../Types/SSO/SsoProviderType";
9
10
  import Timezone from "../../Types/Timezone";
10
11
  import StatusPagePrivateUser from "../../Models/DatabaseModels/StatusPagePrivateUser";
11
12
  import User from "../../Models/DatabaseModels/User";
@@ -139,6 +140,12 @@ class JSONWebToken {
139
140
  sessionId: decoded["sessionId"]
140
141
  ? new ObjectID(decoded["sessionId"] as string)
141
142
  : undefined,
143
+ ssoProviderId: decoded["ssoProviderId"]
144
+ ? new ObjectID(decoded["ssoProviderId"] as string)
145
+ : undefined,
146
+ ssoProviderType: decoded["ssoProviderType"]
147
+ ? (decoded["ssoProviderType"] as SsoProviderType)
148
+ : undefined,
142
149
  };
143
150
  } catch (e) {
144
151
  logger.error(e);
@@ -0,0 +1,119 @@
1
+ import { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax";
2
+ import BadDataException from "../../Types/Exception/BadDataException";
3
+ import ObjectID from "../../Types/ObjectID";
4
+ import Project from "../../Models/DatabaseModels/Project";
5
+ import Team from "../../Models/DatabaseModels/Team";
6
+ import QueryHelper from "../Types/Database/QueryHelper";
7
+ import TeamService from "../Services/TeamService";
8
+
9
+ /*
10
+ * The Admin Dashboard attach form submits the chosen project via the `project`
11
+ * relation, not the `projectId` FK: the entity dropdown sets the related
12
+ * Project (as a model with only `_id`), so `createBy.data.projectId` is
13
+ * undefined at create-hook time. Resolve the FK from either shape so the hook
14
+ * can populate the NOT NULL `projectId` column and validate teams against it.
15
+ */
16
+ export const resolveAttachmentProjectId: (data: {
17
+ projectId?: ObjectID | undefined;
18
+ project?: Project | undefined;
19
+ }) => ObjectID | undefined = (data: {
20
+ projectId?: ObjectID | undefined;
21
+ project?: Project | undefined;
22
+ }): ObjectID | undefined => {
23
+ if (data.projectId) {
24
+ return data.projectId;
25
+ }
26
+
27
+ const project: (Project & { _id?: string }) | undefined = data.project as
28
+ | (Project & { _id?: string })
29
+ | undefined;
30
+
31
+ if (!project) {
32
+ return undefined;
33
+ }
34
+
35
+ const idString: string | undefined =
36
+ project._id?.toString() || (project.id ? project.id.toString() : undefined);
37
+
38
+ return idString ? new ObjectID(idString) : undefined;
39
+ };
40
+
41
+ /*
42
+ * Guards a Global SSO / Global OIDC project-attachment: every default team
43
+ * selected for the attachment MUST belong to the same project the attachment
44
+ * targets.
45
+ *
46
+ * Without this guard, an admin (or a direct API call) could attach a team that
47
+ * lives in project B to an attachment that targets project A. The SSO/OIDC
48
+ * login fan-out (see App/FeatureSet/Identity/API/GlobalSSO.ts) provisions a
49
+ * TeamMember with `projectId = attachment.projectId` but `teamId = team.id`,
50
+ * and because that path runs with `ignoreHooks: true` no service-level
51
+ * validation would catch the mismatch — producing a corrupt, cross-project
52
+ * membership row. This is the server-side backstop for the project-scoped team
53
+ * picker in the Admin Dashboard.
54
+ */
55
+ type ValidateGlobalProviderProjectTeamsFunction = (data: {
56
+ teams: Array<Team> | undefined;
57
+ projectId: ObjectID | undefined;
58
+ }) => Promise<void>;
59
+
60
+ const validateGlobalProviderProjectTeams: ValidateGlobalProviderProjectTeamsFunction =
61
+ async (data: {
62
+ teams: Array<Team> | undefined;
63
+ projectId: ObjectID | undefined;
64
+ }): Promise<void> => {
65
+ const teams: Array<Team> | undefined = data.teams;
66
+
67
+ if (!teams || teams.length === 0) {
68
+ // No default teams selected: nothing to validate.
69
+ return;
70
+ }
71
+
72
+ if (!data.projectId) {
73
+ throw new BadDataException(
74
+ "A project must be selected before choosing default teams.",
75
+ );
76
+ }
77
+
78
+ const projectId: string = data.projectId.toString();
79
+
80
+ const teamIds: Array<string> = teams
81
+ .map((team: Team) => {
82
+ return (
83
+ team.id?.toString() ||
84
+ (team as { _id?: string })._id?.toString() ||
85
+ undefined
86
+ );
87
+ })
88
+ .filter((id: string | undefined): id is string => {
89
+ return Boolean(id);
90
+ });
91
+
92
+ if (teamIds.length === 0) {
93
+ return;
94
+ }
95
+
96
+ const foundTeams: Array<Team> = await TeamService.findBy({
97
+ query: { _id: QueryHelper.any(teamIds) },
98
+ select: { _id: true, projectId: true },
99
+ limit: LIMIT_PER_PROJECT,
100
+ skip: 0,
101
+ props: { isRoot: true },
102
+ });
103
+
104
+ if (foundTeams.length !== teamIds.length) {
105
+ throw new BadDataException(
106
+ "One or more selected teams could not be found.",
107
+ );
108
+ }
109
+
110
+ for (const team of foundTeams) {
111
+ if (team.projectId?.toString() !== projectId) {
112
+ throw new BadDataException(
113
+ "All selected teams must belong to the project this provider is attached to.",
114
+ );
115
+ }
116
+ }
117
+ };
118
+
119
+ export default validateGlobalProviderProjectTeams;
@@ -1,6 +1,7 @@
1
1
  import ProjectMiddleware from "../../../Server/Middleware/ProjectAuthorization";
2
2
  import UserMiddleware from "../../../Server/Middleware/UserAuthorization";
3
3
  import AccessTokenService from "../../../Server/Services/AccessTokenService";
4
+ import GlobalConfigService from "../../../Server/Services/GlobalConfigService";
4
5
  import ProjectService from "../../../Server/Services/ProjectService";
5
6
  import TeamMemberService from "../../../Server/Services/TeamMemberService";
6
7
  import UserService from "../../../Server/Services/UserService";
@@ -26,7 +27,6 @@ import {
26
27
  UserGlobalAccessPermission,
27
28
  UserTenantAccessPermission,
28
29
  } from "../../../Types/Permission";
29
- import Project from "../../../Models/DatabaseModels/Project";
30
30
  import {
31
31
  describe,
32
32
  expect,
@@ -44,6 +44,7 @@ jest.mock("../../../Server/Middleware/ProjectAuthorization");
44
44
  jest.mock("../../../Server/Utils/JsonWebToken");
45
45
  jest.mock("../../../Server/Services/UserService");
46
46
  jest.mock("../../../Server/Services/AccessTokenService");
47
+ jest.mock("../../../Server/Services/GlobalConfigService");
47
48
  jest.mock("../../../Server/Utils/Response");
48
49
  jest.mock("../../../Server/Services/ProjectService");
49
50
  jest.mock("../../../Server/Services/TeamMemberService");
@@ -56,7 +57,6 @@ describe("UserMiddleware", () => {
56
57
  const mockedAccessToken: string = ObjectID.generate().toString();
57
58
  const projectId: ObjectID = ObjectID.generate();
58
59
  const userId: ObjectID = ObjectID.generate();
59
- const mockedProject: Project = { _id: projectId.toString() } as Project;
60
60
 
61
61
  beforeEach(() => {
62
62
  jest.clearAllMocks();
@@ -590,15 +590,32 @@ describe("UserMiddleware", () => {
590
590
  ProjectService,
591
591
  "getRequireSsoForLogin",
592
592
  );
593
+ const spyGetRequireSsoWithSsoProviderId: jest.SpyInstance = getJestSpyOn(
594
+ ProjectService,
595
+ "getRequireSsoWithSsoProviderId",
596
+ );
593
597
  const spyDoesSsoTokenForProjectExist: jest.SpyInstance = getJestSpyOn(
594
598
  UserMiddleware,
595
599
  "doesSsoTokenForProjectExist",
596
600
  );
601
+ const spyGetGlobalRequireSsoForLogin: jest.SpyInstance = getJestSpyOn(
602
+ GlobalConfigService,
603
+ "getRequireSsoForLogin",
604
+ );
597
605
 
598
606
  afterEach(() => {
599
607
  jest.clearAllMocks();
600
608
  });
601
609
 
610
+ /*
611
+ * By default no project requires a specific SSO provider (discriminator),
612
+ * and the instance-wide "Require SSO for Login" flag is off.
613
+ */
614
+ beforeEach(() => {
615
+ spyGetRequireSsoWithSsoProviderId.mockResolvedValue(null);
616
+ spyGetGlobalRequireSsoForLogin.mockResolvedValue(false);
617
+ });
618
+
602
619
  test("should throw 'Invalid tenantId' error, when project is not found for the tenantId", async () => {
603
620
  spyGetRequireSsoForLogin.mockRejectedValueOnce(
604
621
  new BadDataException("Project not found"),
@@ -630,6 +647,7 @@ describe("UserMiddleware", () => {
630
647
  req,
631
648
  projectId,
632
649
  userId,
650
+ undefined,
633
651
  );
634
652
  });
635
653
 
@@ -688,16 +706,37 @@ describe("UserMiddleware", () => {
688
706
  projectId,
689
707
  } as UserTenantAccessPermission;
690
708
 
691
- const spyFindBy: jest.SpyInstance = getJestSpyOn(ProjectService, "findBy");
709
+ const spyGetProjectRequireSsoForLogin: jest.SpyInstance = getJestSpyOn(
710
+ ProjectService,
711
+ "getRequireSsoForLogin",
712
+ );
713
+ const spyGetRequireSsoWithSsoProviderId: jest.SpyInstance = getJestSpyOn(
714
+ ProjectService,
715
+ "getRequireSsoWithSsoProviderId",
716
+ );
692
717
  const spyDoesSsoTokenForProjectExist: jest.SpyInstance = getJestSpyOn(
693
718
  UserMiddleware,
694
719
  "doesSsoTokenForProjectExist",
695
720
  );
721
+ const spyGetGlobalRequireSsoForLogin: jest.SpyInstance = getJestSpyOn(
722
+ GlobalConfigService,
723
+ "getRequireSsoForLogin",
724
+ );
696
725
 
697
726
  afterEach(() => {
698
727
  jest.clearAllMocks();
699
728
  });
700
729
 
730
+ /*
731
+ * By default neither a project's own nor the instance-wide "Require SSO for
732
+ * Login" flag is on, and no project requires a specific SSO provider.
733
+ */
734
+ beforeEach(() => {
735
+ spyGetProjectRequireSsoForLogin.mockResolvedValue(false);
736
+ spyGetRequireSsoWithSsoProviderId.mockResolvedValue(null);
737
+ spyGetGlobalRequireSsoForLogin.mockResolvedValue(false);
738
+ });
739
+
701
740
  test("should return null, when projectIds length is zero", async () => {
702
741
  const result: Dictionary<UserTenantAccessPermission> | null =
703
742
  await UserMiddleware.getUserTenantAccessPermissionForMultiTenant(
@@ -707,14 +746,12 @@ describe("UserMiddleware", () => {
707
746
  );
708
747
 
709
748
  expect(result).toBeNull();
710
- expect(spyFindBy).not.toBeCalled();
749
+ expect(spyGetProjectRequireSsoForLogin).not.toBeCalled();
711
750
  });
712
751
 
713
752
  test("should return default tenant access permission, when project for a projectId is found, sso is required for login, but sso token does not exist for that projectId", async () => {
714
753
  spyDoesSsoTokenForProjectExist.mockReturnValueOnce(false);
715
- spyFindBy.mockResolvedValueOnce([
716
- { ...mockedProject, requireSsoForLogin: true },
717
- ]);
754
+ spyGetProjectRequireSsoForLogin.mockResolvedValueOnce(true);
718
755
 
719
756
  const spyGetDefaultUserTenantAccessPermission: jest.SpyInstance =
720
757
  getJestSpyOn(
@@ -736,6 +773,7 @@ describe("UserMiddleware", () => {
736
773
  req,
737
774
  projectId,
738
775
  userId,
776
+ undefined,
739
777
  );
740
778
  expect(spyGetDefaultUserTenantAccessPermission).toHaveBeenCalledWith(
741
779
  projectId,
@@ -743,8 +781,6 @@ describe("UserMiddleware", () => {
743
781
  });
744
782
 
745
783
  test("should return user tenant access permission, when project for a projectId is found, sso is not required for login and project level permission exist for the projectId", async () => {
746
- spyFindBy.mockResolvedValueOnce([mockedProject]);
747
-
748
784
  const spyGetUserTenantAccessPermission: jest.SpyInstance = getJestSpyOn(
749
785
  AccessTokenService,
750
786
  "getUserTenantAccessPermission",
@@ -768,8 +804,6 @@ describe("UserMiddleware", () => {
768
804
  });
769
805
 
770
806
  test("should return null, when project for a projectId is found, sso is not required for login but project level permission does not exist for the projectId", async () => {
771
- spyFindBy.mockResolvedValueOnce([mockedProject]);
772
-
773
807
  const spyGetUserTenantAccessPermission: jest.SpyInstance = getJestSpyOn(
774
808
  AccessTokenService,
775
809
  "getUserTenantAccessPermission",
@@ -790,7 +824,9 @@ describe("UserMiddleware", () => {
790
824
  });
791
825
 
792
826
  test("should return user tenant access permission, when project for a projectId is not found, but project level permission exist for the projectId", async () => {
793
- spyFindBy.mockResolvedValueOnce([]);
827
+ spyGetProjectRequireSsoForLogin.mockRejectedValueOnce(
828
+ new BadDataException("Project not found"),
829
+ );
794
830
 
795
831
  getJestSpyOn(
796
832
  AccessTokenService,
@@ -810,7 +846,9 @@ describe("UserMiddleware", () => {
810
846
  });
811
847
 
812
848
  test("should return null, when project for a projectId is not found, and project level permission does not exist for the projectId", async () => {
813
- spyFindBy.mockResolvedValueOnce([]);
849
+ spyGetProjectRequireSsoForLogin.mockRejectedValueOnce(
850
+ new BadDataException("Project not found"),
851
+ );
814
852
 
815
853
  const spyGetUserTenantAccessPermission: jest.SpyInstance = getJestSpyOn(
816
854
  AccessTokenService,