Skip to content
Closed
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
40 changes: 38 additions & 2 deletions apps/api/src/google/google-connection.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
import { isGoogleConfigured, signsInWithGoogle } from "@crm/auth";
import {
isGoogleConfigured,
isWorkspaceAdmin,
isWorkspaceRole,
signsInWithGoogle,
WORKSPACE_ID,
type WorkspaceRole,
} from "@crm/auth";
import { type Db, GoogleSyncStatus } from "@crm/db";
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import {
ForbiddenException,
Injectable,
Logger,
NotFoundException,
} from "@nestjs/common";
import { normalizeDomain } from "../companies/domain";
import { ActivityStampService } from "../crm/activity-stamp.service";
import { InjectDatabase } from "../database/database.constants";
Expand Down Expand Up @@ -159,9 +171,18 @@ export class GoogleConnectionService {
}

async suppressDomain(
userId: string,
domain: string,
options: { reason?: string; purge: boolean },
): Promise<{ domain: string; purged: number }> {
const role = await this.roleOf(userId);

if (!isWorkspaceAdmin(role)) {
throw new ForbiddenException(
"Only an owner or an admin can suppress a domain.",
);
}

const normalised = normalizeDomain(domain);
if (!normalised) {
throw new NotFoundException(`"${domain}" is not a domain.`);
Expand Down Expand Up @@ -204,4 +225,19 @@ export class GoogleConnectionService {

return { domain: normalised, purged: threads.count + events.count };
}

private async roleOf(userId: string): Promise<WorkspaceRole | null> {
const member = await this.db.member.findUnique({
where: {
organizationId_userId: { organizationId: WORKSPACE_ID, userId },
},
select: { role: true },

@cubic-dev-ai cubic-dev-ai Bot Aug 5, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The new roleOf/toRole code in GoogleConnectionService duplicates the workspace-role resolution already present in WorkspaceService.roleOf/toRole (and inline in SsoService.roleOf). The PR correctly centralizes the authorization predicates (isWorkspaceAdmin/isWorkspaceRole in @crm/auth), but the member lookup query itself is still copy-pasted across three services. Since this is the exact access-control path used to gate a destructive workspace-global operation, keeping three divergent copies is risky — a future change to how workspace membership/roles are resolved could update one service and silently leave suppressDomain (or another gate) out of sync. Consider extracting the role lookup (e.g. resolveWorkspaceRole(userId)) into @crm/auth alongside isWorkspaceAdmin so all role-gated mutations share one implementation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/google/google-connection.service.ts, line 234:

<comment>The new roleOf/toRole code in GoogleConnectionService duplicates the workspace-role resolution already present in WorkspaceService.roleOf/toRole (and inline in SsoService.roleOf). The PR correctly centralizes the authorization predicates (isWorkspaceAdmin/isWorkspaceRole in @crm/auth), but the member lookup query itself is still copy-pasted across three services. Since this is the exact access-control path used to gate a destructive workspace-global operation, keeping three divergent copies is risky — a future change to how workspace membership/roles are resolved could update one service and silently leave suppressDomain (or another gate) out of sync. Consider extracting the role lookup (e.g. resolveWorkspaceRole(userId)) into @crm/auth alongside isWorkspaceAdmin so all role-gated mutations share one implementation.</comment>

<file context>
@@ -204,4 +225,19 @@ export class GoogleConnectionService {
+			where: {
+				organizationId_userId: { organizationId: WORKSPACE_ID, userId },
+			},
+			select: { role: true },
+		});
+
</file context>
Fix with cubic

});

return member ? toRole(member.role) : null;
}
}

function toRole(value: string): WorkspaceRole {
return isWorkspaceRole(value) ? value : "member";
}
7 changes: 5 additions & 2 deletions apps/api/src/google/google.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,11 @@ export class GoogleRouter {
}

@Mutation({ input: suppressDomainInput })
async suppressDomain(@Input() input: z.infer<typeof suppressDomainInput>) {
return this.connection.suppressDomain(input.domain, {
async suppressDomain(
@Ctx() ctx: AuthedTrpcContext,
@Input() input: z.infer<typeof suppressDomainInput>,
) {
return this.connection.suppressDomain(ctx.user.id, input.domain, {
reason: input.reason,
purge: input.purge,
});
Expand Down
93 changes: 93 additions & 0 deletions apps/api/test/google-connection.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, expect, it } from "bun:test";
import type { Db } from "@crm/db";
import { ForbiddenException } from "@nestjs/common";
import type { ActivityStampService } from "../src/crm/activity-stamp.service";
import { GoogleConnectionService } from "../src/google/google-connection.service";
import type { GoogleMatchService } from "../src/google/google-match.service";
import type { GoogleTokenService } from "../src/google/google-token.service";
import type { SyncStateService } from "../src/google/sync-state.service";

function connection(role: string | null): {
service: GoogleConnectionService;
deleted: { threads: number; events: number };
} {
const deleted = { threads: 0, events: 0 };
const db = {
member: {
findUnique: async () => (role === null ? null : { role }),
},
suppressedDomain: {
upsert: async () => ({ domain: "globex.com" }),
},
company: {
findUnique: async () => ({ id: "company-1" }),
},
$transaction: async (ops: unknown) => {
const list = (await Promise.all(
ops as Array<Promise<{ count: number }>>,
)) as Array<{ count: number }>;
const threads = list[0]?.count ?? 0;
const events = list[1]?.count ?? 0;
deleted.threads = threads;
deleted.events = events;
return [{ count: threads }, { count: events }];
},
emailThread: {
deleteMany: async () => ({ count: 2 }),
},
calendarEvent: {
deleteMany: async () => ({ count: 1 }),
},
} as unknown as Db;

const service = new GoogleConnectionService(
db,
{} as unknown as GoogleTokenService,
{} as unknown as SyncStateService,
{
internalIdentity: async () => ({
domains: new Set(["acme.com"]),
addresses: new Set<string>(),
}),
} as unknown as GoogleMatchService,
{
recomputeAll: async () => undefined,
} as unknown as ActivityStampService,
);

return { service, deleted };
}

describe("suppressDomain", () => {
it("refuses a member", async () => {
const { service } = connection("member");
await expect(
service.suppressDomain("u1", "globex.com", { purge: true }),
).rejects.toThrow(ForbiddenException);
});

it("refuses someone with no workspace membership", async () => {
const { service } = connection(null);
await expect(
service.suppressDomain("u1", "globex.com", { purge: true }),
).rejects.toThrow(ForbiddenException);
});

it("lets an admin suppress without purging", async () => {
const { service } = connection("admin");
const result = await service.suppressDomain("u1", "globex.com", {
purge: false,
});
expect(result).toEqual({ domain: "globex.com", purged: 0 });
});

it("lets an owner suppress and purge", async () => {
const { service, deleted } = connection("owner");
const result = await service.suppressDomain("u1", "globex.com", {
purge: true,
});
expect(result).toEqual({ domain: "globex.com", purged: 3 });
expect(deleted.threads).toBe(2);
expect(deleted.events).toBe(1);
});
});