@oneuptime/common 11.0.3 → 11.0.5

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 (228) 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/GlobalCache.ts +11 -0
  11. package/Server/Infrastructure/Postgres/SchemaMigrations/{1781587937032-MigrationName.ts → 1781750000000-MigrationName.ts} +2 -2
  12. package/Server/Infrastructure/Postgres/SchemaMigrations/1782000000000-AddGlobalSsoAndOidc.ts +176 -0
  13. package/Server/Infrastructure/Postgres/SchemaMigrations/1782100000000-AddStatusPageImageAltText.ts +25 -0
  14. package/Server/Infrastructure/Postgres/SchemaMigrations/1782200000000-AddRequireSsoForLoginToGlobalProviders.ts +25 -0
  15. package/Server/Infrastructure/Postgres/SchemaMigrations/1782300000000-MoveRequireSsoForLoginToGlobalConfig.ts +38 -0
  16. package/Server/Infrastructure/Postgres/SchemaMigrations/1782310000000-MigrationName.ts +299 -0
  17. package/Server/Infrastructure/Postgres/SchemaMigrations/1782400000000-RemoveIsTestedFromGlobalSsoAndOidc.ts +21 -0
  18. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +14 -2
  19. package/Server/Middleware/UserAuthorization.ts +113 -42
  20. package/Server/Services/CephClusterService.ts +22 -10
  21. package/Server/Services/CloudResourceService.ts +22 -10
  22. package/Server/Services/DockerHostService.ts +22 -10
  23. package/Server/Services/DockerSwarmClusterService.ts +22 -10
  24. package/Server/Services/GlobalConfigService.ts +50 -0
  25. package/Server/Services/GlobalOidcProjectService.ts +85 -0
  26. package/Server/Services/GlobalOidcService.ts +10 -0
  27. package/Server/Services/GlobalSsoProjectService.ts +85 -0
  28. package/Server/Services/GlobalSsoService.ts +10 -0
  29. package/Server/Services/HostService.ts +22 -10
  30. package/Server/Services/Index.ts +8 -0
  31. package/Server/Services/KubernetesClusterService.ts +22 -10
  32. package/Server/Services/PodmanHostService.ts +22 -10
  33. package/Server/Services/ProjectService.ts +44 -1
  34. package/Server/Services/ProxmoxClusterService.ts +22 -10
  35. package/Server/Services/RumApplicationService.ts +22 -10
  36. package/Server/Services/ServerlessFunctionService.ts +22 -10
  37. package/Server/Utils/Cookie.ts +39 -5
  38. package/Server/Utils/JsonWebToken.ts +7 -0
  39. package/Server/Utils/ValidateGlobalProviderProjectTeams.ts +119 -0
  40. package/Tests/Server/Infrastructure/GlobalCache.test.ts +43 -0
  41. package/Tests/Server/Middleware/UserAuthorization.test.ts +51 -13
  42. package/Tests/Server/Middleware/UserAuthorizationSSOProvider.test.ts +163 -0
  43. package/Tests/Server/Utils/CookieSSOToken.test.ts +130 -0
  44. package/Types/JsonWebTokenData.ts +3 -0
  45. package/Types/SSO/SsoProviderType.ts +8 -0
  46. package/UI/Components/Accordion/Accordion.tsx +5 -1
  47. package/UI/Components/CardSelect/CardSelect.tsx +6 -1
  48. package/UI/Components/CategoryCheckbox/Index.tsx +2 -1
  49. package/UI/Components/Charts/Area/AreaChart.tsx +4 -8
  50. package/UI/Components/Charts/Bar/BarChart.tsx +4 -8
  51. package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +4 -8
  52. package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +1 -1
  53. package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +1 -1
  54. package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +1 -1
  55. package/UI/Components/Charts/Line/LineChart.tsx +4 -8
  56. package/UI/Components/CodeEditor/CodeEditor.tsx +2 -0
  57. package/UI/Components/CollapsibleSection/CollapsibleSection.tsx +8 -1
  58. package/UI/Components/Dropdown/Dropdown.tsx +2 -0
  59. package/UI/Components/EditionLabel/EditionLabel.tsx +68 -16
  60. package/UI/Components/EntityDropdown/EntityDropdown.tsx +3 -0
  61. package/UI/Components/FilePicker/FilePicker.tsx +2 -0
  62. package/UI/Components/Forms/Fields/ColorPicker.tsx +2 -0
  63. package/UI/Components/Forms/Fields/FieldLabel.tsx +4 -0
  64. package/UI/Components/Forms/Fields/FormField.tsx +72 -15
  65. package/UI/Components/Forms/Fields/IconPicker.tsx +2 -0
  66. package/UI/Components/Forms/Validation.ts +107 -23
  67. package/UI/Components/Input/Input.tsx +4 -0
  68. package/UI/Components/Link/Link.tsx +23 -0
  69. package/UI/Components/Markdown.tsx/MarkdownConverters.ts +0 -0
  70. package/UI/Components/Markdown.tsx/MarkdownEditor.tsx +3 -0
  71. package/UI/Components/Markdown.tsx/MarkdownViewer.tsx +63 -2
  72. package/UI/Components/Modal/Modal.tsx +4 -1
  73. package/UI/Components/Radio/Radio.tsx +2 -0
  74. package/UI/Components/RadioButtons/GroupRadioButtons.tsx +6 -1
  75. package/UI/Components/Tabs/Tabs.tsx +63 -0
  76. package/UI/Components/TextArea/TextArea.tsx +2 -0
  77. package/UI/Components/TimePicker/TimePicker.tsx +2 -0
  78. package/UI/Components/Toggle/Toggle.tsx +2 -1
  79. package/UI/Components/Tooltip/Tooltip.tsx +6 -1
  80. package/build/dist/Models/DatabaseModels/GlobalConfig.js +20 -0
  81. package/build/dist/Models/DatabaseModels/GlobalConfig.js.map +1 -1
  82. package/build/dist/Models/DatabaseModels/GlobalOidc.js +379 -0
  83. package/build/dist/Models/DatabaseModels/GlobalOidc.js.map +1 -0
  84. package/build/dist/Models/DatabaseModels/GlobalOidcProject.js +276 -0
  85. package/build/dist/Models/DatabaseModels/GlobalOidcProject.js.map +1 -0
  86. package/build/dist/Models/DatabaseModels/GlobalSso.js +341 -0
  87. package/build/dist/Models/DatabaseModels/GlobalSso.js.map +1 -0
  88. package/build/dist/Models/DatabaseModels/GlobalSsoProject.js +279 -0
  89. package/build/dist/Models/DatabaseModels/GlobalSsoProject.js.map +1 -0
  90. package/build/dist/Models/DatabaseModels/Index.js +8 -0
  91. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  92. package/build/dist/Models/DatabaseModels/Project.js +32 -0
  93. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  94. package/build/dist/Models/DatabaseModels/StatusPage.js +84 -0
  95. package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
  96. package/build/dist/Server/API/StatusPageAPI.js +2 -0
  97. package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
  98. package/build/dist/Server/Infrastructure/GlobalCache.js +13 -0
  99. package/build/dist/Server/Infrastructure/GlobalCache.js.map +1 -1
  100. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/{1781587937032-MigrationName.js → 1781750000000-MigrationName.js} +3 -3
  101. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/{1781587937032-MigrationName.js.map → 1781750000000-MigrationName.js.map} +1 -1
  102. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1782000000000-AddGlobalSsoAndOidc.js +73 -0
  103. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1782000000000-AddGlobalSsoAndOidc.js.map +1 -0
  104. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1782100000000-AddStatusPageImageAltText.js +14 -0
  105. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1782100000000-AddStatusPageImageAltText.js.map +1 -0
  106. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1782200000000-AddRequireSsoForLoginToGlobalProviders.js +14 -0
  107. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1782200000000-AddRequireSsoForLoginToGlobalProviders.js.map +1 -0
  108. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1782300000000-MoveRequireSsoForLoginToGlobalConfig.js +23 -0
  109. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1782300000000-MoveRequireSsoForLoginToGlobalConfig.js.map +1 -0
  110. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1782310000000-MigrationName.js +106 -0
  111. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1782310000000-MigrationName.js.map +1 -0
  112. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1782400000000-RemoveIsTestedFromGlobalSsoAndOidc.js +14 -0
  113. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1782400000000-RemoveIsTestedFromGlobalSsoAndOidc.js.map +1 -0
  114. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +14 -2
  115. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  116. package/build/dist/Server/Middleware/UserAuthorization.js +77 -34
  117. package/build/dist/Server/Middleware/UserAuthorization.js.map +1 -1
  118. package/build/dist/Server/Services/CephClusterService.js +19 -2
  119. package/build/dist/Server/Services/CephClusterService.js.map +1 -1
  120. package/build/dist/Server/Services/CloudResourceService.js +19 -2
  121. package/build/dist/Server/Services/CloudResourceService.js.map +1 -1
  122. package/build/dist/Server/Services/DockerHostService.js +19 -2
  123. package/build/dist/Server/Services/DockerHostService.js.map +1 -1
  124. package/build/dist/Server/Services/DockerSwarmClusterService.js +19 -2
  125. package/build/dist/Server/Services/DockerSwarmClusterService.js.map +1 -1
  126. package/build/dist/Server/Services/GlobalConfigService.js +55 -0
  127. package/build/dist/Server/Services/GlobalConfigService.js.map +1 -1
  128. package/build/dist/Server/Services/GlobalOidcProjectService.js +80 -0
  129. package/build/dist/Server/Services/GlobalOidcProjectService.js.map +1 -0
  130. package/build/dist/Server/Services/GlobalOidcService.js +9 -0
  131. package/build/dist/Server/Services/GlobalOidcService.js.map +1 -0
  132. package/build/dist/Server/Services/GlobalSsoProjectService.js +80 -0
  133. package/build/dist/Server/Services/GlobalSsoProjectService.js.map +1 -0
  134. package/build/dist/Server/Services/GlobalSsoService.js +9 -0
  135. package/build/dist/Server/Services/GlobalSsoService.js.map +1 -0
  136. package/build/dist/Server/Services/HostService.js +19 -2
  137. package/build/dist/Server/Services/HostService.js.map +1 -1
  138. package/build/dist/Server/Services/Index.js +8 -0
  139. package/build/dist/Server/Services/Index.js.map +1 -1
  140. package/build/dist/Server/Services/KubernetesClusterService.js +19 -2
  141. package/build/dist/Server/Services/KubernetesClusterService.js.map +1 -1
  142. package/build/dist/Server/Services/PodmanHostService.js +19 -2
  143. package/build/dist/Server/Services/PodmanHostService.js.map +1 -1
  144. package/build/dist/Server/Services/ProjectService.js +36 -1
  145. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  146. package/build/dist/Server/Services/ProxmoxClusterService.js +19 -2
  147. package/build/dist/Server/Services/ProxmoxClusterService.js.map +1 -1
  148. package/build/dist/Server/Services/RumApplicationService.js +19 -2
  149. package/build/dist/Server/Services/RumApplicationService.js.map +1 -1
  150. package/build/dist/Server/Services/ServerlessFunctionService.js +19 -2
  151. package/build/dist/Server/Services/ServerlessFunctionService.js.map +1 -1
  152. package/build/dist/Server/Utils/Cookie.js +32 -3
  153. package/build/dist/Server/Utils/Cookie.js.map +1 -1
  154. package/build/dist/Server/Utils/JsonWebToken.js +6 -0
  155. package/build/dist/Server/Utils/JsonWebToken.js.map +1 -1
  156. package/build/dist/Server/Utils/ValidateGlobalProviderProjectTeams.js +66 -0
  157. package/build/dist/Server/Utils/ValidateGlobalProviderProjectTeams.js.map +1 -0
  158. package/build/dist/Types/SSO/SsoProviderType.js +9 -0
  159. package/build/dist/Types/SSO/SsoProviderType.js.map +1 -0
  160. package/build/dist/UI/Components/Accordion/Accordion.js +5 -3
  161. package/build/dist/UI/Components/Accordion/Accordion.js.map +1 -1
  162. package/build/dist/UI/Components/CardSelect/CardSelect.js +1 -1
  163. package/build/dist/UI/Components/CardSelect/CardSelect.js.map +1 -1
  164. package/build/dist/UI/Components/CategoryCheckbox/Index.js +1 -1
  165. package/build/dist/UI/Components/CategoryCheckbox/Index.js.map +1 -1
  166. package/build/dist/UI/Components/Charts/Area/AreaChart.js +2 -6
  167. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  168. package/build/dist/UI/Components/Charts/Bar/BarChart.js +2 -6
  169. package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
  170. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +8 -8
  171. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
  172. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +1 -1
  173. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
  174. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +1 -1
  175. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
  176. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +1 -1
  177. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
  178. package/build/dist/UI/Components/Charts/Line/LineChart.js +2 -6
  179. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  180. package/build/dist/UI/Components/CodeEditor/CodeEditor.js +1 -1
  181. package/build/dist/UI/Components/CodeEditor/CodeEditor.js.map +1 -1
  182. package/build/dist/UI/Components/CollapsibleSection/CollapsibleSection.js +4 -2
  183. package/build/dist/UI/Components/CollapsibleSection/CollapsibleSection.js.map +1 -1
  184. package/build/dist/UI/Components/Dropdown/Dropdown.js +1 -1
  185. package/build/dist/UI/Components/Dropdown/Dropdown.js.map +1 -1
  186. package/build/dist/UI/Components/EditionLabel/EditionLabel.js +31 -5
  187. package/build/dist/UI/Components/EditionLabel/EditionLabel.js.map +1 -1
  188. package/build/dist/UI/Components/EntityDropdown/EntityDropdown.js +2 -2
  189. package/build/dist/UI/Components/EntityDropdown/EntityDropdown.js.map +1 -1
  190. package/build/dist/UI/Components/FilePicker/FilePicker.js +1 -1
  191. package/build/dist/UI/Components/FilePicker/FilePicker.js.map +1 -1
  192. package/build/dist/UI/Components/Forms/Fields/ColorPicker.js +1 -1
  193. package/build/dist/UI/Components/Forms/Fields/ColorPicker.js.map +1 -1
  194. package/build/dist/UI/Components/Forms/Fields/FieldLabel.js +1 -1
  195. package/build/dist/UI/Components/Forms/Fields/FieldLabel.js.map +1 -1
  196. package/build/dist/UI/Components/Forms/Fields/FormField.js +58 -22
  197. package/build/dist/UI/Components/Forms/Fields/FormField.js.map +1 -1
  198. package/build/dist/UI/Components/Forms/Fields/IconPicker.js +1 -1
  199. package/build/dist/UI/Components/Forms/Fields/IconPicker.js.map +1 -1
  200. package/build/dist/UI/Components/Forms/Validation.js +64 -15
  201. package/build/dist/UI/Components/Forms/Validation.js.map +1 -1
  202. package/build/dist/UI/Components/Input/Input.js +1 -1
  203. package/build/dist/UI/Components/Input/Input.js.map +1 -1
  204. package/build/dist/UI/Components/Link/Link.js +22 -1
  205. package/build/dist/UI/Components/Link/Link.js.map +1 -1
  206. package/build/dist/UI/Components/Markdown.tsx/MarkdownConverters.js +0 -0
  207. package/build/dist/UI/Components/Markdown.tsx/MarkdownConverters.js.map +1 -1
  208. package/build/dist/UI/Components/Markdown.tsx/MarkdownEditor.js +2 -2
  209. package/build/dist/UI/Components/Markdown.tsx/MarkdownEditor.js.map +1 -1
  210. package/build/dist/UI/Components/Markdown.tsx/MarkdownViewer.js +46 -2
  211. package/build/dist/UI/Components/Markdown.tsx/MarkdownViewer.js.map +1 -1
  212. package/build/dist/UI/Components/Modal/Modal.js +1 -1
  213. package/build/dist/UI/Components/Modal/Modal.js.map +1 -1
  214. package/build/dist/UI/Components/Radio/Radio.js +1 -1
  215. package/build/dist/UI/Components/Radio/Radio.js.map +1 -1
  216. package/build/dist/UI/Components/RadioButtons/GroupRadioButtons.js +1 -1
  217. package/build/dist/UI/Components/RadioButtons/GroupRadioButtons.js.map +1 -1
  218. package/build/dist/UI/Components/Tabs/Tabs.js +50 -1
  219. package/build/dist/UI/Components/Tabs/Tabs.js.map +1 -1
  220. package/build/dist/UI/Components/TextArea/TextArea.js +1 -1
  221. package/build/dist/UI/Components/TextArea/TextArea.js.map +1 -1
  222. package/build/dist/UI/Components/TimePicker/TimePicker.js +1 -1
  223. package/build/dist/UI/Components/TimePicker/TimePicker.js.map +1 -1
  224. package/build/dist/UI/Components/Toggle/Toggle.js +1 -1
  225. package/build/dist/UI/Components/Toggle/Toggle.js.map +1 -1
  226. package/build/dist/UI/Components/Tooltip/Tooltip.js +6 -1
  227. package/build/dist/UI/Components/Tooltip/Tooltip.js.map +1 -1
  228. package/package.json +1 -1
@@ -170,21 +170,33 @@ export class Service extends DatabaseService<Model> {
170
170
  )
171
171
  .digest("hex");
172
172
 
173
- const cached: string | null = await GlobalCache.getString(
174
- LAST_SEEN_CACHE_NAMESPACE,
175
- cacheKey,
176
- );
173
+ let cached: string | null = null;
174
+ try {
175
+ cached = await GlobalCache.getString(LAST_SEEN_CACHE_NAMESPACE, cacheKey);
176
+ } catch {
177
+ /*
178
+ * Cache unavailable — fail open and refresh lastSeenAt anyway. A
179
+ * cache error must never skip the DB write below, otherwise the
180
+ * resource is wrongly marked "disconnected" while telemetry is
181
+ * still flowing. Mirrors shouldRunMaintenance's fail-open stance.
182
+ */
183
+ cached = null;
184
+ }
177
185
 
178
186
  if (cached === extrasFingerprint) {
179
187
  return; // same data was written recently
180
188
  }
181
189
 
182
- await GlobalCache.setString(
183
- LAST_SEEN_CACHE_NAMESPACE,
184
- cacheKey,
185
- extrasFingerprint,
186
- { expiresInSeconds: LAST_SEEN_THROTTLE_SECONDS },
187
- );
190
+ try {
191
+ await GlobalCache.setString(
192
+ LAST_SEEN_CACHE_NAMESPACE,
193
+ cacheKey,
194
+ extrasFingerprint,
195
+ { expiresInSeconds: LAST_SEEN_THROTTLE_SECONDS },
196
+ );
197
+ } catch {
198
+ // Best-effort throttle write; proceed with the DB update regardless.
199
+ }
188
200
 
189
201
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
190
202
  const data: any = {
@@ -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;
@@ -17,6 +17,7 @@ type MockClient = {
17
17
  set: jest.Mock;
18
18
  expire: jest.Mock;
19
19
  get: jest.Mock;
20
+ del: jest.Mock;
20
21
  };
21
22
 
22
23
  describe("GlobalCache.setString", () => {
@@ -27,6 +28,7 @@ describe("GlobalCache.setString", () => {
27
28
  set: jest.fn().mockResolvedValue("OK"),
28
29
  expire: jest.fn().mockResolvedValue(1),
29
30
  get: jest.fn(),
31
+ del: jest.fn().mockResolvedValue(1),
30
32
  };
31
33
  (Redis.getClient as jest.Mock).mockReturnValue(client);
32
34
  (Redis.isConnected as jest.Mock).mockReturnValue(true);
@@ -98,3 +100,44 @@ describe("GlobalCache.setString", () => {
98
100
  expect(client.expire).not.toHaveBeenCalled();
99
101
  });
100
102
  });
103
+
104
+ describe("GlobalCache.deleteKey", () => {
105
+ let client: MockClient;
106
+
107
+ beforeEach(() => {
108
+ client = {
109
+ set: jest.fn().mockResolvedValue("OK"),
110
+ expire: jest.fn().mockResolvedValue(1),
111
+ get: jest.fn(),
112
+ del: jest.fn().mockResolvedValue(1),
113
+ };
114
+ (Redis.getClient as jest.Mock).mockReturnValue(client);
115
+ (Redis.isConnected as jest.Mock).mockReturnValue(true);
116
+ });
117
+
118
+ afterEach(() => {
119
+ jest.clearAllMocks();
120
+ });
121
+
122
+ /*
123
+ * deleteKey backs clearMaintenanceFence (OtelIngestBaseService): when
124
+ * the fenced maintenance work (updateLastSeen) fails, the fence is
125
+ * released so the next ingest batch retries instead of leaving the
126
+ * resource stranded as "disconnected" for the whole TTL window.
127
+ */
128
+ test("deletes the namespaced key", async () => {
129
+ await GlobalCache.deleteKey("ns", "key");
130
+
131
+ expect(client.del).toHaveBeenCalledTimes(1);
132
+ expect(client.del).toHaveBeenCalledWith("ns-key");
133
+ });
134
+
135
+ test("throws when the cache is not connected", async () => {
136
+ (Redis.isConnected as jest.Mock).mockReturnValue(false);
137
+
138
+ await expect(GlobalCache.deleteKey("ns", "key")).rejects.toThrow(
139
+ DatabaseNotConnectedException,
140
+ );
141
+ expect(client.del).not.toHaveBeenCalled();
142
+ });
143
+ });
@@ -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,
@@ -0,0 +1,163 @@
1
+ import UserMiddleware from "../../../Server/Middleware/UserAuthorization";
2
+ import CookieUtil from "../../../Server/Utils/Cookie";
3
+ import { ExpressRequest } from "../../../Server/Utils/Express";
4
+ import Email from "../../../Types/Email";
5
+ import ObjectID from "../../../Types/ObjectID";
6
+ import SsoProviderType from "../../../Types/SSO/SsoProviderType";
7
+ import User from "../../../Models/DatabaseModels/User";
8
+ import { describe, expect, test, jest } from "@jest/globals";
9
+
10
+ jest.mock("../../../Server/Utils/Logger");
11
+
12
+ /*
13
+ * Tests for UserMiddleware.doesSsoTokenForProjectExist's `requiredSsoProviderId`
14
+ * param. When provided, an SSO token only satisfies the project if it ALSO
15
+ * carries a matching `ssoProviderId` discriminator. Legacy tokens (no
16
+ * discriminator) must pass when no provider is required but fail a
17
+ * specific-provider requirement.
18
+ *
19
+ * These use REAL tokens minted by CookieUtil.getSSOToken and the REAL
20
+ * JSONWebToken.decode() path (no mocking) so they verify the full production
21
+ * wiring end to end — including that decode() surfaces ssoProviderId.
22
+ */
23
+ describe("UserMiddleware.doesSsoTokenForProjectExist - requiredSsoProviderId", () => {
24
+ const projectId: ObjectID = ObjectID.generate();
25
+ const userId: ObjectID = ObjectID.generate();
26
+ const ssoProviderId: ObjectID = ObjectID.generate();
27
+ const otherProviderId: ObjectID = ObjectID.generate();
28
+
29
+ const buildUser: () => User = (): User => {
30
+ const u: User = new User();
31
+ u.id = userId;
32
+ u.email = new Email("sso-user@oneuptime.com");
33
+ return u;
34
+ };
35
+
36
+ // Build a request whose cookies carry a real `sso-<projectId>` token.
37
+ const buildRequestWithSsoToken: (data: {
38
+ tokenProjectId: ObjectID;
39
+ discriminatorProviderId?: ObjectID | undefined;
40
+ }) => ExpressRequest = (data: {
41
+ tokenProjectId: ObjectID;
42
+ discriminatorProviderId?: ObjectID | undefined;
43
+ }): ExpressRequest => {
44
+ const token: string = CookieUtil.getSSOToken({
45
+ user: buildUser(),
46
+ projectId: data.tokenProjectId,
47
+ ssoProviderId: data.discriminatorProviderId,
48
+ ssoProviderType: data.discriminatorProviderId
49
+ ? SsoProviderType.GlobalSSO
50
+ : undefined,
51
+ });
52
+
53
+ return {
54
+ cookies: {
55
+ [CookieUtil.getUserSSOKey(data.tokenProjectId)]: token,
56
+ },
57
+ headers: {},
58
+ } as unknown as ExpressRequest;
59
+ };
60
+
61
+ test("matching project+user, NO requiredProviderId -> true", () => {
62
+ const req: ExpressRequest = buildRequestWithSsoToken({
63
+ tokenProjectId: projectId,
64
+ discriminatorProviderId: ssoProviderId,
65
+ });
66
+
67
+ expect(
68
+ UserMiddleware.doesSsoTokenForProjectExist(req, projectId, userId),
69
+ ).toBe(true);
70
+ });
71
+
72
+ test("matching project+user, requiredProviderId EQUALS token's ssoProviderId -> true", () => {
73
+ const req: ExpressRequest = buildRequestWithSsoToken({
74
+ tokenProjectId: projectId,
75
+ discriminatorProviderId: ssoProviderId,
76
+ });
77
+
78
+ expect(
79
+ UserMiddleware.doesSsoTokenForProjectExist(
80
+ req,
81
+ projectId,
82
+ userId,
83
+ ssoProviderId,
84
+ ),
85
+ ).toBe(true);
86
+ });
87
+
88
+ test("matching project+user, requiredProviderId DIFFERENT from token's ssoProviderId -> false", () => {
89
+ const req: ExpressRequest = buildRequestWithSsoToken({
90
+ tokenProjectId: projectId,
91
+ discriminatorProviderId: ssoProviderId,
92
+ });
93
+
94
+ expect(
95
+ UserMiddleware.doesSsoTokenForProjectExist(
96
+ req,
97
+ projectId,
98
+ userId,
99
+ otherProviderId,
100
+ ),
101
+ ).toBe(false);
102
+ });
103
+
104
+ test("legacy token (NO discriminator) + requiredProviderId given -> false", () => {
105
+ const req: ExpressRequest = buildRequestWithSsoToken({
106
+ tokenProjectId: projectId,
107
+ // no discriminatorProviderId -> legacy token shape
108
+ });
109
+
110
+ expect(
111
+ UserMiddleware.doesSsoTokenForProjectExist(
112
+ req,
113
+ projectId,
114
+ userId,
115
+ ssoProviderId,
116
+ ),
117
+ ).toBe(false);
118
+ });
119
+
120
+ test("legacy token (NO discriminator) + NO requiredProviderId -> true (backwards compatible)", () => {
121
+ const req: ExpressRequest = buildRequestWithSsoToken({
122
+ tokenProjectId: projectId,
123
+ });
124
+
125
+ expect(
126
+ UserMiddleware.doesSsoTokenForProjectExist(req, projectId, userId),
127
+ ).toBe(true);
128
+ });
129
+
130
+ test("wrong userId -> false (even with matching provider)", () => {
131
+ const req: ExpressRequest = buildRequestWithSsoToken({
132
+ tokenProjectId: projectId,
133
+ discriminatorProviderId: ssoProviderId,
134
+ });
135
+
136
+ const differentUserId: ObjectID = ObjectID.generate();
137
+
138
+ expect(
139
+ UserMiddleware.doesSsoTokenForProjectExist(
140
+ req,
141
+ projectId,
142
+ differentUserId,
143
+ ssoProviderId,
144
+ ),
145
+ ).toBe(false);
146
+ });
147
+
148
+ test("no sso cookie for the project -> false", () => {
149
+ const req: ExpressRequest = {
150
+ cookies: {},
151
+ headers: {},
152
+ } as unknown as ExpressRequest;
153
+
154
+ expect(
155
+ UserMiddleware.doesSsoTokenForProjectExist(
156
+ req,
157
+ projectId,
158
+ userId,
159
+ ssoProviderId,
160
+ ),
161
+ ).toBe(false);
162
+ });
163
+ });