@oneuptime/common 12.0.14 → 12.0.15

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 (195) hide show
  1. package/Models/DatabaseModels/GlobalOidc.ts +20 -0
  2. package/Models/DatabaseModels/GlobalSso.ts +20 -0
  3. package/Models/DatabaseModels/MarketingConversion.ts +19 -9
  4. package/Models/DatabaseModels/NetworkDeviceLinkRule.ts +46 -0
  5. package/Models/DatabaseModels/StatusPage.ts +7 -4
  6. package/Server/API/StatusPageAPI.ts +29 -4
  7. package/Server/EnvironmentConfig.ts +7 -0
  8. package/Server/Infrastructure/Postgres/SchemaMigrations/1787800000000-AddScopeToNetworkDeviceLinkRule.ts +19 -0
  9. package/Server/Infrastructure/Postgres/SchemaMigrations/1787900000000-AddRestrictToAttachedProjectsToGlobalSso.ts +40 -0
  10. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
  11. package/Server/Middleware/SlackAuthorization.ts +43 -6
  12. package/Server/Middleware/UserAuthorization.ts +236 -26
  13. package/Server/Middleware/WhatsAppAuthorization.ts +43 -3
  14. package/Server/Services/GlobalOidcProjectService.ts +126 -1
  15. package/Server/Services/GlobalOidcService.ts +132 -0
  16. package/Server/Services/GlobalSsoProjectService.ts +126 -1
  17. package/Server/Services/GlobalSsoService.ts +132 -0
  18. package/Server/Services/UserEmailService.ts +4 -4
  19. package/Server/Utils/CronTab.ts +73 -0
  20. package/Server/Utils/GlobalSsoAuthorization.ts +175 -0
  21. package/Server/Utils/Marketing/ConversionUploadProvider.ts +35 -5
  22. package/Server/Utils/Marketing/Providers/GoogleAds.ts +1 -1
  23. package/Server/Utils/Marketing/Providers/LinkedIn.ts +1 -1
  24. package/Server/Utils/Marketing/Providers/Meta.ts +1 -1
  25. package/Server/Utils/Marketing/Providers/MicrosoftAds.ts +1 -1
  26. package/Server/Utils/Marketing/Providers/Reddit.ts +1 -1
  27. package/Server/Utils/Monitor/Criteria/APIRequestCriteria.ts +48 -33
  28. package/Server/Utils/Monitor/Criteria/DnsMonitorCriteria.ts +35 -30
  29. package/Server/Utils/Monitor/Criteria/DomainMonitorCriteria.ts +33 -28
  30. package/Server/Utils/Monitor/Criteria/EvaluateOverTime.ts +475 -47
  31. package/Server/Utils/Monitor/Criteria/ExternalStatusPageMonitorCriteria.ts +35 -30
  32. package/Server/Utils/Monitor/Criteria/IncomingRequestCriteria.ts +34 -28
  33. package/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.ts +33 -28
  34. package/Server/Utils/Monitor/Criteria/ServerMonitorCriteria.ts +69 -40
  35. package/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.ts +35 -29
  36. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +8 -0
  37. package/Server/Utils/Monitor/MonitorResource.ts +161 -21
  38. package/Server/Utils/StatusPageContentSecurityPolicy.ts +56 -0
  39. package/Server/Utils/StatusPageCustomizationAccess.ts +117 -0
  40. package/Tests/Server/API/StatusPageCustomizationOrigin.test.ts +348 -0
  41. package/Tests/Server/Infrastructure/Postgres/FixTotpOtpUrlAlgorithmMigration.test.ts +9 -59
  42. package/Tests/Server/Infrastructure/Postgres/SchemaMigrationsOrdering.test.ts +227 -0
  43. package/Tests/Server/Middleware/SlackAuthorization.test.ts +149 -0
  44. package/Tests/Server/Middleware/UserAuthorization.test.ts +27 -13
  45. package/Tests/Server/Middleware/UserAuthorizationGlobalSsoGovernance.test.ts +1455 -0
  46. package/Tests/Server/Middleware/UserAuthorizationSSOProvider.test.ts +91 -2
  47. package/Tests/Server/Middleware/WhatsAppAuthorization.test.ts +233 -0
  48. package/Tests/Server/Services/DiscoveryScanClaimHookFreeSafety.test.ts +21 -8
  49. package/Tests/Server/Services/GlobalSsoProviderAuthorization.test.ts +1242 -0
  50. package/Tests/Server/Services/MonitorResourceProbeAgreementHydration.test.ts +459 -0
  51. package/Tests/Server/Services/MonitorResourceProbeAgreementReuse.test.ts +10 -0
  52. package/Tests/Server/Types/AnalyticsDatabase/AggregateUtilBucketTimestamp.test.ts +416 -0
  53. package/Tests/Server/Types/Database/QueryHelperManyToManyAndEmptyValues.test.ts +551 -0
  54. package/Tests/Server/Types/Database/SelectUtil.test.ts +348 -0
  55. package/Tests/Server/Utils/CronTab.test.ts +116 -0
  56. package/Tests/Server/Utils/GlobalSSOToken.test.ts +581 -0
  57. package/Tests/Server/Utils/GlobalSsoAuthorization.test.ts +1401 -0
  58. package/Tests/Server/Utils/Marketing/AdUploadableConversionTypes.test.ts +254 -0
  59. package/Tests/Server/Utils/Marketing/ConversionUploadProvider.test.ts +1 -1
  60. package/Tests/Server/Utils/Monitor/Criteria/APIRequestCriteriaEvaluateOverTime.test.ts +508 -0
  61. package/Tests/Server/Utils/Monitor/Criteria/APIRequestCriteriaPortTimings.test.ts +30 -9
  62. package/Tests/Server/Utils/Monitor/Criteria/EvaluateOverTime.test.ts +987 -0
  63. package/Tests/Server/Utils/Monitor/Criteria/ExternalStatusPageMonitorCriteria.test.ts +916 -0
  64. package/Tests/Server/Utils/Monitor/Criteria/ProfileMonitorCriteria.test.ts +382 -0
  65. package/Tests/Server/Utils/Monitor/Criteria/ServerMonitorCriteria.test.ts +1219 -0
  66. package/Tests/Server/Utils/Monitor/MonitorResourceIngestedStepSelection.test.ts +86 -0
  67. package/Tests/Server/Utils/StatusPageContentSecurityPolicy.test.ts +136 -0
  68. package/Tests/Server/Utils/StatusPageCustomizationAccess.test.ts +223 -0
  69. package/Tests/Types/Monitor/MonitorStepConfigHelpers.test.ts +165 -0
  70. package/Tests/Types/NetworkDevice/NetworkDeviceLinkRuleScope.test.ts +215 -0
  71. package/Tests/UI/Components/Forms/FormSubmitAnalytics.test.tsx +206 -0
  72. package/Tests/UI/Utils/AIChatExport/ConversationMarkdown.test.ts +774 -0
  73. package/Tests/UI/Utils/AIChatExport/MarkdownBlocks.test.ts +791 -0
  74. package/Tests/UI/Utils/AIChatExport/MarkdownSafety.test.ts +544 -0
  75. package/Tests/Utils/Analytics.test.ts +187 -0
  76. package/Tests/Utils/Monitor/MonitorMetricType.test.ts +80 -0
  77. package/Tests/Utils/Monitor/NetworkDeviceLinkRuleUtil.test.ts +1291 -0
  78. package/Tests/Utils/NetworkDiscovery/UnclaimedScanDiagnosis.test.ts +214 -0
  79. package/Types/Marketing/MarketingConversion.ts +12 -0
  80. package/Types/Monitor/CriteriaFilter.ts +9 -0
  81. package/Types/NetworkDevice/NetworkDeviceLinkRuleScope.ts +51 -0
  82. package/UI/Components/FormModal/BasicFormModal.tsx +12 -0
  83. package/UI/Components/Forms/BasicForm.tsx +13 -1
  84. package/UI/Components/Forms/BasicModelForm.tsx +2 -1
  85. package/UI/Components/Forms/ModelForm.tsx +10 -1
  86. package/UI/Components/Forms/Utils/FormAnalyticsName.ts +39 -0
  87. package/UI/Components/ModelFormModal/ModelFormModal.tsx +6 -1
  88. package/Utils/Analytics.ts +55 -2
  89. package/Utils/Monitor/MonitorMetricType.ts +40 -0
  90. package/Utils/Monitor/NetworkDeviceLinkRuleUtil.ts +587 -37
  91. package/Utils/NetworkDiscovery/UnclaimedScanDiagnosis.ts +115 -0
  92. package/build/dist/Models/DatabaseModels/GlobalOidc.js +21 -0
  93. package/build/dist/Models/DatabaseModels/GlobalOidc.js.map +1 -1
  94. package/build/dist/Models/DatabaseModels/GlobalSso.js +21 -0
  95. package/build/dist/Models/DatabaseModels/GlobalSso.js.map +1 -1
  96. package/build/dist/Models/DatabaseModels/MarketingConversion.js +20 -10
  97. package/build/dist/Models/DatabaseModels/MarketingConversion.js.map +1 -1
  98. package/build/dist/Models/DatabaseModels/NetworkDeviceLinkRule.js +47 -0
  99. package/build/dist/Models/DatabaseModels/NetworkDeviceLinkRule.js.map +1 -1
  100. package/build/dist/Models/DatabaseModels/StatusPage.js +4 -4
  101. package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
  102. package/build/dist/Server/API/StatusPageAPI.js +25 -4
  103. package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
  104. package/build/dist/Server/EnvironmentConfig.js +6 -0
  105. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  106. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787800000000-AddScopeToNetworkDeviceLinkRule.js +12 -0
  107. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787800000000-AddScopeToNetworkDeviceLinkRule.js.map +1 -0
  108. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787900000000-AddRestrictToAttachedProjectsToGlobalSso.js +29 -0
  109. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787900000000-AddRestrictToAttachedProjectsToGlobalSso.js.map +1 -0
  110. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
  111. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  112. package/build/dist/Server/Middleware/SlackAuthorization.js +28 -4
  113. package/build/dist/Server/Middleware/SlackAuthorization.js.map +1 -1
  114. package/build/dist/Server/Middleware/UserAuthorization.js +197 -22
  115. package/build/dist/Server/Middleware/UserAuthorization.js.map +1 -1
  116. package/build/dist/Server/Middleware/WhatsAppAuthorization.js +32 -2
  117. package/build/dist/Server/Middleware/WhatsAppAuthorization.js.map +1 -1
  118. package/build/dist/Server/Services/GlobalOidcProjectService.js +105 -0
  119. package/build/dist/Server/Services/GlobalOidcProjectService.js.map +1 -1
  120. package/build/dist/Server/Services/GlobalOidcService.js +127 -0
  121. package/build/dist/Server/Services/GlobalOidcService.js.map +1 -1
  122. package/build/dist/Server/Services/GlobalSsoProjectService.js +105 -0
  123. package/build/dist/Server/Services/GlobalSsoProjectService.js.map +1 -1
  124. package/build/dist/Server/Services/GlobalSsoService.js +127 -0
  125. package/build/dist/Server/Services/GlobalSsoService.js.map +1 -1
  126. package/build/dist/Server/Services/UserEmailService.js +4 -4
  127. package/build/dist/Server/Utils/CronTab.js +66 -0
  128. package/build/dist/Server/Utils/CronTab.js.map +1 -1
  129. package/build/dist/Server/Utils/GlobalSsoAuthorization.js +112 -0
  130. package/build/dist/Server/Utils/GlobalSsoAuthorization.js.map +1 -0
  131. package/build/dist/Server/Utils/Marketing/ConversionUploadProvider.js +17 -1
  132. package/build/dist/Server/Utils/Marketing/ConversionUploadProvider.js.map +1 -1
  133. package/build/dist/Server/Utils/Marketing/Providers/GoogleAds.js +1 -1
  134. package/build/dist/Server/Utils/Marketing/Providers/GoogleAds.js.map +1 -1
  135. package/build/dist/Server/Utils/Marketing/Providers/LinkedIn.js +1 -1
  136. package/build/dist/Server/Utils/Marketing/Providers/LinkedIn.js.map +1 -1
  137. package/build/dist/Server/Utils/Marketing/Providers/Meta.js +1 -1
  138. package/build/dist/Server/Utils/Marketing/Providers/Meta.js.map +1 -1
  139. package/build/dist/Server/Utils/Marketing/Providers/MicrosoftAds.js +1 -1
  140. package/build/dist/Server/Utils/Marketing/Providers/MicrosoftAds.js.map +1 -1
  141. package/build/dist/Server/Utils/Marketing/Providers/Reddit.js +1 -1
  142. package/build/dist/Server/Utils/Marketing/Providers/Reddit.js.map +1 -1
  143. package/build/dist/Server/Utils/Monitor/Criteria/APIRequestCriteria.js +37 -35
  144. package/build/dist/Server/Utils/Monitor/Criteria/APIRequestCriteria.js.map +1 -1
  145. package/build/dist/Server/Utils/Monitor/Criteria/DnsMonitorCriteria.js +21 -25
  146. package/build/dist/Server/Utils/Monitor/Criteria/DnsMonitorCriteria.js.map +1 -1
  147. package/build/dist/Server/Utils/Monitor/Criteria/DomainMonitorCriteria.js +20 -22
  148. package/build/dist/Server/Utils/Monitor/Criteria/DomainMonitorCriteria.js.map +1 -1
  149. package/build/dist/Server/Utils/Monitor/Criteria/EvaluateOverTime.js +370 -42
  150. package/build/dist/Server/Utils/Monitor/Criteria/EvaluateOverTime.js.map +1 -1
  151. package/build/dist/Server/Utils/Monitor/Criteria/ExternalStatusPageMonitorCriteria.js +21 -25
  152. package/build/dist/Server/Utils/Monitor/Criteria/ExternalStatusPageMonitorCriteria.js.map +1 -1
  153. package/build/dist/Server/Utils/Monitor/Criteria/IncomingRequestCriteria.js +21 -25
  154. package/build/dist/Server/Utils/Monitor/Criteria/IncomingRequestCriteria.js.map +1 -1
  155. package/build/dist/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.js +21 -25
  156. package/build/dist/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.js.map +1 -1
  157. package/build/dist/Server/Utils/Monitor/Criteria/ServerMonitorCriteria.js +64 -52
  158. package/build/dist/Server/Utils/Monitor/Criteria/ServerMonitorCriteria.js.map +1 -1
  159. package/build/dist/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.js +25 -29
  160. package/build/dist/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.js.map +1 -1
  161. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +8 -0
  162. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  163. package/build/dist/Server/Utils/Monitor/MonitorResource.js +123 -23
  164. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  165. package/build/dist/Server/Utils/StatusPageContentSecurityPolicy.js +39 -0
  166. package/build/dist/Server/Utils/StatusPageContentSecurityPolicy.js.map +1 -0
  167. package/build/dist/Server/Utils/StatusPageCustomizationAccess.js +85 -0
  168. package/build/dist/Server/Utils/StatusPageCustomizationAccess.js.map +1 -0
  169. package/build/dist/Types/Marketing/MarketingConversion.js +10 -0
  170. package/build/dist/Types/Marketing/MarketingConversion.js.map +1 -1
  171. package/build/dist/Types/Monitor/CriteriaFilter.js +1 -0
  172. package/build/dist/Types/Monitor/CriteriaFilter.js.map +1 -1
  173. package/build/dist/Types/NetworkDevice/NetworkDeviceLinkRuleScope.js +46 -0
  174. package/build/dist/Types/NetworkDevice/NetworkDeviceLinkRuleScope.js.map +1 -0
  175. package/build/dist/UI/Components/FormModal/BasicFormModal.js +2 -1
  176. package/build/dist/UI/Components/FormModal/BasicFormModal.js.map +1 -1
  177. package/build/dist/UI/Components/Forms/BasicForm.js +10 -1
  178. package/build/dist/UI/Components/Forms/BasicForm.js.map +1 -1
  179. package/build/dist/UI/Components/Forms/BasicModelForm.js +2 -1
  180. package/build/dist/UI/Components/Forms/BasicModelForm.js.map +1 -1
  181. package/build/dist/UI/Components/Forms/ModelForm.js +4 -1
  182. package/build/dist/UI/Components/Forms/ModelForm.js.map +1 -1
  183. package/build/dist/UI/Components/Forms/Utils/FormAnalyticsName.js +33 -0
  184. package/build/dist/UI/Components/Forms/Utils/FormAnalyticsName.js.map +1 -0
  185. package/build/dist/UI/Components/ModelFormModal/ModelFormModal.js +2 -1
  186. package/build/dist/UI/Components/ModelFormModal/ModelFormModal.js.map +1 -1
  187. package/build/dist/Utils/Analytics.js +50 -2
  188. package/build/dist/Utils/Analytics.js.map +1 -1
  189. package/build/dist/Utils/Monitor/MonitorMetricType.js +37 -0
  190. package/build/dist/Utils/Monitor/MonitorMetricType.js.map +1 -1
  191. package/build/dist/Utils/Monitor/NetworkDeviceLinkRuleUtil.js +318 -37
  192. package/build/dist/Utils/Monitor/NetworkDeviceLinkRuleUtil.js.map +1 -1
  193. package/build/dist/Utils/NetworkDiscovery/UnclaimedScanDiagnosis.js +87 -0
  194. package/build/dist/Utils/NetworkDiscovery/UnclaimedScanDiagnosis.js.map +1 -0
  195. package/package.json +1 -1
@@ -4,18 +4,138 @@ import Team from "../../Models/DatabaseModels/Team";
4
4
  import { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax";
5
5
  import ObjectID from "../../Types/ObjectID";
6
6
  import CreateBy from "../Types/Database/CreateBy";
7
- import { OnCreate, OnUpdate } from "../Types/Database/Hooks";
7
+ import { OnCreate, OnDelete, OnUpdate } from "../Types/Database/Hooks";
8
8
  import UpdateBy from "../Types/Database/UpdateBy";
9
9
  import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
10
10
  import validateGlobalProviderProjectTeams, {
11
11
  resolveAttachmentProjectId,
12
12
  } from "../Utils/ValidateGlobalProviderProjectTeams";
13
+ import {
14
+ GLOBAL_SSO_AUTHORIZATION_CACHE_TTL_MS,
15
+ GlobalProviderAttachments,
16
+ clearGlobalSsoAuthorizationCaches,
17
+ doAttachmentsGovernProject,
18
+ globalProviderCacheKey,
19
+ globalSsoAttachmentsCache,
20
+ loadAttachmentsOnce,
21
+ } from "../Utils/GlobalSsoAuthorization";
22
+ import DeleteBy from "../Types/Database/DeleteBy";
13
23
 
14
24
  export class Service extends DatabaseService<Model> {
15
25
  public constructor() {
16
26
  super(Model);
17
27
  }
18
28
 
29
+ /**
30
+ * Whether this provider's attachments cover `projectId`.
31
+ *
32
+ * Only consulted when the provider has `restrictToAttachedProjects` on -
33
+ * attachments are the PROVISIONING allow-list by default, not an access
34
+ * boundary, and reading them as one without the admin asking would lock
35
+ * existing users out of projects they legitimately reach.
36
+ *
37
+ * "No attachment rows at all" means instance-wide, matching the login
38
+ * router's default-all mode. "Rows exist but none are enabled" is a
39
+ * different answer and denies - otherwise an admin disabling the last
40
+ * attachment would WIDEN the provider to every project.
41
+ *
42
+ * Cached for 60s, with concurrent misses sharing one query.
43
+ */
44
+ @CaptureSpan()
45
+ public async doesProviderGovernProject(data: {
46
+ globalSsoId: ObjectID;
47
+ projectId: ObjectID;
48
+ }): Promise<boolean> {
49
+ const key: string = globalProviderCacheKey("sso", data.globalSsoId);
50
+
51
+ const cached: GlobalProviderAttachments | undefined =
52
+ globalSsoAttachmentsCache.get(key);
53
+
54
+ if (cached !== undefined) {
55
+ return doAttachmentsGovernProject(cached, data.projectId);
56
+ }
57
+
58
+ const attachments: GlobalProviderAttachments = await loadAttachmentsOnce(
59
+ key,
60
+ async (): Promise<GlobalProviderAttachments> => {
61
+ /*
62
+ * Fetched WITHOUT the isEnabled filter so a disabled row still counts
63
+ * as "this provider has attachments"; the enabled ones are selected
64
+ * out below.
65
+ */
66
+ const rows: Array<Model> = await this.findBy({
67
+ query: { globalSsoId: data.globalSsoId },
68
+ select: { _id: true, projectId: true, isEnabled: true },
69
+ limit: LIMIT_PER_PROJECT,
70
+ skip: 0,
71
+ props: { isRoot: true },
72
+ });
73
+
74
+ const loaded: GlobalProviderAttachments = {
75
+ hasAnyAttachmentRows: rows.length > 0,
76
+ enabledProjectIds: rows
77
+ .filter((row: Model) => {
78
+ return Boolean(row.isEnabled) && Boolean(row.projectId);
79
+ })
80
+ .map((row: Model) => {
81
+ return row.projectId!.toString();
82
+ }),
83
+ };
84
+
85
+ globalSsoAttachmentsCache.set(
86
+ key,
87
+ loaded,
88
+ GLOBAL_SSO_AUTHORIZATION_CACHE_TTL_MS,
89
+ );
90
+
91
+ return loaded;
92
+ },
93
+ );
94
+
95
+ return doAttachmentsGovernProject(attachments, data.projectId);
96
+ }
97
+
98
+ @CaptureSpan()
99
+ protected override async onBeforeDelete(
100
+ deleteBy: DeleteBy<Model>,
101
+ ): Promise<OnDelete<Model>> {
102
+ // Detaching a project has to take effect now, not in 60s, on this node.
103
+ clearGlobalSsoAuthorizationCaches();
104
+ return { deleteBy, carryForward: null };
105
+ }
106
+
107
+ /*
108
+ * Cleared again AFTER the write commits. A clear that runs before the row
109
+ * lands can be immediately re-filled with the pre-change answer by a
110
+ * concurrent request, handing the old attachment set another full TTL.
111
+ */
112
+ @CaptureSpan()
113
+ protected override async onDeleteSuccess(
114
+ onDelete: OnDelete<Model>,
115
+ _itemIdsBeforeDelete: Array<ObjectID>,
116
+ ): Promise<OnDelete<Model>> {
117
+ clearGlobalSsoAuthorizationCaches();
118
+ return onDelete;
119
+ }
120
+
121
+ @CaptureSpan()
122
+ protected override async onCreateSuccess(
123
+ _onCreate: OnCreate<Model>,
124
+ createdItem: Model,
125
+ ): Promise<Model> {
126
+ clearGlobalSsoAuthorizationCaches();
127
+ return createdItem;
128
+ }
129
+
130
+ @CaptureSpan()
131
+ protected override async onUpdateSuccess(
132
+ onUpdate: OnUpdate<Model>,
133
+ _updatedItemIds: Array<ObjectID>,
134
+ ): Promise<OnUpdate<Model>> {
135
+ clearGlobalSsoAuthorizationCaches();
136
+ return onUpdate;
137
+ }
138
+
19
139
  @CaptureSpan()
20
140
  protected override async onBeforeCreate(
21
141
  createBy: CreateBy<Model>,
@@ -38,6 +158,9 @@ export class Service extends DatabaseService<Model> {
38
158
  projectId,
39
159
  });
40
160
 
161
+ // A new attachment narrows (or widens) which projects the provider governs.
162
+ clearGlobalSsoAuthorizationCaches();
163
+
41
164
  return { createBy, carryForward: null };
42
165
  }
43
166
 
@@ -78,6 +201,8 @@ export class Service extends DatabaseService<Model> {
78
201
  }
79
202
  }
80
203
 
204
+ clearGlobalSsoAuthorizationCaches();
205
+
81
206
  return { updateBy, carryForward: null };
82
207
  }
83
208
  }
@@ -1,10 +1,142 @@
1
1
  import DatabaseService from "./DatabaseService";
2
2
  import Model from "../../Models/DatabaseModels/GlobalSso";
3
+ import ObjectID from "../../Types/ObjectID";
4
+ import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
5
+ import CreateBy from "../Types/Database/CreateBy";
6
+ import DeleteBy from "../Types/Database/DeleteBy";
7
+ import UpdateBy from "../Types/Database/UpdateBy";
8
+ import { OnCreate, OnDelete, OnUpdate } from "../Types/Database/Hooks";
9
+ import {
10
+ GLOBAL_SSO_AUTHORIZATION_CACHE_TTL_MS,
11
+ GlobalProviderTrust,
12
+ clearGlobalSsoAuthorizationCaches,
13
+ globalProviderCacheKey,
14
+ globalSsoProviderTrustCache,
15
+ loadTrustOnce,
16
+ } from "../Utils/GlobalSsoAuthorization";
3
17
 
4
18
  export class Service extends DatabaseService<Model> {
5
19
  public constructor() {
6
20
  super(Model);
7
21
  }
22
+
23
+ /**
24
+ * What the SSO-enforcement middleware needs to know about this provider:
25
+ * whether it is still usable at all, and whether the admin opted it into
26
+ * attachment-scoped access.
27
+ *
28
+ * Called on every authenticated request against an SSO-enforced project, so
29
+ * the answer is cached for 60s and concurrent misses share one query.
30
+ * Without this lookup, turning a provider off leaves every token it ever
31
+ * issued working until each one expires - up to thirty days.
32
+ */
33
+ @CaptureSpan()
34
+ public async getProviderTrust(
35
+ providerId: ObjectID,
36
+ ): Promise<GlobalProviderTrust> {
37
+ const key: string = globalProviderCacheKey("sso", providerId);
38
+
39
+ const cached: GlobalProviderTrust | undefined =
40
+ globalSsoProviderTrustCache.get(key);
41
+
42
+ if (cached !== undefined) {
43
+ return cached;
44
+ }
45
+
46
+ return loadTrustOnce(key, async (): Promise<GlobalProviderTrust> => {
47
+ const provider: Model | null = await this.findOneBy({
48
+ query: { _id: providerId.toString() },
49
+ select: {
50
+ _id: true,
51
+ isEnabled: true,
52
+ restrictToAttachedProjects: true,
53
+ },
54
+ props: { isRoot: true },
55
+ });
56
+
57
+ /*
58
+ * A deleted provider and a disabled one are the same answer: no. Both
59
+ * are cached, so a revoked provider does not cost a query per request.
60
+ */
61
+ const trust: GlobalProviderTrust = {
62
+ isUsable: Boolean(provider && provider.isEnabled),
63
+ restrictToAttachedProjects: Boolean(
64
+ provider && provider.restrictToAttachedProjects,
65
+ ),
66
+ };
67
+
68
+ globalSsoProviderTrustCache.set(
69
+ key,
70
+ trust,
71
+ GLOBAL_SSO_AUTHORIZATION_CACHE_TTL_MS,
72
+ );
73
+
74
+ return trust;
75
+ });
76
+ }
77
+
78
+ /*
79
+ * Cache invalidation runs in the SUCCESS hooks, not the before-hooks: a
80
+ * clear that happens before the row is written can be immediately re-filled
81
+ * with the pre-change answer by a concurrent request, which would hand a
82
+ * disabled provider another full TTL of life on the very node that served
83
+ * the disable. Clearing in both is deliberate - the before-hook narrows the
84
+ * window, the success hook closes it.
85
+ *
86
+ * These caches are per-process, so the hooks make a change immediate on the
87
+ * node that served it and the TTL bounds every other node.
88
+ */
89
+
90
+ @CaptureSpan()
91
+ protected override async onBeforeUpdate(
92
+ updateBy: UpdateBy<Model>,
93
+ ): Promise<OnUpdate<Model>> {
94
+ clearGlobalSsoAuthorizationCaches();
95
+ return { updateBy, carryForward: null };
96
+ }
97
+
98
+ @CaptureSpan()
99
+ protected override async onUpdateSuccess(
100
+ onUpdate: OnUpdate<Model>,
101
+ _updatedItemIds: Array<ObjectID>,
102
+ ): Promise<OnUpdate<Model>> {
103
+ clearGlobalSsoAuthorizationCaches();
104
+ return onUpdate;
105
+ }
106
+
107
+ @CaptureSpan()
108
+ protected override async onBeforeDelete(
109
+ deleteBy: DeleteBy<Model>,
110
+ ): Promise<OnDelete<Model>> {
111
+ clearGlobalSsoAuthorizationCaches();
112
+ return { deleteBy, carryForward: null };
113
+ }
114
+
115
+ @CaptureSpan()
116
+ protected override async onDeleteSuccess(
117
+ onDelete: OnDelete<Model>,
118
+ _itemIdsBeforeDelete: Array<ObjectID>,
119
+ ): Promise<OnDelete<Model>> {
120
+ clearGlobalSsoAuthorizationCaches();
121
+ return onDelete;
122
+ }
123
+
124
+ @CaptureSpan()
125
+ protected override async onBeforeCreate(
126
+ createBy: CreateBy<Model>,
127
+ ): Promise<OnCreate<Model>> {
128
+ clearGlobalSsoAuthorizationCaches();
129
+ return { createBy, carryForward: null };
130
+ }
131
+
132
+ @CaptureSpan()
133
+ protected override async onCreateSuccess(
134
+ _onCreate: OnCreate<Model>,
135
+ createdItem: Model,
136
+ ): Promise<Model> {
137
+ clearGlobalSsoAuthorizationCaches();
138
+ return createdItem;
139
+ }
8
140
  }
9
141
 
10
142
  export default new Service();
@@ -117,10 +117,10 @@ export class Service extends DatabaseService<Model> {
117
117
  * create response for it to confirm the address the way a user would.
118
118
  *
119
119
  * It is gated on an environment variable that is unset in every shipped
120
- * config and set true ONLY in the CI e2e job (docker-compose passes it
121
- * through, .github/workflows/test-release.yaml sets it for the SaaS run).
122
- * In production the flag is absent, this branch never runs, and the code
123
- * never leaves the mail path.
120
+ * config and set true ONLY in the CI e2e jobs (docker-compose passes it
121
+ * through; both SaaS e2e jobs set it — test-release.yaml for master and
122
+ * release.yml for the release branch). In production the flag is absent,
123
+ * this branch never runs, and the code never leaves the mail path.
124
124
  */
125
125
  if (
126
126
  process.env["EXPOSE_VERIFICATION_CODE_IN_API_RESPONSE_FOR_E2E"] ===
@@ -15,4 +15,77 @@ export default class CronTab {
15
15
  throw new BadDataException(`Invalid cron expression: ${crontab}`);
16
16
  }
17
17
  }
18
+
19
+ /**
20
+ * Longest gap (in seconds) between two consecutive runs of this cron.
21
+ *
22
+ * Used to reason about how much data an evaluation window can possibly
23
+ * contain: a monitor polled once every five minutes produces at most one
24
+ * sample per five minutes, so "all values over the last 5 minutes" can
25
+ * never be backed by more than one sample. Callers use this to decide
26
+ * whether a window is actually covered by data or is merely still
27
+ * filling up.
28
+ *
29
+ * The *longest* gap is deliberate. Irregular expressions (say
30
+ * `0,1,30 * * * *`) have both very short and very long gaps; taking the
31
+ * shortest would make callers demand data at a cadence the cron does not
32
+ * actually deliver, and they would wait forever.
33
+ *
34
+ * Returns null when the expression is missing or unparseable so callers
35
+ * can fall back to inferring the cadence from the data itself.
36
+ */
37
+ @CaptureSpan()
38
+ public static getIntervalInSeconds(
39
+ crontab: string | undefined | null,
40
+ referenceDate?: Date | undefined,
41
+ ): number | null {
42
+ if (!crontab) {
43
+ return null;
44
+ }
45
+
46
+ try {
47
+ const interval: CronExpression = CronParser.parseExpression(crontab, {
48
+ currentDate: referenceDate || new Date(),
49
+ });
50
+
51
+ /*
52
+ * Six executions -> five gaps. Enough to cover a full hour-wrap for
53
+ * every interval offered in the UI without walking the schedule far
54
+ * into the future.
55
+ */
56
+ const executionTimes: Array<Date> = [];
57
+
58
+ for (let i: number = 0; i < 6; i++) {
59
+ executionTimes.push(interval.next().toDate());
60
+ }
61
+
62
+ let longestGapInSeconds: number = 0;
63
+
64
+ for (let i: number = 1; i < executionTimes.length; i++) {
65
+ const gapInSeconds: number =
66
+ (executionTimes[i]!.getTime() - executionTimes[i - 1]!.getTime()) /
67
+ 1000;
68
+
69
+ if (gapInSeconds > longestGapInSeconds) {
70
+ longestGapInSeconds = gapInSeconds;
71
+ }
72
+ }
73
+
74
+ if (longestGapInSeconds <= 0) {
75
+ return null;
76
+ }
77
+
78
+ return longestGapInSeconds;
79
+ } catch (error) {
80
+ /*
81
+ * Debug rather than error: this runs on the criteria-evaluation hot
82
+ * path, and monitors created before the interval was a cron (or
83
+ * through a Terraform example using the "Every 5 minutes" label) would
84
+ * otherwise log on every single check. Callers treat null as "cadence
85
+ * unknown" and fall back to inferring it from the data.
86
+ */
87
+ logger.debug(error);
88
+ return null;
89
+ }
90
+ }
18
91
  }
@@ -0,0 +1,175 @@
1
+ import ObjectID from "../../Types/ObjectID";
2
+ import InMemoryTTLCache from "../Infrastructure/InMemoryTTLCache";
3
+
4
+ /*
5
+ * The stateful half of Global SSO enforcement.
6
+ *
7
+ * A Global SSO/OIDC token is a 30-day, self-contained JWT with no project
8
+ * binding, so on its own it answers only "this person authenticated against
9
+ * SOME instance-wide identity provider at some point in the last month". Two
10
+ * questions it cannot answer:
11
+ *
12
+ * 1. IS THE PROVIDER STILL TRUSTED? Turning a provider off, or deleting it,
13
+ * has to actually cut off access. Without a check at request time the
14
+ * "Enabled" toggle does nothing to anyone already signed in, for up to
15
+ * thirty days. This check is UNCONDITIONAL - "disabled" has only one
16
+ * possible meaning.
17
+ *
18
+ * 2. DOES THE PROVIDER GOVERN THIS PROJECT? Deliberately OPT-IN, per
19
+ * provider, via `restrictToAttachedProjects`. The attachment rows
20
+ * (GlobalSsoProject / GlobalOidcProject) were introduced as the
21
+ * PROVISIONING allow-list - "a SAML assertion can only ever provision a
22
+ * user into projects that a master admin has explicitly attached here",
23
+ * per the model's own docstring - and the login routers gate a session on
24
+ * membership of ANY project, not of an attached one. Reading attachments
25
+ * as an access boundary by default would therefore lock existing users
26
+ * out of projects they legitimately reach today, immediately on upgrade,
27
+ * with no way to recover from inside the product. So it stays off unless
28
+ * an admin asks for it.
29
+ *
30
+ * Both answers come from Postgres and run on every authenticated request
31
+ * against an SSO-enforced project, so both are cached in-process for 60
32
+ * seconds, and concurrent misses share one query.
33
+ */
34
+
35
+ export const GLOBAL_SSO_AUTHORIZATION_CACHE_TTL_MS: number = 60_000;
36
+
37
+ /** What the enforcement path needs to know about a global provider. */
38
+ export interface GlobalProviderTrust {
39
+ // The provider row exists and its "Enabled" toggle is on.
40
+ isUsable: boolean;
41
+ // The admin opted this provider into attachment-scoped access.
42
+ restrictToAttachedProjects: boolean;
43
+ }
44
+
45
+ /*
46
+ * Absence of an entry means "not yet looked up", which is why these are read
47
+ * with an explicit `undefined` check rather than a truthiness test: a cached
48
+ * "this provider is disabled" must not be mistaken for a cache miss and
49
+ * re-queried on every single request.
50
+ */
51
+ export const globalSsoProviderTrustCache: InMemoryTTLCache<GlobalProviderTrust> =
52
+ new InMemoryTTLCache(10_000);
53
+
54
+ /**
55
+ * A provider's attachment rows.
56
+ *
57
+ * `hasAnyAttachmentRows` distinguishes "this provider has no attachments at
58
+ * all" from "it has attachments but every one of them is disabled". Collapsing
59
+ * those two would mean an admin disabling the last attachment WIDENS the
60
+ * provider to every project - the exact opposite of the intent.
61
+ */
62
+ export interface GlobalProviderAttachments {
63
+ hasAnyAttachmentRows: boolean;
64
+ enabledProjectIds: Array<string>;
65
+ }
66
+
67
+ export const globalSsoAttachmentsCache: InMemoryTTLCache<GlobalProviderAttachments> =
68
+ new InMemoryTTLCache(10_000);
69
+
70
+ export type GlobalProviderKind = "sso" | "oidc";
71
+
72
+ /*
73
+ * Namespaced by kind. A Global SSO provider and a Global OIDC provider are
74
+ * different rows in different tables and could in principle share an id;
75
+ * collapsing the keys would let one vouch for the other.
76
+ */
77
+ export function globalProviderCacheKey(
78
+ providerKind: GlobalProviderKind,
79
+ providerId: ObjectID,
80
+ ): string {
81
+ return `${providerKind}:${providerId.toString()}`;
82
+ }
83
+
84
+ /**
85
+ * Decides whether a provider's attachments cover `projectId`, for a provider
86
+ * that has opted into attachment-scoped access.
87
+ *
88
+ * A provider with no attachment rows at all is instance-wide, matching the
89
+ * "default-all" reading the login routers use. A provider that HAS attachment
90
+ * rows governs exactly the enabled ones - so disabling them all denies rather
91
+ * than widens.
92
+ */
93
+ export function doAttachmentsGovernProject(
94
+ attachments: GlobalProviderAttachments,
95
+ projectId: ObjectID,
96
+ ): boolean {
97
+ if (!attachments.hasAnyAttachmentRows) {
98
+ return true;
99
+ }
100
+
101
+ return attachments.enabledProjectIds.includes(projectId.toString());
102
+ }
103
+
104
+ /** Drops every cached answer. Used by the write hooks and by tests. */
105
+ export function clearGlobalSsoAuthorizationCaches(): void {
106
+ globalSsoProviderTrustCache.clear();
107
+ globalSsoAttachmentsCache.clear();
108
+ inFlightTrustLookups.clear();
109
+ inFlightAttachmentLookups.clear();
110
+ }
111
+
112
+ /*
113
+ * In-flight de-duplication.
114
+ *
115
+ * The multi-tenant permission path fans out over every project the user
116
+ * belongs to CONCURRENTLY. Without this, a cold cache means N simultaneous
117
+ * copies of the same two queries - for a user in fifty projects, a hundred
118
+ * queries where two would do. Worse, failures are never cached, so a database
119
+ * that is already struggling gets the full fan-out again on every request.
120
+ *
121
+ * Sharing the promise collapses all of that to one query per provider, and a
122
+ * rejection is shared too rather than being retried N times in the same tick.
123
+ */
124
+ const inFlightTrustLookups: Map<
125
+ string,
126
+ Promise<GlobalProviderTrust>
127
+ > = new Map();
128
+ const inFlightAttachmentLookups: Map<
129
+ string,
130
+ Promise<GlobalProviderAttachments>
131
+ > = new Map();
132
+
133
+ async function loadOnce<T>(
134
+ inFlight: Map<string, Promise<T>>,
135
+ key: string,
136
+ load: () => Promise<T>,
137
+ ): Promise<T> {
138
+ const existing: Promise<T> | undefined = inFlight.get(key);
139
+
140
+ if (existing) {
141
+ return existing;
142
+ }
143
+
144
+ const pending: Promise<T> = load().finally((): void => {
145
+ /*
146
+ * Only clear the slot if it is still OURS. A cache clear between the two
147
+ * (every write to a Global SSO/OIDC service does exactly that, twice) lets
148
+ * a NEWER lookup take the key while this one is still on the wire; a blind
149
+ * delete would evict that newer entry when this one settles, and the fan-
150
+ * out that follows would stop de-duplicating - the one situation this
151
+ * exists for.
152
+ */
153
+ if (inFlight.get(key) === pending) {
154
+ inFlight.delete(key);
155
+ }
156
+ });
157
+
158
+ inFlight.set(key, pending);
159
+
160
+ return pending;
161
+ }
162
+
163
+ export function loadTrustOnce(
164
+ key: string,
165
+ load: () => Promise<GlobalProviderTrust>,
166
+ ): Promise<GlobalProviderTrust> {
167
+ return loadOnce(inFlightTrustLookups, key, load);
168
+ }
169
+
170
+ export function loadAttachmentsOnce(
171
+ key: string,
172
+ load: () => Promise<GlobalProviderAttachments>,
173
+ ): Promise<GlobalProviderAttachments> {
174
+ return loadOnce(inFlightAttachmentLookups, key, load);
175
+ }
@@ -1,6 +1,9 @@
1
1
  import { AxiosError } from "axios";
2
2
  import { JSONObject } from "../../../Types/JSON";
3
- import { MarketingConversionType } from "../../../Types/Marketing/MarketingConversion";
3
+ import {
4
+ AdUploadableMarketingConversionTypes,
5
+ MarketingConversionType,
6
+ } from "../../../Types/Marketing/MarketingConversion";
4
7
  import MarketingConversion from "../../../Models/DatabaseModels/MarketingConversion";
5
8
 
6
9
  /*
@@ -11,9 +14,12 @@ import MarketingConversion from "../../../Models/DatabaseModels/MarketingConvers
11
14
  *
12
15
  * Contract:
13
16
  * - getSkipReason returns a human-readable reason when this conversion can
14
- * never be uploaded to this platform (wrong/missing click id, outside the
15
- * platform's upload window, provider not configured for this conversion
16
- * type). Null means uploadable.
17
+ * never be uploaded to this platform (conversion type with no ad-platform
18
+ * mapping, wrong/missing click id, outside the platform's upload window,
19
+ * provider not configured for this conversion type). Null means uploadable.
20
+ * It is implemented here and must not be overridden — providers implement
21
+ * getProviderSkipReason instead, which only runs for conversion types that
22
+ * are ad-uploadable at all.
17
23
  * - upload() sends one batch. Per-conversion PERMANENT rejections are
18
24
  * returned in the result keyed by array index; they will not be retried.
19
25
  * Transport/auth-level failures must THROW — the whole batch stays pending
@@ -52,7 +58,31 @@ export default abstract class ConversionUploadProvider {
52
58
 
53
59
  public abstract isConfigured(): boolean;
54
60
 
55
- public abstract getSkipReason(
61
+ /*
62
+ * Every provider maps a conversion onto a platform conversion action by
63
+ * branching on isSignUp() — so a conversion type the ad platforms have no
64
+ * mapping for (a booked meeting) would otherwise be silently uploaded as a
65
+ * purchase. Screen those out here, before any provider sees them, so no
66
+ * provider or call site can skip the check.
67
+ */
68
+ public getSkipReason(conversion: MarketingConversion): ConversionSkip | null {
69
+ if (
70
+ !AdUploadableMarketingConversionTypes.includes(
71
+ conversion.conversionType as MarketingConversionType,
72
+ )
73
+ ) {
74
+ return {
75
+ reason: `Conversion type ${
76
+ conversion.conversionType || "unknown"
77
+ } has no ad platform conversion mapping`,
78
+ isPermanent: true,
79
+ };
80
+ }
81
+
82
+ return this.getProviderSkipReason(conversion);
83
+ }
84
+
85
+ protected abstract getProviderSkipReason(
56
86
  conversion: MarketingConversion,
57
87
  ): ConversionSkip | null;
58
88
 
@@ -46,7 +46,7 @@ export default class GoogleAdsProvider extends ConversionUploadProvider {
46
46
  );
47
47
  }
48
48
 
49
- public override getSkipReason(
49
+ protected override getProviderSkipReason(
50
50
  conversion: MarketingConversion,
51
51
  ): ConversionSkip | null {
52
52
  if (!this.getGoogleClickId(conversion)) {
@@ -41,7 +41,7 @@ export default class LinkedInProvider extends ConversionUploadProvider {
41
41
  return Boolean(LinkedInConversionsAccessToken);
42
42
  }
43
43
 
44
- public override getSkipReason(
44
+ protected override getProviderSkipReason(
45
45
  conversion: MarketingConversion,
46
46
  ): ConversionSkip | null {
47
47
  if (!this.getClickId(conversion, "li_fat_id")) {
@@ -32,7 +32,7 @@ export default class MetaProvider extends ConversionUploadProvider {
32
32
  return Boolean(MetaConversionsPixelId && MetaConversionsAccessToken);
33
33
  }
34
34
 
35
- public override getSkipReason(
35
+ protected override getProviderSkipReason(
36
36
  conversion: MarketingConversion,
37
37
  ): ConversionSkip | null {
38
38
  if (!this.getClickId(conversion, "fbclid")) {
@@ -45,7 +45,7 @@ export default class MicrosoftAdsProvider extends ConversionUploadProvider {
45
45
  );
46
46
  }
47
47
 
48
- public override getSkipReason(
48
+ protected override getProviderSkipReason(
49
49
  conversion: MarketingConversion,
50
50
  ): ConversionSkip | null {
51
51
  if (!this.getClickId(conversion, "msclkid")) {
@@ -41,7 +41,7 @@ export default class RedditProvider extends ConversionUploadProvider {
41
41
  );
42
42
  }
43
43
 
44
- public override getSkipReason(
44
+ protected override getProviderSkipReason(
45
45
  conversion: MarketingConversion,
46
46
  ): ConversionSkip | null {
47
47
  if (!this.getClickId(conversion, "rdt_cid")) {