Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/scripts/src/cli.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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`);
}
Expand Down Expand Up @@ -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)
Expand Down
65 changes: 65 additions & 0 deletions packages/scripts/src/commands/audit-connection-identity.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
}
}
Original file line number Diff line number Diff line change
@@ -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<string> => {
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([]);
});
});
97 changes: 97 additions & 0 deletions packages/scripts/src/commands/audit-connection-identity/audit.ts
Original file line number Diff line number Diff line change
@@ -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<ConnectionIdentityAuditReport> {
const loginByGoogleId = new Map<string, { userId: string; email: string }>();
const users = compassDb
.collection<UserGoogleLoginDoc>(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<ConnectionDoc>(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,
};
}
Loading