diff --git a/apps/meteor/app/metrics/server/lib/metrics.ts b/apps/meteor/app/metrics/server/lib/metrics.ts index 939b2edbd0089..08761629b0b9e 100644 --- a/apps/meteor/app/metrics/server/lib/metrics.ts +++ b/apps/meteor/app/metrics/server/lib/metrics.ts @@ -325,6 +325,31 @@ export const metrics = { help: 'Histogram of time taken in seconds for an item to be processed for the first time by Omni queues', buckets: queueWaitBuckets, }), + enterpriseCalendarSyncTotal: new client.Counter({ + name: 'rocketchat_enterprise_calendar_sync_total', + labelNames: ['provider', 'result'], + help: 'Enterprise calendar mailbox synchronization attempts by provider and sanitized result', + }), + enterpriseCalendarSyncDurationSeconds: new client.Histogram({ + name: 'rocketchat_enterprise_calendar_sync_duration_seconds', + labelNames: ['provider'], + help: 'Enterprise calendar mailbox synchronization duration', + buckets: latencyBuckets, + }), + enterpriseCalendarNotificationsTotal: new client.Counter({ + name: 'rocketchat_enterprise_calendar_notifications_total', + labelNames: ['result'], + help: 'Microsoft Graph calendar notifications by validation result', + }), + enterpriseCalendarPresenceTransitionsTotal: new client.Counter({ + name: 'rocketchat_enterprise_calendar_presence_transitions_total', + labelNames: ['status'], + help: 'Enterprise calendar presence transitions by content-free status', + }), + enterpriseCalendarConfiguredUsers: new client.Gauge({ + name: 'rocketchat_enterprise_calendar_configured_users', + help: 'Number of explicitly enabled enterprise calendar mappings', + }), }; // Metrics diff --git a/apps/meteor/ee/server/api/enterpriseCalendar.ts b/apps/meteor/ee/server/api/enterpriseCalendar.ts new file mode 100644 index 0000000000000..0ec90f233cb60 --- /dev/null +++ b/apps/meteor/ee/server/api/enterpriseCalendar.ts @@ -0,0 +1,191 @@ +import { Settings } from '@rocket.chat/models'; +import { + ajv, + validateBadRequestErrorResponse, + validateForbiddenErrorResponse, + validateUnauthorizedErrorResponse, +} from '@rocket.chat/rest-typings'; + +import { notifyOnSettingChangedById } from '../../../app/lib/server/lib/notifyListener'; +import { settings } from '../../../app/settings/server'; +import { API } from '../../../server/api/api'; +import { updateAuditedByUser } from '../../../server/settings/lib/auditedSettingUpdates'; +import { + createGraphProvider, + encryptCalendarSetting, + generateWebhookClientState, + getEnterpriseCalendarHealth, + invalidateGraphSubscriptions, + recordGraphConnectionTest, + requestEnterpriseCalendarResync, +} from '../enterprise-calendar/runtime'; + +type ConfigureBody = + | { credentialType: 'client-secret'; clientSecret: string; webhookClientState?: string } + | { credentialType: 'certificate'; certificate: string; privateKey: string; webhookClientState?: string }; + +const configureBody = ajv.compile({ + oneOf: [ + { + type: 'object', + properties: { + credentialType: { const: 'client-secret' }, + clientSecret: { type: 'string', minLength: 1, maxLength: 4096 }, + webhookClientState: { type: 'string', minLength: 32, maxLength: 255 }, + }, + required: ['credentialType', 'clientSecret'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + credentialType: { const: 'certificate' }, + certificate: { type: 'string', minLength: 1, maxLength: 16384 }, + privateKey: { type: 'string', minLength: 1, maxLength: 32768 }, + webhookClientState: { type: 'string', minLength: 32, maxLength: 255 }, + }, + required: ['credentialType', 'certificate', 'privateKey'], + additionalProperties: false, + }, + ], +}); + +const testBody = ajv.compile<{ mailbox?: string }>({ + type: 'object', + properties: { mailbox: { type: 'string', format: 'email', maxLength: 320 } }, + additionalProperties: false, +}); + +const resyncBody = ajv.compile<{ userId?: string }>({ + type: 'object', + properties: { userId: { type: 'string', minLength: 1, maxLength: 64 } }, + additionalProperties: false, +}); + +const genericResponse = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] }, result: { type: 'object' } }, + required: ['success', 'result'], + additionalProperties: false, +}); + +API.v1.post( + 'enterprise-calendar.configure-graph-credential', + { + authRequired: true, + permissionsRequired: ['edit-privileged-setting'], + license: ['outlook-calendar'], + body: configureBody, + rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 60_000 }, + response: { + 200: genericResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const updates: Array<[string, string]> = [['Enterprise_Calendar_Graph_Credential_Type', this.bodyParams.credentialType]]; + if (this.bodyParams.credentialType === 'client-secret') { + updates.push([ + 'Enterprise_Calendar_Graph_Client_Secret', + encryptCalendarSetting('Enterprise_Calendar_Graph_Client_Secret', this.bodyParams.clientSecret), + ]); + updates.push(['Enterprise_Calendar_Graph_Certificate', ''], ['Enterprise_Calendar_Graph_Private_Key', '']); + } else { + updates.push( + [ + 'Enterprise_Calendar_Graph_Certificate', + encryptCalendarSetting('Enterprise_Calendar_Graph_Certificate', this.bodyParams.certificate), + ], + [ + 'Enterprise_Calendar_Graph_Private_Key', + encryptCalendarSetting('Enterprise_Calendar_Graph_Private_Key', this.bodyParams.privateKey), + ], + ); + updates.push(['Enterprise_Calendar_Graph_Client_Secret', '']); + } + if (this.bodyParams.webhookClientState || !settings.get('Enterprise_Calendar_Graph_Webhook_Client_State')) { + const clientState = this.bodyParams.webhookClientState ?? generateWebhookClientState(); + updates.push([ + 'Enterprise_Calendar_Graph_Webhook_Client_State', + encryptCalendarSetting('Enterprise_Calendar_Graph_Webhook_Client_State', clientState), + ]); + } + const audit = updateAuditedByUser({ + _id: this.userId, + username: this.user.username ?? '', + ip: this.requestIp ?? '', + useragent: this.request.headers.get('user-agent') ?? '', + }); + for (const [id, value] of updates) { + await audit(Settings.updateValueById, id, value); + void notifyOnSettingChangedById(id); + } + if (updates.some(([id]) => id === 'Enterprise_Calendar_Graph_Webhook_Client_State')) { + await invalidateGraphSubscriptions(); + } + return API.v1.success({ result: { credentialConfigured: true, webhookClientStateConfigured: true } }); + }, +); + +API.v1.get( + 'enterprise-calendar.health', + { + authRequired: true, + permissionsRequired: ['view-privileged-setting'], + license: ['outlook-calendar'], + response: { + 200: genericResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + return API.v1.success({ result: await getEnterpriseCalendarHealth() }); + }, +); + +API.v1.post( + 'enterprise-calendar.resync', + { + authRequired: true, + permissionsRequired: ['edit-privileged-setting'], + license: ['outlook-calendar'], + body: resyncBody, + rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 60_000 }, + response: { + 200: genericResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + return API.v1.success({ result: { queuedUsers: await requestEnterpriseCalendarResync(this.bodyParams.userId) } }); + }, +); + +API.v1.post( + 'enterprise-calendar.test-graph', + { + authRequired: true, + permissionsRequired: ['view-privileged-setting'], + license: ['outlook-calendar'], + body: testBody, + rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 60_000 }, + response: { + 200: genericResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const validation = await createGraphProvider().validateConfiguration( + this.bodyParams.mailbox ? { provider: 'microsoft-graph', address: this.bodyParams.mailbox } : undefined, + ); + await recordGraphConnectionTest(validation); + return API.v1.success({ result: validation }); + }, +); diff --git a/apps/meteor/ee/server/api/index.ts b/apps/meteor/ee/server/api/index.ts index 35e8b0f0f60df..8b77d0d978ad1 100644 --- a/apps/meteor/ee/server/api/index.ts +++ b/apps/meteor/ee/server/api/index.ts @@ -8,3 +8,4 @@ import '../apps/communication/uikit'; import './engagementDashboard'; import './audit'; import './abac'; +import './enterpriseCalendar'; diff --git a/apps/meteor/ee/server/configuration/outlookCalendar.ts b/apps/meteor/ee/server/configuration/outlookCalendar.ts index b7a60e655e052..7f3e6bcccdbec 100644 --- a/apps/meteor/ee/server/configuration/outlookCalendar.ts +++ b/apps/meteor/ee/server/configuration/outlookCalendar.ts @@ -2,7 +2,9 @@ import { Calendar } from '@rocket.chat/core-services'; import { License } from '@rocket.chat/license'; import { Meteor } from 'meteor/meteor'; +import { setupEnterpriseCalendar } from '../enterprise-calendar/runtime'; import { addSettings } from '../settings/outlookCalendar'; +import '../enterprise-calendar/webhook'; Meteor.startup(() => License.onLicense('outlook-calendar', async () => { @@ -10,5 +12,6 @@ Meteor.startup(() => await Calendar.setupNextNotification(); await Calendar.setupNextStatusChange(); + await setupEnterpriseCalendar(); }), ); diff --git a/apps/meteor/ee/server/enterprise-calendar/runtime.ts b/apps/meteor/ee/server/enterprise-calendar/runtime.ts new file mode 100644 index 0000000000000..14bd83434ce4e --- /dev/null +++ b/apps/meteor/ee/server/enterprise-calendar/runtime.ts @@ -0,0 +1,612 @@ +import { createHash, randomBytes } from 'node:crypto'; + +import { Presence } from '@rocket.chat/core-services'; +import { UserStatus } from '@rocket.chat/core-typings'; +import { cronJobs } from '@rocket.chat/cron'; +import { + CalendarPresenceProjector, + CalendarProjectionFactory, + CalendarProviderRegistry, + CalendarSecretBox, + EnterpriseCalendarOrchestrator, + GraphNotificationProcessor, + MailboxResolver, + MicrosoftGraphCalendarProvider, + validateGraphHandshake, +} from '@rocket.chat/enterprise-calendar'; +import type { + CalendarMailboxIdentity, + CalendarProjection, + CalendarConfigurationValidation, + ICalendarProjectionStore, + CalendarSyncState, + ICalendarSyncStateStore, + GraphChangeNotification, + GraphProviderConfiguration, + HttpClient, + ExplicitMailboxMapping, + MicrosoftCloud, + INotificationDeduplicationStore, +} from '@rocket.chat/enterprise-calendar'; +import { Logger } from '@rocket.chat/logger'; +import { CalendarEvent, Users } from '@rocket.chat/models'; +import { serverFetch } from '@rocket.chat/server-fetch'; +import type { Collection } from 'mongodb'; + +import { metrics } from '../../../app/metrics/server/lib/metrics'; +import { settings } from '../../../app/settings/server'; +import { db } from '../../../server/database/utils'; +import { i18n } from '../../../server/lib/i18n'; + +const logger = new Logger('EnterpriseCalendar'); +const STATE_COLLECTION = 'rocketchat_enterprise_calendar_state'; +const NOTIFICATION_COLLECTION = 'rocketchat_enterprise_calendar_notification'; +const HEALTH_COLLECTION = 'rocketchat_enterprise_calendar_health'; +const CRON_JOB = 'enterprise-calendar-reconciliation'; +const TRANSITION_JOB = 'enterprise-calendar-presence-transition'; +let settingsWatcherRegistered = false; +export const WEBHOOK_PATH = '/api/v1/enterprise-calendar/graph/notifications'; + +type PersistedState = CalendarSyncState & { + _id: string; + notificationPending?: boolean; + subscriptionId?: string; + subscriptionExpiresAt?: Date; +}; + +const stateCollection = (): Collection => db.collection(STATE_COLLECTION); + +const encryptedSetting = (id: string, secretBox: CalendarSecretBox): string => { + const value = settings.get(id); + if (!value) throw new Error(`${id}-required`); + return secretBox.decrypt(value, id); +}; + +export const getSecretBox = (): CalendarSecretBox => + CalendarSecretBox.fromBase64Key(process.env.ROCKETCHAT_ENTERPRISE_CALENDAR_ENCRYPTION_KEY); + +export const encryptCalendarSetting = (id: string, plaintext: string): string => getSecretBox().encrypt(plaintext, id); + +const getGraphConfiguration = (): GraphProviderConfiguration => { + const secretBox = getSecretBox(); + const credentialType = settings.get('Enterprise_Calendar_Graph_Credential_Type'); + const credential = + credentialType === 'client-secret' + ? { type: 'client-secret' as const, clientSecret: encryptedSetting('Enterprise_Calendar_Graph_Client_Secret', secretBox) } + : { + type: 'certificate' as const, + certificate: encryptedSetting('Enterprise_Calendar_Graph_Certificate', secretBox), + privateKey: encryptedSetting('Enterprise_Calendar_Graph_Private_Key', secretBox), + }; + const webhookEnabled = settings.get('Enterprise_Calendar_Graph_Webhook_Enabled'); + return { + cloud: settings.get('Enterprise_Calendar_Graph_Cloud') ?? 'global', + tenantId: settings.get('Enterprise_Calendar_Graph_Tenant_Id') ?? '', + clientId: settings.get('Enterprise_Calendar_Graph_Client_Id') ?? '', + credential, + ...(webhookEnabled && { + webhookUrl: settings.get('Enterprise_Calendar_Graph_Webhook_Url'), + webhookClientState: encryptedSetting('Enterprise_Calendar_Graph_Webhook_Client_State', secretBox), + }), + requestTimeoutMs: 20_000, + }; +}; + +const httpClient: HttpClient = async (url, request) => + serverFetch(url, { + method: request.method, + headers: request.headers, + body: request.body, + timeout: request.timeoutMs, + ignoreSsrfValidation: false, + allowList: [], + }); + +class MongoStateStore implements ICalendarSyncStateStore { + async get(userId: string): Promise { + return stateCollection().findOne({ _id: userId }); + } + + async save(state: CalendarSyncState): Promise { + const entries = Object.entries(state).filter(([, value]) => value !== undefined); + const definedState = Object.fromEntries(entries) as Partial; + const optionalKeys = ['cursor', 'lastAttemptAt', 'lastSuccessAt', 'lastErrorCategory', 'backoffUntil'] as const; + const unset = Object.fromEntries(optionalKeys.filter((key) => state[key] === undefined).map((key) => [key, ''])); + await stateCollection().updateOne( + { _id: state.userId }, + { $set: definedState, ...(Object.keys(unset).length > 0 ? { $unset: unset } : {}), $setOnInsert: { _id: state.userId } }, + { upsert: true }, + ); + } +} + +class MongoProjectionStore implements ICalendarProjectionStore { + async upsert(events: CalendarProjection[]): Promise { + for (const event of events) { + await CalendarEvent.updateOne( + { uid: event.userId, provider: event.provider, externalId: event.eventHash, source: 'enterprise-calendar' } as never, + { + $set: { + uid: event.userId, + externalId: event.eventHash, + startTime: event.start, + endTime: event.end, + subject: '', + description: '', + notificationSent: true, + // Older Rocket.Chat nodes must never schedule this provider-owned + // projection through the legacy calendar presence path. + busy: false, + calendarPresenceEnabled: + (!event.isAllDay || settings.get('Enterprise_Calendar_Include_All_Day')) && + (event.availability === 'busy' || + event.availability === 'outOfOffice' || + (event.availability === 'tentative' && settings.get('Enterprise_Calendar_Include_Tentative')) || + (event.availability === 'workingElsewhere' && settings.get('Enterprise_Calendar_Include_Working_Elsewhere'))), + source: 'enterprise-calendar', + provider: event.provider, + mailboxHash: event.mailboxHash, + availability: event.availability, + isAllDay: event.isAllDay, + isPrivate: event.isPrivate, + lastModifiedAt: event.lastModifiedAt, + } as never, + }, + { upsert: true }, + ); + } + } + + async remove(userId: string, provider: CalendarMailboxIdentity['provider'], eventHashes: string[]): Promise { + if (!eventHashes.length) return; + await CalendarEvent.deleteMany({ uid: userId, provider, source: 'enterprise-calendar', externalId: { $in: eventHashes } } as never); + } + + async replaceWindow( + userId: string, + provider: CalendarMailboxIdentity['provider'], + _windowStart: Date, + _windowEnd: Date, + events: CalendarProjection[], + ): Promise { + await CalendarEvent.deleteMany({ + uid: userId, + provider, + source: 'enterprise-calendar', + } as never); + await this.upsert(events); + } + + async findActive(userId: string, at: Date): Promise { + const records = await CalendarEvent.find({ + uid: userId, + source: 'enterprise-calendar', + startTime: { $lte: at }, + endTime: { $gt: at }, + } as never).toArray(); + return records.map((event) => ({ + userId, + provider: (event as never as CalendarProjection).provider, + mailboxHash: (event as never as CalendarProjection).mailboxHash, + eventHash: event.externalId ?? event._id, + start: event.startTime, + end: event.endTime as Date, + availability: (event as never as CalendarProjection).availability, + isAllDay: Boolean((event as never as CalendarProjection).isAllDay), + isPrivate: Boolean((event as never as CalendarProjection).isPrivate), + })); + } + + async removeExpired(before: Date): Promise { + const result = await CalendarEvent.deleteMany({ source: 'enterprise-calendar', endTime: { $lt: before } } as never); + return result.deletedCount; + } +} + +const presenceAdapter = { + async apply(userId: string, status: 'busy' | 'away', expiresAt: Date): Promise { + const user = await Users.findOneById(userId, { projection: { language: 1 } }); + const lng = user?.language || settings.get('Language') || 'en'; + await Presence.setActiveState(userId, { + statusDefault: status === 'away' ? UserStatus.AWAY : UserStatus.BUSY, + statusText: i18n.t('Presence_status_outlook_in_a_meeting', { lng }), + statusSource: 'external', + statusExpiresAt: expiresAt, + statusId: 'enterprise-calendar', + }); + metrics.enterpriseCalendarPresenceTransitionsTotal.inc({ status }); + }, + async clear(userId: string): Promise { + await Presence.endActiveState(userId, 'enterprise-calendar'); + metrics.enterpriseCalendarPresenceTransitionsTotal.inc({ status: 'clear' }); + }, +}; + +const parseMappings = (): ExplicitMailboxMapping[] => { + const raw = settings.get('Enterprise_Calendar_Mailbox_Mappings') || '[]'; + const value: unknown = JSON.parse(raw); + if (!Array.isArray(value) || value.length > 10_000) throw new Error('invalid-enterprise-calendar-mailbox-mappings'); + const mappings = value.map((entry) => { + if (!entry || typeof entry !== 'object') throw new Error('invalid-enterprise-calendar-mailbox-mapping'); + const record = entry as Record; + const { provider } = record; + if (typeof record.userId !== 'string' || typeof record.address !== 'string') + throw new Error('invalid-enterprise-calendar-mailbox-mapping'); + if (provider !== 'microsoft-graph' && provider !== 'exchange-ews') throw new Error('invalid-enterprise-calendar-provider'); + const typedProvider: ExplicitMailboxMapping['provider'] = provider; + return { + userId: record.userId, + address: record.address, + provider: typedProvider, + enabled: record.enabled !== false, + ...(typeof record.externalUserId === 'string' && { externalUserId: record.externalUserId }), + ...(typeof record.tenantId === 'string' && { tenantId: record.tenantId }), + }; + }); + new MailboxResolver(mappings).validateNoDuplicates(); + return mappings; +}; + +export const createGraphProvider = (): MicrosoftGraphCalendarProvider => + new MicrosoftGraphCalendarProvider(getGraphConfiguration(), httpClient); + +const getRuntime = () => { + const encryptionKey = Buffer.from(process.env.ROCKETCHAT_ENTERPRISE_CALENDAR_ENCRYPTION_KEY ?? '', 'base64'); + const registry = new CalendarProviderRegistry(); + const graph = createGraphProvider(); + registry.register(graph); + const projections = new MongoProjectionStore(); + const mapping = { + includeAllDay: settings.get('Enterprise_Calendar_Include_All_Day'), + tentative: settings.get('Enterprise_Calendar_Include_Tentative') ? ('busy' as const) : ('none' as const), + workingElsewhere: settings.get('Enterprise_Calendar_Include_Working_Elsewhere') ? ('busy' as const) : ('none' as const), + }; + return { + graph, + projections, + presence: new CalendarPresenceProjector(presenceAdapter, mapping), + orchestrator: new EnterpriseCalendarOrchestrator( + registry, + new MongoStateStore(), + projections, + new CalendarProjectionFactory(createHash('sha256').update(encryptionKey).update('projection').digest()), + new CalendarPresenceProjector(presenceAdapter, mapping), + { + pastMs: Math.min(Math.max(settings.get('Enterprise_Calendar_Sync_Past_Hours') || 1, 1), 168) * 60 * 60_000, + futureMs: Math.min(Math.max(settings.get('Enterprise_Calendar_Sync_Future_Days') || 14, 1), 90) * 24 * 60 * 60_000, + }, + ), + }; +}; + +const setupNextEnterpriseTransition = async (): Promise => { + if (await cronJobs.has(TRANSITION_JOB)) await cronJobs.remove(TRANSITION_JOB); + if (!settings.get('Enterprise_Calendar_Enabled')) return; + const now = new Date(); + const [nextStart, nextEnd] = await Promise.all([ + CalendarEvent.findOne({ source: 'enterprise-calendar', calendarPresenceEnabled: true, startTime: { $gt: now } } as never, { + sort: { startTime: 1 }, + projection: { startTime: 1 }, + }), + CalendarEvent.findOne({ source: 'enterprise-calendar', calendarPresenceEnabled: true, endTime: { $gt: now } } as never, { + sort: { endTime: 1 }, + projection: { endTime: 1 }, + }), + ]); + const candidates = [nextStart?.startTime, nextEnd?.endTime].filter((value): value is Date => value instanceof Date); + if (!candidates.length) return; + const transitionAt = candidates.reduce((earliest, candidate) => (candidate < earliest ? candidate : earliest)); + await cronJobs.addAtTimestamp(TRANSITION_JOB, transitionAt, processEnterpriseTransitions); +}; + +const processEnterpriseTransitions = async (): Promise => { + const now = new Date(); + const lowerBound = new Date(now.getTime() - 90_000); + const events = await CalendarEvent.find( + { + source: 'enterprise-calendar', + calendarPresenceEnabled: true, + $or: [{ startTime: { $gt: lowerBound, $lte: now } }, { endTime: { $gt: lowerBound, $lte: now } }], + } as never, + { projection: { uid: 1 } }, + ).toArray(); + const { presence, projections } = getRuntime(); + for (const userId of new Set(events.map(({ uid }) => uid))) { + await presence.recompute(userId, await projections.findActive(userId, now), now); + } + await setupNextEnterpriseTransition(); +}; + +const renewSubscription = async ( + graph: MicrosoftGraphCalendarProvider, + userId: string, + mailbox: CalendarMailboxIdentity, +): Promise => { + if (!settings.get('Enterprise_Calendar_Graph_Webhook_Enabled')) return; + const state = await stateCollection().findOne({ _id: userId }); + if (state?.subscriptionExpiresAt && state.subscriptionExpiresAt.getTime() - Date.now() > 24 * 60 * 60_000) return; + const clientState = encryptedSetting('Enterprise_Calendar_Graph_Webhook_Client_State', getSecretBox()); + const subscription = await graph.createOrRenewSubscription( + mailbox, + state?.subscriptionId && state.subscriptionExpiresAt + ? { + id: state.subscriptionId, + mailbox, + expiresAt: state.subscriptionExpiresAt, + clientStateHash: createHash('sha256').update(clientState).digest('hex'), + } + : undefined, + ); + await stateCollection().updateOne( + { _id: userId }, + { $set: { subscriptionId: subscription.id, subscriptionExpiresAt: subscription.expiresAt } }, + { upsert: true }, + ); +}; + +const hasMailboxChanged = (state: PersistedState | null, mailbox: CalendarMailboxIdentity): boolean => + Boolean( + state && + (state.mailbox.provider !== mailbox.provider || + state.mailbox.address.toLocaleLowerCase('en-US') !== mailbox.address.toLocaleLowerCase('en-US') || + state.mailbox.externalUserId !== mailbox.externalUserId || + state.mailbox.tenantId !== mailbox.tenantId), + ); + +export const reconcileEnterpriseCalendars = async (): Promise => { + if (!settings.get('Enterprise_Calendar_Enabled')) { + const mappings = parseMappings().filter(({ enabled }) => enabled); + let graph: MicrosoftGraphCalendarProvider | undefined; + try { + graph = createGraphProvider(); + } catch { + // Local cleanup must still complete when credentials are unavailable. + } + for (const mapping of mappings) { + const state = await stateCollection().findOne({ _id: mapping.userId }); + if (graph && state?.subscriptionId && state.subscriptionExpiresAt && mapping.provider === 'microsoft-graph') { + try { + await graph.removeSubscription({ + id: state.subscriptionId, + mailbox: { provider: mapping.provider, address: mapping.address, externalUserId: mapping.externalUserId }, + expiresAt: state.subscriptionExpiresAt, + clientStateHash: '', + }); + } catch { + // The remote subscription has a bounded lifetime and will expire. + } + } + await Presence.endActiveState(mapping.userId, 'enterprise-calendar'); + } + await CalendarEvent.deleteMany({ source: 'enterprise-calendar' } as never); + await stateCollection().deleteMany({}); + if (await cronJobs.has(TRANSITION_JOB)) await cronJobs.remove(TRANSITION_JOB); + metrics.enterpriseCalendarConfiguredUsers.set(0); + return; + } + const { graph, orchestrator, projections } = getRuntime(); + const mappings = parseMappings().filter(({ enabled, provider }) => enabled && provider === 'microsoft-graph'); + metrics.enterpriseCalendarConfiguredUsers.set(mappings.length); + const limit = Math.min(Math.max(settings.get('Enterprise_Calendar_Max_Users_Per_Run') || 100, 1), 1_000); + const attempts = await stateCollection() + .find({ _id: { $in: mappings.map(({ userId }) => userId) } }, { projection: { lastAttemptAt: 1, notificationPending: 1 } }) + .toArray(); + const attemptByUserId = new Map(attempts.map(({ _id, lastAttemptAt }) => [_id, lastAttemptAt?.getTime() ?? 0])); + const pendingUserIds = new Set(attempts.filter(({ notificationPending }) => notificationPending).map(({ _id }) => _id)); + const batch = mappings + .sort((left, right) => { + const pendingDifference = Number(pendingUserIds.has(right.userId)) - Number(pendingUserIds.has(left.userId)); + return pendingDifference || (attemptByUserId.get(left.userId) ?? 0) - (attemptByUserId.get(right.userId) ?? 0); + }) + .slice(0, limit); + for (const mapping of batch) { + const mailbox = { + provider: mapping.provider, + address: mapping.address, + ...(mapping.externalUserId && { externalUserId: mapping.externalUserId }), + ...(mapping.tenantId && { tenantId: mapping.tenantId }), + }; + const stopTimer = metrics.enterpriseCalendarSyncDurationSeconds.startTimer({ provider: mapping.provider }); + try { + const user = await Users.findOneById(mapping.userId, { projection: { active: 1 } }); + if (!user?.active) { + const state = await stateCollection().findOne({ _id: mapping.userId }); + if (state?.subscriptionId && state.subscriptionExpiresAt) { + try { + await graph.removeSubscription({ + id: state.subscriptionId, + mailbox, + expiresAt: state.subscriptionExpiresAt, + clientStateHash: '', + }); + } catch (error) { + logger.warn({ msg: 'Calendar subscription cleanup failed', provider: mapping.provider, userId: mapping.userId, err: error }); + } + } + await CalendarEvent.deleteMany({ uid: mapping.userId, source: 'enterprise-calendar' } as never); + await Presence.endActiveState(mapping.userId, 'enterprise-calendar'); + await stateCollection().deleteOne({ _id: mapping.userId }); + metrics.enterpriseCalendarSyncTotal.inc({ provider: mapping.provider, result: 'disabled-user-cleanup' }); + continue; + } + const previousState = await stateCollection().findOne({ _id: mapping.userId }); + const mailboxChanged = hasMailboxChanged(previousState, mailbox); + if (mailboxChanged) { + if (previousState.subscriptionId && previousState.subscriptionExpiresAt) { + try { + await graph.removeSubscription({ + id: previousState.subscriptionId, + mailbox: previousState.mailbox, + expiresAt: previousState.subscriptionExpiresAt, + clientStateHash: '', + }); + } catch { + // The old subscription will expire automatically. + } + } + await CalendarEvent.deleteMany({ uid: mapping.userId, source: 'enterprise-calendar' } as never); + await Presence.endActiveState(mapping.userId, 'enterprise-calendar'); + await stateCollection().updateOne( + { _id: mapping.userId }, + { + $set: { mailbox, fullResyncRequired: true, notificationPending: true, retryCount: 0 }, + $unset: { cursor: '', subscriptionId: '', subscriptionExpiresAt: '', backoffUntil: '', lastErrorCategory: '' }, + }, + ); + } + const synchronized = await orchestrator.synchronize(mapping.userId, mailbox); + // Successful server ownership ends only the legacy calendar claim; delegated + // credentials and historical records remain available for rollback. + await Presence.endActiveState(mapping.userId, 'calendar'); + if (synchronized) await stateCollection().updateOne({ _id: mapping.userId }, { $unset: { notificationPending: '' } }); + await renewSubscription(graph, mapping.userId, mailbox); + metrics.enterpriseCalendarSyncTotal.inc({ provider: mapping.provider, result: synchronized ? 'success' : 'deferred' }); + } catch (error) { + metrics.enterpriseCalendarSyncTotal.inc({ provider: mapping.provider, result: 'failure' }); + logger.error({ msg: 'Calendar mailbox synchronization failed', provider: mapping.provider, userId: mapping.userId, err: error }); + } finally { + stopTimer(); + } + } + await projections.removeExpired(new Date(Date.now() - 24 * 60 * 60_000)); + await setupNextEnterpriseTransition(); +}; + +export const setupEnterpriseCalendar = async (): Promise => { + await stateCollection().createIndexes([{ key: { subscriptionId: 1 }, sparse: true, unique: true }, { key: { backoffUntil: 1 } }]); + await db.collection(NOTIFICATION_COLLECTION).createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }); + await cronJobs.add(CRON_JOB, '*/5 * * * *', reconcileEnterpriseCalendars); + if (!settingsWatcherRegistered) { + settingsWatcherRegistered = true; + let initial = true; + settings.watchMultiple( + [ + 'Enterprise_Calendar_Enabled', + 'Enterprise_Calendar_Mailbox_Mappings', + 'Enterprise_Calendar_Sync_Past_Hours', + 'Enterprise_Calendar_Sync_Future_Days', + 'Enterprise_Calendar_Include_All_Day', + 'Enterprise_Calendar_Include_Tentative', + 'Enterprise_Calendar_Include_Working_Elsewhere', + ], + () => { + if (initial) { + initial = false; + return; + } + void requestEnterpriseCalendarResync() + .then(reconcileEnterpriseCalendars) + .catch((error) => logger.error({ msg: 'Calendar configuration reconciliation failed', err: error })); + }, + ); + } + await reconcileEnterpriseCalendars(); +}; + +class MongoNotificationDeduplication implements INotificationDeduplicationStore { + async claim(key: string, expiresAt: Date): Promise { + try { + await db.collection(NOTIFICATION_COLLECTION).insertOne({ _id: key, expiresAt }); + return true; + } catch (error: any) { + if (error?.code === 11000) return false; + throw error; + } + } +} + +export const processGraphNotifications = async (notifications: GraphChangeNotification[]) => { + const clientState = encryptedSetting('Enterprise_Calendar_Graph_Webhook_Client_State', getSecretBox()); + const processor = new GraphNotificationProcessor(clientState, new MongoNotificationDeduplication(), { + async enqueueSubscription(subscriptionId) { + await stateCollection().updateOne({ subscriptionId }, { $set: { notificationPending: true } }); + }, + }); + const result = await processor.process(notifications); + if (result.accepted) metrics.enterpriseCalendarNotificationsTotal.inc({ result: 'accepted' }, result.accepted); + if (result.rejected) metrics.enterpriseCalendarNotificationsTotal.inc({ result: 'invalid' }, result.rejected); + return result; +}; + +export const getEnterpriseCalendarHealth = async () => { + const mappings = parseMappings().filter(({ enabled }) => enabled); + const states = await stateCollection() + .find({ _id: { $in: mappings.map(({ userId }) => userId) } }) + .project>({ + lastSuccessAt: 1, + lastErrorCategory: 1, + subscriptionExpiresAt: 1, + }) + .toArray(); + const successes = states.map(({ lastSuccessAt }) => lastSuccessAt).filter((value): value is Date => value instanceof Date); + const renewals = states.map(({ subscriptionExpiresAt }) => subscriptionExpiresAt).filter((value): value is Date => value instanceof Date); + const connectionTest = await db.collection(HEALTH_COLLECTION).findOne({ _id: 'microsoft-graph' }); + return { + enabled: settings.get('Enterprise_Calendar_Enabled') === true, + provider: 'microsoft-graph', + configuredUsers: mappings.length, + failingUsers: states.filter(({ lastErrorCategory }) => Boolean(lastErrorCategory)).length, + lastSuccessfulSynchronization: successes.length ? new Date(Math.max(...successes.map((value) => value.getTime()))) : null, + nextSubscriptionRenewal: renewals.length ? new Date(Math.min(...renewals.map((value) => value.getTime()))) : null, + latestErrorCategory: states.find(({ lastErrorCategory }) => lastErrorCategory)?.lastErrorCategory ?? null, + lastConnectionTest: connectionTest + ? { at: connectionTest.at, valid: connectionTest.valid, code: connectionTest.code ?? null, message: connectionTest.message ?? null } + : null, + }; +}; + +export const recordGraphConnectionTest = async (validation: CalendarConfigurationValidation): Promise => { + await db.collection(HEALTH_COLLECTION).updateOne( + { _id: 'microsoft-graph' }, + { + $set: { + at: new Date(), + valid: validation.valid, + code: validation.code ?? null, + message: validation.message ?? null, + }, + }, + { upsert: true }, + ); +}; + +export const requestEnterpriseCalendarResync = async (userId?: string): Promise => { + const configuredUserIds = parseMappings() + .filter(({ enabled }) => enabled) + .map((mapping) => mapping.userId); + const userIds = userId ? configuredUserIds.filter((candidate) => candidate === userId) : configuredUserIds; + if (userId && !userIds.length) throw new Error('calendar-user-not-mapped'); + if (!userIds.length) return 0; + for (const mappedUserId of userIds) { + const mapping = parseMappings().find(({ userId: candidate }) => candidate === mappedUserId); + if (!mapping) continue; + await stateCollection().updateOne( + { _id: mappedUserId }, + { + $set: { notificationPending: true, fullResyncRequired: true }, + $setOnInsert: { + _id: mappedUserId, + userId: mappedUserId, + mailbox: { provider: mapping.provider, address: mapping.address, externalUserId: mapping.externalUserId }, + retryCount: 0, + }, + }, + { upsert: true }, + ); + } + return userIds.length; +}; + +export const invalidateGraphSubscriptions = async (): Promise => { + await stateCollection().updateMany( + { subscriptionId: { $exists: true } }, + { + $set: { notificationPending: true }, + $unset: { subscriptionId: '', subscriptionExpiresAt: '' }, + }, + ); +}; + +export { validateGraphHandshake }; + +export const generateWebhookClientState = (): string => randomBytes(32).toString('base64url'); diff --git a/apps/meteor/ee/server/enterprise-calendar/webhook.ts b/apps/meteor/ee/server/enterprise-calendar/webhook.ts new file mode 100644 index 0000000000000..76794ae101432 --- /dev/null +++ b/apps/meteor/ee/server/enterprise-calendar/webhook.ts @@ -0,0 +1,51 @@ +import { WebApp } from 'meteor/webapp'; + +import { processGraphNotifications, validateGraphHandshake, WEBHOOK_PATH } from './runtime'; +import { settings } from '../../../app/settings/server'; + +const MAX_BODY_BYTES = 256 * 1024; + +WebApp.rawConnectHandlers.use(WEBHOOK_PATH, async (request, response) => { + const requestUrl = new URL(request.url ?? WEBHOOK_PATH, 'http://localhost'); + const validationToken = validateGraphHandshake(requestUrl.searchParams.get('validationToken')); + if (validationToken) { + response.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' }); + response.end(validationToken); + return; + } + + if (request.method !== 'POST' || !settings.get('Enterprise_Calendar_Enabled')) { + response.writeHead(404); + response.end(); + return; + } + + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += buffer.length; + if (size > MAX_BODY_BYTES) { + response.writeHead(413); + response.end(); + return; + } + chunks.push(buffer); + } + + try { + const payload = JSON.parse(Buffer.concat(chunks).toString('utf8')) as { value?: unknown }; + if (!Array.isArray(payload.value)) throw new Error('invalid-notification-payload'); + const result = await processGraphNotifications(payload.value); + if (result.rejected > 0 && result.accepted === 0) { + response.writeHead(401); + response.end(); + return; + } + response.writeHead(202, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + response.end('{"accepted":true}'); + } catch { + response.writeHead(400); + response.end(); + } +}); diff --git a/apps/meteor/ee/server/settings/outlookCalendar.ts b/apps/meteor/ee/server/settings/outlookCalendar.ts index 7252a388e9f52..cacddef0333c0 100644 --- a/apps/meteor/ee/server/settings/outlookCalendar.ts +++ b/apps/meteor/ee/server/settings/outlookCalendar.ts @@ -8,6 +8,89 @@ export function addSettings(): void { modules: ['outlook-calendar'], }, async function () { + await this.section('Enterprise_Calendar_Server', async function () { + await this.add('Enterprise_Calendar_Enabled', false, { + type: 'boolean', + public: false, + invalidValue: false, + }); + + await this.add('Enterprise_Calendar_Graph_Cloud', 'global', { + type: 'select', + public: false, + invalidValue: 'global', + values: [ + { key: 'global', i18nLabel: 'Enterprise_Calendar_Cloud_Global' }, + { key: 'us-gov', i18nLabel: 'Enterprise_Calendar_Cloud_US_Gov' }, + { key: 'us-gov-dod', i18nLabel: 'Enterprise_Calendar_Cloud_US_Gov_DoD' }, + { key: 'china', i18nLabel: 'Enterprise_Calendar_Cloud_China' }, + ], + }); + + await this.add('Enterprise_Calendar_Graph_Tenant_Id', '', { type: 'string', public: false, invalidValue: '' }); + await this.add('Enterprise_Calendar_Graph_Client_Id', '', { type: 'string', public: false, invalidValue: '' }); + await this.add('Enterprise_Calendar_Graph_Credential_Type', 'certificate', { + type: 'select', + public: false, + invalidValue: 'certificate', + values: [ + { key: 'certificate', i18nLabel: 'Enterprise_Calendar_Credential_Certificate' }, + { key: 'client-secret', i18nLabel: 'Enterprise_Calendar_Credential_Client_Secret' }, + ], + }); + await this.add('Enterprise_Calendar_Graph_Client_Secret', '', { + type: 'password', + secret: true, + public: false, + invalidValue: '', + readonly: true, + }); + await this.add('Enterprise_Calendar_Graph_Certificate', '', { + type: 'password', + secret: true, + public: false, + invalidValue: '', + readonly: true, + }); + await this.add('Enterprise_Calendar_Graph_Private_Key', '', { + type: 'password', + secret: true, + public: false, + invalidValue: '', + readonly: true, + }); + await this.add('Enterprise_Calendar_Graph_Webhook_Enabled', false, { + type: 'boolean', + public: false, + invalidValue: false, + }); + await this.add('Enterprise_Calendar_Graph_Webhook_Url', '', { type: 'string', public: false, invalidValue: '' }); + await this.add('Enterprise_Calendar_Graph_Webhook_Client_State', '', { + type: 'password', + secret: true, + public: false, + invalidValue: '', + readonly: true, + }); + await this.add('Enterprise_Calendar_Sync_Past_Hours', 1, { type: 'int', public: false, invalidValue: 1 }); + await this.add('Enterprise_Calendar_Sync_Future_Days', 14, { type: 'int', public: false, invalidValue: 14 }); + await this.add('Enterprise_Calendar_Max_Users_Per_Run', 100, { type: 'int', public: false, invalidValue: 100 }); + await this.add('Enterprise_Calendar_Include_All_Day', false, { type: 'boolean', public: false, invalidValue: false }); + await this.add('Enterprise_Calendar_Include_Tentative', false, { type: 'boolean', public: false, invalidValue: false }); + await this.add('Enterprise_Calendar_Include_Working_Elsewhere', false, { + type: 'boolean', + public: false, + invalidValue: false, + }); + await this.add('Enterprise_Calendar_Mailbox_Mappings', '[]', { + type: 'code', + multiline: true, + public: false, + code: 'application/json', + invalidValue: '[]', + }); + }); + await this.add('Outlook_Calendar_Enabled', false, { type: 'boolean', public: true, diff --git a/apps/meteor/package.json b/apps/meteor/package.json index b14e0cbd90834..1a726b1f318a6 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -105,6 +105,7 @@ "@rocket.chat/css-in-js": "^0.32.0", "@rocket.chat/ddp-client": "workspace:^", "@rocket.chat/emitter": "^0.32.0", + "@rocket.chat/enterprise-calendar": "workspace:^", "@rocket.chat/favicon": "workspace:^", "@rocket.chat/federation-matrix": "workspace:^", "@rocket.chat/federation-sdk": "0.6.3", diff --git a/apps/meteor/server/services/calendar/service.ts b/apps/meteor/server/services/calendar/service.ts index 9e842397a5202..f31c380f9e6f1 100644 --- a/apps/meteor/server/services/calendar/service.ts +++ b/apps/meteor/server/services/calendar/service.ts @@ -49,12 +49,15 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe } public async import(data: Omit, 'notificationSent'>): Promise { - const { externalId } = data; + const { externalId, uid } = data; + if (this.isEnterpriseManagedUser(uid)) { + throw new Error('calendar-managed-by-server'); + } if (!externalId) { return this.create(data); } - const { uid, startTime, endTime, subject, description, reminderMinutesBeforeStart, busy } = data; + const { startTime, endTime, subject, description, reminderMinutesBeforeStart, busy } = data; const meetingUrl = data.meetingUrl ? data.meetingUrl : await this.parseDescriptionForMeetingUrl(description); const reminderTime = reminderMinutesBeforeStart ? getShiftedTime(startTime, -reminderMinutesBeforeStart) : undefined; @@ -390,4 +393,21 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe return undefined; } + + private isEnterpriseManagedUser(uid: IUser['_id']): boolean { + if (!settings.get('Enterprise_Calendar_Enabled')) return false; + try { + const mappings = JSON.parse(settings.get('Enterprise_Calendar_Mailbox_Mappings') || '[]') as unknown; + return ( + Array.isArray(mappings) && + mappings.some((mapping) => { + if (!mapping || typeof mapping !== 'object') return false; + const record = mapping as Record; + return record.userId === uid && record.enabled !== false; + }) + ); + } catch { + return false; + } + } } diff --git a/apps/meteor/tests/unit/server/services/calendar/service.tests.ts b/apps/meteor/tests/unit/server/services/calendar/service.tests.ts index 740ab5fb0e56b..04b3c299f6e22 100644 --- a/apps/meteor/tests/unit/server/services/calendar/service.tests.ts +++ b/apps/meteor/tests/unit/server/services/calendar/service.tests.ts @@ -169,6 +169,24 @@ describe('CalendarService', () => { }); describe('#import', () => { + it('rejects delegated imports after server ownership is assigned', async () => { + settingsMock.set('Enterprise_Calendar_Enabled', true); + settingsMock.set( + 'Enterprise_Calendar_Mailbox_Mappings', + JSON.stringify([{ userId: fakeUserId, provider: 'microsoft-graph', address: 'user@example.com', enabled: true }]), + ); + + await expect( + service.import({ + uid: fakeUserId, + startTime: fakeStartTime, + subject: fakeSubject, + description: fakeDescription, + }), + ).to.be.rejectedWith('calendar-managed-by-server'); + sinon.assert.notCalled(CalendarEventMock.insertOne); + }); + it('should create a new event if externalId is not provided', async () => { const eventData = { uid: fakeUserId, diff --git a/docs/architecture/adr-enterprise-calendar-presence.md b/docs/architecture/adr-enterprise-calendar-presence.md new file mode 100644 index 0000000000000..955a7a97f60d7 --- /dev/null +++ b/docs/architecture/adr-enterprise-calendar-presence.md @@ -0,0 +1,81 @@ +# ADR: server-to-server Exchange calendar presence + +Status: accepted + +## Decision + +Introduce a provider-independent EE calendar package and keep infrastructure +behind narrow adapters. Providers return normalized availability events; an +orchestrator persists minimal projections and asks a presence projector to +recompute one calendar-owned claim per Rocket.Chat user. + +Exchange Online uses tenant-specific OAuth 2.0 client credentials and Microsoft +Graph. Certificate credentials are preferred; client secrets are supported as a +secondary mode. Authority and Graph roots are selected centrally for global, +US Government L4, US Government L5/DoD, and China clouds. + +The primary sync primitive is bounded `calendarView/delta`. Change +notifications are signals that enqueue a coalesced mailbox sync; periodic delta +reconciliation remains mandatory. A notification is never treated as calendar +truth and webhook requests never perform Graph reads synchronously. + +Subscriptions are per mailbox because Microsoft Graph Outlook event +subscriptions target `/users/{id}/events`. This also provides deterministic +mailbox failure isolation. Deployments that cannot expose HTTPS use delta +polling only. + +Hybrid routing is explicit and persisted. A mapping resolves to exactly one +provider; synchronization never probes Graph and then EWS as a fallback. + +## Permissions + +The required Microsoft Graph application permission is `Calendars.Read` with +administrator consent. `Calendars.ReadBasic.All` was evaluated first, but the +Microsoft Graph `calendarView/delta` API currently lists `Calendars.Read` as the +least-privileged application permission. No write, mail, contact, Teams or +directory permission is requested. Mailboxes are derived from explicit +mappings, a trusted UPN, or a verified Rocket.Chat email, so `User.Read.All` is +not needed. Exchange Online App RBAC is the recommended mailbox scope. + +The provider selects only `id`, `start`, `end`, `showAs`, cancellation/all-day +flags, sensitivity, last-modified timestamp, occurrence type and series ID. It +does not request subject, body, preview, attendees, attachments, location, +online meeting data or meeting URLs. + +## Persistence and privacy + +Provider event IDs are HMACed before projection persistence. Stored projections +contain Rocket.Chat user ID, provider, mailbox hash, event hash, times, +availability and change markers. Raw Graph responses are never stored. + +Credentials are AES-256-GCM encrypted with a deployment key supplied through +`ROCKETCHAT_ENTERPRISE_CALENDAR_ENCRYPTION_KEY`. The integration refuses to +store or use credentials when the key is absent or malformed. Settings marked +`secret` remain write-only in UI/audit paths, but masking alone is not treated +as encryption. + +Delta links are operational secrets: they are persisted in sync state, never +logged, never returned to clients, and accepted only when their origin matches +the configured Microsoft Graph cloud. + +## Presence + +Calendar events are reduced to one effective interval using an explicit +availability strength table. The adapter applies or ends only status ID +`enterprise-calendar`; it never snapshots and blindly restores user state. +Rocket.Chat's presence engine continues to resolve internal, manual, external +and connection state and its reaper bounds stale claims. + +The compatibility default is: busy and out-of-office produce the existing busy +claim; free, tentative, working-elsewhere, unknown and cancelled events do not +override presence. Administrators may opt into tentative/working-elsewhere or +all-day processing without exposing event content. + +## EWS status + +The provider contract, configuration validation boundary, normalized model and +hybrid routing support EWS. Network operations are intentionally unavailable in +this change: no maintained dependency in the repository covers the required +Kerberos/NTLM, impersonation, SyncFolderItems and notification behavior, and a +safe SOAP implementation requires a separate reviewed change. UI and health +responses must therefore report EWS as unavailable, not supported. diff --git a/docs/enterprise-calendar-assessment.md b/docs/enterprise-calendar-assessment.md new file mode 100644 index 0000000000000..f30772f8abd90 --- /dev/null +++ b/docs/enterprise-calendar-assessment.md @@ -0,0 +1,98 @@ +# Enterprise calendar presence: repository assessment + +## Existing implementation + +The existing Outlook integration is split between Rocket.Chat core, Enterprise +Edition (EE), and Rocket.Chat Desktop: + +- `apps/meteor/ee/server/configuration/outlookCalendar.ts` enables the feature + only while the `outlook-calendar` license module is active. +- `apps/meteor/ee/server/settings/outlookCalendar.ts` registers public settings + for the desktop EWS and Outlook Web URLs, meeting URL extraction, status sync, + and per-domain URL overrides. +- `apps/meteor/client/views/outlookCalendar` is a desktop-only UI. It calls the + `window.RocketChatDesktop` bridge to test/clear credentials and fetch events. +- The desktop application owns Microsoft/Exchange authentication and token or + password refresh. No Microsoft credential or delegated token is stored in + this repository. Users authenticate on each desktop installation. +- The desktop imports events through the authenticated `calendar-events.*` + REST endpoints in `apps/meteor/server/api/v1/calendar.ts`. +- `CalendarService` persists imported events in `rocketchat_calendar_event`, + schedules reminders and starts, and projects active busy events through the + EE `Presence` service. + +The current database record includes subject, description, meeting URL, +external ID, start/end, reminder state and a boolean `busy` flag. It has no +provider, tenant, mailbox, delta cursor or subscription state. Calendar data is +therefore persisted, including content that is not needed for presence. + +The current presence behavior is: + +- a busy event creates one `external` claim with status ID `calendar`; +- the claim is `busy`, has localized text `Outlook: In a meeting`, and expires + at the latest end of all currently overlapping busy events; +- the EE presence engine applies `internal > manual > external` precedence; +- a higher-priority claim can stash one lower-priority claim and expiration is + handled by `PresenceReaper`; +- deleting or changing an active event recomputes the remaining calendar claim. + +This is safer than blindly restoring a snapshot, but the presence engine still +has only one stashed slot. The enterprise calendar layer must aggregate all +calendar events into one owned claim and recompute it whenever projections +change. + +## Tests and baseline + +Relevant tests are: + +- `apps/meteor/tests/unit/server/services/calendar/service.tests.ts`; +- `apps/meteor/tests/unit/server/services/calendar/utils`; +- `apps/meteor/tests/end-to-end/api/calendar.ts`; +- `apps/meteor/tests/e2e/calendar.spec.ts`; +- `ee/packages/presence/src/Presence.spec.ts` and + `ee/packages/presence/src/lib/presenceEngine.spec.ts`. + +The pre-change targeted Jest command could not start because this fresh shallow +checkout had no dependency state. `yarn install --immutable` fetched the +workspace dependencies but three unrelated native packages (`deasync`, +`isolated-vm`, and `gc-stats`) failed to build. The subsequent calendar test +attempt failed before test discovery because workspace `dist` outputs had not +been built. This baseline infrastructure failure is recorded rather than hidden. + +## Reusable components + +- EE license module `outlook-calendar` and settings registry. +- `@rocket.chat/server-fetch` for timeouts, DNS pinning and SSRF checks. +- Agenda-backed `@rocket.chat/cron` for single, cluster-safe scheduled jobs. +- `CalendarEvent` model and `CalendarService` for legacy compatibility. +- EE `Presence` claims, expiration/reaper, cluster propagation and precedence. +- REST authorization/rate-limiter middleware and audited setting updates. +- Logger and existing Prometheus/metrics conventions. + +## Components to replace or extend + +- Desktop-owned authentication and event fetching are not usable for + server-to-server synchronization. +- Legacy records are content-heavy and lack provider ownership. Server-side + projections must be content-free and separately owned. +- There is no tenant/mailbox mapping, sync cursor, webhook subscription, retry + or health persistence. +- Existing secret settings are masked from clients and audits, but are not an + encrypted-at-rest vault. Enterprise calendar credentials therefore require a + dedicated authenticated-encryption boundary and a deployment key. +- The single global start scheduler is suitable for legacy events but server + reconciliation also needs periodic jobs and persistent state. + +## Implementation modules + +`ee/packages/enterprise-calendar` contains provider-independent contracts, +cloud endpoint selection, app-only token acquisition, the Microsoft Graph +provider, event normalization, mailbox resolution, sync orchestration, +notification validation/coalescing, presence projection, retry policy, +secret encryption, and the explicit EWS boundary. Meteor adapters are kept in +`apps/meteor/ee/server/enterprise-calendar` so provider code never updates +presence or MongoDB directly. + +The legacy desktop integration remains available for controlled coexistence. +Server mappings own only server-projected records, preventing both integrations +from updating the same event or deleting each other's data. diff --git a/docs/features/enterprise-calendar-presence.md b/docs/features/enterprise-calendar-presence.md new file mode 100644 index 0000000000000..22181865cec12 --- /dev/null +++ b/docs/features/enterprise-calendar-presence.md @@ -0,0 +1,566 @@ +# Enterprise Exchange calendar presence + +Enterprise calendar presence lets an administrator connect Rocket.Chat to +Exchange Online once. Eligible users do not authorize Microsoft, and no active +desktop or browser session is required. + +This page is both a deployment guide and an end-to-end test runbook. + +## Support status + +| Environment | Provider | Status | +| --- | --- | --- | +| Microsoft 365 / Exchange Online | Microsoft Graph | Supported | +| Hybrid, with the mailbox in Exchange Online | Microsoft Graph | Supported | +| Hybrid, with the mailbox only on premises | EWS | Not implemented | +| Exchange Server 2016, 2019, or Subscription Edition | EWS | Not implemented | + +Do not configure `exchange-ews` mappings for this release. The shared provider +boundary contains EWS types for future work, but there are no SOAP calendar +reads, impersonation, Autodiscover, or EWS notifications yet. + +## Before you start + +You need: + +- a Rocket.Chat Enterprise deployment whose nodes can reach Microsoft identity + and Graph endpoints over HTTPS; +- a Rocket.Chat administrator account and REST API credentials; +- a Microsoft 365 tenant administrator who can register an application and + grant application permissions; +- an Exchange Online administrator if access will be limited with Exchange App + RBAC; +- at least one Exchange Online test mailbox and its corresponding Rocket.Chat + user; and +- a stable 32-byte encryption key in the secret manager used by every + Rocket.Chat node. + +Use a dedicated Entra application for this integration. Start with one or two +test mailboxes and expand the scope only after the acceptance tests pass. + +### Microsoft cloud endpoints + +Select the cloud that contains the Exchange Online tenant. + +| Cloud | Authority host | Microsoft Graph host | +| --- | --- | --- | +| Global | `https://login.microsoftonline.com` | `https://graph.microsoft.com` | +| US Government L4 | `https://login.microsoftonline.us` | `https://graph.microsoft.us` | +| US Government L5 (DoD) | `https://login.microsoftonline.us` | `https://dod-graph.microsoft.us` | +| China | `https://login.chinacloudapi.cn` | `https://microsoftgraph.chinacloudapi.cn` | + +Rocket.Chat derives these endpoints from the selected cloud. Do not paste an +authority or Graph URL into a mailbox mapping. + +## 1. Prepare test users and events + +Create or identify: + +1. An Exchange Online mailbox, for example `calendar.test@example.com`. +2. A Rocket.Chat user that should follow that mailbox. +3. A second Exchange Online mailbox outside the allowed scope, if App RBAC will + be tested. + +In Outlook, create events that cover the behavior you intend to accept: + +- a short `Busy` event starting soon; +- an `Out of office` event; +- a `Free` event; +- a tentative event; +- an all-day event; +- overlapping busy and out-of-office events; +- a private event; and +- one recurring event that can be edited and cancelled during testing. + +Subjects and other event content are deliberately irrelevant. Rocket.Chat does +not request or store them. + +## 2. Register the Microsoft Entra application + +1. Open **Microsoft Entra admin center > Identity > Applications > App + registrations > New registration**. +2. Enter a descriptive name, such as `Rocket.Chat Enterprise Calendar`. +3. Select **Accounts in this organizational directory only**. +4. Do not configure a redirect URI. Client-credentials authentication does not + use one. +5. Register the application. +6. From **Overview**, record the **Application (client) ID** and **Directory + (tenant) ID**. +7. Open **Enterprise applications**, select the created application, and record + its **Object ID**. This is the service-principal Object ID. It is not the + Object ID shown on the app registration. + +## 3. Authorize calendar reads + +Choose exactly one of the following models. + +### Option A: tenant-wide Graph permission (lab only) + +This is the simplest functional test, but the service principal can read every +mailbox in the tenant. + +1. Open the app registration's **API permissions**. +2. Select **Add a permission > Microsoft Graph > Application permissions**. +3. Add only `Calendars.Read`. +4. Select **Grant admin consent** and verify that consent is granted. + +Do not add `Calendars.ReadWrite`, mail, contacts, Teams, or directory +permissions. `Calendars.ReadBasic.All` is insufficient for the +`calendarView/delta` operation used by Rocket.Chat. + +Explicit Rocket.Chat mappings control which users are synchronized, but they do +not reduce what Microsoft authorizes the application to read. + +### Option B: Exchange Online App RBAC (recommended) + +App RBAC limits `Application Calendars.Read` to a recipient scope. In Exchange +Online PowerShell, run: + +```powershell +Install-Module ExchangeOnlineManagement -Scope CurrentUser +Connect-ExchangeOnline + +$AppId = "" +$ServicePrincipalObjectId = "" +$ScopeName = "RocketChat Calendar Mailboxes" + +Set-Mailbox calendar.test@example.com -CustomAttribute1 "RocketChatCalendar" + +New-ManagementScope ` + -Name $ScopeName ` + -RecipientRestrictionFilter "CustomAttribute1 -eq 'RocketChatCalendar'" + +New-ServicePrincipal ` + -AppId $AppId ` + -ObjectId $ServicePrincipalObjectId ` + -DisplayName "Rocket.Chat Enterprise Calendar" + +New-ManagementRoleAssignment ` + -Name "RocketChat Calendar Read" ` + -Role "Application Calendars.Read" ` + -App $ServicePrincipalObjectId ` + -CustomResourceScope $ScopeName +``` + +If `New-ServicePrincipal` reports that the service principal already exists, +confirm the existing entry uses the correct application and Enterprise +Application Object IDs instead of creating another entry. + +Verify both a positive and negative mailbox: + +```powershell +Test-ServicePrincipalAuthorization ` + -Identity $ServicePrincipalObjectId ` + -Resource calendar.test@example.com + +Test-ServicePrincipalAuthorization ` + -Identity $ServicePrincipalObjectId ` + -Resource outside.scope@example.com +``` + +The allowed mailbox must show `InScope: True`; the other mailbox must not. + +Entra API permissions and Exchange App RBAC grants are additive. After the +scoped role works, remove any organization-wide Microsoft Graph +`Calendars.Read` application permission from **API permissions**. Leaving that +permission assigned would bypass the intended mailbox restriction. + +Application Access Policies are a legacy scoping mechanism. Use App RBAC for +new deployments. + +## 4. Create an application credential + +A certificate is recommended. Use a certificate issued by your organization's +trusted CA in production. The following self-signed certificate is suitable +only for a short-lived test: + +```bash +umask 077 +openssl req -x509 -newkey rsa:3072 -sha256 -days 90 -nodes \ + -keyout rocket-chat-calendar.key.pem \ + -out rocket-chat-calendar.cert.pem \ + -subj "/CN=Rocket.Chat Enterprise Calendar Test" +openssl x509 -in rocket-chat-calendar.cert.pem -outform der \ + -out rocket-chat-calendar.cert.cer +chmod 600 rocket-chat-calendar.key.pem +``` + +In the Entra app registration, open **Certificates & secrets > Certificates > +Upload certificate** and upload `rocket-chat-calendar.cert.cer`. Rocket.Chat +will receive the PEM certificate and private key later; never upload the +private key to Entra. + +A client secret can be used for a lab or during certificate rotation. Copy its +value when it is created because Entra does not display it again. + +## 5. Configure every Rocket.Chat node + +Generate the deployment encryption key once: + +```bash +openssl rand -base64 32 +``` + +Store the result in the deployment secret manager and expose it to every +Rocket.Chat node: + +```text +ROCKETCHAT_ENTERPRISE_CALENDAR_ENCRYPTION_KEY= +``` + +Restart all nodes after adding the variable. They must use the identical value. +Back it up securely: losing or changing it makes the stored Microsoft +credential unreadable. Do not put it in Rocket.Chat settings, logs, or source +control. + +## 6. Configure Rocket.Chat settings + +In **Administration > Outlook Calendar > Enterprise Calendar Server**: + +1. Select the Microsoft cloud. +2. Enter the tenant ID and client ID recorded earlier. +3. Select `certificate` or `client-secret` as the credential type. +4. Leave webhooks disabled for the first test. +5. Set a small sync window, such as 1 past hour and 2 future days. +6. Set the maximum users per run to a small test batch. +7. Choose whether all-day, tentative, and working-elsewhere events should + affect presence. +8. Leave enterprise calendar presence disabled until the connection and mapping + tests pass. + +The underlying setting IDs are: + +```text +Enterprise_Calendar_Graph_Cloud +Enterprise_Calendar_Graph_Tenant_Id +Enterprise_Calendar_Graph_Client_Id +Enterprise_Calendar_Graph_Credential_Type +Enterprise_Calendar_Graph_Webhook_Enabled +Enterprise_Calendar_Graph_Webhook_Url +Enterprise_Calendar_Sync_Past_Hours +Enterprise_Calendar_Sync_Future_Days +Enterprise_Calendar_Max_Users_Per_Run +Enterprise_Calendar_Include_All_Day +Enterprise_Calendar_Include_Tentative +Enterprise_Calendar_Include_Working_Elsewhere +Enterprise_Calendar_Mailbox_Mappings +Enterprise_Calendar_Enabled +``` + +Settings can also be changed through `POST /api/v1/settings/:id` with a JSON +body such as `{"value": false}`. Prefer the administration UI unless the test +environment is provisioned automatically. + +## 7. Save the write-only credential + +Set reusable shell variables for the administrator API. Keep tokens out of +source control and shared shell transcripts. + +```bash +export RC_URL="https://chat.example.com" +export RC_USER_ID="" +export RC_AUTH_TOKEN="" +``` + +For a certificate, send the PEM files without embedding them in shell +arguments: + +```bash +jq -n \ + --rawfile certificate rocket-chat-calendar.cert.pem \ + --rawfile privateKey rocket-chat-calendar.key.pem \ + '{credentialType:"certificate",certificate:$certificate,privateKey:$privateKey}' | +curl --fail-with-body \ + -H "X-Auth-Token: $RC_AUTH_TOKEN" \ + -H "X-User-Id: $RC_USER_ID" \ + -H "Content-Type: application/json" \ + --data-binary @- \ + "$RC_URL/api/v1/enterprise-calendar.configure-graph-credential" +``` + +For a client secret: + +```bash +read -rsp "Microsoft client secret: " RC_CLIENT_SECRET +printf '\n' +jq -n --arg clientSecret "$RC_CLIENT_SECRET" \ + '{credentialType:"client-secret",clientSecret:$clientSecret}' | +curl --fail-with-body \ + -H "X-Auth-Token: $RC_AUTH_TOKEN" \ + -H "X-User-Id: $RC_USER_ID" \ + -H "Content-Type: application/json" \ + --data-binary @- \ + "$RC_URL/api/v1/enterprise-calendar.configure-graph-credential" +unset RC_CLIENT_SECRET +``` + +Secret values are write-only. A successful response reports configuration +booleans but never returns the secret, certificate private key, or generated +webhook client state. + +## 8. Test Microsoft identity and mailbox access + +Test the application and one in-scope mailbox before enabling synchronization: + +```bash +curl --fail-with-body \ + -H "X-Auth-Token: $RC_AUTH_TOKEN" \ + -H "X-User-Id: $RC_USER_ID" \ + -H "Content-Type: application/json" \ + -d '{"mailbox":"calendar.test@example.com"}' \ + "$RC_URL/api/v1/enterprise-calendar.test-graph" +``` + +With App RBAC, repeat with the out-of-scope mailbox and confirm Microsoft denies +the request. A successful tenant-only test is not proof that mailbox +authorization is correct. + +## 9. Add explicit mailbox mappings + +Find a Rocket.Chat user ID if needed: + +```bash +curl --get --fail-with-body \ + -H "X-Auth-Token: $RC_AUTH_TOKEN" \ + -H "X-User-Id: $RC_USER_ID" \ + --data-urlencode "username=calendar.test" \ + "$RC_URL/api/v1/users.info" +``` + +Set `Enterprise_Calendar_Mailbox_Mappings` to a JSON array: + +```json +[ + { + "userId": "rocketChatUserId", + "provider": "microsoft-graph", + "address": "calendar.test@example.com", + "externalUserId": "entra-user-object-guid", + "enabled": true + } +] +``` + +`externalUserId` is optional but recommended. It keeps the identity stable when +an SMTP address changes. Display names are never used. Duplicate mailbox +ownership and multiple enabled mappings for one user are rejected. Disabled or +deleted Rocket.Chat users are removed from synchronization. + +## 10. Run the polling acceptance test + +Keep webhooks disabled, enable `Enterprise_Calendar_Enabled`, and request +health: + +```bash +curl --fail-with-body \ + -H "X-Auth-Token: $RC_AUTH_TOKEN" \ + -H "X-User-Id: $RC_USER_ID" \ + "$RC_URL/api/v1/enterprise-calendar.health" +``` + +The first synchronization is bounded by the configured past/future window. +Polling reconciliation normally runs within five minutes. Exact event start and +end transitions can update presence without waiting for another Microsoft +read. + +Verify the default behavior: + +| Calendar state | Default Rocket.Chat effect | +| --- | --- | +| Busy | `busy` claim | +| Out of office | `busy` claim | +| Free | No override | +| Tentative | No override | +| Working elsewhere | No override | +| Cancelled | No override | +| All-day | No override | + +The optional settings enable tentative, working-elsewhere, or all-day +projections. For overlaps, precedence is out-of-office, busy, tentative, then +working-elsewhere. + +Complete these checks: + +1. Wait for the initial successful sync and confirm the busy event affects only + the mapped user. +2. Edit the event time and confirm the presence window changes. +3. Cancel the event and confirm its claim clears. +4. Test an overlapping event and confirm precedence. +5. Test a recurring occurrence edit and cancellation. +6. Confirm a private event behaves like an ordinary content-free projection. +7. Disable the mapping and confirm the enterprise-calendar claim is cleaned up. +8. Re-enable it and force a full resynchronization: + + ```bash + curl --fail-with-body \ + -H "X-Auth-Token: $RC_AUTH_TOKEN" \ + -H "X-User-Id: $RC_USER_ID" \ + -H "Content-Type: application/json" \ + -d '{"userId":"rocketChatUserId"}' \ + "$RC_URL/api/v1/enterprise-calendar.resync" + ``` + +Omit `userId` to resynchronize all enabled mappings. + +## 11. Enable and test Microsoft Graph webhooks + +Polling is sufficient for private development environments. For faster updates, +publish this exact HTTPS endpoint: + +```text +https://chat.example.com/api/v1/enterprise-calendar/graph/notifications +``` + +The reverse proxy must: + +- allow unauthenticated Microsoft POST requests to this path; +- preserve the query string, including `validationToken`; +- pass the request body unchanged; +- avoid redirects and interactive authentication; and +- use a publicly trusted TLS certificate. + +Check routing with a harmless validation request: + +```bash +curl --fail-with-body \ + "$RC_URL/api/v1/enterprise-calendar/graph/notifications?validationToken=rocket-chat-webhook-test" +``` + +The response must be plain text `rocket-chat-webhook-test`. This checks proxy +routing, not Microsoft subscription authorization. + +Set `Enterprise_Calendar_Graph_Webhook_Url` to the public URL and enable +`Enterprise_Calendar_Graph_Webhook_Enabled`. Call the credential endpoint again +if credentials were configured before webhook client state existed, then call +the Graph connection test with a mailbox. In webhook mode that test creates and +deletes a temporary subscription, exercising Microsoft's validation request. + +Enable the integration, edit a test event, and confirm the health response shows +successful subscription creation and notification processing. Polling remains +enabled as reconciliation and repairs missed notifications. Subscriptions are +renewed before expiry. + +## Health interpretation + +`GET /api/v1/enterprise-calendar.health` is administrator-only and returns +sanitized operational state. Use it to check: + +- whether the integration and provider are configured; +- the last successful reconciliation and its next scheduled run; +- mapped-user, projection, subscription, and pending-notification counts; +- the oldest pending work and retry window; and +- categorized errors without tokens, calendar content, mailbox addresses, or + delta links. + +A growing pending count, expired subscription, or repeated retry time indicates +a connectivity, authorization, throttling, or worker problem. + +## Data and privacy behavior + +Rocket.Chat requests only event ID, start/end, `showAs`, cancellation/all-day +flags, sensitivity, last-modified time, occurrence type, and series ID. It does +not request or store subject, body, attendees, attachments, location, meeting +URL, or online-meeting data. + +Raw provider event IDs and mailbox addresses are HMACed before projection +storage. Delta URLs are stored only as sync state, accepted only for the selected +Graph cloud, never logged, and never returned by administrator APIs. Access +tokens remain in process memory and are refreshed before expiry. + +The integration owns one `enterprise-calendar` external presence claim. It +never restores a captured status snapshot. Rocket.Chat's existing +`internal > manual > external` precedence remains authoritative. Expired +projections are retained for no more than 24 hours. + +## Troubleshooting + +| Symptom or category | Checks | +| --- | --- | +| `authentication` | Confirm cloud, tenant ID, client ID, credential type, certificate/key pair, and certificate validity. Verify the credential belongs to this app registration. | +| `authorization` | For tenant-wide access, confirm admin consent for application `Calendars.Read`. For App RBAC, run `Test-ServicePrincipalAuthorization` and ensure no unintended Entra grant remains. Allow time for Microsoft permission changes to propagate. | +| `mailbox-not-found` | Confirm the mailbox is in Exchange Online, the SMTP address or object ID is correct, and the recipient is inside the App RBAC scope. | +| `invalid-cursor` | This can follow long downtime or a sync-window change. Rocket.Chat schedules a bounded full sync. | +| `throttled` | Reduce users per run. Rocket.Chat honors Microsoft `Retry-After` and applies bounded backoff. | +| Webhook validation fails | Confirm public HTTPS reaches the exact path, returns the validation token as plain text, preserves query parameters, and does not require proxy authentication. | +| Subscription renewal fails | Check Graph reachability, permissions, public URL, TLS trust, and health retry time. Polling continues. | +| No presence change | Confirm the event is inside the sync window, the mapping and user are enabled, the event `showAs` is supported by current settings, and manual/internal presence is not taking precedence. Force a resync. | +| Encryption-key error | Confirm every node has the same base64-encoded 32-byte deployment key and was restarted. Restore the original key or replace the stored credential intentionally. | + +Administrator errors are sanitized. They must never include OAuth responses, +tokens, keys, passwords, delta links, mailbox addresses, or calendar content. + +## Credential rotation and revocation + +For certificate rotation: + +1. Upload the new public certificate to Entra. +2. Save its certificate and private key through the credential endpoint. +3. Run the connection test for an in-scope mailbox. +4. Remove the old certificate from Entra. + +For a client secret, create and test the replacement before deleting the old +value. Keep the deployment encryption key until encrypted credentials have been +replaced or intentionally abandoned. + +To revoke the integration, disable it in Rocket.Chat and remove the Microsoft +authorization. For the App RBAC example: + +```powershell +Remove-ManagementRoleAssignment "RocketChat Calendar Read" -Confirm:$false +Remove-ManagementScope $ScopeName -Confirm:$false +Remove-ServicePrincipal -Identity $ServicePrincipalObjectId -Confirm:$false +Disconnect-ExchangeOnline +``` + +Remove the Entra credential and any API permission assigned to the application. +Delete the Entra application only if it was dedicated to Rocket.Chat and is no +longer needed. + +## Migration from desktop Outlook + +The legacy desktop integration remains installed for rollback. No desktop +credential or delegated token is deleted because those credentials live in the +desktop application, outside this repository. + +Cut over in this order: + +1. Configure and test Graph while server synchronization is disabled. +2. Add a small mapping set and enable synchronization. +3. Wait for a successful initial sync and verify presence. +4. Once server ownership is assigned, delegated `calendar-events.import` + requests for that user return `calendar-managed-by-server`, preventing two + integrations from writing the same presence. +5. A successful server sync ends only the old `calendar` presence claim. Legacy + event records remain for rollback and age out under existing policy. +6. Expand mappings in bounded batches while monitoring health. + +Rollback by disabling the mapping or server integration, then re-enable desktop +sync. No delegated credentials are automatically revoked. + +For a mailbox move, disable the old mapping, allow subscription and projection +cleanup, update the address/object ID, re-enable it, and force a full resync. +Rocket.Chat never probes Graph and EWS in sequence. + +## Exchange Server and hybrid limitations + +Graph client credentials cannot read a mailbox that exists only on an on-premise +Exchange server. In a hybrid organization, this runbook applies only after the +test mailbox is in Exchange Online. + +EWS configuration types and validation boundaries are present for future work, +but the provider currently returns `ews-not-implemented`. A later implementation +must add an SSRF-protected endpoint policy, safe custom-CA handling, XML size and +XXE defenses, impersonated CalendarView and SyncFolderItems, watermark recovery, +notifications, and Exchange throttling tests before EWS can be enabled. + +## Microsoft references + +- [Register an application and service principal](https://learn.microsoft.com/en-us/entra/identity-platform/howto-create-service-principal-portal) +- [Microsoft Graph client-credentials authentication](https://learn.microsoft.com/en-us/graph/auth-v2-service) +- [Add a certificate credential](https://learn.microsoft.com/en-us/graph/applications-how-to-add-certificate) +- [Microsoft Graph permissions reference](https://learn.microsoft.com/en-us/graph/permissions-reference) +- [Microsoft Graph national cloud deployments](https://learn.microsoft.com/en-us/graph/deployments) +- [Deliver change notifications through webhooks](https://learn.microsoft.com/en-us/graph/change-notifications-delivery-webhooks) +- [Exchange Online Application RBAC](https://learn.microsoft.com/en-us/exchange/permissions-exo/application-rbac) +- [New-ServicePrincipal](https://learn.microsoft.com/en-us/powershell/module/exchangepowershell/new-serviceprincipal) +- [New-ManagementScope](https://learn.microsoft.com/en-us/powershell/module/exchangepowershell/new-managementscope) diff --git a/ee/packages/enterprise-calendar/jest.config.ts b/ee/packages/enterprise-calendar/jest.config.ts new file mode 100644 index 0000000000000..c37f06bd20728 --- /dev/null +++ b/ee/packages/enterprise-calendar/jest.config.ts @@ -0,0 +1,4 @@ +import server from '@rocket.chat/jest-presets/server'; +import type { Config } from 'jest'; + +export default { preset: server.preset } satisfies Config; diff --git a/ee/packages/enterprise-calendar/package.json b/ee/packages/enterprise-calendar/package.json new file mode 100644 index 0000000000000..37d8d7ff4c4f9 --- /dev/null +++ b/ee/packages/enterprise-calendar/package.json @@ -0,0 +1,30 @@ +{ + "name": "@rocket.chat/enterprise-calendar", + "version": "0.1.0", + "private": true, + "main": "./dist/index.js", + "typings": "./dist/index.d.ts", + "files": [ + "/dist" + ], + "scripts": { + "build": "tsc", + "lint": "eslint .", + "lint:fix": "eslint --fix .", + "test": "jest", + "testunit": "jest", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@rocket.chat/jest-presets": "workspace:~", + "@rocket.chat/tsconfig": "workspace:*", + "@types/jest": "~30.0.0", + "@types/node": "~22.19.17", + "eslint": "~9.39.4", + "jest": "~30.2.0", + "typescript": "~5.9.3" + }, + "volta": { + "extends": "../../../package.json" + } +} diff --git a/ee/packages/enterprise-calendar/src/clouds.spec.ts b/ee/packages/enterprise-calendar/src/clouds.spec.ts new file mode 100644 index 0000000000000..bc477e91fdb98 --- /dev/null +++ b/ee/packages/enterprise-calendar/src/clouds.spec.ts @@ -0,0 +1,17 @@ +import { getGraphDefaultScope, getMicrosoftCloudEndpoints, getTenantTokenEndpoint } from './clouds'; + +describe('Microsoft cloud endpoints', () => { + it.each([ + ['global', 'https://login.microsoftonline.com', 'https://graph.microsoft.com'], + ['us-gov', 'https://login.microsoftonline.us', 'https://graph.microsoft.us'], + ['us-gov-dod', 'https://login.microsoftonline.us', 'https://dod-graph.microsoft.us'], + ['china', 'https://login.chinacloudapi.cn', 'https://microsoftgraph.chinacloudapi.cn'], + ] as const)('selects %s', (cloud, authority, graph) => { + expect(getMicrosoftCloudEndpoints(cloud)).toEqual({ authority, graph }); + expect(getGraphDefaultScope(cloud)).toBe(`${graph}/.default`); + }); + + it('rejects a non-guid tenant instead of composing an authority URL', () => { + expect(() => getTenantTokenEndpoint('global', '../common')).toThrow('invalid-tenant-id'); + }); +}); diff --git a/ee/packages/enterprise-calendar/src/clouds.ts b/ee/packages/enterprise-calendar/src/clouds.ts new file mode 100644 index 0000000000000..511537c9dec13 --- /dev/null +++ b/ee/packages/enterprise-calendar/src/clouds.ts @@ -0,0 +1,25 @@ +import type { MicrosoftCloud } from './types'; + +export type MicrosoftCloudEndpoints = { + authority: string; + graph: string; +}; + +const CLOUDS: Record = { + 'global': { authority: 'https://login.microsoftonline.com', graph: 'https://graph.microsoft.com' }, + 'us-gov': { authority: 'https://login.microsoftonline.us', graph: 'https://graph.microsoft.us' }, + 'us-gov-dod': { authority: 'https://login.microsoftonline.us', graph: 'https://dod-graph.microsoft.us' }, + 'china': { authority: 'https://login.chinacloudapi.cn', graph: 'https://microsoftgraph.chinacloudapi.cn' }, +}; + +export const getMicrosoftCloudEndpoints = (cloud: MicrosoftCloud): MicrosoftCloudEndpoints => CLOUDS[cloud]; + +export const getGraphDefaultScope = (cloud: MicrosoftCloud): string => `${getMicrosoftCloudEndpoints(cloud).graph}/.default`; + +export const getTenantTokenEndpoint = (cloud: MicrosoftCloud, tenantId: string): string => { + if (!/^[0-9a-f-]{36}$/i.test(tenantId)) { + throw new Error('invalid-tenant-id'); + } + + return `${getMicrosoftCloudEndpoints(cloud).authority}/${tenantId}/oauth2/v2.0/token`; +}; diff --git a/ee/packages/enterprise-calendar/src/errors.ts b/ee/packages/enterprise-calendar/src/errors.ts new file mode 100644 index 0000000000000..8ea21c2f9190e --- /dev/null +++ b/ee/packages/enterprise-calendar/src/errors.ts @@ -0,0 +1,37 @@ +export type CalendarErrorCategory = + | 'authentication' + | 'authorization' + | 'mailbox-not-found' + | 'invalid-cursor' + | 'throttled' + | 'timeout' + | 'service-unavailable' + | 'invalid-response' + | 'configuration' + | 'unsupported'; + +export class EnterpriseCalendarError extends Error { + constructor( + public readonly category: CalendarErrorCategory, + public readonly retryable: boolean, + message: string, + public readonly retryAfterMs?: number, + ) { + super(message); + this.name = 'EnterpriseCalendarError'; + } +} + +export const sanitizeGraphError = (status: number, code?: string, retryAfterMs?: number): EnterpriseCalendarError => { + if (status === 401) return new EnterpriseCalendarError('authentication', false, 'Microsoft credential is invalid or expired'); + if (status === 403) return new EnterpriseCalendarError('authorization', false, 'Calendar permission or mailbox scope denied'); + if (status === 404) + return new EnterpriseCalendarError('mailbox-not-found', false, 'Mailbox was not found or is outside application scope'); + if (status === 410 || code === 'ErrorInvalidSyncStateData') { + return new EnterpriseCalendarError('invalid-cursor', false, 'Calendar synchronization cursor is no longer valid'); + } + if (status === 429) return new EnterpriseCalendarError('throttled', true, 'Microsoft Graph throttled the request', retryAfterMs); + if (status >= 500) + return new EnterpriseCalendarError('service-unavailable', true, 'Microsoft Graph is temporarily unavailable', retryAfterMs); + return new EnterpriseCalendarError('invalid-response', false, 'Microsoft Graph rejected the calendar request'); +}; diff --git a/ee/packages/enterprise-calendar/src/ewsProvider.spec.ts b/ee/packages/enterprise-calendar/src/ewsProvider.spec.ts new file mode 100644 index 0000000000000..4467126042c35 --- /dev/null +++ b/ee/packages/enterprise-calendar/src/ewsProvider.spec.ts @@ -0,0 +1,27 @@ +import { ExchangeEwsProviderBoundary, validateEwsConfiguration } from './ewsProvider'; + +const configuration = { + endpoint: 'https://exchange.example.com/EWS/Exchange.asmx', + authentication: { type: 'negotiate' as const }, + impersonation: true as const, + exchangeVersion: 'ExchangeSE' as const, +}; + +describe('EWS provider boundary', () => { + it('does not claim operational EWS support', async () => { + expect(validateEwsConfiguration(configuration)).toMatchObject({ valid: false, code: 'ews-not-implemented' }); + const provider = new ExchangeEwsProviderBoundary(configuration); + await expect( + provider.getCalendarWindow({ provider: 'exchange-ews', address: 'u@example.com' }, new Date(), new Date()), + ).rejects.toMatchObject({ + category: 'unsupported', + }); + }); + + it('rejects non-TLS and credential-bearing endpoints before network use', () => { + expect(validateEwsConfiguration({ ...configuration, endpoint: 'http://user:pass@example.com/EWS' })).toMatchObject({ + valid: false, + code: 'invalid-ews-url', + }); + }); +}); diff --git a/ee/packages/enterprise-calendar/src/ewsProvider.ts b/ee/packages/enterprise-calendar/src/ewsProvider.ts new file mode 100644 index 0000000000000..3a68187207fb7 --- /dev/null +++ b/ee/packages/enterprise-calendar/src/ewsProvider.ts @@ -0,0 +1,58 @@ +import { EnterpriseCalendarError } from './errors'; +import type { + CalendarConfigurationValidation, + CalendarMailboxIdentity, + CalendarSyncCursor, + CalendarSyncResult, + CalendarUserIdentity, + IEnterpriseCalendarProvider, + EwsProviderConfiguration, + NormalizedCalendarEvent, +} from './types'; + +export const validateEwsConfiguration = (configuration: EwsProviderConfiguration): CalendarConfigurationValidation => { + let endpoint: URL; + try { + endpoint = new URL(configuration.endpoint); + } catch { + return { valid: false, code: 'invalid-ews-url', message: 'EWS endpoint is invalid' }; + } + if (endpoint.protocol !== 'https:' || endpoint.username || endpoint.password || endpoint.hash) { + return { valid: false, code: 'invalid-ews-url', message: 'EWS endpoint must use HTTPS and contain no credentials' }; + } + if (configuration.authentication.type === 'basic' && configuration.authentication.explicitlyAllowed !== true) { + return { valid: false, code: 'basic-auth-not-approved', message: 'Basic authentication requires explicit legacy approval' }; + } + if (!configuration.impersonation) { + return { valid: false, code: 'impersonation-required', message: 'Scoped Exchange impersonation is required' }; + } + return { valid: false, code: 'ews-not-implemented', message: 'EWS network synchronization is not available in this release' }; +}; + +export class ExchangeEwsProviderBoundary implements IEnterpriseCalendarProvider { + readonly type = 'exchange-ews' as const; + + constructor(private readonly configuration: EwsProviderConfiguration) {} + + async validateConfiguration(): Promise { + return validateEwsConfiguration(this.configuration); + } + + async resolveMailbox(user: CalendarUserIdentity): Promise { + if (!user.active || user.providerHint !== this.type) return null; + const address = user.trustedUpn ?? (user.verifiedEmails.length === 1 ? user.verifiedEmails[0] : undefined); + return address ? { provider: this.type, address } : null; + } + + async getCalendarWindow(_mailbox: CalendarMailboxIdentity, _start: Date, _end: Date): Promise { + throw this.unsupported(); + } + + async synchronizeChanges(_mailbox: CalendarMailboxIdentity, _cursor: CalendarSyncCursor): Promise { + throw this.unsupported(); + } + + private unsupported(): EnterpriseCalendarError { + return new EnterpriseCalendarError('unsupported', false, 'Exchange EWS synchronization is not implemented'); + } +} diff --git a/ee/packages/enterprise-calendar/src/graphProvider.spec.ts b/ee/packages/enterprise-calendar/src/graphProvider.spec.ts new file mode 100644 index 0000000000000..2182cad13c16c --- /dev/null +++ b/ee/packages/enterprise-calendar/src/graphProvider.spec.ts @@ -0,0 +1,93 @@ +import { MicrosoftGraphCalendarProvider } from './graphProvider'; +import type { GraphProviderConfiguration, HttpClient } from './types'; + +const configuration: GraphProviderConfiguration = { + cloud: 'global', + tenantId: '11111111-1111-4111-8111-111111111111', + clientId: 'client', + credential: { type: 'client-secret', clientSecret: 'secret' }, +}; +const mailbox = { provider: 'microsoft-graph' as const, address: 'person@example.com' }; +const response = (status: number, value?: unknown, headers: Record = {}) => ({ + status, + headers: { get: (name: string) => headers[name.toLowerCase()] ?? null }, + text: async () => (value === undefined ? '' : JSON.stringify(value)), +}); + +describe('MicrosoftGraphCalendarProvider', () => { + it('paginates delta, normalizes privacy-safe fields, and handles tombstones', async () => { + const http = jest + .fn() + .mockResolvedValueOnce(response(200, { access_token: 'token', expires_in: 3600 })) + .mockResolvedValueOnce( + response(200, { + 'value': [ + { + id: 'one', + start: { dateTime: '2026-07-11T10:00:00.0000000', timeZone: 'UTC' }, + end: { dateTime: '2026-07-11T11:00:00.0000000', timeZone: 'UTC' }, + showAs: 'busy', + sensitivity: 'private', + }, + ], + '@odata.nextLink': 'https://graph.microsoft.com/v1.0/users/u/calendarView/delta?$skiptoken=opaque', + }), + ) + .mockResolvedValueOnce( + response(200, { + 'value': [{ 'id': 'deleted', '@removed': { reason: 'deleted' } }], + '@odata.deltaLink': 'https://graph.microsoft.com/v1.0/users/u/calendarView/delta?$deltatoken=opaque', + }), + ); + const provider = new MicrosoftGraphCalendarProvider(configuration, http); + const result = await provider.getInitialDelta(mailbox, new Date('2026-07-11T00:00:00Z'), new Date('2026-07-12T00:00:00Z')); + expect(result.events).toHaveLength(1); + expect(result.events[0]).toMatchObject({ externalId: 'one', availability: 'busy', isPrivate: true }); + expect(result.deletedExternalIds).toEqual(['deleted']); + expect(result.nextCursor?.value).toContain('$deltatoken=opaque'); + const graphRequest = http.mock.calls[1][1]; + expect(graphRequest.headers?.Authorization).toBe('Bearer token'); + expect(http.mock.calls[1][0]).not.toContain('subject'); + }); + + it('rejects an attacker-controlled delta URL without making a request', async () => { + const http = jest.fn(); + const provider = new MicrosoftGraphCalendarProvider(configuration, http); + await expect( + provider.synchronizeChanges(mailbox, { + value: 'https://169.254.169.254/latest/meta-data', + windowStart: new Date('2026-07-11T00:00:00Z'), + windowEnd: new Date('2026-07-12T00:00:00Z'), + }), + ).rejects.toThrow('Untrusted Microsoft Graph cursor URL'); + expect(http).not.toHaveBeenCalled(); + }); + + it('returns a full-resync signal for an expired cursor', async () => { + const http = jest + .fn() + .mockResolvedValueOnce(response(200, { access_token: 'token', expires_in: 3600 })) + .mockResolvedValueOnce(response(410, { error: { code: 'SyncStateNotFound' } })); + const provider = new MicrosoftGraphCalendarProvider(configuration, http); + await expect( + provider.synchronizeChanges(mailbox, { + value: 'https://graph.microsoft.com/v1.0/users/u/calendarView/delta?$deltatoken=old', + windowStart: new Date('2026-07-11T00:00:00Z'), + windowEnd: new Date('2026-07-12T00:00:00Z'), + }), + ).resolves.toMatchObject({ requiresFullResync: true }); + }); + + it('classifies throttling and respects Retry-After', async () => { + const http = jest + .fn() + .mockResolvedValueOnce(response(200, { access_token: 'token', expires_in: 3600 })) + .mockResolvedValueOnce(response(429, { error: { code: 'TooManyRequests' } }, { 'retry-after': '7' })); + const provider = new MicrosoftGraphCalendarProvider(configuration, http); + await expect(provider.getCalendarWindow(mailbox, new Date('2026-07-11'), new Date('2026-07-12'))).rejects.toMatchObject({ + category: 'throttled', + retryable: true, + retryAfterMs: 7_000, + }); + }); +}); diff --git a/ee/packages/enterprise-calendar/src/graphProvider.ts b/ee/packages/enterprise-calendar/src/graphProvider.ts new file mode 100644 index 0000000000000..903de2262d5c7 --- /dev/null +++ b/ee/packages/enterprise-calendar/src/graphProvider.ts @@ -0,0 +1,293 @@ +import { createHash, timingSafeEqual } from 'node:crypto'; + +import { getMicrosoftCloudEndpoints } from './clouds'; +import { EnterpriseCalendarError, sanitizeGraphError } from './errors'; +import { GraphTokenProvider } from './graphTokenProvider'; +import { parseRetryAfterMs } from './retry'; +import type { + CalendarAvailability, + CalendarConfigurationValidation, + CalendarMailboxIdentity, + CalendarSubscription, + CalendarSyncCursor, + CalendarSyncResult, + CalendarUserIdentity, + IEnterpriseCalendarProvider, + GraphProviderConfiguration, + HttpClient, + NormalizedCalendarEvent, +} from './types'; + +type GraphDateTime = { dateTime?: string; timeZone?: string }; +type GraphEvent = { + 'id'?: string; + 'start'?: GraphDateTime; + 'end'?: GraphDateTime; + 'showAs'?: string; + 'isCancelled'?: boolean; + 'isAllDay'?: boolean; + 'sensitivity'?: string; + 'lastModifiedDateTime'?: string; + 'seriesMasterId'?: string; + '@removed'?: { reason?: string }; +}; +type GraphPage = { + 'value'?: GraphEvent[]; + '@odata.nextLink'?: string; + '@odata.deltaLink'?: string; +}; + +const SELECT_FIELDS = 'id,start,end,showAs,isCancelled,isAllDay,sensitivity,lastModifiedDateTime,seriesMasterId,type'; + +const parseGraphDate = (value?: GraphDateTime): Date | null => { + if (!value?.dateTime) return null; + const raw = /(?:Z|[+-]\d\d:\d\d)$/.test(value.dateTime) ? value.dateTime : `${value.dateTime}Z`; + const date = new Date(raw); + return Number.isNaN(date.getTime()) ? null : date; +}; + +export const normalizeGraphAvailability = (showAs?: string): CalendarAvailability => { + switch (showAs) { + case 'free': + case 'workingElsewhere': + case 'tentative': + case 'busy': + case 'outOfOffice': + return showAs; + default: + return 'unknown'; + } +}; + +const normalizeEvent = (event: GraphEvent, mailbox: CalendarMailboxIdentity): NormalizedCalendarEvent | null => { + if (!event.id || event['@removed']) return null; + const start = parseGraphDate(event.start); + const end = parseGraphDate(event.end); + if (!start || !end || start >= end) return null; + const lastModifiedAt = event.lastModifiedDateTime ? new Date(event.lastModifiedDateTime) : undefined; + return { + externalId: event.id, + mailbox, + start, + end, + availability: normalizeGraphAvailability(event.showAs), + isCancelled: event.isCancelled === true, + isAllDay: event.isAllDay === true, + isPrivate: event.sensitivity === 'private', + ...(lastModifiedAt && !Number.isNaN(lastModifiedAt.getTime()) && { lastModifiedAt }), + ...(event.seriesMasterId && { changeKey: event.seriesMasterId }), + }; +}; + +export class MicrosoftGraphCalendarProvider implements IEnterpriseCalendarProvider { + readonly type = 'microsoft-graph' as const; + + private readonly tokenProvider: GraphTokenProvider; + + private readonly graphRoot: string; + + constructor( + private readonly configuration: GraphProviderConfiguration, + private readonly http: HttpClient, + ) { + this.tokenProvider = new GraphTokenProvider(configuration, http); + this.graphRoot = getMicrosoftCloudEndpoints(configuration.cloud).graph; + } + + async validateConfiguration(testMailbox?: CalendarMailboxIdentity): Promise { + try { + await this.tokenProvider.getToken(); + if (testMailbox) { + const now = new Date(); + await this.getCalendarWindow(testMailbox, new Date(now.getTime() - 60_000), new Date(now.getTime() + 60_000)); + } + if (this.configuration.webhookUrl) { + const url = new URL(this.configuration.webhookUrl); + if (url.protocol !== 'https:' || url.username || url.password || url.hash) { + return { valid: false, code: 'invalid-webhook-url', message: 'Webhook URL must be a public HTTPS URL without credentials' }; + } + if (testMailbox) { + const subscription = await this.createOrRenewSubscription(testMailbox); + await this.removeSubscription(subscription); + } + } + return { valid: true }; + } catch (error) { + if (error instanceof EnterpriseCalendarError) return { valid: false, code: error.category, message: error.message }; + return { valid: false, code: 'configuration', message: 'Microsoft Graph configuration is invalid' }; + } + } + + async resolveMailbox(user: CalendarUserIdentity): Promise { + if (!user.active || user.providerHint === 'exchange-ews') return null; + const address = user.trustedUpn ?? (user.verifiedEmails.length === 1 ? user.verifiedEmails[0] : undefined); + if (!address) return null; + return { provider: this.type, address, tenantId: this.configuration.tenantId }; + } + + async getCalendarWindow(mailbox: CalendarMailboxIdentity, start: Date, end: Date): Promise { + this.assertWindow(start, end); + const url = new URL(`${this.graphRoot}/v1.0/users/${encodeURIComponent(mailbox.externalUserId ?? mailbox.address)}/calendarView`); + url.searchParams.set('startDateTime', start.toISOString()); + url.searchParams.set('endDateTime', end.toISOString()); + url.searchParams.set('$select', SELECT_FIELDS); + const { events } = await this.readPages(url.toString(), mailbox); + return events; + } + + async synchronizeChanges(mailbox: CalendarMailboxIdentity, cursor: CalendarSyncCursor): Promise { + this.assertWindow(cursor.windowStart, cursor.windowEnd); + this.assertTrustedGraphUrl(cursor.value); + try { + const result = await this.readPages(cursor.value, mailbox); + return { + events: result.events, + deletedExternalIds: result.deletedExternalIds, + ...(result.nextLink && { + nextCursor: { value: result.nextLink, windowStart: cursor.windowStart, windowEnd: cursor.windowEnd }, + }), + }; + } catch (error) { + if (error instanceof EnterpriseCalendarError && error.category === 'invalid-cursor') { + return { events: [], deletedExternalIds: [], requiresFullResync: true }; + } + throw error; + } + } + + async getInitialDelta(mailbox: CalendarMailboxIdentity, start: Date, end: Date): Promise { + this.assertWindow(start, end); + const url = new URL(`${this.graphRoot}/v1.0/users/${encodeURIComponent(mailbox.externalUserId ?? mailbox.address)}/calendarView/delta`); + url.searchParams.set('startDateTime', start.toISOString()); + url.searchParams.set('endDateTime', end.toISOString()); + url.searchParams.set('$select', SELECT_FIELDS); + const result = await this.readPages(url.toString(), mailbox); + return { + events: result.events, + deletedExternalIds: result.deletedExternalIds, + ...(result.nextLink && { nextCursor: { value: result.nextLink, windowStart: start, windowEnd: end } }), + }; + } + + async createOrRenewSubscription( + mailbox: CalendarMailboxIdentity, + existingSubscription?: CalendarSubscription, + ): Promise { + if (!this.configuration.webhookUrl || !this.configuration.webhookClientState) { + throw new EnterpriseCalendarError('configuration', false, 'Webhook URL and client state are required'); + } + const expiresAt = new Date(Date.now() + 6 * 24 * 60 * 60_000); + const id = existingSubscription?.id; + const url = id ? `${this.graphRoot}/v1.0/subscriptions/${encodeURIComponent(id)}` : `${this.graphRoot}/v1.0/subscriptions`; + const body = id + ? { expirationDateTime: expiresAt.toISOString() } + : { + changeType: 'created,updated,deleted', + notificationUrl: this.configuration.webhookUrl, + lifecycleNotificationUrl: this.configuration.webhookUrl, + resource: `/users/${mailbox.externalUserId ?? mailbox.address}/events`, + expirationDateTime: expiresAt.toISOString(), + clientState: this.configuration.webhookClientState, + }; + const payload = await this.requestJson<{ id?: string; expirationDateTime?: string }>(url, id ? 'PATCH' : 'POST', body); + if (!payload.id || !payload.expirationDateTime) + throw new EnterpriseCalendarError('invalid-response', false, 'Invalid subscription response'); + return { + id: payload.id, + mailbox, + expiresAt: new Date(payload.expirationDateTime), + clientStateHash: createHash('sha256').update(this.configuration.webhookClientState).digest('hex'), + }; + } + + async removeSubscription(subscription: CalendarSubscription): Promise { + await this.requestJson(`${this.graphRoot}/v1.0/subscriptions/${encodeURIComponent(subscription.id)}`, 'DELETE'); + } + + validateClientState(value: string): boolean { + const expected = this.configuration.webhookClientState; + if (!expected) return false; + const actualBuffer = Buffer.from(value); + const expectedBuffer = Buffer.from(expected); + return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer); + } + + private async readPages(url: string, mailbox: CalendarMailboxIdentity) { + const events: NormalizedCalendarEvent[] = []; + const deletedExternalIds: string[] = []; + let next: string | undefined = url; + let terminalLink: string | undefined; + let pages = 0; + while (next) { + if (++pages > 100) throw new EnterpriseCalendarError('invalid-response', false, 'Calendar response exceeded pagination limit'); + this.assertTrustedGraphUrl(next); + const page: GraphPage = await this.requestJson(next, 'GET'); + if (!Array.isArray(page.value)) + throw new EnterpriseCalendarError('invalid-response', false, 'Calendar response contains no event list'); + for (const graphEvent of page.value) { + if (graphEvent['@removed'] && graphEvent.id) deletedExternalIds.push(graphEvent.id); + else { + const event = normalizeEvent(graphEvent, mailbox); + if (event) events.push(event); + } + } + next = page['@odata.nextLink']; + terminalLink = page['@odata.deltaLink'] ?? next; + } + return { events, deletedExternalIds, nextLink: terminalLink }; + } + + private async requestJson>( + url: string, + method: 'GET' | 'POST' | 'PATCH' | 'DELETE', + body?: object, + ): Promise { + this.assertTrustedGraphUrl(url); + const token = await this.tokenProvider.getToken(); + const response = await this.http(url, { + method, + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + Prefer: 'outlook.timezone="UTC"', + ...(body && { 'Content-Type': 'application/json' }), + }, + ...(body && { body: JSON.stringify(body) }), + timeoutMs: this.configuration.requestTimeoutMs, + }); + const text = await response.text(); + if (Buffer.byteLength(text) > 10 * 1024 * 1024) { + throw new EnterpriseCalendarError('invalid-response', false, 'Microsoft Graph response exceeded the size limit'); + } + if (response.status < 200 || response.status >= 300) { + let code: string | undefined; + try { + code = (JSON.parse(text) as { error?: { code?: string } }).error?.code; + } catch { + // Error content is intentionally discarded. + } + throw sanitizeGraphError(response.status, code, parseRetryAfterMs(response.headers.get('retry-after'))); + } + if (!text) return {} as T; + try { + return JSON.parse(text) as T; + } catch { + throw new EnterpriseCalendarError('invalid-response', false, 'Microsoft Graph returned invalid JSON'); + } + } + + private assertTrustedGraphUrl(value: string): void { + const url = new URL(value); + const root = new URL(this.graphRoot); + if (url.protocol !== 'https:' || url.origin !== root.origin || !url.pathname.startsWith('/v1.0/')) { + throw new EnterpriseCalendarError('configuration', false, 'Untrusted Microsoft Graph cursor URL'); + } + } + + private assertWindow(start: Date, end: Date): void { + const length = end.getTime() - start.getTime(); + if (!Number.isFinite(length) || length <= 0 || length > 120 * 24 * 60 * 60_000) { + throw new EnterpriseCalendarError('configuration', false, 'Calendar window must be between zero and 120 days'); + } + } +} diff --git a/ee/packages/enterprise-calendar/src/graphTokenProvider.spec.ts b/ee/packages/enterprise-calendar/src/graphTokenProvider.spec.ts new file mode 100644 index 0000000000000..538482f4fed8e --- /dev/null +++ b/ee/packages/enterprise-calendar/src/graphTokenProvider.spec.ts @@ -0,0 +1,49 @@ +import { GraphTokenProvider } from './graphTokenProvider'; +import type { GraphProviderConfiguration, HttpClient } from './types'; + +const configuration: GraphProviderConfiguration = { + cloud: 'global', + tenantId: '11111111-1111-4111-8111-111111111111', + clientId: 'client', + credential: { type: 'client-secret', clientSecret: 'do-not-log-this' }, +}; + +const response = (status: number, value: unknown) => ({ + status, + headers: { get: () => null }, + text: async () => JSON.stringify(value), +}); + +describe('GraphTokenProvider', () => { + it('uses client credentials, .default, and caches the token', async () => { + const http = jest.fn().mockResolvedValue(response(200, { access_token: 'token', expires_in: 3600 })); + const provider = new GraphTokenProvider(configuration, http, () => 1_000); + expect(await Promise.all([provider.getToken(), provider.getToken()])).toEqual(['token', 'token']); + expect(http).toHaveBeenCalledTimes(1); + const [, request] = http.mock.calls[0]; + const body = new URLSearchParams(request.body); + expect(body.get('grant_type')).toBe('client_credentials'); + expect(body.get('scope')).toBe('https://graph.microsoft.com/.default'); + expect(body.get('client_secret')).toBe('do-not-log-this'); + }); + + it('refreshes before expiration and sanitizes token failures', async () => { + let now = 1_000; + const http = jest + .fn() + .mockResolvedValueOnce(response(200, { access_token: 'first', expires_in: 301 })) + .mockResolvedValueOnce(response(401, { error: 'invalid_client', error_description: 'contains secret detail' })); + const provider = new GraphTokenProvider(configuration, http, () => now); + expect(await provider.getToken()).toBe('first'); + now += 2_000; + let failure: unknown; + try { + await provider.getToken(); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toBe('Microsoft credential is invalid or expired'); + expect((failure as Error).message).not.toContain('contains secret detail'); + }); +}); diff --git a/ee/packages/enterprise-calendar/src/graphTokenProvider.ts b/ee/packages/enterprise-calendar/src/graphTokenProvider.ts new file mode 100644 index 0000000000000..9602fb7162fc6 --- /dev/null +++ b/ee/packages/enterprise-calendar/src/graphTokenProvider.ts @@ -0,0 +1,95 @@ +import { createHash, randomUUID, sign, X509Certificate } from 'node:crypto'; + +import { getGraphDefaultScope, getTenantTokenEndpoint } from './clouds'; +import { EnterpriseCalendarError, sanitizeGraphError } from './errors'; +import type { GraphProviderConfiguration, HttpClient } from './types'; + +type CachedToken = { value: string; expiresAt: number }; + +const encode = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString('base64url'); + +export class GraphTokenProvider { + private cached?: CachedToken; + + private pending?: Promise; + + constructor( + private readonly configuration: GraphProviderConfiguration, + private readonly http: HttpClient, + private readonly now: () => number = Date.now, + ) {} + + async getToken(): Promise { + if (this.cached && this.cached.expiresAt - 5 * 60_000 > this.now()) return this.cached.value; + if (this.pending) return this.pending; + this.pending = this.acquire(); + try { + return await this.pending; + } finally { + this.pending = undefined; + } + } + + clear(): void { + this.cached = undefined; + } + + private async acquire(): Promise { + const endpoint = getTenantTokenEndpoint(this.configuration.cloud, this.configuration.tenantId); + const body = new URLSearchParams({ + client_id: this.configuration.clientId, + scope: getGraphDefaultScope(this.configuration.cloud), + grant_type: 'client_credentials', + }); + + if (this.configuration.credential.type === 'client-secret') { + body.set('client_secret', this.configuration.credential.clientSecret); + } else { + body.set('client_assertion_type', 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'); + body.set('client_assertion', this.createClientAssertion(endpoint)); + } + + const response = await this.http(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + timeoutMs: this.configuration.requestTimeoutMs, + }); + const text = await response.text(); + if (Buffer.byteLength(text) > 1024 * 1024) { + throw new EnterpriseCalendarError('invalid-response', false, 'Microsoft identity response exceeded the size limit'); + } + let payload: { access_token?: string; expires_in?: number; error?: string }; + try { + payload = JSON.parse(text); + } catch { + throw new EnterpriseCalendarError('invalid-response', false, 'Microsoft identity returned an invalid response'); + } + if (response.status < 200 || response.status >= 300 || !payload.access_token || !payload.expires_in) { + throw sanitizeGraphError(response.status, payload.error); + } + + this.cached = { value: payload.access_token, expiresAt: this.now() + payload.expires_in * 1_000 }; + return this.cached.value; + } + + private createClientAssertion(audience: string): string { + const { credential } = this.configuration; + if (credential.type !== 'certificate') throw new Error('certificate-credential-required'); + const certificate = new X509Certificate(credential.certificate); + const x5t = createHash('sha1').update(certificate.raw).digest('base64url'); + const nowSeconds = Math.floor(this.now() / 1_000); + const header = encode({ alg: 'RS256', typ: 'JWT', x5t }); + const claims = encode({ + aud: audience, + iss: this.configuration.clientId, + sub: this.configuration.clientId, + jti: randomUUID(), + nbf: nowSeconds - 60, + exp: nowSeconds + 5 * 60, + }); + const signingInput = `${header}.${claims}`; + const signature = sign('RSA-SHA256', Buffer.from(signingInput), credential.privateKey).toString('base64url'); + return `${signingInput}.${signature}`; + } +} diff --git a/ee/packages/enterprise-calendar/src/index.ts b/ee/packages/enterprise-calendar/src/index.ts new file mode 100644 index 0000000000000..5ff1c6a3a6741 --- /dev/null +++ b/ee/packages/enterprise-calendar/src/index.ts @@ -0,0 +1,13 @@ +export * from './clouds'; +export * from './errors'; +export * from './ewsProvider'; +export * from './graphProvider'; +export * from './graphTokenProvider'; +export * from './mailboxResolver'; +export * from './orchestrator'; +export * from './presenceProjection'; +export * from './projection'; +export * from './retry'; +export * from './secretBox'; +export type * from './types'; +export * from './webhook'; diff --git a/ee/packages/enterprise-calendar/src/mailboxResolver.spec.ts b/ee/packages/enterprise-calendar/src/mailboxResolver.spec.ts new file mode 100644 index 0000000000000..24ea1f152d798 --- /dev/null +++ b/ee/packages/enterprise-calendar/src/mailboxResolver.spec.ts @@ -0,0 +1,32 @@ +import { MailboxResolver } from './mailboxResolver'; + +describe('MailboxResolver', () => { + it('uses an explicit mapping before trusted identities and persists provider selection', () => { + const resolver = new MailboxResolver([ + { userId: 'u1', provider: 'exchange-ews', address: 'Explicit@Example.COM', externalUserId: 'stable', enabled: true }, + ]); + expect(resolver.resolve({ userId: 'u1', active: true, trustedUpn: 'upn@example.com', verifiedEmails: [] }, 'microsoft-graph')).toEqual({ + provider: 'exchange-ews', + address: 'explicit@example.com', + externalUserId: 'stable', + }); + }); + + it('uses exactly one verified email and never guesses among ambiguous identities', () => { + const resolver = new MailboxResolver([]); + expect(resolver.resolve({ userId: 'u1', active: true, verifiedEmails: ['One@Example.com'] }, 'microsoft-graph')).toMatchObject({ + address: 'one@example.com', + }); + expect( + resolver.resolve({ userId: 'u2', active: true, verifiedEmails: ['one@example.com', 'two@example.com'] }, 'microsoft-graph'), + ).toBeNull(); + }); + + it('rejects duplicate mailbox ownership', () => { + const resolver = new MailboxResolver([ + { userId: 'u1', provider: 'microsoft-graph', address: 'person@example.com', enabled: true }, + { userId: 'u2', provider: 'microsoft-graph', address: 'PERSON@example.com', enabled: true }, + ]); + expect(() => resolver.validateNoDuplicates()).toThrow('calendar-mailbox-mapped-to-multiple-users'); + }); +}); diff --git a/ee/packages/enterprise-calendar/src/mailboxResolver.ts b/ee/packages/enterprise-calendar/src/mailboxResolver.ts new file mode 100644 index 0000000000000..8f07ce533bd72 --- /dev/null +++ b/ee/packages/enterprise-calendar/src/mailboxResolver.ts @@ -0,0 +1,46 @@ +import type { CalendarMailboxIdentity, CalendarProviderType, CalendarUserIdentity } from './types'; + +export type ExplicitMailboxMapping = { + userId: string; + provider: CalendarProviderType; + address: string; + externalUserId?: string; + tenantId?: string; + enabled: boolean; +}; + +const normalizeAddress = (address: string): string => address.trim().toLocaleLowerCase('en-US'); + +export class MailboxResolver { + constructor(private readonly mappings: ExplicitMailboxMapping[]) {} + + resolve(user: CalendarUserIdentity, defaultProvider: CalendarProviderType): CalendarMailboxIdentity | null { + if (!user.active) return null; + const explicit = this.mappings.filter((mapping) => mapping.userId === user.userId && mapping.enabled); + if (explicit.length > 1) throw new Error('ambiguous-calendar-mailbox-mapping'); + if (explicit.length === 1) { + const mapping = explicit[0]; + return { + provider: mapping.provider, + address: normalizeAddress(mapping.address), + ...(mapping.externalUserId && { externalUserId: mapping.externalUserId }), + ...(mapping.tenantId && { tenantId: mapping.tenantId }), + }; + } + + if (user.trustedUpn) return { provider: user.providerHint ?? defaultProvider, address: normalizeAddress(user.trustedUpn) }; + const uniqueEmails = [...new Set(user.verifiedEmails.map(normalizeAddress))]; + if (uniqueEmails.length !== 1) return null; + return { provider: user.providerHint ?? defaultProvider, address: uniqueEmails[0] }; + } + + validateNoDuplicates(): void { + const seen = new Map(); + for (const mapping of this.mappings.filter(({ enabled }) => enabled)) { + const key = `${mapping.provider}:${normalizeAddress(mapping.address)}`; + const owner = seen.get(key); + if (owner && owner !== mapping.userId) throw new Error('calendar-mailbox-mapped-to-multiple-users'); + seen.set(key, mapping.userId); + } + } +} diff --git a/ee/packages/enterprise-calendar/src/orchestrator.spec.ts b/ee/packages/enterprise-calendar/src/orchestrator.spec.ts new file mode 100644 index 0000000000000..0610fcc3c335d --- /dev/null +++ b/ee/packages/enterprise-calendar/src/orchestrator.spec.ts @@ -0,0 +1,71 @@ +import { CalendarProviderRegistry, EnterpriseCalendarOrchestrator } from './orchestrator'; +import { CalendarPresenceProjector } from './presenceProjection'; +import { CalendarProjectionFactory } from './projection'; +import type { + CalendarProjection, + CalendarSyncState, + ICalendarProjectionStore, + ICalendarSyncStateStore, + IEnterpriseCalendarProvider, +} from './types'; + +describe('EnterpriseCalendarOrchestrator', () => { + it('performs an idempotent bounded full sync and recomputes presence from persisted projections', async () => { + const mailbox = { provider: 'exchange-ews' as const, address: 'person@example.com' }; + const event = { + externalId: 'provider-id', + mailbox, + start: new Date('2026-07-11T11:00:00Z'), + end: new Date('2026-07-11T13:00:00Z'), + availability: 'busy' as const, + isCancelled: false, + isAllDay: false, + isPrivate: true, + }; + const provider: IEnterpriseCalendarProvider = { + type: 'exchange-ews', + validateConfiguration: async () => ({ valid: true }), + resolveMailbox: async () => mailbox, + getCalendarWindow: jest.fn().mockResolvedValue([event]), + synchronizeChanges: jest.fn(), + }; + let state: CalendarSyncState | null = null; + let persisted: CalendarProjection[] = []; + const states: ICalendarSyncStateStore = { + get: async () => state, + save: async (value) => { + state = value; + }, + }; + const projections: ICalendarProjectionStore = { + upsert: async (values) => { + persisted = values; + }, + remove: async () => undefined, + replaceWindow: async (_userId, _provider, _start, _end, values) => { + persisted = values; + }, + findActive: async () => persisted, + removeExpired: async () => 0, + }; + const presence = { apply: jest.fn().mockResolvedValue(undefined), clear: jest.fn().mockResolvedValue(undefined) }; + const registry = new CalendarProviderRegistry(); + registry.register(provider); + const orchestrator = new EnterpriseCalendarOrchestrator( + registry, + states, + projections, + new CalendarProjectionFactory(Buffer.alloc(32, 1)), + new CalendarPresenceProjector(presence), + { pastMs: 60_000, futureMs: 60_000 }, + () => new Date('2026-07-11T12:00:00Z'), + ); + + await orchestrator.synchronize('u1', mailbox); + expect(provider.getCalendarWindow).toHaveBeenCalledWith(mailbox, new Date('2026-07-11T11:59:00Z'), new Date('2026-07-11T12:01:00Z')); + expect(persisted[0]).toMatchObject({ userId: 'u1', isPrivate: true, availability: 'busy' }); + expect(persisted[0].eventHash).not.toContain('provider-id'); + expect(presence.apply).toHaveBeenCalledWith('u1', 'busy', event.end); + expect(state).toMatchObject({ retryCount: 0, fullResyncRequired: false }); + }); +}); diff --git a/ee/packages/enterprise-calendar/src/orchestrator.ts b/ee/packages/enterprise-calendar/src/orchestrator.ts new file mode 100644 index 0000000000000..71f1f08996c1a --- /dev/null +++ b/ee/packages/enterprise-calendar/src/orchestrator.ts @@ -0,0 +1,154 @@ +import { EnterpriseCalendarError } from './errors'; +import { MicrosoftGraphCalendarProvider } from './graphProvider'; +import type { CalendarPresenceProjector } from './presenceProjection'; +import type { CalendarProjectionFactory } from './projection'; +import { calculateBackoffMs } from './retry'; +import type { + CalendarMailboxIdentity, + ICalendarProjectionStore, + CalendarSyncState, + ICalendarSyncStateStore, + IEnterpriseCalendarProvider, +} from './types'; + +export class CalendarProviderRegistry { + private readonly providers = new Map(); + + register(provider: IEnterpriseCalendarProvider): void { + if (this.providers.has(provider.type)) throw new Error(`calendar-provider-already-registered:${provider.type}`); + this.providers.set(provider.type, provider); + } + + get(type: CalendarMailboxIdentity['provider']): IEnterpriseCalendarProvider { + const provider = this.providers.get(type); + if (!provider) throw new Error(`calendar-provider-not-configured:${type}`); + return provider; + } +} + +export type SyncWindowPolicy = { pastMs: number; futureMs: number; maxMs?: number }; + +export class EnterpriseCalendarOrchestrator { + constructor( + private readonly registry: CalendarProviderRegistry, + private readonly states: ICalendarSyncStateStore, + private readonly projections: ICalendarProjectionStore, + private readonly projectionFactory: CalendarProjectionFactory, + private readonly presence: CalendarPresenceProjector, + private readonly windowPolicy: SyncWindowPolicy = { pastMs: 60 * 60_000, futureMs: 14 * 24 * 60 * 60_000 }, + private readonly now: () => Date = () => new Date(), + ) {} + + async synchronize(userId: string, mailbox: CalendarMailboxIdentity, forceFull = false): Promise { + const attemptedAt = this.now(); + const existing = (await this.states.get(userId)) ?? this.createState(userId, mailbox); + if (existing.backoffUntil && existing.backoffUntil > attemptedAt && !forceFull) return false; + const mailboxChanged = + existing.mailbox.provider !== mailbox.provider || + existing.mailbox.address.toLocaleLowerCase('en-US') !== mailbox.address.toLocaleLowerCase('en-US') || + existing.mailbox.externalUserId !== mailbox.externalUserId || + existing.mailbox.tenantId !== mailbox.tenantId; + const state = { + ...existing, + mailbox, + lastAttemptAt: attemptedAt, + ...(mailboxChanged && { cursor: undefined, fullResyncRequired: true, retryCount: 0, backoffUntil: undefined }), + }; + await this.states.save(state); + + try { + const provider = this.registry.get(mailbox.provider); + const rollingWindowInvalid = + state.cursor && + (state.cursor.windowEnd <= attemptedAt || + attemptedAt.getTime() - state.cursor.windowStart.getTime() > (this.windowPolicy.maxMs ?? 120 * 86_400_000)); + if (forceFull || state.fullResyncRequired || !state.cursor || rollingWindowInvalid) { + await this.fullSync(userId, mailbox, provider, attemptedAt, state); + } else { + await this.incrementalSync(userId, mailbox, provider, state); + } + const active = await this.projections.findActive(userId, this.now()); + await this.presence.recompute(userId, active, this.now()); + return true; + } catch (error) { + const retryable = error instanceof EnterpriseCalendarError ? error.retryable : true; + const retryCount = state.retryCount + 1; + await this.states.save({ + ...state, + retryCount, + lastErrorCategory: error instanceof EnterpriseCalendarError ? error.category : 'unknown', + ...(retryable && { + backoffUntil: new Date( + this.now().getTime() + + calculateBackoffMs(retryCount, { retryAfterMs: error instanceof EnterpriseCalendarError ? error.retryAfterMs : undefined }), + ), + }), + }); + throw error; + } + } + + private async fullSync( + userId: string, + mailbox: CalendarMailboxIdentity, + provider: IEnterpriseCalendarProvider, + now: Date, + state: CalendarSyncState, + ): Promise { + const start = new Date(now.getTime() - this.windowPolicy.pastMs); + const end = new Date(now.getTime() + this.windowPolicy.futureMs); + const result = + provider instanceof MicrosoftGraphCalendarProvider + ? await provider.getInitialDelta(mailbox, start, end) + : { events: await provider.getCalendarWindow(mailbox, start, end), deletedExternalIds: [] }; + const normalized = result.events.map((event) => this.projectionFactory.fromEvent(userId, event)).filter((event) => event !== null); + await this.projections.replaceWindow(userId, mailbox.provider, start, end, normalized); + await this.states.save({ + ...state, + cursor: result.nextCursor, + lastSuccessAt: this.now(), + retryCount: 0, + fullResyncRequired: false, + lastErrorCategory: undefined, + backoffUntil: undefined, + }); + } + + private async incrementalSync( + userId: string, + mailbox: CalendarMailboxIdentity, + provider: IEnterpriseCalendarProvider, + state: CalendarSyncState, + ): Promise { + if (!state.cursor) throw new Error('calendar-cursor-required'); + const result = await provider.synchronizeChanges(mailbox, state.cursor); + if (result.requiresFullResync) { + await this.states.save({ ...state, fullResyncRequired: true, cursor: undefined }); + await this.synchronize(userId, mailbox, true); + return; + } + const upserts = result.events.map((event) => this.projectionFactory.fromEvent(userId, event)).filter((event) => event !== null); + const implicitDeletes = result.events + .filter((event) => event.isCancelled || event.availability === 'free') + .map((event) => event.externalId); + await this.projections.upsert(upserts); + await this.projections.remove( + userId, + mailbox.provider, + [...result.deletedExternalIds, ...implicitDeletes].map((id) => this.projectionFactory.eventHash(id)), + ); + await this.states.save({ + ...state, + cursor: result.nextCursor ?? state.cursor, + lastSuccessAt: this.now(), + retryCount: 0, + fullResyncRequired: false, + lastErrorCategory: undefined, + backoffUntil: undefined, + }); + } + + private createState(userId: string, mailbox: CalendarMailboxIdentity): CalendarSyncState { + return { userId, mailbox, retryCount: 0, fullResyncRequired: true }; + } +} diff --git a/ee/packages/enterprise-calendar/src/presenceProjection.spec.ts b/ee/packages/enterprise-calendar/src/presenceProjection.spec.ts new file mode 100644 index 0000000000000..6b6e7ece9d44e --- /dev/null +++ b/ee/packages/enterprise-calendar/src/presenceProjection.spec.ts @@ -0,0 +1,46 @@ +import { DEFAULT_PRESENCE_MAPPING, CalendarPresenceProjector, selectEffectiveProjection } from './presenceProjection'; +import type { CalendarProjection } from './types'; + +const now = new Date('2026-07-11T12:00:00Z'); +const projection = (availability: CalendarProjection['availability'], end = '2026-07-11T13:00:00Z'): CalendarProjection => ({ + userId: 'u1', + provider: 'microsoft-graph', + mailboxHash: 'mailbox', + eventHash: availability, + start: new Date('2026-07-11T11:00:00Z'), + end: new Date(end), + availability, + isAllDay: false, + isPrivate: false, +}); + +describe('calendar presence projection', () => { + it('preserves compatibility defaults and ignores tentative/free/all-day events', () => { + expect(selectEffectiveProjection([projection('tentative'), projection('free')], now)).toBeNull(); + expect(selectEffectiveProjection([{ ...projection('busy'), isAllDay: true }], now)).toBeNull(); + }); + + it('chooses deterministic strongest overlapping availability', () => { + expect(selectEffectiveProjection([projection('busy', '2026-07-11T15:00:00Z'), projection('outOfOffice')], now)).toEqual({ + status: 'busy', + expiresAt: new Date('2026-07-11T13:00:00Z'), + }); + }); + + it('can map out-of-office to away without changing provider code', () => { + expect(selectEffectiveProjection([projection('outOfOffice')], now, { ...DEFAULT_PRESENCE_MAPPING, outOfOffice: 'away' })).toMatchObject( + { + status: 'away', + }, + ); + }); + + it('recomputes through the owned adapter rather than restoring snapshots', async () => { + const adapter = { apply: jest.fn().mockResolvedValue(undefined), clear: jest.fn().mockResolvedValue(undefined) }; + const projector = new CalendarPresenceProjector(adapter); + await projector.recompute('u1', [projection('busy')], now); + expect(adapter.apply).toHaveBeenCalledWith('u1', 'busy', new Date('2026-07-11T13:00:00Z')); + await projector.recompute('u1', [], now); + expect(adapter.clear).toHaveBeenCalledWith('u1'); + }); +}); diff --git a/ee/packages/enterprise-calendar/src/presenceProjection.ts b/ee/packages/enterprise-calendar/src/presenceProjection.ts new file mode 100644 index 0000000000000..5877a00ab41cb --- /dev/null +++ b/ee/packages/enterprise-calendar/src/presenceProjection.ts @@ -0,0 +1,55 @@ +import type { CalendarAvailability, CalendarProjection, ICalendarPresenceAdapter } from './types'; + +export type PresenceMapping = Partial> & { includeAllDay?: boolean }; + +const AVAILABILITY_STRENGTH: Record = { + free: 0, + unknown: 0, + workingElsewhere: 1, + tentative: 2, + busy: 3, + outOfOffice: 4, +}; + +export const DEFAULT_PRESENCE_MAPPING: PresenceMapping = { + free: 'none', + unknown: 'none', + workingElsewhere: 'none', + tentative: 'none', + busy: 'busy', + outOfOffice: 'busy', + includeAllDay: false, +}; + +export const selectEffectiveProjection = ( + projections: CalendarProjection[], + at: Date, + mapping: PresenceMapping = DEFAULT_PRESENCE_MAPPING, +): { status: 'busy' | 'away'; expiresAt: Date } | null => { + const eligible = projections + .filter((event) => event.start <= at && event.end > at && (mapping.includeAllDay || !event.isAllDay)) + .filter((event) => (mapping[event.availability] ?? DEFAULT_PRESENCE_MAPPING[event.availability]) !== 'none') + .sort((a, b) => AVAILABILITY_STRENGTH[b.availability] - AVAILABILITY_STRENGTH[a.availability] || b.end.getTime() - a.end.getTime()); + if (!eligible.length) return null; + const strongest = eligible[0]; + const status = mapping[strongest.availability] ?? DEFAULT_PRESENCE_MAPPING[strongest.availability]; + if (!status || status === 'none') return null; + const sameOrStronger = eligible.filter( + (event) => AVAILABILITY_STRENGTH[event.availability] >= AVAILABILITY_STRENGTH[strongest.availability], + ); + const expiresAt = sameOrStronger.reduce((latest, event) => (event.end > latest ? event.end : latest), strongest.end); + return { status, expiresAt }; +}; + +export class CalendarPresenceProjector { + constructor( + private readonly adapter: ICalendarPresenceAdapter, + private readonly mapping: PresenceMapping = DEFAULT_PRESENCE_MAPPING, + ) {} + + async recompute(userId: string, projections: CalendarProjection[], at = new Date()): Promise { + const effective = selectEffectiveProjection(projections, at, this.mapping); + if (!effective) return this.adapter.clear(userId); + return this.adapter.apply(userId, effective.status, effective.expiresAt); + } +} diff --git a/ee/packages/enterprise-calendar/src/projection.ts b/ee/packages/enterprise-calendar/src/projection.ts new file mode 100644 index 0000000000000..72c1d30810018 --- /dev/null +++ b/ee/packages/enterprise-calendar/src/projection.ts @@ -0,0 +1,32 @@ +import { createHmac } from 'node:crypto'; + +import type { CalendarProjection, NormalizedCalendarEvent } from './types'; + +const digest = (key: Buffer, namespace: string, value: string): string => + createHmac('sha256', key).update(namespace).update('\0').update(value).digest('base64url'); + +export class CalendarProjectionFactory { + constructor(private readonly hmacKey: Buffer) { + if (hmacKey.length < 32) throw new Error('calendar-projection-hmac-key-too-short'); + } + + fromEvent(userId: string, event: NormalizedCalendarEvent): CalendarProjection | null { + if (event.isCancelled || event.availability === 'free' || event.start >= event.end) return null; + return { + userId, + provider: event.mailbox.provider, + mailboxHash: digest(this.hmacKey, 'mailbox', event.mailbox.address.toLocaleLowerCase('en-US')), + eventHash: digest(this.hmacKey, 'event', event.externalId), + start: event.start, + end: event.end, + availability: event.availability, + isAllDay: event.isAllDay, + isPrivate: event.isPrivate, + ...(event.lastModifiedAt && { lastModifiedAt: event.lastModifiedAt }), + }; + } + + eventHash(externalId: string): string { + return digest(this.hmacKey, 'event', externalId); + } +} diff --git a/ee/packages/enterprise-calendar/src/retry.spec.ts b/ee/packages/enterprise-calendar/src/retry.spec.ts new file mode 100644 index 0000000000000..dfc49e296da0b --- /dev/null +++ b/ee/packages/enterprise-calendar/src/retry.spec.ts @@ -0,0 +1,13 @@ +import { calculateBackoffMs, parseRetryAfterMs } from './retry'; + +describe('calendar retry policy', () => { + it('uses bounded exponential backoff with jitter', () => { + expect(calculateBackoffMs(3, { baseMs: 1_000, maxMs: 60_000, jitter: () => 0 })).toBe(4_000); + expect(calculateBackoffMs(20, { baseMs: 1_000, maxMs: 60_000, jitter: () => 1 })).toBe(60_000); + }); + + it('honors Retry-After seconds and dates', () => { + expect(parseRetryAfterMs('10', 0)).toBe(10_000); + expect(parseRetryAfterMs('Thu, 01 Jan 1970 00:00:05 GMT', 1_000)).toBe(4_000); + }); +}); diff --git a/ee/packages/enterprise-calendar/src/retry.ts b/ee/packages/enterprise-calendar/src/retry.ts new file mode 100644 index 0000000000000..80d72d78e2bf4 --- /dev/null +++ b/ee/packages/enterprise-calendar/src/retry.ts @@ -0,0 +1,18 @@ +import { randomInt } from 'node:crypto'; + +export const calculateBackoffMs = ( + retryCount: number, + options: { baseMs?: number; maxMs?: number; retryAfterMs?: number; jitter?: () => number } = {}, +): number => { + const { baseMs = 1_000, maxMs = 15 * 60_000, retryAfterMs, jitter = () => randomInt(0, 1_001) / 1_000 } = options; + if (retryAfterMs != null) return Math.min(Math.max(retryAfterMs, 0), maxMs); + const exponential = Math.min(baseMs * 2 ** Math.min(Math.max(retryCount, 0), 16), maxMs); + return Math.round(exponential * (0.5 + jitter() * 0.5)); +}; + +export const parseRetryAfterMs = (value: string | null, now = Date.now()): number | undefined => { + if (!value) return undefined; + if (/^\d+$/.test(value)) return Number(value) * 1_000; + const at = Date.parse(value); + return Number.isNaN(at) ? undefined : Math.max(at - now, 0); +}; diff --git a/ee/packages/enterprise-calendar/src/secretBox.spec.ts b/ee/packages/enterprise-calendar/src/secretBox.spec.ts new file mode 100644 index 0000000000000..7d7a36e118e7d --- /dev/null +++ b/ee/packages/enterprise-calendar/src/secretBox.spec.ts @@ -0,0 +1,20 @@ +import { randomBytes } from 'node:crypto'; + +import { CalendarSecretBox } from './secretBox'; + +describe('CalendarSecretBox', () => { + it('encrypts credentials with authenticated context and does not retain plaintext', () => { + const box = CalendarSecretBox.fromBase64Key(randomBytes(32).toString('base64')); + const encrypted = box.encrypt('client-secret-value', 'tenant-a:client-secret'); + expect(encrypted).not.toContain('client-secret-value'); + expect(box.decrypt(encrypted, 'tenant-a:client-secret')).toBe('client-secret-value'); + expect(() => box.decrypt(encrypted, 'tenant-b:client-secret')).toThrow(); + }); + + it('refuses missing and undersized deployment keys', () => { + expect(() => CalendarSecretBox.fromBase64Key(undefined)).toThrow('enterprise-calendar-encryption-key-required'); + expect(() => CalendarSecretBox.fromBase64Key(Buffer.alloc(16).toString('base64'))).toThrow( + 'enterprise-calendar-encryption-key-must-be-32-bytes', + ); + }); +}); diff --git a/ee/packages/enterprise-calendar/src/secretBox.ts b/ee/packages/enterprise-calendar/src/secretBox.ts new file mode 100644 index 0000000000000..5874810d623c6 --- /dev/null +++ b/ee/packages/enterprise-calendar/src/secretBox.ts @@ -0,0 +1,31 @@ +import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; + +const PREFIX = 'ec1'; + +export class CalendarSecretBox { + private constructor(private readonly key: Buffer) {} + + static fromBase64Key(value: string | undefined): CalendarSecretBox { + if (!value) throw new Error('enterprise-calendar-encryption-key-required'); + const key = Buffer.from(value, 'base64'); + if (key.length !== 32) throw new Error('enterprise-calendar-encryption-key-must-be-32-bytes'); + return new CalendarSecretBox(key); + } + + encrypt(plaintext: string, context: string): string { + const nonce = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', this.key, nonce); + cipher.setAAD(Buffer.from(context)); + const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + return [PREFIX, nonce.toString('base64url'), cipher.getAuthTag().toString('base64url'), ciphertext.toString('base64url')].join('.'); + } + + decrypt(value: string, context: string): string { + const [prefix, encodedNonce, encodedTag, encodedCiphertext] = value.split('.'); + if (prefix !== PREFIX || !encodedNonce || !encodedTag || !encodedCiphertext) throw new Error('invalid-encrypted-calendar-secret'); + const decipher = createDecipheriv('aes-256-gcm', this.key, Buffer.from(encodedNonce, 'base64url')); + decipher.setAAD(Buffer.from(context)); + decipher.setAuthTag(Buffer.from(encodedTag, 'base64url')); + return Buffer.concat([decipher.update(Buffer.from(encodedCiphertext, 'base64url')), decipher.final()]).toString('utf8'); + } +} diff --git a/ee/packages/enterprise-calendar/src/types.ts b/ee/packages/enterprise-calendar/src/types.ts new file mode 100644 index 0000000000000..1e73e2d21db77 --- /dev/null +++ b/ee/packages/enterprise-calendar/src/types.ts @@ -0,0 +1,161 @@ +export type CalendarProviderType = 'microsoft-graph' | 'exchange-ews'; + +export type CalendarAvailability = 'free' | 'workingElsewhere' | 'tentative' | 'busy' | 'outOfOffice' | 'unknown'; + +export type MicrosoftCloud = 'global' | 'us-gov' | 'us-gov-dod' | 'china'; + +export type CalendarMailboxIdentity = { + provider: CalendarProviderType; + address: string; + externalUserId?: string; + tenantId?: string; +}; + +export type CalendarUserIdentity = { + userId: string; + active: boolean; + trustedUpn?: string; + verifiedEmails: string[]; + providerHint?: CalendarProviderType; +}; + +export type NormalizedCalendarEvent = { + externalId: string; + mailbox: CalendarMailboxIdentity; + start: Date; + end: Date; + availability: CalendarAvailability; + isCancelled: boolean; + isAllDay: boolean; + isPrivate: boolean; + lastModifiedAt?: Date; + changeKey?: string; +}; + +export type CalendarSyncCursor = { + value: string; + windowStart: Date; + windowEnd: Date; + expiresAt?: Date; +}; + +export type CalendarSyncResult = { + events: NormalizedCalendarEvent[]; + deletedExternalIds: string[]; + nextCursor?: CalendarSyncCursor; + requiresFullResync?: boolean; +}; + +export type CalendarSubscription = { + id: string; + mailbox: CalendarMailboxIdentity; + expiresAt: Date; + clientStateHash: string; +}; + +export type CalendarConfigurationValidation = { + valid: boolean; + code?: string; + message?: string; +}; + +export interface IEnterpriseCalendarProvider { + readonly type: CalendarProviderType; + validateConfiguration(testMailbox?: CalendarMailboxIdentity): Promise; + resolveMailbox(user: CalendarUserIdentity): Promise; + getCalendarWindow(mailbox: CalendarMailboxIdentity, start: Date, end: Date): Promise; + synchronizeChanges(mailbox: CalendarMailboxIdentity, cursor: CalendarSyncCursor): Promise; + createOrRenewSubscription?(mailbox: CalendarMailboxIdentity, existingSubscription?: CalendarSubscription): Promise; + removeSubscription?(subscription: CalendarSubscription): Promise; +} + +export type GraphCredential = + | { type: 'client-secret'; clientSecret: string } + | { type: 'certificate'; certificate: string; privateKey: string }; + +export type GraphProviderConfiguration = { + cloud: MicrosoftCloud; + tenantId: string; + clientId: string; + credential: GraphCredential; + webhookUrl?: string; + webhookClientState?: string; + requestTimeoutMs?: number; +}; + +export type EwsAuthentication = + | { type: 'negotiate'; servicePrincipal?: string } + | { type: 'ntlm'; domain: string; username: string; password: string } + | { type: 'basic'; username: string; password: string; explicitlyAllowed: true }; + +export type EwsProviderConfiguration = { + endpoint: string; + authentication: EwsAuthentication; + impersonation: true; + exchangeVersion: 'Exchange2016' | 'Exchange2019' | 'ExchangeSE'; + customCa?: string; +}; + +export type HttpRequest = { + method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'; + headers?: Record; + body?: string; + timeoutMs?: number; +}; + +export type HttpResponse = { + status: number; + headers: { get(name: string): string | null }; + text(): Promise; +}; + +export type HttpClient = (url: string, request: HttpRequest) => Promise; + +export type CalendarProjection = { + userId: string; + provider: CalendarProviderType; + mailboxHash: string; + eventHash: string; + start: Date; + end: Date; + availability: CalendarAvailability; + isAllDay: boolean; + isPrivate: boolean; + lastModifiedAt?: Date; +}; + +export type CalendarSyncState = { + userId: string; + mailbox: CalendarMailboxIdentity; + cursor?: CalendarSyncCursor; + lastAttemptAt?: Date; + lastSuccessAt?: Date; + lastErrorCategory?: string; + retryCount: number; + backoffUntil?: Date; + fullResyncRequired: boolean; +}; + +export interface ICalendarProjectionStore { + upsert(events: CalendarProjection[]): Promise; + remove(userId: string, provider: CalendarProviderType, eventHashes: string[]): Promise; + replaceWindow( + userId: string, + provider: CalendarProviderType, + windowStart: Date, + windowEnd: Date, + events: CalendarProjection[], + ): Promise; + findActive(userId: string, at: Date): Promise; + removeExpired(before: Date): Promise; +} + +export interface ICalendarSyncStateStore { + get(userId: string): Promise; + save(state: CalendarSyncState): Promise; +} + +export interface ICalendarPresenceAdapter { + apply(userId: string, status: 'busy' | 'away', expiresAt: Date): Promise; + clear(userId: string): Promise; +} diff --git a/ee/packages/enterprise-calendar/src/webhook.spec.ts b/ee/packages/enterprise-calendar/src/webhook.spec.ts new file mode 100644 index 0000000000000..4e0b532477a13 --- /dev/null +++ b/ee/packages/enterprise-calendar/src/webhook.spec.ts @@ -0,0 +1,28 @@ +import { GraphNotificationProcessor, validateGraphHandshake } from './webhook'; + +describe('Graph notifications', () => { + it('validates client state, deduplicates, and coalesces per subscription', async () => { + const claimed = new Set(); + const deduplication = { claim: jest.fn(async (key: string) => (claimed.has(key) ? false : (claimed.add(key), true))) }; + const queue = { enqueueSubscription: jest.fn().mockResolvedValue(undefined) }; + const processor = new GraphNotificationProcessor('expected-secret', deduplication, queue); + const notification = { subscriptionId: 'sub1', clientState: 'expected-secret', resource: 'users/x/events/1', changeType: 'updated' }; + const result = await processor.process([notification, notification, { ...notification, clientState: 'wrong' }]); + expect(result).toEqual({ accepted: 1, rejected: 1, enqueued: 1 }); + expect(queue.enqueueSubscription).toHaveBeenCalledTimes(1); + expect(queue.enqueueSubscription).toHaveBeenCalledWith('sub1', 'change'); + }); + + it('turns lifecycle missed notifications into reconciliation work', async () => { + const queue = { enqueueSubscription: jest.fn().mockResolvedValue(undefined) }; + const processor = new GraphNotificationProcessor('secret', { claim: async () => true }, queue); + await processor.process([{ subscriptionId: 'sub1', clientState: 'secret', lifecycleEvent: 'missed' }]); + expect(queue.enqueueSubscription).toHaveBeenCalledWith('sub1', 'missed'); + }); + + it('returns only safe validation tokens', () => { + expect(validateGraphHandshake('opaque token')).toBe('opaque token'); + expect(validateGraphHandshake('bad\r\nheader')).toBeNull(); + expect(validateGraphHandshake('x'.repeat(256))).toBeNull(); + }); +}); diff --git a/ee/packages/enterprise-calendar/src/webhook.ts b/ee/packages/enterprise-calendar/src/webhook.ts new file mode 100644 index 0000000000000..12913072b223a --- /dev/null +++ b/ee/packages/enterprise-calendar/src/webhook.ts @@ -0,0 +1,80 @@ +import { createHash, timingSafeEqual } from 'node:crypto'; + +export type GraphChangeNotification = { + subscriptionId?: string; + clientState?: string; + resource?: string; + changeType?: string; + sequenceNumber?: string; + lifecycleEvent?: 'missed' | 'subscriptionRemoved' | 'reauthorizationRequired'; +}; + +export interface INotificationDeduplicationStore { + claim(key: string, expiresAt: Date): Promise; +} + +export interface ICalendarSyncQueue { + enqueueSubscription(subscriptionId: string, reason: 'change' | 'missed' | 'subscription-removed' | 'reauthorize'): Promise; +} + +export type NotificationProcessingResult = { accepted: number; rejected: number; enqueued: number }; + +const safeEqual = (actual: string, expected: string): boolean => { + const left = Buffer.from(actual); + const right = Buffer.from(expected); + return left.length === right.length && timingSafeEqual(left, right); +}; + +const getNotificationReason = ( + lifecycleEvent: GraphChangeNotification['lifecycleEvent'], +): 'change' | 'missed' | 'subscription-removed' | 'reauthorize' => { + switch (lifecycleEvent) { + case 'missed': + return 'missed'; + case 'subscriptionRemoved': + return 'subscription-removed'; + case 'reauthorizationRequired': + return 'reauthorize'; + default: + return 'change'; + } +}; + +export class GraphNotificationProcessor { + constructor( + private readonly expectedClientState: string, + private readonly deduplication: INotificationDeduplicationStore, + private readonly queue: ICalendarSyncQueue, + private readonly now: () => Date = () => new Date(), + ) { + if (!expectedClientState) throw new Error('webhook-client-state-required'); + } + + async process(notifications: GraphChangeNotification[]): Promise { + const subscriptions = new Map(); + let rejected = 0; + let accepted = 0; + for (const notification of notifications.slice(0, 1_000)) { + if (!notification.subscriptionId || !notification.clientState || !safeEqual(notification.clientState, this.expectedClientState)) { + rejected++; + continue; + } + const fingerprint = createHash('sha256') + .update(notification.subscriptionId) + .update('\0') + .update(notification.sequenceNumber ?? notification.resource ?? notification.changeType ?? notification.lifecycleEvent ?? '') + .digest('base64url'); + if (!(await this.deduplication.claim(fingerprint, new Date(this.now().getTime() + 15 * 60_000)))) continue; + accepted++; + const reason = getNotificationReason(notification.lifecycleEvent); + subscriptions.set(notification.subscriptionId, reason); + } + for (const [subscriptionId, reason] of subscriptions) await this.queue.enqueueSubscription(subscriptionId, reason); + return { accepted, rejected, enqueued: subscriptions.size }; + } +} + +export const validateGraphHandshake = (value: unknown): string | null => { + if (typeof value !== 'string' || value.length === 0 || value.length > 255 || /[\r\n\0]/.test(value)) return null; + return value; +}; diff --git a/ee/packages/enterprise-calendar/tsconfig.json b/ee/packages/enterprise-calendar/tsconfig.json new file mode 100644 index 0000000000000..8636c87b8641a --- /dev/null +++ b/ee/packages/enterprise-calendar/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@rocket.chat/tsconfig/server.json", + "compilerOptions": { + "declaration": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["./src/**/*.ts"] +} diff --git a/packages/core-typings/src/ICalendarEvent.ts b/packages/core-typings/src/ICalendarEvent.ts index 2c5ad1e1ec959..4a72a0e7284ea 100644 --- a/packages/core-typings/src/ICalendarEvent.ts +++ b/packages/core-typings/src/ICalendarEvent.ts @@ -1,6 +1,10 @@ import type { IRocketChatRecord } from './IRocketChatRecord'; import type { IUser } from './IUser'; +export type CalendarEventSource = 'legacy' | 'enterprise-calendar'; +export type CalendarEventProvider = 'microsoft-graph' | 'exchange-ews'; +export type CalendarEventAvailability = 'free' | 'workingElsewhere' | 'tentative' | 'busy' | 'outOfOffice' | 'unknown'; + export interface ICalendarEvent extends IRocketChatRecord { startTime: Date; endTime?: Date; @@ -17,4 +21,14 @@ export interface ICalendarEvent extends IRocketChatRecord { reminderTime?: Date; busy?: boolean; + + /** Provider-owned, content-free presence projection metadata. */ + source?: CalendarEventSource; + provider?: CalendarEventProvider; + mailboxHash?: string; + availability?: CalendarEventAvailability; + isAllDay?: boolean; + isPrivate?: boolean; + lastModifiedAt?: Date; + calendarPresenceEnabled?: boolean; } diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index c839b5d3e99ac..3181d1ebee713 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -4144,6 +4144,31 @@ "Outgoing_voice_call": "Outgoing voice call", "Outgoing_WebHook": "Outgoing WebHook", "Outgoing_WebHook_Description": "Get data out of Rocket.Chat in real-time.", + "Enterprise_Calendar_Server": "Enterprise calendar server", + "Enterprise_Calendar_Enabled": "Enable server-to-server calendar presence", + "Enterprise_Calendar_Graph_Cloud": "Microsoft cloud", + "Enterprise_Calendar_Cloud_Global": "Microsoft global cloud", + "Enterprise_Calendar_Cloud_US_Gov": "Microsoft US Government (GCC High)", + "Enterprise_Calendar_Cloud_US_Gov_DoD": "Microsoft US Government DoD", + "Enterprise_Calendar_Cloud_China": "Microsoft 365 operated by 21Vianet", + "Enterprise_Calendar_Graph_Tenant_Id": "Microsoft Entra tenant ID", + "Enterprise_Calendar_Graph_Client_Id": "Microsoft Entra application client ID", + "Enterprise_Calendar_Graph_Credential_Type": "Application credential type", + "Enterprise_Calendar_Credential_Certificate": "Certificate (recommended)", + "Enterprise_Calendar_Credential_Client_Secret": "Client secret", + "Enterprise_Calendar_Graph_Client_Secret": "Encrypted client secret (use the credential API)", + "Enterprise_Calendar_Graph_Certificate": "Encrypted certificate (use the credential API)", + "Enterprise_Calendar_Graph_Private_Key": "Encrypted private key (use the credential API)", + "Enterprise_Calendar_Graph_Webhook_Enabled": "Enable Microsoft Graph change notifications", + "Enterprise_Calendar_Graph_Webhook_Url": "Public HTTPS notification URL", + "Enterprise_Calendar_Graph_Webhook_Client_State": "Encrypted webhook client state", + "Enterprise_Calendar_Sync_Past_Hours": "Synchronization lookback (hours)", + "Enterprise_Calendar_Sync_Future_Days": "Synchronization future window (days)", + "Enterprise_Calendar_Max_Users_Per_Run": "Maximum mailboxes per reconciliation run", + "Enterprise_Calendar_Include_All_Day": "Treat all-day busy events as presence", + "Enterprise_Calendar_Include_Tentative": "Treat tentative events as busy", + "Enterprise_Calendar_Include_Working_Elsewhere": "Treat working-elsewhere events as busy", + "Enterprise_Calendar_Mailbox_Mappings": "Explicit Rocket.Chat user-to-mailbox mappings", "Outlook_Calendar": "Outlook Calendar", "Outlook_Calendar_Enabled": "Enabled", "Outlook_Calendar_Exchange_Url": "Exchange URL", @@ -7340,4 +7365,4 @@ "Select_message_from_user": "Select message from {{username}}", "Select_message_from_user_with_preview": "Select message from {{username}}: {{message}}", "current": "current" -} \ No newline at end of file +} diff --git a/packages/models/src/models/CalendarEvent.ts b/packages/models/src/models/CalendarEvent.ts index 2c7a567080f2e..73051d2ed46bb 100644 --- a/packages/models/src/models/CalendarEvent.ts +++ b/packages/models/src/models/CalendarEvent.ts @@ -17,6 +17,11 @@ export class CalendarEventRaw extends BaseRaw implements ICalend { key: { reminderTime: -1, notificationSent: 1 }, }, + { + key: { uid: 1, source: 1, provider: 1, externalId: 1 }, + unique: true, + partialFilterExpression: { source: 'enterprise-calendar', externalId: { $type: 'string' } }, + }, ]; } @@ -134,6 +139,7 @@ export class CalendarEventRaw extends BaseRaw implements ICalend _id: { $ne: eventId }, // Exclude current event uid, busy: { $ne: false }, + source: { $ne: 'enterprise-calendar' }, $or: [ // Event starts during our event { startTime: { $gte: startTime, $lt: endTime } }, @@ -150,6 +156,7 @@ export class CalendarEventRaw extends BaseRaw implements ICalend { startTime: { $gte: startTime }, busy: { $ne: false }, + source: { $ne: 'enterprise-calendar' }, endTime: { $exists: true }, }, { @@ -169,6 +176,7 @@ export class CalendarEventRaw extends BaseRaw implements ICalend $lt: new Date(now.getTime() + offset), }, busy: { $ne: false }, + source: { $ne: 'enterprise-calendar' }, }, { projection: { diff --git a/yarn.lock b/yarn.lock index a2c9d52d3f32a..8be0d365ce2d4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9063,6 +9063,20 @@ __metadata: languageName: node linkType: hard +"@rocket.chat/enterprise-calendar@workspace:^, @rocket.chat/enterprise-calendar@workspace:ee/packages/enterprise-calendar": + version: 0.0.0-use.local + resolution: "@rocket.chat/enterprise-calendar@workspace:ee/packages/enterprise-calendar" + dependencies: + "@rocket.chat/jest-presets": "workspace:~" + "@rocket.chat/tsconfig": "workspace:*" + "@types/jest": "npm:~30.0.0" + "@types/node": "npm:~22.19.17" + eslint: "npm:~9.39.4" + jest: "npm:~30.2.0" + typescript: "npm:~5.9.3" + languageName: unknown + linkType: soft + "@rocket.chat/eslint-config@workspace:packages/eslint-config, @rocket.chat/eslint-config@workspace:~": version: 0.0.0-use.local resolution: "@rocket.chat/eslint-config@workspace:packages/eslint-config" @@ -9738,6 +9752,7 @@ __metadata: "@rocket.chat/ddp-client": "workspace:^" "@rocket.chat/desktop-api": "workspace:~" "@rocket.chat/emitter": "npm:^0.32.0" + "@rocket.chat/enterprise-calendar": "workspace:^" "@rocket.chat/favicon": "workspace:^" "@rocket.chat/federation-matrix": "workspace:^" "@rocket.chat/federation-sdk": "npm:0.6.3"