@oneuptime/common 12.0.29 → 12.0.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Models/DatabaseModels/Index.ts +11 -0
- package/Models/DatabaseModels/OnCallDutyPolicySchedule.ts +43 -0
- package/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.ts +662 -0
- package/Models/DatabaseModels/ProjectOnCallCalendarFeed.ts +584 -0
- package/Models/DatabaseModels/UserOnCallCalendarFeed.ts +546 -0
- package/Models/DatabaseModels/UserOnCallShiftReminder.ts +231 -0
- package/Models/DatabaseModels/UserOnCallShiftReminderLog.ts +331 -0
- package/Server/API/OnCallCalendarAPI.ts +2194 -0
- package/Server/EnvironmentConfig.ts +138 -0
- package/Server/Infrastructure/OnCallCalendarFeedCache.ts +1050 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.ts +253 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
- package/Server/Middleware/OnCallCalendarFeedRateLimit.ts +487 -0
- package/Server/Services/Index.ts +12 -0
- package/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.ts +32 -0
- package/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.ts +260 -0
- package/Server/Services/OnCallDutyPolicyScheduleLayerService.ts +70 -3
- package/Server/Services/OnCallDutyPolicyScheduleLayerUserService.ts +138 -3
- package/Server/Services/OnCallDutyPolicyScheduleService.ts +1465 -134
- package/Server/Services/OnCallDutyPolicyUserOverrideService.ts +167 -31
- package/Server/Services/ProjectOnCallCalendarFeedService.ts +192 -0
- package/Server/Services/TeamMemberService.ts +489 -1
- package/Server/Services/TeamService.ts +50 -0
- package/Server/Services/UserNotificationSettingService.ts +44 -1
- package/Server/Services/UserOnCallCalendarFeedService.ts +159 -0
- package/Server/Services/UserOnCallShiftReminderLogService.ts +151 -0
- package/Server/Services/UserOnCallShiftReminderService.ts +112 -0
- package/Server/Utils/OnCall/CalendarFeedToken.ts +241 -0
- package/Server/Utils/OnCall/OnCallCalendarFeedRenderer.ts +1488 -0
- package/Server/Utils/OnCall/OnCallCalendarFeedUrls.ts +230 -0
- package/Server/Utils/OnCall/OnCallShiftChangeListeners.ts +227 -0
- package/Server/Utils/OnCall/OnCallShiftMaterializer.ts +1127 -0
- package/Server/Utils/OnCall/OnCallShiftReminderListener.ts +174 -0
- package/Server/Utils/OnCall/OnCallShiftReminderRunner.ts +2584 -0
- package/Server/Utils/Response.ts +134 -0
- package/Server/Utils/StartServer.ts +10 -0
- package/Server/Utils/Telemetry/AppMetrics.ts +51 -0
- package/Tests/App/Dashboard/OnCallCalendarFeedLinks.test.tsx +229 -0
- package/Tests/App/Dashboard/OnCallCalendarFeedPlanGate.test.tsx +489 -0
- package/Tests/App/Dashboard/OnCallCalendarFeedSideMenus.test.tsx +149 -0
- package/Tests/App/Dashboard/OnCallCalendarFeedUtil.test.ts +855 -0
- package/Tests/App/Dashboard/OnCallPersonalCalendarFeed.test.tsx +1209 -0
- package/Tests/App/Dashboard/OnCallSharedCalendarFeedCard.test.tsx +1019 -0
- package/Tests/App/Dashboard/OnCallShiftRemindersAndUpcomingShifts.test.tsx +655 -0
- package/Tests/App/Dashboard/UserSettingsSetupChecklistModel.test.ts +73 -0
- package/Tests/Models/OnCallCalendarFeedModels.test.ts +642 -0
- package/Tests/Models/OnCallDutyPolicyScheduleCalendarFeed.test.ts +288 -0
- package/Tests/Models/OnCallDutyPolicyScheduleShiftConfigVersion.test.ts +88 -0
- package/Tests/Models/UserOnCallShiftReminderModels.test.ts +368 -0
- package/Tests/Server/API/Helpers.ts +8 -0
- package/Tests/Server/API/OnCallCalendarAPI.test.ts +4461 -0
- package/Tests/Server/EnvironmentConfigOnCallCalendarFeed.test.ts +357 -0
- package/Tests/Server/Infrastructure/OnCallCalendarFeedCache.test.ts +1589 -0
- package/Tests/Server/Infrastructure/Postgres/AddOnCallCalendarFeedsMigration.test.ts +369 -0
- package/Tests/Server/Middleware/OnCallCalendarFeedRateLimit.test.ts +1365 -0
- package/Tests/Server/Services/FeedRetentionConsistency.test.ts +70 -0
- package/Tests/Server/Services/OnCallCalendarFeedServices.test.ts +1103 -0
- package/Tests/Server/Services/OnCallDutyPolicyScheduleResolver.test.ts +1037 -0
- package/Tests/Server/Services/OnCallShiftConfigPropagation.test.ts +1270 -0
- package/Tests/Server/Services/TeamDeleteMemberCleanup.test.ts +156 -0
- package/Tests/Server/Services/TeamMemberOnCallCleanup.test.ts +755 -0
- package/Tests/Server/Services/UserNotificationSettingShiftReminderDefaults.test.ts +267 -0
- package/Tests/Server/Services/UserOnCallShiftReminderServices.test.ts +583 -0
- package/Tests/Server/Types/Database/Permissions/OnCallCalendarFeedPermissions.test.ts +254 -0
- package/Tests/Server/Utils/OnCall/CalendarFeedToken.test.ts +467 -0
- package/Tests/Server/Utils/OnCall/OnCallCalendarFeedRenderer.test.ts +2142 -0
- package/Tests/Server/Utils/OnCall/OnCallCalendarFeedUrls.test.ts +278 -0
- package/Tests/Server/Utils/OnCall/OnCallResolverTestHarness.ts +440 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftChangeListeners.test.ts +314 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftMaterializer.test.ts +1502 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftReminderListener.test.ts +491 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftReminderRunner.test.ts +3088 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftReminderTestHarness.ts +910 -0
- package/Tests/Server/Utils/ResponseSendCalendarResponse.test.ts +561 -0
- package/Tests/Server/Utils/StartServerEncryptionSecretWarning.test.ts +64 -0
- package/Tests/Server/Utils/Telemetry/AppMetrics.test.ts +29 -0
- package/Tests/Types/Calendar/ICalendar.test.ts +488 -0
- package/Tests/Types/NotificationSetting/NotificationSettingEventType.test.ts +53 -0
- package/Tests/Types/OnCallDutyPolicy/CalendarFeedTestFixtures.ts +310 -0
- package/Tests/Types/OnCallDutyPolicy/CalendarFeedWindow.test.ts +205 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilLayerMetaAndIterationCap.test.ts +458 -0
- package/Tests/Types/OnCallDutyPolicy/MaterializedShift.test.ts +197 -0
- package/Tests/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.test.ts +1986 -0
- package/Tests/Types/OnCallDutyPolicy/ScheduleShiftUtilGroupKey.test.ts +678 -0
- package/Tests/Types/OnCallDutyPolicy/ShiftSeamUtil.test.ts +417 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +10 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +2 -0
- package/Types/Calendar/ICalendar.ts +442 -0
- package/Types/Email/EmailTemplateType.ts +2 -0
- package/Types/NotificationSetting/NotificationSettingEventType.ts +4 -0
- package/Types/OnCallDutyPolicy/CalendarFeedWindow.ts +140 -0
- package/Types/OnCallDutyPolicy/Layer.ts +190 -35
- package/Types/OnCallDutyPolicy/MaterializedShift.ts +274 -0
- package/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.ts +1415 -0
- package/Types/OnCallDutyPolicy/ScheduleShiftUtil.ts +159 -4
- package/Types/OnCallDutyPolicy/ShiftSeamUtil.ts +106 -0
- package/build/dist/Models/DatabaseModels/Index.js +11 -0
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js +44 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js.map +1 -1
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js +699 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js.map +1 -0
- package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js +620 -0
- package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js.map +1 -0
- package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js +585 -0
- package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js.map +1 -0
- package/build/dist/Models/DatabaseModels/UserOnCallShiftReminder.js +247 -0
- package/build/dist/Models/DatabaseModels/UserOnCallShiftReminder.js.map +1 -0
- package/build/dist/Models/DatabaseModels/UserOnCallShiftReminderLog.js +355 -0
- package/build/dist/Models/DatabaseModels/UserOnCallShiftReminderLog.js.map +1 -0
- package/build/dist/Server/API/OnCallCalendarAPI.js +1371 -0
- package/build/dist/Server/API/OnCallCalendarAPI.js.map +1 -0
- package/build/dist/Server/EnvironmentConfig.js +102 -0
- package/build/dist/Server/EnvironmentConfig.js.map +1 -1
- package/build/dist/Server/Infrastructure/OnCallCalendarFeedCache.js +663 -0
- package/build/dist/Server/Infrastructure/OnCallCalendarFeedCache.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.js +103 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Middleware/OnCallCalendarFeedRateLimit.js +342 -0
- package/build/dist/Server/Middleware/OnCallCalendarFeedRateLimit.js.map +1 -0
- package/build/dist/Server/Services/Index.js +12 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js +20 -0
- package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.js +219 -0
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.js.map +1 -0
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js +47 -3
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js +112 -3
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js +955 -9
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js +93 -6
- package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js.map +1 -1
- package/build/dist/Server/Services/ProjectOnCallCalendarFeedService.js +157 -0
- package/build/dist/Server/Services/ProjectOnCallCalendarFeedService.js.map +1 -0
- package/build/dist/Server/Services/TeamMemberService.js +396 -1
- package/build/dist/Server/Services/TeamMemberService.js.map +1 -1
- package/build/dist/Server/Services/TeamService.js +43 -0
- package/build/dist/Server/Services/TeamService.js.map +1 -1
- package/build/dist/Server/Services/UserNotificationSettingService.js +26 -1
- package/build/dist/Server/Services/UserNotificationSettingService.js.map +1 -1
- package/build/dist/Server/Services/UserOnCallCalendarFeedService.js +140 -0
- package/build/dist/Server/Services/UserOnCallCalendarFeedService.js.map +1 -0
- package/build/dist/Server/Services/UserOnCallShiftReminderLogService.js +116 -0
- package/build/dist/Server/Services/UserOnCallShiftReminderLogService.js.map +1 -0
- package/build/dist/Server/Services/UserOnCallShiftReminderService.js +92 -0
- package/build/dist/Server/Services/UserOnCallShiftReminderService.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/CalendarFeedToken.js +161 -0
- package/build/dist/Server/Utils/OnCall/CalendarFeedToken.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedRenderer.js +983 -0
- package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedRenderer.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedUrls.js +155 -0
- package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedUrls.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftChangeListeners.js +145 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftChangeListeners.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftMaterializer.js +752 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftMaterializer.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftReminderListener.js +92 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftReminderListener.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftReminderRunner.js +1786 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftReminderRunner.js.map +1 -0
- package/build/dist/Server/Utils/Response.js +97 -0
- package/build/dist/Server/Utils/Response.js.map +1 -1
- package/build/dist/Server/Utils/StartServer.js +9 -1
- package/build/dist/Server/Utils/StartServer.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/AppMetrics.js +41 -0
- package/build/dist/Server/Utils/Telemetry/AppMetrics.js.map +1 -1
- package/build/dist/Types/Calendar/ICalendar.js +290 -0
- package/build/dist/Types/Calendar/ICalendar.js.map +1 -0
- package/build/dist/Types/Email/EmailTemplateType.js +2 -0
- package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
- package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js +3 -0
- package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js.map +1 -1
- package/build/dist/Types/OnCallDutyPolicy/CalendarFeedWindow.js +97 -0
- package/build/dist/Types/OnCallDutyPolicy/CalendarFeedWindow.js.map +1 -0
- package/build/dist/Types/OnCallDutyPolicy/Layer.js +113 -19
- package/build/dist/Types/OnCallDutyPolicy/Layer.js.map +1 -1
- package/build/dist/Types/OnCallDutyPolicy/MaterializedShift.js +134 -0
- package/build/dist/Types/OnCallDutyPolicy/MaterializedShift.js.map +1 -0
- package/build/dist/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.js +800 -0
- package/build/dist/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.js.map +1 -0
- package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js +97 -3
- package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js.map +1 -1
- package/build/dist/Types/OnCallDutyPolicy/ShiftSeamUtil.js +77 -0
- package/build/dist/Types/OnCallDutyPolicy/ShiftSeamUtil.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
import Redis, { ClientType } from "../Infrastructure/Redis";
|
|
2
|
+
import {
|
|
3
|
+
ExpressRequest,
|
|
4
|
+
ExpressResponse,
|
|
5
|
+
NextFunction,
|
|
6
|
+
OneUptimeRequest,
|
|
7
|
+
} from "../Utils/Express";
|
|
8
|
+
import logger, { getLogAttributesFromRequest } from "../Utils/Logger";
|
|
9
|
+
import resolveTrustedClientIp, { ClientIpRequestLike } from "../Utils/ClientIp";
|
|
10
|
+
import Response from "../Utils/Response";
|
|
11
|
+
import {
|
|
12
|
+
OnCallCalendarFeedRateLimitPerIpPerWindow,
|
|
13
|
+
OnCallCalendarFeedRateLimitPerTokenPerWindow,
|
|
14
|
+
OnCallCalendarFeedRateLimitWindowSeconds,
|
|
15
|
+
} from "../EnvironmentConfig";
|
|
16
|
+
import TooManyRequestsException from "../../Types/Exception/TooManyRequestsException";
|
|
17
|
+
import { createHash } from "crypto";
|
|
18
|
+
|
|
19
|
+
/*
|
|
20
|
+
* Rate limiting for the public on-call calendar feed routes:
|
|
21
|
+
*
|
|
22
|
+
* GET /api/on-call-calendar/user/:token/shifts.ics
|
|
23
|
+
* GET /api/on-call-calendar/schedule/:token/schedule.ics
|
|
24
|
+
* GET /api/on-call-calendar/project/:token/project.ics
|
|
25
|
+
*
|
|
26
|
+
* These are polled by calendar clients (Google, Outlook, Apple) with no session
|
|
27
|
+
* and no API key -- the token in the path is the whole credential -- so there
|
|
28
|
+
* is nothing else in front of them that bounds request volume. Rendering a
|
|
29
|
+
* feed expands every layer of every candidate schedule across up to 180 days,
|
|
30
|
+
* which is exactly the kind of work an unbounded anonymous route must not be
|
|
31
|
+
* allowed to drive in a loop.
|
|
32
|
+
*
|
|
33
|
+
* A sibling of PublicDashboardRateLimit rather than a reuse of it: that class
|
|
34
|
+
* derives its key from dashboard ids and domains, and the one thing that must
|
|
35
|
+
* be different here is that the key is derived from a SECRET. It is never used
|
|
36
|
+
* raw -- not in a Redis key, not in a log line -- see resolveTokenKey.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/*
|
|
40
|
+
* Every request consumes TWO counters, and either one can reject it:
|
|
41
|
+
*
|
|
42
|
+
* - the token counter, keyed on a hash of the token + client address. This is
|
|
43
|
+
* the budget one subscribed calendar gets. Default 60 per minute: Apple
|
|
44
|
+
* Calendar's most eager setting is one poll every five minutes, so a real
|
|
45
|
+
* client never comes near it, and a whole office of clients behind one NAT
|
|
46
|
+
* all polling the same shared team link still has headroom.
|
|
47
|
+
*
|
|
48
|
+
* - the address counter, keyed on client address alone. Without it the token
|
|
49
|
+
* counter is trivially bypassed by rotating tokens: every guess lands in a
|
|
50
|
+
* fresh bucket and still costs a Postgres lookup even though it is a 404.
|
|
51
|
+
* Default 3000 per minute is the ceiling that survives rotation.
|
|
52
|
+
*
|
|
53
|
+
* Both are incremented on every request including a rejected one, so a client
|
|
54
|
+
* that keeps hammering keeps its window pinned rather than being handed a
|
|
55
|
+
* fresh allowance for free.
|
|
56
|
+
*/
|
|
57
|
+
export enum OnCallCalendarFeedRateLimitScope {
|
|
58
|
+
Token = "token",
|
|
59
|
+
Ip = "ip",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export enum OnCallCalendarFeedRateLimitOutcome {
|
|
63
|
+
Allowed = "allowed",
|
|
64
|
+
RateLimited = "rate-limited",
|
|
65
|
+
|
|
66
|
+
/* Redis is unreachable, so no limit can be honoured either way. */
|
|
67
|
+
CounterUnavailable = "counter-unavailable",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface OnCallCalendarFeedRateLimitDecision {
|
|
71
|
+
outcome: OnCallCalendarFeedRateLimitOutcome;
|
|
72
|
+
retryAfterSeconds?: number | undefined;
|
|
73
|
+
|
|
74
|
+
/* Which of the two counters rejected, for logs. Unset unless rejected. */
|
|
75
|
+
scope?: OnCallCalendarFeedRateLimitScope | undefined;
|
|
76
|
+
|
|
77
|
+
/*
|
|
78
|
+
* True only on the request that first crossed the line in this window, so
|
|
79
|
+
* the middleware logs one line per key per window rather than turning a
|
|
80
|
+
* flood into a second flood in the log pipeline.
|
|
81
|
+
*/
|
|
82
|
+
isFirstRejectionInWindow?: boolean | undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface OnCallCalendarFeedRateLimitConfig {
|
|
86
|
+
windowSeconds: number;
|
|
87
|
+
perTokenLimit: number;
|
|
88
|
+
perIpLimit: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/*
|
|
92
|
+
* The token shape the feed routes accept: 32 random bytes as base64url, which
|
|
93
|
+
* is always exactly 43 characters. Anything else is not a token that could
|
|
94
|
+
* exist and shares one small "invalid" bucket (see resolveTokenKey).
|
|
95
|
+
*/
|
|
96
|
+
export const ON_CALL_CALENDAR_FEED_TOKEN_PATTERN: RegExp =
|
|
97
|
+
/^[A-Za-z0-9_-]{43}$/;
|
|
98
|
+
|
|
99
|
+
const KEY_PREFIX: string = "oncal:rl:";
|
|
100
|
+
|
|
101
|
+
/*
|
|
102
|
+
* Counter keys outlive their window by one full window, so a request landing
|
|
103
|
+
* on a boundary cannot read a key that was reclaimed mid-window.
|
|
104
|
+
*/
|
|
105
|
+
const TTL_MULTIPLIER: number = 2;
|
|
106
|
+
|
|
107
|
+
/* Bounds a Redis key built from attacker-supplied path segments. */
|
|
108
|
+
const MAX_KEY_SEGMENT_LENGTH: number = 64;
|
|
109
|
+
|
|
110
|
+
/*
|
|
111
|
+
* How much of the token hash goes into the Redis key. 16 hex characters is 64
|
|
112
|
+
* bits -- far more than enough to keep distinct tokens in distinct buckets --
|
|
113
|
+
* and short enough that a KEYS/SCAN listing stays readable.
|
|
114
|
+
*/
|
|
115
|
+
const TOKEN_KEY_HASH_LENGTH: number = 16;
|
|
116
|
+
|
|
117
|
+
/* How often the "counter unavailable" condition may be logged. */
|
|
118
|
+
const COUNTER_UNAVAILABLE_LOG_INTERVAL_MS: number = 60 * 1000;
|
|
119
|
+
|
|
120
|
+
export default class OnCallCalendarFeedRateLimit {
|
|
121
|
+
private static readonly config: OnCallCalendarFeedRateLimitConfig = {
|
|
122
|
+
windowSeconds: OnCallCalendarFeedRateLimitWindowSeconds,
|
|
123
|
+
perTokenLimit: OnCallCalendarFeedRateLimitPerTokenPerWindow,
|
|
124
|
+
perIpLimit: OnCallCalendarFeedRateLimitPerIpPerWindow,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
private static counterUnavailableLastLoggedAt: number = 0;
|
|
128
|
+
|
|
129
|
+
/* The limits in force, for tests and for operators' diagnostics. */
|
|
130
|
+
public static getConfig(): OnCallCalendarFeedRateLimitConfig {
|
|
131
|
+
return { ...OnCallCalendarFeedRateLimit.config };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/*
|
|
135
|
+
* The client address to bill this request to.
|
|
136
|
+
*
|
|
137
|
+
* Delegates to the shared ClientIp helper, which reads X-Forwarded-For from
|
|
138
|
+
* the trusted (right-hand) end under the instance-wide TRUSTED_PROXY_HOPS
|
|
139
|
+
* setting. Deliberately NOT Express.getClientIp: that takes the LEFTMOST
|
|
140
|
+
* entry, which any caller can set by sending its own X-Forwarded-For header,
|
|
141
|
+
* and for a rate limiter that is fatal -- a fresh spoofed value per request
|
|
142
|
+
* means a fresh bucket per request and no limit at all.
|
|
143
|
+
*/
|
|
144
|
+
public static resolveClientIp(req: ExpressRequest): string {
|
|
145
|
+
const clientIp: string | undefined = resolveTrustedClientIp(
|
|
146
|
+
req as unknown as ClientIpRequestLike,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
if (clientIp) {
|
|
150
|
+
return OnCallCalendarFeedRateLimit.sanitizeKeySegment(clientIp);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/*
|
|
154
|
+
* No address at all. Everything in this state shares one bucket, which is
|
|
155
|
+
* the conservative direction -- an unidentifiable caller should not get its
|
|
156
|
+
* own private allowance.
|
|
157
|
+
*/
|
|
158
|
+
return "unknown";
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/*
|
|
162
|
+
* The token this request presents, as a key segment that is NOT the token.
|
|
163
|
+
*
|
|
164
|
+
* The token is a bearer credential. Redis keys show up in KEYS/SCAN output,
|
|
165
|
+
* in monitoring dashboards and in Redis's own slow log, so a raw token in a
|
|
166
|
+
* key is a token written down somewhere it was never meant to be. The key
|
|
167
|
+
* therefore carries a truncated SHA-256 of it: enough bits to keep distinct
|
|
168
|
+
* tokens apart, no way back to the token.
|
|
169
|
+
*
|
|
170
|
+
* The hash is unkeyed on purpose. This is a rate-limit bucket name, not the
|
|
171
|
+
* stored lookup hash the feed row is found by (that one is a full SHA-256 and
|
|
172
|
+
* lives in Postgres); 64 bits of a preimage-resistant hash of 256 bits of
|
|
173
|
+
* randomness is not a useful oracle for anything.
|
|
174
|
+
*
|
|
175
|
+
* Anything that is not shaped like a token collapses into one shared
|
|
176
|
+
* "invalid" bucket. It cannot correspond to a real feed, so a small shared
|
|
177
|
+
* allowance between all such requests is the right grouping, and it bounds
|
|
178
|
+
* Redis memory against a caller feeding junk path segments.
|
|
179
|
+
*/
|
|
180
|
+
public static resolveTokenKey(req: ExpressRequest): string {
|
|
181
|
+
const raw: unknown = req.params?.["token"];
|
|
182
|
+
|
|
183
|
+
if (typeof raw !== "string") {
|
|
184
|
+
return "none";
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const value: string = raw.trim();
|
|
188
|
+
|
|
189
|
+
if (!value) {
|
|
190
|
+
return "none";
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (!ON_CALL_CALENDAR_FEED_TOKEN_PATTERN.test(value)) {
|
|
194
|
+
return "invalid";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return OnCallCalendarFeedRateLimit.hashToken(value);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/*
|
|
201
|
+
* Bucket segment for a token the caller already validated. Exposed so a
|
|
202
|
+
* route that has the token in hand (or a test) can build the same key the
|
|
203
|
+
* middleware does.
|
|
204
|
+
*/
|
|
205
|
+
public static hashToken(token: string): string {
|
|
206
|
+
return `t:${createHash("sha256")
|
|
207
|
+
.update(token, "utf8")
|
|
208
|
+
.digest("hex")
|
|
209
|
+
.slice(0, TOKEN_KEY_HASH_LENGTH)}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private static sanitizeKeySegment(value: string): string {
|
|
213
|
+
return (
|
|
214
|
+
value
|
|
215
|
+
.slice(0, MAX_KEY_SEGMENT_LENGTH)
|
|
216
|
+
/*
|
|
217
|
+
* Redis keys are binary safe, but a predictable charset keeps
|
|
218
|
+
* operational tooling (KEYS/SCAN patterns, dashboards) sane.
|
|
219
|
+
*/
|
|
220
|
+
.replace(/[^a-zA-Z0-9._:%\-[\]]/g, "_")
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/*
|
|
225
|
+
* Increment both counters for this request and decide.
|
|
226
|
+
*
|
|
227
|
+
* Both INCRs go out in one pipeline so the common path costs a single round
|
|
228
|
+
* trip; the EXPIRE follow-ups only happen on the request that created a key.
|
|
229
|
+
*/
|
|
230
|
+
public static async consume(data: {
|
|
231
|
+
tokenKey: string;
|
|
232
|
+
clientIp: string;
|
|
233
|
+
}): Promise<OnCallCalendarFeedRateLimitDecision> {
|
|
234
|
+
const client: ClientType | null = Redis.getClient();
|
|
235
|
+
|
|
236
|
+
if (!client || !Redis.isConnected()) {
|
|
237
|
+
return {
|
|
238
|
+
outcome: OnCallCalendarFeedRateLimitOutcome.CounterUnavailable,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const config: OnCallCalendarFeedRateLimitConfig =
|
|
243
|
+
OnCallCalendarFeedRateLimit.config;
|
|
244
|
+
|
|
245
|
+
const windowMs: number = config.windowSeconds * 1000;
|
|
246
|
+
const windowIndex: number = Math.floor(Date.now() / windowMs);
|
|
247
|
+
|
|
248
|
+
const tokenCounterKey: string = `${KEY_PREFIX}t:${data.tokenKey}:${data.clientIp}:${windowIndex}`;
|
|
249
|
+
const ipCounterKey: string = `${KEY_PREFIX}i:${data.clientIp}:${windowIndex}`;
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
const pipelineResults: Array<[Error | null, unknown]> | null =
|
|
253
|
+
(await client
|
|
254
|
+
.pipeline()
|
|
255
|
+
.incr(tokenCounterKey)
|
|
256
|
+
.incr(ipCounterKey)
|
|
257
|
+
.exec()) as Array<[Error | null, unknown]> | null;
|
|
258
|
+
|
|
259
|
+
if (!pipelineResults || pipelineResults.length < 2) {
|
|
260
|
+
throw new Error("Rate limit pipeline returned no result");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const tokenCount: number = OnCallCalendarFeedRateLimit.readCounterResult(
|
|
264
|
+
pipelineResults[0],
|
|
265
|
+
);
|
|
266
|
+
const ipCount: number = OnCallCalendarFeedRateLimit.readCounterResult(
|
|
267
|
+
pipelineResults[1],
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
/*
|
|
271
|
+
* Set the expiry only on the write that created the key. Re-issuing
|
|
272
|
+
* EXPIRE on every increment would slide the window forward for as long
|
|
273
|
+
* as the load continues, so the counter would never reset and a client
|
|
274
|
+
* that tripped the limit once could never recover.
|
|
275
|
+
*/
|
|
276
|
+
const ttlSeconds: number = config.windowSeconds * TTL_MULTIPLIER;
|
|
277
|
+
const keysToExpire: Array<string> = [];
|
|
278
|
+
|
|
279
|
+
if (tokenCount === 1) {
|
|
280
|
+
keysToExpire.push(tokenCounterKey);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (ipCount === 1) {
|
|
284
|
+
keysToExpire.push(ipCounterKey);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (keysToExpire.length > 0) {
|
|
288
|
+
const expirePipeline: ReturnType<ClientType["pipeline"]> =
|
|
289
|
+
client.pipeline();
|
|
290
|
+
|
|
291
|
+
for (const key of keysToExpire) {
|
|
292
|
+
expirePipeline.expire(key, ttlSeconds);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
await expirePipeline.exec();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const retryAfterSeconds: number =
|
|
299
|
+
OnCallCalendarFeedRateLimit.getSecondsUntilWindowEnd(
|
|
300
|
+
config.windowSeconds,
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
/*
|
|
304
|
+
* Token counter is reported first when both are over: it is the more
|
|
305
|
+
* specific of the two and the more useful thing to see in a log line.
|
|
306
|
+
*/
|
|
307
|
+
if (tokenCount > config.perTokenLimit) {
|
|
308
|
+
return {
|
|
309
|
+
outcome: OnCallCalendarFeedRateLimitOutcome.RateLimited,
|
|
310
|
+
retryAfterSeconds,
|
|
311
|
+
scope: OnCallCalendarFeedRateLimitScope.Token,
|
|
312
|
+
isFirstRejectionInWindow: tokenCount === config.perTokenLimit + 1,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (ipCount > config.perIpLimit) {
|
|
317
|
+
return {
|
|
318
|
+
outcome: OnCallCalendarFeedRateLimitOutcome.RateLimited,
|
|
319
|
+
retryAfterSeconds,
|
|
320
|
+
scope: OnCallCalendarFeedRateLimitScope.Ip,
|
|
321
|
+
isFirstRejectionInWindow: ipCount === config.perIpLimit + 1,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return { outcome: OnCallCalendarFeedRateLimitOutcome.Allowed };
|
|
326
|
+
} catch (err) {
|
|
327
|
+
/*
|
|
328
|
+
* Throttled: whatever broke Redis is unlikely to break it for one
|
|
329
|
+
* request only, and a per-request log line buries the incident it is
|
|
330
|
+
* reporting. The log names neither the token nor its hash.
|
|
331
|
+
*/
|
|
332
|
+
if (OnCallCalendarFeedRateLimit.shouldLogCounterUnavailable()) {
|
|
333
|
+
logger.warn(
|
|
334
|
+
`OnCallCalendarFeedRateLimit: counter failed for a request from ${data.clientIp}`,
|
|
335
|
+
);
|
|
336
|
+
logger.warn(err);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
outcome: OnCallCalendarFeedRateLimitOutcome.CounterUnavailable,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private static readCounterResult(
|
|
346
|
+
result: [Error | null, unknown] | undefined,
|
|
347
|
+
): number {
|
|
348
|
+
if (!result) {
|
|
349
|
+
throw new Error("Rate limit counter returned no result");
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const [error, value] = result;
|
|
353
|
+
|
|
354
|
+
if (error) {
|
|
355
|
+
throw error;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (typeof value !== "number") {
|
|
359
|
+
throw new Error("Rate limit counter returned a non-numeric value");
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return value;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/*
|
|
366
|
+
* Remaining seconds in the current fixed window, so every rejected caller
|
|
367
|
+
* is told to come back when the window actually rolls rather than all
|
|
368
|
+
* backing off by the same constant and re-colliding.
|
|
369
|
+
*/
|
|
370
|
+
private static getSecondsUntilWindowEnd(windowSeconds: number): number {
|
|
371
|
+
const windowMs: number = windowSeconds * 1000;
|
|
372
|
+
const msIntoWindow: number = Date.now() % windowMs;
|
|
373
|
+
|
|
374
|
+
return Math.max(1, Math.ceil((windowMs - msIntoWindow) / 1000));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/*
|
|
378
|
+
* Express middleware. Mounted on each feed route (it reads `:token` from
|
|
379
|
+
* req.params, so it must sit on the route, not on the router) ahead of the
|
|
380
|
+
* token lookup, so a flood is refused before it costs a Postgres read, let
|
|
381
|
+
* alone a render.
|
|
382
|
+
*
|
|
383
|
+
* FAILS OPEN. When Redis is unreachable every request is let through and
|
|
384
|
+
* the condition is logged (throttled). The limiter is load control: the
|
|
385
|
+
* token is still checked by the route behind it, and blacking out every
|
|
386
|
+
* subscribed calendar over a Redis blip -- clients that get a 503 keep the
|
|
387
|
+
* copy they have, but one that gets it repeatedly may drop the calendar --
|
|
388
|
+
* is a worse outcome than an unbounded window for the duration of the blip.
|
|
389
|
+
*/
|
|
390
|
+
public static getMiddleware(): (
|
|
391
|
+
req: ExpressRequest,
|
|
392
|
+
res: ExpressResponse,
|
|
393
|
+
next: NextFunction,
|
|
394
|
+
) => Promise<void> {
|
|
395
|
+
return async (
|
|
396
|
+
req: ExpressRequest,
|
|
397
|
+
res: ExpressResponse,
|
|
398
|
+
next: NextFunction,
|
|
399
|
+
): Promise<void> => {
|
|
400
|
+
const tokenKey: string = OnCallCalendarFeedRateLimit.resolveTokenKey(req);
|
|
401
|
+
const clientIp: string = OnCallCalendarFeedRateLimit.resolveClientIp(req);
|
|
402
|
+
|
|
403
|
+
const decision: OnCallCalendarFeedRateLimitDecision =
|
|
404
|
+
await OnCallCalendarFeedRateLimit.consume({ tokenKey, clientIp });
|
|
405
|
+
|
|
406
|
+
if (decision.outcome === OnCallCalendarFeedRateLimitOutcome.RateLimited) {
|
|
407
|
+
if (decision.retryAfterSeconds) {
|
|
408
|
+
OnCallCalendarFeedRateLimit.setRetryAfterHeader(
|
|
409
|
+
res,
|
|
410
|
+
decision.retryAfterSeconds,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/*
|
|
415
|
+
* The crossing request only, and never the token or its hash: an
|
|
416
|
+
* operator needs to know an address is hammering the feeds, not which
|
|
417
|
+
* feed. The route's own attributes (requestId, no token) are enough to
|
|
418
|
+
* correlate.
|
|
419
|
+
*/
|
|
420
|
+
if (decision.isFirstRejectionInWindow) {
|
|
421
|
+
logger.warn(
|
|
422
|
+
`OnCallCalendarFeedRateLimit: rejected calendar feed request from ${clientIp} (${decision.scope} limit)`,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return Response.sendErrorResponse(
|
|
427
|
+
req,
|
|
428
|
+
res,
|
|
429
|
+
new TooManyRequestsException(
|
|
430
|
+
"Too many requests. Please try again later.",
|
|
431
|
+
),
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (
|
|
436
|
+
decision.outcome ===
|
|
437
|
+
OnCallCalendarFeedRateLimitOutcome.CounterUnavailable
|
|
438
|
+
) {
|
|
439
|
+
if (OnCallCalendarFeedRateLimit.shouldLogCounterUnavailable()) {
|
|
440
|
+
logger.warn(
|
|
441
|
+
"OnCallCalendarFeedRateLimit: rate limit counter unavailable, allowing calendar feed requests unthrottled",
|
|
442
|
+
);
|
|
443
|
+
logger.warn(getLogAttributesFromRequest(req as OneUptimeRequest));
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return next();
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/*
|
|
452
|
+
* A Redis outage means EVERY request takes the unavailable path, so an
|
|
453
|
+
* unguarded log line there is one per request for as long as the outage
|
|
454
|
+
* lasts. One line per interval is enough to make the condition visible.
|
|
455
|
+
*/
|
|
456
|
+
private static shouldLogCounterUnavailable(): boolean {
|
|
457
|
+
const now: number = Date.now();
|
|
458
|
+
|
|
459
|
+
if (
|
|
460
|
+
now - OnCallCalendarFeedRateLimit.counterUnavailableLastLoggedAt <
|
|
461
|
+
COUNTER_UNAVAILABLE_LOG_INTERVAL_MS
|
|
462
|
+
) {
|
|
463
|
+
return false;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
OnCallCalendarFeedRateLimit.counterUnavailableLastLoggedAt = now;
|
|
467
|
+
|
|
468
|
+
return true;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
private static setRetryAfterHeader(
|
|
472
|
+
res: ExpressResponse,
|
|
473
|
+
retryAfterSeconds: number,
|
|
474
|
+
): void {
|
|
475
|
+
const setHeader: unknown = (res as unknown as Record<string, unknown>)[
|
|
476
|
+
"setHeader"
|
|
477
|
+
];
|
|
478
|
+
|
|
479
|
+
if (typeof setHeader === "function") {
|
|
480
|
+
(setHeader as (name: string, value: string) => void).call(
|
|
481
|
+
res,
|
|
482
|
+
"Retry-After",
|
|
483
|
+
String(retryAfterSeconds),
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
package/Server/Services/Index.ts
CHANGED
|
@@ -166,6 +166,12 @@ import OnCallDutyPolicyScheduleOwnerTeamService from "./OnCallDutyPolicySchedule
|
|
|
166
166
|
import OnCallDutyPolicyScheduleLabelRuleService from "./OnCallDutyPolicyScheduleLabelRuleService";
|
|
167
167
|
import OnCallDutyPolicyScheduleLayerUserService from "./OnCallDutyPolicyScheduleLayerUserService";
|
|
168
168
|
import OnCallDutyPolicyScheduleService from "./OnCallDutyPolicyScheduleService";
|
|
169
|
+
// On-call calendar feeds and shift reminders
|
|
170
|
+
import UserOnCallCalendarFeedService from "./UserOnCallCalendarFeedService";
|
|
171
|
+
import OnCallDutyPolicyScheduleCalendarFeedService from "./OnCallDutyPolicyScheduleCalendarFeedService";
|
|
172
|
+
import ProjectOnCallCalendarFeedService from "./ProjectOnCallCalendarFeedService";
|
|
173
|
+
import UserOnCallShiftReminderService from "./UserOnCallShiftReminderService";
|
|
174
|
+
import UserOnCallShiftReminderLogService from "./UserOnCallShiftReminderLogService";
|
|
169
175
|
// On-Call Duty
|
|
170
176
|
import OnCallDutyPolicyService from "./OnCallDutyPolicyService";
|
|
171
177
|
import ProbeService from "./ProbeService";
|
|
@@ -688,6 +694,12 @@ const services: Array<BaseService> = [
|
|
|
688
694
|
OnCallDutyPolicyScheduleOwnerTeamService,
|
|
689
695
|
OnCallDutyPolicyScheduleLabelRuleService,
|
|
690
696
|
OnCallDutyPolicyScheduleLayerUserService,
|
|
697
|
+
// On-call calendar feeds and shift reminders
|
|
698
|
+
UserOnCallCalendarFeedService,
|
|
699
|
+
OnCallDutyPolicyScheduleCalendarFeedService,
|
|
700
|
+
ProjectOnCallCalendarFeedService,
|
|
701
|
+
UserOnCallShiftReminderService,
|
|
702
|
+
UserOnCallShiftReminderLogService,
|
|
691
703
|
OnCallDutyPolicyScheduleLayerService,
|
|
692
704
|
OnCallDutyPolicyEscalationRuleScheduleService,
|
|
693
705
|
|
|
@@ -25,12 +25,35 @@ import { WhatsAppMessagePayload } from "../../Types/WhatsApp/WhatsAppMessage";
|
|
|
25
25
|
import OnCallDutyPolicyTimeLogService from "./OnCallDutyPolicyTimeLogService";
|
|
26
26
|
import OneUptimeDate from "../../Types/Date";
|
|
27
27
|
import logger, { LogAttributes } from "../Utils/Logger";
|
|
28
|
+
import { OnCallShiftChangeReason } from "../Utils/OnCall/OnCallShiftChangeListeners";
|
|
28
29
|
|
|
29
30
|
export class Service extends DatabaseService<Model> {
|
|
30
31
|
public constructor() {
|
|
31
32
|
super(Model);
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Attaching to / detaching from a policy changes the schedule's policy
|
|
37
|
+
* context (which overrides apply, and which policies its calendar events
|
|
38
|
+
* list), so it is a configuration change for the calendar feeds and the
|
|
39
|
+
* reminders: bump shiftConfigVersion, purge the feed caches, notify the
|
|
40
|
+
* listeners. Runs after the roster refresh; never throws.
|
|
41
|
+
*/
|
|
42
|
+
private async propagateAttachmentChange(
|
|
43
|
+
onCallDutyPolicyScheduleId: ObjectID | null | undefined,
|
|
44
|
+
projectId: ObjectID | null | undefined,
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
if (!onCallDutyPolicyScheduleId) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
await OnCallDutyPolicyScheduleService.propagateShiftConfigChange({
|
|
51
|
+
scheduleIds: [onCallDutyPolicyScheduleId],
|
|
52
|
+
projectId: projectId || null,
|
|
53
|
+
reason: OnCallShiftChangeReason.PolicyAttachmentChanged,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
34
57
|
/**
|
|
35
58
|
* Re-resolve and persist the schedule's roster after it is attached to, or
|
|
36
59
|
* detached from, an on-call policy.
|
|
@@ -123,6 +146,11 @@ export class Service extends DatabaseService<Model> {
|
|
|
123
146
|
*/
|
|
124
147
|
await this.refreshScheduleRoster(createdModel.onCallDutyPolicyScheduleId);
|
|
125
148
|
|
|
149
|
+
await this.propagateAttachmentChange(
|
|
150
|
+
createdModel.onCallDutyPolicyScheduleId,
|
|
151
|
+
createdModel.projectId,
|
|
152
|
+
);
|
|
153
|
+
|
|
126
154
|
// send notification to the new current user.
|
|
127
155
|
|
|
128
156
|
const userOnSchedule: ObjectID | null =
|
|
@@ -387,6 +415,10 @@ export class Service extends DatabaseService<Model> {
|
|
|
387
415
|
}
|
|
388
416
|
refreshedScheduleIds.add(scheduleId);
|
|
389
417
|
await this.refreshScheduleRoster(deletedItem.onCallDutyPolicyScheduleId);
|
|
418
|
+
await this.propagateAttachmentChange(
|
|
419
|
+
deletedItem.onCallDutyPolicyScheduleId,
|
|
420
|
+
deletedItem.projectId,
|
|
421
|
+
);
|
|
390
422
|
}
|
|
391
423
|
|
|
392
424
|
for (const deletedItem of deletedItems) {
|