diff --git a/packages/scripts/src/cli.ts b/packages/scripts/src/cli.ts index 2e83d9f436..164baf8e57 100644 --- a/packages/scripts/src/cli.ts +++ b/packages/scripts/src/cli.ts @@ -1,4 +1,5 @@ import { CliValidator } from "@scripts/cli.validator"; +import { runAuditConnectionIdentity } from "@scripts/commands/audit-connection-identity"; import { runBackfillIcalUid } from "@scripts/commands/backfill-icaluid"; import { runManageFailedJobs } from "@scripts/commands/manage-failed-jobs"; import { runPurgeCorruptSyncEvents } from "@scripts/commands/purge-corrupt-sync-events"; @@ -39,6 +40,9 @@ export default class CompassCLI { case cmd === "backfill-icaluid": await runBackfillIcalUid(); break; + case cmd === "audit-connection-identity": + await runAuditConnectionIdentity(); + break; default: this.validator.exitHelpfully(`${cmd as string} is not a supported cmd`); } @@ -89,6 +93,14 @@ export default class CompassCLI { "Copy Google's cross-copy correlation key onto events that predate it (--apply to write)", ); + program + .command("audit-connection-identity") + .helpOption(false) + .allowUnknownOption(true) + .description( + "Report connected Google accounts that are another Compass user's login identity (read-only)", + ); + program .command("manage-failed-jobs") .helpOption(false) diff --git a/packages/scripts/src/commands/audit-connection-identity.ts b/packages/scripts/src/commands/audit-connection-identity.ts new file mode 100644 index 0000000000..6b64e66c63 --- /dev/null +++ b/packages/scripts/src/commands/audit-connection-identity.ts @@ -0,0 +1,65 @@ +import { auditConnectionIdentity } from "@scripts/commands/audit-connection-identity/audit"; +import { loadCompassConfig } from "@core/config/compass.config"; +import { Logger } from "@core/logger/winston.logger"; +import mongoService from "@backend/common/services/mongo.service"; +import { SyncMongoService } from "@sync/storage/sync-mongo.service"; + +const logger = Logger("scripts.commands.audit-connection-identity"); + +function syncMongoUri(): string { + const fromEnv = process.env["SYNC_MONGO_URI"]?.trim(); + if (fromEnv) return fromEnv; + const uri = loadCompassConfig().sync?.mongoUri?.trim(); + if (!uri) { + throw new Error( + "Set SYNC_MONGO_URI or add sync.mongoUri to compass.yaml before audit-connection-identity", + ); + } + return uri; +} + +/** + * Reports any connected Google account that is actually another Compass + * user's sign-in identity - added accounts are meant to be data-only (A2), + * so this should normally report zero. Read-only; safe to run anytime, + * including on a schedule. + * + * bun run cli audit-connection-identity + */ +export async function runAuditConnectionIdentity(): Promise { + const syncMongo = new SyncMongoService(); + try { + await mongoService.start(); + await syncMongo.connect({ + uri: syncMongoUri(), + enforceLeastPrivilege: false, + forbiddenDatabaseName: "prod_calendar", + }); + + const report = await auditConnectionIdentity(mongoService.db, syncMongo.db); + + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + if (report.collisions.length > 0) { + logger.warn( + `audit-connection-identity found ${report.collisions.length} collision(s) ` + + `out of ${report.connectionsChecked} connections checked`, + ); + } else { + logger.info( + `audit-connection-identity clean: ${report.connectionsChecked} connections checked, 0 collisions`, + ); + } + + await syncMongo.disconnect(); + await mongoService.stop(); + process.exit(0); + } catch (error) { + logger.error(error); + try { + await syncMongo.disconnect(); + } catch { + // ignore + } + process.exit(1); + } +} diff --git a/packages/scripts/src/commands/audit-connection-identity/audit.db.test.ts b/packages/scripts/src/commands/audit-connection-identity/audit.db.test.ts new file mode 100644 index 0000000000..97f4ab6e14 --- /dev/null +++ b/packages/scripts/src/commands/audit-connection-identity/audit.db.test.ts @@ -0,0 +1,132 @@ +import { auditConnectionIdentity } from "@scripts/commands/audit-connection-identity/audit"; +import { ObjectId } from "mongodb"; +import { + cleanupCollections, + cleanupTestDb, + setupTestDb, +} from "@backend/__tests__/helpers/mock.db.setup"; +import mongoService from "@backend/common/services/mongo.service"; +import { setupSyncStorage } from "@sync/__tests__/helpers/storage"; +import { ProviderConnectionRepository } from "@sync/storage/repositories/provider-connection.repository"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"; + +describe("auditConnectionIdentity (db)", () => { + const syncStorage = setupSyncStorage(import.meta.url); + let connections: ProviderConnectionRepository; + + beforeAll(() => setupTestDb(import.meta.url)); + afterEach(async () => { + await cleanupCollections(); + await mongoService.user.deleteMany({}); + }); + afterAll(cleanupTestDb); + + const run = () => auditConnectionIdentity(mongoService.db, syncStorage.db()); + + const seedLoginUser = async ( + email: string, + googleId: string, + ): Promise => { + const userId = new ObjectId(); + await mongoService.user.insertOne({ + _id: userId, + email, + name: email, + firstName: email, + lastName: "", + locale: "not provided", + google: { googleId, picture: "", gRefreshToken: "refresh-token" }, + }); + return userId.toHexString(); + }; + + const seedConnection = async ( + principalId: string, + providerAccountId: string, + email: string, + ) => { + connections = new ProviderConnectionRepository(syncStorage.db()); + return connections.upsertByProviderAccount({ + tenantId: principalId, + principalId, + provider: "google", + account: { providerAccountId, email, displayName: null }, + capabilities: ["readEvents", "readBusy", "writeEvents"], + state: "healthy", + stateReason: null, + lastSyncedAt: null, + lastHealthyAt: null, + }); + }; + + it("reports zero collisions when every connection is its own owner's login", async () => { + const userId = await seedLoginUser("ahab@pequod.com", "google-sub-1"); + await seedConnection(userId, "google-sub-1", "ahab@pequod.com"); + // A second, data-only account with no matching login anywhere. + await seedConnection(userId, "google-sub-2", "ahab@gmail.com"); + + const report = await run(); + + expect(report.connectionsChecked).toBe(2); + expect(report.collisions).toEqual([]); + }); + + it("flags a connection whose account is another Compass user's login identity", async () => { + const victim = await seedLoginUser("victim@example.com", "google-sub-1"); + const attacker = await seedLoginUser( + "attacker@example.com", + "google-sub-2", + ); + await seedConnection(attacker, "google-sub-1", "victim@example.com"); + + const report = await run(); + + expect(report.collisions).toEqual([ + { + connectingUserId: attacker, + connectionId: expect.any(String), + accountEmail: "victim@example.com", + loginOwnerUserId: victim, + loginOwnerEmail: "victim@example.com", + }, + ]); + }); + + it("matches by the stable providerAccountId, not the mutable email", async () => { + // Two different Google accounts that happen to share a display email (a + // real re-registration pattern) must not false-positive against each + // other; only the sub id is ownership proof. + const owner = await seedLoginUser("shared@example.com", "google-sub-1"); + const other = await seedLoginUser("other@example.com", "google-sub-2"); + await seedConnection(other, "google-sub-2", "shared@example.com"); + + const report = await run(); + + expect(report.collisions).toEqual([]); + // Confirms the fixture actually shares an email, so this is testing the + // right thing and not accidentally trivial. + expect(owner).not.toBe(other); + }); + + it("ignores a Compass user connecting their own login account a second time", async () => { + const userId = await seedLoginUser("ahab@pequod.com", "google-sub-1"); + // Reconnect / re-add flows resume the SAME connection id in practice, but + // even a hypothetical duplicate row for one's own account is not a + // collision under A2. + await seedConnection(userId, "google-sub-1", "ahab@pequod.com"); + + const report = await run(); + + expect(report.collisions).toEqual([]); + }); + + it("ignores connections for a Google account with no Compass login at all", async () => { + const userId = await seedLoginUser("ahab@pequod.com", "google-sub-1"); + await seedConnection(userId, "google-sub-unregistered", "second@gmail.com"); + + const report = await run(); + + expect(report.usersWithGoogleLogin).toBe(1); + expect(report.collisions).toEqual([]); + }); +}); diff --git a/packages/scripts/src/commands/audit-connection-identity/audit.ts b/packages/scripts/src/commands/audit-connection-identity/audit.ts new file mode 100644 index 0000000000..f5e00f7877 --- /dev/null +++ b/packages/scripts/src/commands/audit-connection-identity/audit.ts @@ -0,0 +1,97 @@ +import { type Db } from "mongodb"; +import { Collections } from "@backend/common/constants/collections"; +import { SYNC_COLLECTIONS } from "@sync/storage/collections"; + +export type ConnectionIdentityCollision = { + // The Compass user who added the connection (data-only, per A2). + connectingUserId: string; + connectionId: string; + // The connected Google account. + accountEmail: string | null; + // The Compass user whose SIGN-IN identity that Google account is. + loginOwnerUserId: string; + loginOwnerEmail: string; +}; + +export type ConnectionIdentityAuditReport = { + generatedAt: string; + usersWithGoogleLogin: number; + connectionsChecked: number; + collisions: ConnectionIdentityCollision[]; +}; + +interface UserGoogleLoginDoc { + _id: unknown; + email: string; + google?: { googleId?: string }; +} + +interface ConnectionDoc { + _id: unknown; + principalId: string; + account: { providerAccountId: string; email: string | null }; +} + +/** + * Read-only: finds every connected Google account that is actually another + * Compass user's SIGN-IN identity - the collision A2 says a connect attempt + * should reject going forward, and this reports for anything that already + * slipped through (there was a window before that guard existed, and any + * future regression in it). + * + * Never writes. Matches by `providerAccountId` (Google's stable subject id), + * never email - email is mutable display data on both sides + * (ProviderAccountFactsSchema's own doc comment), so an email match alone + * would be a false positive whenever two different Google accounts have ever + * shared an address (a common re-registration pattern), and a false negative + * whenever the same account's email changed. + */ +export async function auditConnectionIdentity( + compassDb: Db, + syncDb: Db, +): Promise { + const loginByGoogleId = new Map(); + const users = compassDb + .collection(Collections.USER) + .find({ "google.googleId": { $exists: true } }); + for await (const user of users) { + const googleId = user.google?.googleId; + if (!googleId) continue; + loginByGoogleId.set(googleId, { + userId: String(user._id), + email: user.email, + }); + } + + const collisions: ConnectionIdentityCollision[] = []; + let connectionsChecked = 0; + + const connections = syncDb + .collection(SYNC_COLLECTIONS.providerConnections) + .find({ provider: "google", disconnectedAt: null }); + for await (const connection of connections) { + connectionsChecked += 1; + const loginOwner = loginByGoogleId.get( + connection.account.providerAccountId, + ); + if (!loginOwner) continue; + // Reconnecting/re-adding your OWN login account as a data connection is + // not a collision - A2 only forbids using someone ELSE's login identity. + if (loginOwner.userId === connection.principalId) continue; + + collisions.push({ + connectingUserId: connection.principalId, + connectionId: String(connection._id), + accountEmail: connection.account.email, + loginOwnerUserId: loginOwner.userId, + loginOwnerEmail: loginOwner.email, + }); + } + + return { + generatedAt: new Date().toISOString(), + usersWithGoogleLogin: loginByGoogleId.size, + connectionsChecked, + collisions, + }; +} diff --git a/packages/web/src/calendars/useCalendarLookup.test.ts b/packages/web/src/calendars/useCalendarLookup.test.ts new file mode 100644 index 0000000000..a02923130a --- /dev/null +++ b/packages/web/src/calendars/useCalendarLookup.test.ts @@ -0,0 +1,143 @@ +import { + type Calendar, + getCalendarCapabilities, +} from "@core/types/calendar.contracts"; +import { CalendarIdSchema } from "@core/types/domain-primitives"; +import { createObjectIdString } from "@web/common/utils/id/object-id.util"; +import { + buildCalendarLookup, + calendarAccentAccessibleSuffix, + calendarAccentStyle, + findCrossAccountDuplicate, + resolveCalendarCardIdentity, +} from "./useCalendarLookup"; +import { describe, expect, it } from "bun:test"; + +const calendar = (overrides: Partial = {}): Calendar => ({ + id: CalendarIdSchema.parse(createObjectIdString()), + name: "Work", + description: "", + timeZone: null, + foregroundColor: "#000000", + backgroundColor: "#3b82f6", + provider: "google", + access: "owner", + capabilities: getCalendarCapabilities("owner"), + isPrimary: false, + isVisible: true, + isActive: true, + ...overrides, +}); + +describe("resolveCalendarCardIdentity", () => { + it("returns null with only one calendar (nothing to distinguish)", () => { + const solo = calendar(); + const lookup = buildCalendarLookup([solo]); + + expect(resolveCalendarCardIdentity(lookup, solo.id)).toBeNull(); + }); + + it("returns the calendar's name and color with two or more calendars", () => { + const work = calendar({ name: "Work", backgroundColor: "#3b82f6" }); + const personal = calendar({ name: "Personal" }); + const lookup = buildCalendarLookup([work, personal]); + + expect(resolveCalendarCardIdentity(lookup, work.id)).toEqual({ + name: "Work", + backgroundColor: "#3b82f6", + }); + }); + + it("carries the cross-account duplicate through when given one", () => { + const work = calendar({ name: "Work" }); + const personal = calendar({ name: "Personal" }); + const lookup = buildCalendarLookup([work, personal]); + const duplicate = { + accountEmail: "ahab@gmail.com", + backgroundColor: "#ef4444", + }; + + expect(resolveCalendarCardIdentity(lookup, work.id, duplicate)).toEqual({ + name: "Work", + backgroundColor: work.backgroundColor, + otherAccount: duplicate, + }); + }); + + it("returns null for a calendarId not in the lookup", () => { + const work = calendar(); + const personal = calendar(); + const lookup = buildCalendarLookup([work, personal]); + const missing = CalendarIdSchema.parse(createObjectIdString()); + + expect(resolveCalendarCardIdentity(lookup, missing)).toBeNull(); + }); +}); + +describe("findCrossAccountDuplicate", () => { + it("returns undefined when duplicates or the event id are absent", () => { + expect(findCrossAccountDuplicate(undefined, "ev-1")).toBeUndefined(); + expect(findCrossAccountDuplicate(new Map(), undefined)).toBeUndefined(); + }); + + it("returns the entry for the given event id", () => { + const duplicate = { + accountEmail: "ahab@gmail.com", + backgroundColor: "#ef4444", + }; + const duplicates = new Map([["ev-1", duplicate]]); + + expect(findCrossAccountDuplicate(duplicates, "ev-1")).toBe(duplicate); + expect(findCrossAccountDuplicate(duplicates, "ev-2")).toBeUndefined(); + }); +}); + +describe("calendarAccentStyle", () => { + it("is a flat fill for an ordinary card", () => { + expect( + calendarAccentStyle({ name: "Work", backgroundColor: "#3b82f6" }), + ).toEqual({ backgroundColor: "#3b82f6" }); + }); + + it("is a two-stop gradient into the other account's color for a merged card", () => { + const style = calendarAccentStyle({ + name: "Work", + backgroundColor: "#3b82f6", + otherAccount: { + accountEmail: "ahab@gmail.com", + backgroundColor: "#ef4444", + }, + }); + + expect(style).toEqual({ + backgroundImage: "linear-gradient(to bottom, #3b82f6, #ef4444)", + }); + // A flat fill and a gradient must never both apply - the gradient would + // render underneath an opaque solid color and never be visible. + expect(style).not.toHaveProperty("backgroundColor"); + }); +}); + +describe("calendarAccentAccessibleSuffix", () => { + it("names only the calendar for an ordinary card", () => { + expect( + calendarAccentAccessibleSuffix({ + name: "Work", + backgroundColor: "#3b82f6", + }), + ).toBe(", Work calendar"); + }); + + it("names the other account too for a merged card", () => { + expect( + calendarAccentAccessibleSuffix({ + name: "Work", + backgroundColor: "#3b82f6", + otherAccount: { + accountEmail: "ahab@gmail.com", + backgroundColor: "#ef4444", + }, + }), + ).toBe(", Work calendar, also on ahab@gmail.com"); + }); +}); diff --git a/packages/web/src/calendars/useCalendarLookup.ts b/packages/web/src/calendars/useCalendarLookup.ts index d7d02f619b..4f803afe26 100644 --- a/packages/web/src/calendars/useCalendarLookup.ts +++ b/packages/web/src/calendars/useCalendarLookup.ts @@ -1,6 +1,10 @@ import { type Calendar } from "@core/types/calendar.contracts"; import { type CalendarId } from "@core/types/domain-primitives"; import { useCalendarsQuery } from "@web/calendars/calendar.query"; +import { + type CrossAccountDuplicate, + type CrossAccountDuplicates, +} from "@web/events/queries/merge-cross-account-duplicates"; const EMPTY_CALENDAR_LOOKUP: ReadonlyMap = new Map(); @@ -58,6 +62,15 @@ export function useCalendarLookup(): ReadonlyMap { export type CalendarCardIdentity = { name: string; backgroundColor: string; + /** + * Set when this card is standing in for a meeting that also exists on + * another connected account (mergeCrossAccountDuplicates). The accent + * becomes a two-color gradient of this calendar's color and the other + * account's, and the accessible label names the other account - the merge + * is otherwise invisible (A5), so this is the only surviving signal that a + * second copy exists. + */ + otherAccount?: CrossAccountDuplicate; }; /** @@ -67,17 +80,69 @@ export type CalendarCardIdentity = { * active calendar - a single-calendar account's cards gain nothing from * either the accent or a redundant name suffix, since every card would say * the same thing. + * + * `duplicate` is looked up by the caller (keyed by event id) and passed in + * rather than looked up here, since a card's merge status is a property of + * the specific event instance, not of its calendar. */ export function resolveCalendarCardIdentity( lookup: ReadonlyMap, calendarId: CalendarId | null | undefined, + duplicate?: CrossAccountDuplicate, ): CalendarCardIdentity | null { if (!calendarId || lookup.size <= 1) return null; const calendar = lookup.get(calendarId); - return calendar - ? { name: calendar.name, backgroundColor: calendar.backgroundColor } - : null; + if (!calendar) return null; + + return { + name: calendar.name, + backgroundColor: calendar.backgroundColor, + ...(duplicate ? { otherAccount: duplicate } : {}), + }; +} + +/** Looks up a card's cross-account duplicate info by event id, or undefined. */ +export function findCrossAccountDuplicate( + duplicates: CrossAccountDuplicates | undefined, + eventId: string | undefined, +): CrossAccountDuplicate | undefined { + if (!duplicates || !eventId) return undefined; + return duplicates.get(eventId); +} + +/** + * The accent fill for a card's identity strip: this calendar's color, or a + * top-to-bottom two-stop gradient into the other account's color when the + * card is standing in for a cross-account duplicate (A5). Shared by + * TimedEventCard and AllDayEventCard so the gradient direction/shape can't + * drift between the two. + */ +export function calendarAccentStyle(identity: CalendarCardIdentity): { + backgroundColor?: string; + backgroundImage?: string; +} { + if (identity.otherAccount) { + return { + backgroundImage: `linear-gradient(to bottom, ${identity.backgroundColor}, ${identity.otherAccount.backgroundColor})`, + }; + } + return { backgroundColor: identity.backgroundColor }; +} + +/** + * The accessible-label suffix for a card's calendar identity, naming the + * other account when this card is a cross-account duplicate merge - the + * gradient accent is otherwise the only visual sign a second copy exists, and + * accent color alone is never how identity is conveyed (A9). + */ +export function calendarAccentAccessibleSuffix( + identity: CalendarCardIdentity, +): string { + const calendarSuffix = `, ${identity.name} calendar`; + return identity.otherAccount + ? `${calendarSuffix}, also on ${identity.otherAccount.accountEmail}` + : calendarSuffix; } /** diff --git a/packages/web/src/grid/components/AllDayEventCard.tsx b/packages/web/src/grid/components/AllDayEventCard.tsx index 37b36922ec..3aca3086a0 100644 --- a/packages/web/src/grid/components/AllDayEventCard.tsx +++ b/packages/web/src/grid/components/AllDayEventCard.tsx @@ -8,7 +8,11 @@ import { } from "react"; import dayjs from "@core/util/date/dayjs"; import { isRecurringEvent } from "@core/util/event/event.util"; -import { type CalendarCardIdentity } from "@web/calendars/useCalendarLookup"; +import { + type CalendarCardIdentity, + calendarAccentAccessibleSuffix, + calendarAccentStyle, +} from "@web/calendars/useCalendarLookup"; import { DATA_EVENT_ELEMENT_ID, ZIndex, @@ -111,7 +115,7 @@ const AllDayEventCardBase = ( // calendar signal, and the name (never color alone) is what makes it // accessible (A9). const accessibleLabel = calendarIdentity - ? `${baseAccessibleLabel}, ${calendarIdentity.name} calendar` + ? `${baseAccessibleLabel}${calendarAccentAccessibleSuffix(calendarIdentity)}` : baseAccessibleLabel; return ( @@ -154,7 +158,7 @@ const AllDayEventCardBase = (