diff --git a/apps/api/src/admin-organizations/admin-organizations.service.ts b/apps/api/src/admin-organizations/admin-organizations.service.ts index a1a5c33f5..fc4ca96c5 100644 --- a/apps/api/src/admin-organizations/admin-organizations.service.ts +++ b/apps/api/src/admin-organizations/admin-organizations.service.ts @@ -240,6 +240,7 @@ export class AdminOrganizationsService { onboardingCompleted: true, website: true, backgroundCheckStepEnabled: true, + isInternal: true, members: { where: { isActive: true, deactivated: false }, select: { diff --git a/apps/api/src/admin-organizations/dto/update-admin-organization.dto.ts b/apps/api/src/admin-organizations/dto/update-admin-organization.dto.ts index 2026fee16..7398270f3 100644 --- a/apps/api/src/admin-organizations/dto/update-admin-organization.dto.ts +++ b/apps/api/src/admin-organizations/dto/update-admin-organization.dto.ts @@ -1,5 +1,5 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsOptional } from 'class-validator'; +import { IsBoolean, IsOptional, ValidateIf } from 'class-validator'; export class UpdateAdminOrganizationDto { @ApiPropertyOptional({ @@ -17,4 +17,14 @@ export class UpdateAdminOrganizationDto { @IsOptional() @IsBoolean() backgroundCheckStepEnabled?: boolean; + + @ApiPropertyOptional({ + description: + "When true, the organization is platform-operated (e.g. Comp AI's own org). Platform admins are then treated as real participants — assignable, counted in compliance, and notified. Leave false for all customer orgs.", + }) + // Optional, but reject an explicit `null` at validation (400) rather than + // letting it hit the non-null DB column and 500. + @ValidateIf((_, value) => value !== undefined) + @IsBoolean() + isInternal?: boolean; } diff --git a/apps/api/src/comments/comment-mention-notifier.service.ts b/apps/api/src/comments/comment-mention-notifier.service.ts index f15b641ab..8f432f662 100644 --- a/apps/api/src/comments/comment-mention-notifier.service.ts +++ b/apps/api/src/comments/comment-mention-notifier.service.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { db } from '@db'; +import { orgParticipantMemberWhere } from '../utils/org-participation'; import { isUserUnsubscribed } from '@trycompai/email'; import { triggerEmail } from '../email/trigger-email'; import { CommentMentionedEmail } from '../email/templates/comment-mentioned'; @@ -261,16 +262,17 @@ export class CommentMentionNotifierService { return; } - // Get mentioned users: exclude platform admins unless they are an owner of this org + // Get mentioned users: exclude platform admins unless the org is internal + // (where platform admins are real members). `orgParticipantMemberWhere` + // returns an AND-wrapped fragment so it intersects with the mentioned-user + // filter above rather than overwriting it. + const participantWhere = await orgParticipantMemberWhere(organizationId); const mentionedMembers = await db.member.findMany({ where: { organizationId, deactivated: false, user: { id: { in: mentionedUserIds } }, - OR: [ - { user: { role: { not: 'admin' } } }, - { role: { contains: 'owner' } }, - ], + ...participantWhere, }, include: { user: true }, }); diff --git a/apps/api/src/people/people-fleet.helper.ts b/apps/api/src/people/people-fleet.helper.ts index 784d26cc8..be4ed40c9 100644 --- a/apps/api/src/people/people-fleet.helper.ts +++ b/apps/api/src/people/people-fleet.helper.ts @@ -1,5 +1,6 @@ import { Logger } from '@nestjs/common'; import { db } from '@db'; +import { orgParticipantMemberWhere } from '../utils/org-participation'; import { FleetService } from '../lib/fleet.service'; const MDM_POLICY_ID = -9999; @@ -115,14 +116,12 @@ export async function getAllEmployeeDevices( organizationId: string, ) { try { + const participantWhere = await orgParticipantMemberWhere(organizationId); const employees = await db.member.findMany({ where: { organizationId, deactivated: false, - OR: [ - { user: { role: { not: 'admin' } } }, - { role: { contains: 'owner' } }, - ], + ...participantWhere, }, include: { user: true }, }); diff --git a/apps/api/src/policies/policies.service.ts b/apps/api/src/policies/policies.service.ts index f420dc3ce..eb0fdefdb 100644 --- a/apps/api/src/policies/policies.service.ts +++ b/apps/api/src/policies/policies.service.ts @@ -5,14 +5,12 @@ import { NotFoundException, } from '@nestjs/common'; import { db, Frequency, PolicyStatus, Prisma } from '@db'; -import { - HeadObjectCommand, - PutObjectCommand, -} from '@aws-sdk/client-s3'; +import { HeadObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; import { PDFDocument, rgb, StandardFonts } from 'pdf-lib'; import { AttachmentsService } from '../attachments/attachments.service'; import { PolicyPdfRendererService } from '../trust-portal/policy-pdf-renderer.service'; import { filterComplianceMembers } from '../utils/compliance-filters'; +import { isMemberOrgParticipant } from '../utils/org-participation'; import { BUCKET_NAME, getSignedUrl, s3Client } from '../app/s3'; import type { CreatePolicyDto } from './dto/create-policy.dto'; import type { UpdatePolicyDto } from './dto/update-policy.dto'; @@ -99,9 +97,7 @@ export class PoliciesService { name: true, description: true, status: true, - ...(excludeContent - ? {} - : { content: true, draftContent: true }), + ...(excludeContent ? {} : { content: true, draftContent: true }), frequency: true, department: true, isRequiredToSign: true, @@ -215,7 +211,10 @@ export class PoliciesService { organizationId, timelinesService: this.timelinesService, }).catch((err) => { - this.logger.warn('timeline auto-complete check failed after publish-all', err); + this.logger.warn( + 'timeline auto-complete check failed after publish-all', + err, + ); }); return { @@ -301,7 +300,12 @@ export class PoliciesService { where: { id: createData.assigneeId, organizationId }, include: { user: { select: { role: true } } }, }); - if (assignee?.user.role === 'admin') { + if (!assignee) { + throw new BadRequestException( + 'Assignee is not a member of this organization', + ); + } + if (!(await isMemberOrgParticipant(assignee.user.role, organizationId))) { throw new BadRequestException( 'Cannot assign a platform admin as assignee', ); @@ -757,9 +761,10 @@ export class PoliciesService { sourceVersion = requestedVersion; } - const contentForVersion = (sourceVersion - ? (sourceVersion.content as Prisma.InputJsonValue[]) - : (policy.content as Prisma.InputJsonValue[])) ?? []; + const contentForVersion = + (sourceVersion + ? (sourceVersion.content as Prisma.InputJsonValue[]) + : (policy.content as Prisma.InputJsonValue[])) ?? []; const sourcePdfUrl = sourceVersion?.pdfUrl ?? policy.pdfUrl; // S3 copy is done AFTER the transaction to prevent orphaned files on retry @@ -1116,8 +1121,8 @@ export class PoliciesService { organizationId, timelinesService: this.timelinesService, }).catch((err) => { - this.logger.warn('timeline auto-complete check failed', err); - }); + this.logger.warn('timeline auto-complete check failed', err); + }); return result; } catch (error) { @@ -1180,8 +1185,8 @@ export class PoliciesService { organizationId, timelinesService: this.timelinesService, }).catch((err) => { - this.logger.warn('timeline auto-complete check failed', err); - }); + this.logger.warn('timeline auto-complete check failed', err); + }); return { versionId: version.id, @@ -1236,12 +1241,12 @@ export class PoliciesService { ); } - // Cannot assign a platform admin as approver + // Cannot assign a platform admin as approver (unless this is an internal org) const approverUser = await db.user.findUnique({ where: { id: approver.userId }, select: { role: true }, }); - if (approverUser?.role === 'admin') { + if (!(await isMemberOrgParticipant(approverUser?.role, organizationId))) { throw new BadRequestException( 'Cannot assign a platform admin as approver', ); @@ -1340,8 +1345,8 @@ export class PoliciesService { organizationId, timelinesService: this.timelinesService, }).catch((err) => { - this.logger.warn('timeline auto-complete check failed', err); - }); + this.logger.warn('timeline auto-complete check failed', err); + }); // Publishing cleared signedBy[] above, so everyone with the compliance // obligation must (re-)acknowledge the new version. Surface that audience so @@ -1491,10 +1496,7 @@ export class PoliciesService { /** * Download all published policies as a single PDF bundle (no watermark) */ - async downloadAllPoliciesPdf( - organizationId: string, - policyIds?: string[], - ) { + async downloadAllPoliciesPdf(organizationId: string, policyIds?: string[]) { // Get organization info const organization = await db.organization.findUnique({ where: { id: organizationId }, @@ -1511,9 +1513,7 @@ export class PoliciesService { organizationId, isArchived: false, archivedAt: null, - ...(policyIds && policyIds.length > 0 - ? { id: { in: policyIds } } - : {}), + ...(policyIds && policyIds.length > 0 ? { id: { in: policyIds } } : {}), }, select: { id: true, @@ -1831,10 +1831,7 @@ export class PoliciesService { select: { id: true, version: true }, }); if (!version) throw new NotFoundException('Version not found'); - if ( - version.id === policy.currentVersionId && - policy.status !== 'draft' - ) { + if (version.id === policy.currentVersionId && policy.status !== 'draft') { throw new BadRequestException( 'Cannot upload PDF to the published version', ); diff --git a/apps/api/src/risks/risks.service.ts b/apps/api/src/risks/risks.service.ts index e4d29cff9..b237e95c0 100644 --- a/apps/api/src/risks/risks.service.ts +++ b/apps/api/src/risks/risks.service.ts @@ -5,6 +5,7 @@ import { Logger, } from '@nestjs/common'; import { db, Prisma } from '@db'; +import { isMemberOrgParticipant } from '../utils/org-participation'; import { CreateRiskDto } from './dto/create-risk.dto'; import { GetRisksQueryDto } from './dto/get-risks-query.dto'; import { UpdateRiskDto } from './dto/update-risk.dto'; @@ -39,7 +40,12 @@ export class RisksService { where: { id: assigneeId, organizationId }, include: { user: { select: { role: true } } }, }); - if (member?.user.role === 'admin') { + if (!member) { + throw new BadRequestException( + 'Assignee is not a member of this organization', + ); + } + if (!(await isMemberOrgParticipant(member.user.role, organizationId))) { throw new BadRequestException( 'Cannot assign a platform admin as assignee', ); diff --git a/apps/api/src/soa/soa.service.spec.ts b/apps/api/src/soa/soa.service.spec.ts index 8a9b69ff3..ae0a199ad 100644 --- a/apps/api/src/soa/soa.service.spec.ts +++ b/apps/api/src/soa/soa.service.spec.ts @@ -26,6 +26,7 @@ jest.mock('@db', () => ({ }, member: { findFirst: jest.fn() }, user: { findUnique: jest.fn() }, + organization: { findUnique: jest.fn() }, sOAAnswer: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn() }, }, })); @@ -366,9 +367,10 @@ describe('SOAService', () => { status: 'completed', }), }); - expect((mockDb.sOADocument.update as jest.Mock).mock.calls[0][0].data.declinedAt).toBeInstanceOf( - Date, - ); + expect( + (mockDb.sOADocument.update as jest.Mock).mock.calls[0][0].data + .declinedAt, + ).toBeInstanceOf(Date); }); }); @@ -480,7 +482,9 @@ describe('SOAService', () => { it('throws NotFoundException when document not found', async () => { (mockDb.sOADocument.findFirst as jest.Mock).mockResolvedValue(null); - await expect(service.exportDocument(dto)).rejects.toThrow(NotFoundException); + await expect(service.exportDocument(dto)).rejects.toThrow( + NotFoundException, + ); }); it('maps document data and delegates to generateSOAExportFile', async () => { diff --git a/apps/api/src/soa/soa.service.ts b/apps/api/src/soa/soa.service.ts index d0c4c1f83..016e433b9 100644 --- a/apps/api/src/soa/soa.service.ts +++ b/apps/api/src/soa/soa.service.ts @@ -7,6 +7,7 @@ import { InternalServerErrorException, } from '@nestjs/common'; import { db } from '@db'; +import { isMemberOrgParticipant } from '../utils/org-participation'; import { SaveSOAAnswerDto } from './dto/save-soa-answer.dto'; import { CreateSOADocumentDto } from './dto/create-soa-document.dto'; import { EnsureSOASetupDto } from './dto/ensure-soa-setup.dto'; @@ -447,7 +448,9 @@ export class SOAService { where: { id: approverMember.userId }, select: { role: true }, }); - if (approverUser?.role === 'admin') { + if ( + !(await isMemberOrgParticipant(approverUser?.role, dto.organizationId)) + ) { throw new BadRequestException( 'Cannot assign a platform admin as approver', ); @@ -554,9 +557,7 @@ export class SOAService { declinedAt: (document as { declinedAt?: Date | null }).declinedAt ?? null, status: document.status, approverName: - document.approver?.user?.name || - document.approver?.user?.email || - null, + document.approver?.user?.name || document.approver?.user?.email || null, }; return generateSOAExportFile( diff --git a/apps/api/src/task-management/task-item-assignment-notifier.service.ts b/apps/api/src/task-management/task-item-assignment-notifier.service.ts index 8abc54e0b..5fe78653b 100644 --- a/apps/api/src/task-management/task-item-assignment-notifier.service.ts +++ b/apps/api/src/task-management/task-item-assignment-notifier.service.ts @@ -1,4 +1,5 @@ import { db } from '@db'; +import { isOrgParticipant } from '@trycompai/auth/participation'; import { Injectable, Logger } from '@nestjs/common'; import { isUserUnsubscribed } from '@trycompai/email'; import { triggerEmail } from '../email/trigger-email'; @@ -55,13 +56,12 @@ export class TaskItemAssignmentNotifierService { const [organization, assigneeMember, assignedByUser] = await Promise.all([ db.organization.findUnique({ where: { id: organizationId }, - select: { name: true }, + select: { name: true, isInternal: true }, }), db.member.findUnique({ where: { id: assigneeMemberId }, select: { id: true, - role: true, user: { select: { id: true, @@ -92,14 +92,15 @@ export class TaskItemAssignmentNotifierService { return; } - // Skip notifications for platform admin members unless they are an owner - const isOwner = assigneeMember.role - ?.split(',') - .map((r: string) => r.trim()) - .includes('owner'); - if (assigneeUser.role === 'admin' && !isOwner) { + // Skip notifications for platform admins unless this is an internal org + // (where platform admins are real members) — the single participation rule. + if ( + !isOrgParticipant(assigneeUser.role, { + orgIsInternal: organization?.isInternal ?? false, + }) + ) { this.logger.log( - `Skipping assignment notification: assignee ${assigneeUser.email} is a platform admin (non-owner)`, + `Skipping assignment notification: assignee ${assigneeUser.email} is a platform admin`, ); return; } diff --git a/apps/api/src/task-management/task-item-mention-notifier.service.ts b/apps/api/src/task-management/task-item-mention-notifier.service.ts index b45c6605f..9dced1ae2 100644 --- a/apps/api/src/task-management/task-item-mention-notifier.service.ts +++ b/apps/api/src/task-management/task-item-mention-notifier.service.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { db } from '@db'; +import { orgParticipantMemberWhere } from '../utils/org-participation'; import { isUserUnsubscribed } from '@trycompai/email'; import { triggerEmail } from '../email/trigger-email'; import { TaskItemMentionedEmail } from '../email/templates/task-item-mentioned'; @@ -50,16 +51,17 @@ export class TaskItemMentionNotifierService { return; } - // Get mentioned users: exclude platform admins unless they are an owner of this org + // Get mentioned users: exclude platform admins unless the org is internal + // (where platform admins are real members). `orgParticipantMemberWhere` + // returns an AND-wrapped fragment so it intersects with the mentioned-user + // filter above rather than overwriting it. + const participantWhere = await orgParticipantMemberWhere(organizationId); const mentionedMembers = await db.member.findMany({ where: { organizationId, deactivated: false, user: { id: { in: mentionedUserIds } }, - OR: [ - { user: { role: { not: 'admin' } } }, - { role: { contains: 'owner' } }, - ], + ...participantWhere, }, include: { user: true }, }); diff --git a/apps/api/src/task-management/task-management.service.ts b/apps/api/src/task-management/task-management.service.ts index 9960c1504..f39b96d6e 100644 --- a/apps/api/src/task-management/task-management.service.ts +++ b/apps/api/src/task-management/task-management.service.ts @@ -1,5 +1,6 @@ import { db } from '@db'; import { TaskItemEntityType, TaskItemStatus, TaskItemPriority } from '@db'; +import { isMemberOrgParticipant } from '../utils/org-participation'; import { BadRequestException, @@ -54,7 +55,11 @@ export class TaskManagementService { orderBy: { createdAt: 'asc' }, }); if (member?.userId) { - return { memberId: member.id, userId: member.userId, viaApiKey: true }; + return { + memberId: member.id, + userId: member.userId, + viaApiKey: true, + }; } } throw new BadRequestException( @@ -304,7 +309,17 @@ export class TaskManagementService { where: { id: createTaskItemDto.assigneeId, organizationId }, include: { user: { select: { role: true } } }, }); - if (assigneeMember?.user.role === 'admin') { + if (!assigneeMember) { + throw new BadRequestException( + 'Assignee is not a member of this organization', + ); + } + if ( + !(await isMemberOrgParticipant( + assigneeMember.user.role, + organizationId, + )) + ) { throw new BadRequestException( 'Cannot assign a platform admin as assignee', ); @@ -513,7 +528,17 @@ export class TaskManagementService { where: { id: updateTaskItemDto.assigneeId, organizationId }, include: { user: { select: { role: true } } }, }); - if (assigneeMember?.user.role === 'admin') { + if (!assigneeMember) { + throw new BadRequestException( + 'Assignee is not a member of this organization', + ); + } + if ( + !(await isMemberOrgParticipant( + assigneeMember.user.role, + organizationId, + )) + ) { throw new BadRequestException( 'Cannot assign a platform admin as assignee', ); diff --git a/apps/api/src/tasks/task-notifier.service.ts b/apps/api/src/tasks/task-notifier.service.ts index 2ace0f781..b981148e5 100644 --- a/apps/api/src/tasks/task-notifier.service.ts +++ b/apps/api/src/tasks/task-notifier.service.ts @@ -1,4 +1,5 @@ import { db } from '@db'; +import { orgParticipantMemberWhereForFlag } from '../utils/org-participation'; import { Injectable, Logger } from '@nestjs/common'; import { TaskStatus } from '@db'; import { isUserUnsubscribed } from '@trycompai/email'; @@ -1085,11 +1086,14 @@ export class TaskNotifierService { } = params; try { - const [organization, task, allMembers] = await Promise.all([ - db.organization.findUnique({ - where: { id: organizationId }, - select: { name: true }, - }), + const organization = await db.organization.findUnique({ + where: { id: organizationId }, + select: { name: true, isInternal: true }, + }); + const participantWhere = orgParticipantMemberWhereForFlag( + organization?.isInternal ?? false, + ); + const [task, allMembers] = await Promise.all([ db.task.findUnique({ where: { id: taskId }, select: { @@ -1110,10 +1114,7 @@ export class TaskNotifierService { where: { organizationId, deactivated: false, - OR: [ - { user: { role: { not: 'admin' } } }, - { role: { contains: 'owner' } }, - ], + ...participantWhere, }, select: { id: true, @@ -1289,12 +1290,15 @@ export class TaskNotifierService { try { const taskIds = failedTasks.map((t) => t.taskId); + const organization = await db.organization.findUnique({ + where: { id: organizationId }, + select: { name: true, isInternal: true }, + }); + const participantWhere = orgParticipantMemberWhereForFlag( + organization?.isInternal ?? false, + ); - const [organization, tasks, allMembers] = await Promise.all([ - db.organization.findUnique({ - where: { id: organizationId }, - select: { name: true }, - }), + const [tasks, allMembers] = await Promise.all([ db.task.findMany({ where: { id: { in: taskIds }, @@ -1319,10 +1323,7 @@ export class TaskNotifierService { where: { organizationId, deactivated: false, - OR: [ - { user: { role: { not: 'admin' } } }, - { role: { contains: 'owner' } }, - ], + ...participantWhere, }, select: { id: true, diff --git a/apps/api/src/tasks/tasks.service.spec.ts b/apps/api/src/tasks/tasks.service.spec.ts index 29c3b802a..4a9ec6a1e 100644 --- a/apps/api/src/tasks/tasks.service.spec.ts +++ b/apps/api/src/tasks/tasks.service.spec.ts @@ -217,4 +217,17 @@ describe('TasksService approval gating', () => { expect(where.status).toEqual({ not: 'in_review' }); }); }); + + describe('updateTasksAssignee', () => { + it('SECURITY: rejects an assignee that is not a member of the organization', async () => { + // A cross-org or non-existent member id makes the org-scoped lookup return + // null; the guard must reject it rather than persist a foreign assignee. + memberFindFirst.mockResolvedValueOnce(null); + + await expect( + service.updateTasksAssignee(ORG_ID, [TASK_ID], 'mem_other_org', USER_ID), + ).rejects.toThrow('Assignee is not a member of this organization'); + expect(taskUpdateMany).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/api/src/tasks/tasks.service.ts b/apps/api/src/tasks/tasks.service.ts index f6cfa76ba..5188d8938 100644 --- a/apps/api/src/tasks/tasks.service.ts +++ b/apps/api/src/tasks/tasks.service.ts @@ -12,6 +12,7 @@ import { TaskResponseDto } from './dto/task-responses.dto'; import { TaskNotifierService } from './task-notifier.service'; import { checkAutoCompletePhases } from '../frameworks/frameworks-timeline.helper'; import { TimelinesService } from '../timelines/timelines.service'; +import { isMemberOrgParticipant } from '../utils/org-participation'; function computeNextTaskReviewDate( frequency: TaskFrequency | null | undefined, @@ -472,7 +473,17 @@ export class TasksService { where: { id: assigneeId, organizationId }, include: { user: { select: { role: true } } }, }); - if (assigneeMember?.user.role === 'admin') { + if (!assigneeMember) { + throw new BadRequestException( + 'Assignee is not a member of this organization', + ); + } + if ( + !(await isMemberOrgParticipant( + assigneeMember.user.role, + organizationId, + )) + ) { throw new BadRequestException( 'Cannot assign a platform admin as assignee', ); @@ -675,7 +686,17 @@ export class TasksService { where: { id: updateData.assigneeId, organizationId }, include: { user: { select: { role: true } } }, }); - if (assigneeMember?.user.role === 'admin') { + if (!assigneeMember) { + throw new BadRequestException( + 'Assignee is not a member of this organization', + ); + } + if ( + !(await isMemberOrgParticipant( + assigneeMember.user.role, + organizationId, + )) + ) { throw new BadRequestException( 'Cannot assign a platform admin as assignee', ); @@ -761,8 +782,7 @@ export class TasksService { newValue: updateData.status, ...(updateData.status === TaskStatus.not_relevant && updateData.notRelevantJustification && { - notRelevantJustification: - updateData.notRelevantJustification, + notRelevantJustification: updateData.notRelevantJustification, }), }, }, @@ -1056,7 +1076,7 @@ export class TasksService { throw new BadRequestException('Approver not found or is deactivated'); } - if (approver.user.role === 'admin') { + if (!(await isMemberOrgParticipant(approver.user.role, organizationId))) { throw new BadRequestException( 'Cannot assign a platform admin as approver', ); @@ -1135,7 +1155,7 @@ export class TasksService { throw new BadRequestException('Approver not found or is deactivated'); } - if (approver.user.role === 'admin') { + if (!(await isMemberOrgParticipant(approver.user.role, organizationId))) { throw new BadRequestException( 'Cannot assign a platform admin as approver', ); diff --git a/apps/api/src/trigger/browser-automation/run-browser-automation.ts b/apps/api/src/trigger/browser-automation/run-browser-automation.ts index eb649e6c6..35373e040 100644 --- a/apps/api/src/trigger/browser-automation/run-browser-automation.ts +++ b/apps/api/src/trigger/browser-automation/run-browser-automation.ts @@ -1,4 +1,5 @@ import { db } from '@db'; +import { orgParticipantMemberWhereForFlag } from '../../utils/org-participation'; import { logger, tags, task } from '@trigger.dev/sdk'; import { BrowserbaseService } from '../../browserbase/browserbase.service'; import { triggerEmail } from '../../email/trigger-email'; @@ -39,12 +40,17 @@ async function sendTaskStatusChangeEmails(params: { const { organizationId, taskId, taskTitle, oldStatus, newStatus } = params; try { - // Get organization, task assignee, and org owners - const [organization, task, allMembers] = await Promise.all([ - db.organization.findUnique({ - where: { id: organizationId }, - select: { name: true }, - }), + // Use the shared participation rule so this path stays aligned with the + // other task notifiers: internal (platform-operated) orgs include platform + // admins; other orgs exclude them. + const organization = await db.organization.findUnique({ + where: { id: organizationId }, + select: { name: true, isInternal: true }, + }); + const participantWhere = orgParticipantMemberWhereForFlag( + organization?.isInternal ?? false, + ); + const [task, allMembers] = await Promise.all([ db.task.findUnique({ where: { id: taskId }, select: { @@ -65,7 +71,7 @@ async function sendTaskStatusChangeEmails(params: { where: { organizationId, deactivated: false, - user: { role: { not: 'admin' } }, + ...participantWhere, }, select: { role: true, diff --git a/apps/api/src/utils/compliance-filters.ts b/apps/api/src/utils/compliance-filters.ts index dd64b3c94..7c864f40d 100644 --- a/apps/api/src/utils/compliance-filters.ts +++ b/apps/api/src/utils/compliance-filters.ts @@ -1,9 +1,12 @@ import { BUILT_IN_ROLE_OBLIGATIONS, + PLATFORM_ADMIN_ROLE, type RoleObligations, allRoles, + isOrgParticipant, } from '@trycompai/auth'; import { db } from '@db'; +import { getOrgIsInternal } from './org-participation'; /** * Check if any of the given role names have the compliance obligation, @@ -42,6 +45,15 @@ export async function filterComplianceMembers( ): Promise { if (members.length === 0) return []; + // The internal flag only changes a platform admin's participation, so skip the + // extra query entirely when the list has no platform admins (the common case). + const hasPlatformAdmin = members.some( + (m) => m.user?.role === PLATFORM_ADMIN_ROLE, + ); + const orgIsInternal = hasPlatformAdmin + ? await getOrgIsInternal(organizationId) + : false; + // Collect all custom role names const allCustomRoleNames = new Set(); const builtInRoleNames = new Set(Object.keys(allRoles)); @@ -75,8 +87,9 @@ export async function filterComplianceMembers( return memberRoles .filter(({ member, roleNames }) => { - // Platform admins are excluded — they join customer orgs to debug - if (member.user?.role === 'admin') return false; + // Platform admins are excluded — they join customer orgs to debug — unless + // this is an internal (platform-operated) org where they are real members. + if (!isOrgParticipant(member.user?.role, { orgIsInternal })) return false; return hasComplianceObligation(roleNames, customObligationMap); }) .map(({ member }) => member); diff --git a/apps/api/src/utils/org-participation.spec.ts b/apps/api/src/utils/org-participation.spec.ts new file mode 100644 index 000000000..aa66b9b4a --- /dev/null +++ b/apps/api/src/utils/org-participation.spec.ts @@ -0,0 +1,98 @@ +jest.mock('@db', () => ({ + db: { organization: { findUnique: jest.fn() } }, + Prisma: {}, +})); + +import { db } from '@db'; +import { + getOrgIsInternal, + isMemberOrgParticipant, + orgParticipantMemberWhere, + orgParticipantMemberWhereForFlag, +} from './org-participation'; + +const orgFindUnique = db.organization.findUnique as jest.Mock; + +describe('org-participation', () => { + beforeEach(() => jest.clearAllMocks()); + + describe('getOrgIsInternal', () => { + it('returns true when the org is internal', async () => { + orgFindUnique.mockResolvedValue({ isInternal: true }); + await expect(getOrgIsInternal('org_1')).resolves.toBe(true); + }); + + it('returns false when the org is not internal', async () => { + orgFindUnique.mockResolvedValue({ isInternal: false }); + await expect(getOrgIsInternal('org_1')).resolves.toBe(false); + }); + + it('returns false when the org is not found', async () => { + orgFindUnique.mockResolvedValue(null); + await expect(getOrgIsInternal('missing')).resolves.toBe(false); + }); + }); + + describe('isMemberOrgParticipant', () => { + it('excludes a platform admin in a customer org', async () => { + orgFindUnique.mockResolvedValue({ isInternal: false }); + await expect(isMemberOrgParticipant('admin', 'org_1')).resolves.toBe( + false, + ); + }); + + it('includes a platform admin in an internal org', async () => { + orgFindUnique.mockResolvedValue({ isInternal: true }); + await expect(isMemberOrgParticipant('admin', 'org_1')).resolves.toBe( + true, + ); + }); + + it('includes non-admins regardless of org type', async () => { + orgFindUnique.mockResolvedValue({ isInternal: false }); + await expect(isMemberOrgParticipant('user', 'org_1')).resolves.toBe(true); + await expect(isMemberOrgParticipant(null, 'org_1')).resolves.toBe(true); + }); + }); + + describe('orgParticipantMemberWhereForFlag', () => { + it('returns an empty fragment for internal orgs (no exclusion)', () => { + expect(orgParticipantMemberWhereForFlag(true)).toEqual({}); + }); + + it('excludes only platform admins (incl. null roles) for customer orgs', () => { + expect(orgParticipantMemberWhereForFlag(false)).toEqual({ + AND: [{ user: { OR: [{ role: { not: 'admin' } }, { role: null }] } }], + }); + }); + + it('is spread-safe: does not clobber a caller-supplied user filter', () => { + // Regression: the fragment used to return a bare `user` key, which + // overwrote the mention notifiers' `user: { id: { in } }` filter and + // broadcast to the whole org. `AND` must keep both conditions. + const where = { + organizationId: 'org_1', + user: { id: { in: ['u1', 'u2'] } }, + ...orgParticipantMemberWhereForFlag(false), + }; + expect(where.user).toEqual({ id: { in: ['u1', 'u2'] } }); + expect(where.AND).toEqual([ + { user: { OR: [{ role: { not: 'admin' } }, { role: null }] } }, + ]); + }); + }); + + describe('orgParticipantMemberWhere', () => { + it('returns an empty fragment for internal orgs (no exclusion)', async () => { + orgFindUnique.mockResolvedValue({ isInternal: true }); + await expect(orgParticipantMemberWhere('org_1')).resolves.toEqual({}); + }); + + it('excludes only platform admins (incl. null roles) for customer orgs', async () => { + orgFindUnique.mockResolvedValue({ isInternal: false }); + await expect(orgParticipantMemberWhere('org_1')).resolves.toEqual({ + AND: [{ user: { OR: [{ role: { not: 'admin' } }, { role: null }] } }], + }); + }); + }); +}); diff --git a/apps/api/src/utils/org-participation.ts b/apps/api/src/utils/org-participation.ts new file mode 100644 index 000000000..f8413eac8 --- /dev/null +++ b/apps/api/src/utils/org-participation.ts @@ -0,0 +1,74 @@ +import { db, Prisma } from '@db'; +// Import from the dedicated subpath (not the package index) so this stays free +// of better-auth — keeps the util light and Jest-loadable without ESM interop. +import { + PLATFORM_ADMIN_ROLE, + isOrgParticipant, +} from '@trycompai/auth/participation'; + +/** + * Resolve whether an organization is platform-operated ("internal", e.g. Comp + * AI's own org). Internal orgs treat platform admins as real participants. + */ +export async function getOrgIsInternal( + organizationId: string, +): Promise { + const org = await db.organization.findUnique({ + where: { id: organizationId }, + select: { isInternal: true }, + }); + return org?.isInternal ?? false; +} + +/** + * Whether a member with the given global `User.role` may participate in the org + * (be an assignee/approver, count toward compliance, receive notifications). + * Fetches the org's internal flag, then delegates to the shared predicate. + */ +export async function isMemberOrgParticipant( + userRole: string | null | undefined, + organizationId: string, +): Promise { + return isOrgParticipant(userRole, { + orgIsInternal: await getOrgIsInternal(organizationId), + }); +} + +/** + * A Prisma `Member` where-fragment that keeps only org participants — the SQL + * translation of {@link isOrgParticipant}, built from an already-resolved + * internal flag. Returns an empty fragment for internal orgs (everyone + * participates). For other orgs it excludes only platform admins; `role: { not }` + * skips NULL in SQL, so null roles are included explicitly to match the + * predicate (a null global role is a normal member, not a platform admin). + * + * The exclusion is wrapped in `AND` rather than a bare `user` key so this + * fragment is safe to spread into a query that already constrains `user` (e.g. + * the mention notifiers filter `user: { id: { in } }`) — a bare `user` key would + * silently overwrite the caller's, broadening the recipient set. `AND` makes + * Prisma intersect the two conditions instead. Use for recipient/participant + * lists (notifications, mentions, devices). + */ +export function orgParticipantMemberWhereForFlag( + orgIsInternal: boolean, +): Prisma.MemberWhereInput { + if (orgIsInternal) return {}; + return { + AND: [ + { user: { OR: [{ role: { not: PLATFORM_ADMIN_ROLE } }, { role: null }] } }, + ], + }; +} + +/** + * Async convenience over {@link orgParticipantMemberWhereForFlag} that resolves + * the org's internal flag first. Prefer the `-ForFlag` variant when the caller + * has already loaded the organization, to avoid a redundant query. + */ +export async function orgParticipantMemberWhere( + organizationId: string, +): Promise { + return orgParticipantMemberWhereForFlag( + await getOrgIsInternal(organizationId), + ); +} diff --git a/apps/api/src/vendors/vendors.service.ts b/apps/api/src/vendors/vendors.service.ts index 349a2cff9..cdf39e5ca 100644 --- a/apps/api/src/vendors/vendors.service.ts +++ b/apps/api/src/vendors/vendors.service.ts @@ -12,6 +12,7 @@ import { Prisma } from '@db'; import type { TriggerVendorRiskAssessmentVendorDto } from './dto/trigger-vendor-risk-assessment.dto'; import { resolveTaskCreatorAndAssignee } from '../trigger/vendor/vendor-risk-assessment/assignee'; import { resolveStrategyDescriptionUpdate } from '../risks/strategy-descriptions'; +import { isMemberOrgParticipant } from '../utils/org-participation'; const normalizeWebsite = ( website: string | null | undefined, @@ -225,7 +226,12 @@ export class VendorsService { where: { id: assigneeId, organizationId }, include: { user: { select: { role: true } } }, }); - if (member?.user.role === 'admin') { + if (!member) { + throw new BadRequestException( + 'Assignee is not a member of this organization', + ); + } + if (!(await isMemberOrgParticipant(member.user.role, organizationId))) { throw new BadRequestException( 'Cannot assign a platform admin as assignee', ); diff --git a/apps/app/src/actions/policies/accept-requested-policy-changes.ts b/apps/app/src/actions/policies/accept-requested-policy-changes.ts index 1d7a7c460..24814a8db 100644 --- a/apps/app/src/actions/policies/accept-requested-policy-changes.ts +++ b/apps/app/src/actions/policies/accept-requested-policy-changes.ts @@ -1,5 +1,6 @@ 'use server'; +import { getOrgIsInternal } from '@/lib/org-participation'; import { sendNewPolicyEmail } from '@/trigger/tasks/email/new-policy-email'; import { db, PolicyStatus, type Prisma } from '@db/server'; import { tasks } from '@trigger.dev/sdk'; @@ -102,12 +103,13 @@ export const acceptRequestedPolicyChangesAction = authActionClient // Get all active members — the downstream isUserUnsubscribed check // handles role-based notification filtering via the org's notification matrix. + const orgIsInternal = await getOrgIsInternal(session.activeOrganizationId); const members = await db.member.findMany({ where: { organizationId: session.activeOrganizationId, isActive: true, deactivated: false, - user: { role: { not: 'admin' } }, + ...(orgIsInternal ? {} : { user: { role: { not: 'admin' } } }), }, include: { user: true, diff --git a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.test.tsx b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.test.tsx index e0cf395d6..085951d71 100644 --- a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.test.tsx @@ -39,6 +39,7 @@ const mockOrg: AdminOrgDetail = { onboardingCompleted: true, website: 'https://test.com', backgroundCheckStepEnabled: true, + isInternal: false, members: [ { id: 'mem_1', diff --git a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.tsx b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.tsx index 31b90ec8f..44dee3237 100644 --- a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.tsx @@ -55,6 +55,7 @@ export interface AdminOrgDetail { onboardingCompleted: boolean; website: string | null; backgroundCheckStepEnabled: boolean; + isInternal: boolean; members: OrgMember[]; } diff --git a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/OrganizationDetail.test.tsx b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/OrganizationDetail.test.tsx index dbd1ade80..19b71c135 100644 --- a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/OrganizationDetail.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/OrganizationDetail.test.tsx @@ -21,6 +21,7 @@ const baseOrg = { onboardingCompleted: true, members: [], backgroundCheckStepEnabled: true, + isInternal: false, }; describe('OrganizationDetail — background-check toggle', () => { @@ -91,3 +92,64 @@ describe('OrganizationDetail — background-check toggle', () => { }); }); }); + +describe('OrganizationDetail — internal-organization toggle', () => { + beforeEach(() => { + patchMock.mockReset(); + patchMock.mockResolvedValue({ data: { success: true } }); + }); + + it('asks for confirmation before saving (no PATCH on the toggle click)', async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.click( + screen.getByRole('switch', { name: /internal organization/i }), + ); + + expect(await screen.findByRole('alertdialog')).toBeInTheDocument(); + expect(patchMock).not.toHaveBeenCalled(); + }); + + it('PATCHes isInternal only after the change is confirmed', async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.click( + screen.getByRole('switch', { name: /internal organization/i }), + ); + await user.click( + await screen.findByRole('button', { name: /mark as internal/i }), + ); + + await waitFor(() => { + expect(patchMock).toHaveBeenCalledWith('/v1/admin/organizations/org_1', { + isInternal: true, + }); + }); + }); + + it('does not PATCH when the confirmation is canceled', async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.click( + screen.getByRole('switch', { name: /internal organization/i }), + ); + await user.click(await screen.findByRole('button', { name: /cancel/i })); + + await waitFor(() => { + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + }); + expect(patchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/OrganizationDetail.tsx b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/OrganizationDetail.tsx index 0d9b21b3d..df6780133 100644 --- a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/OrganizationDetail.tsx +++ b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/OrganizationDetail.tsx @@ -1,18 +1,27 @@ 'use client'; -import { useState } from 'react'; -import { toast } from 'sonner'; -import useSWR from 'swr'; -import { apiClient } from '@/lib/api-client'; import { RecentAuditLogs } from '@/components/RecentAuditLogs'; import type { AuditLogWithRelations } from '@/hooks/use-audit-logs'; +import { apiClient } from '@/lib/api-client'; import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, Badge, Section, Stack, Switch, Text, } from '@trycompai/design-system'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import useSWR from 'swr'; interface AdminOrgDetail { id: string; @@ -22,30 +31,33 @@ interface AdminOrgDetail { onboardingCompleted: boolean; members: { id: string }[]; backgroundCheckStepEnabled: boolean; + isInternal: boolean; } export function OrganizationDetail({ org, + currentOrgId, hasAccess, }: { org: AdminOrgDetail; currentOrgId: string; hasAccess: boolean; }) { - const [bgCheckEnabled, setBgCheckEnabled] = useState( - org.backgroundCheckStepEnabled, - ); + const router = useRouter(); + const [bgCheckEnabled, setBgCheckEnabled] = useState(org.backgroundCheckStepEnabled); const [savingBgCheck, setSavingBgCheck] = useState(false); + const [isInternal, setIsInternal] = useState(org.isInternal); + const [savingInternal, setSavingInternal] = useState(false); + const [pendingInternal, setPendingInternal] = useState(null); const handleToggleBgCheck = async (next: boolean) => { const previous = bgCheckEnabled; setBgCheckEnabled(next); setSavingBgCheck(true); - const res = await apiClient.patch( - `/v1/admin/organizations/${org.id}`, - { backgroundCheckStepEnabled: next }, - ); + const res = await apiClient.patch(`/v1/admin/organizations/${org.id}`, { + backgroundCheckStepEnabled: next, + }); setSavingBgCheck(false); @@ -55,10 +67,48 @@ export function OrganizationDetail({ return; } + toast.success( + next ? 'Background checks now required' : 'Background checks bypassed for this organization', + ); + }; + + // Toggling `isInternal` changes org-wide membership semantics, so confirm + // first (the switch flips only after the admin confirms). + const handleRequestToggleInternal = (next: boolean) => { + setPendingInternal(next); + }; + + const handleConfirmToggleInternal = async () => { + if (pendingInternal === null) return; + const next = pendingInternal; + const previous = isInternal; + setPendingInternal(null); + setIsInternal(next); + setSavingInternal(true); + + const res = await apiClient.patch(`/v1/admin/organizations/${org.id}`, { + isInternal: next, + }); + + setSavingInternal(false); + + if (res.error) { + setIsInternal(previous); + toast.error('Failed to update internal-organization setting'); + return; + } + + // If this is the org the admin is currently browsing, refresh the server + // layout so OrgInternalProvider (and consumers like the assignee picker) + // reflect the new flag without a full page reload. + if (org.id === currentOrgId) { + router.refresh(); + } + toast.success( next - ? 'Background checks now required' - : 'Background checks bypassed for this organization', + ? 'Marked as internal — platform admins can now participate here' + : 'Unmarked as internal — platform admins are excluded again', ); }; @@ -81,14 +131,8 @@ export function OrganizationDetail({ variant={hasAccess ? 'default' : 'destructive'} /> - - + +
@@ -96,9 +140,8 @@ export function OrganizationDetail({
Require background checks - When off, this org's members do not need to pass a background - check to count toward people completion. Existing requests stay - accessible. + When off, this org's members do not need to pass a background check to count + toward people completion. Existing requests stay accessible.
+
+
+
+ Internal organization + + For Comp AI-operated orgs only. When on, platform admins are treated as real members + here — assignable, counted in compliance, and notified. Leave off for every customer + organization. + +
+ +
+
+ {isLoading ? (
@@ -124,6 +186,34 @@ export function OrganizationDetail({ ) : ( )} + + { + if (!open) setPendingInternal(null); + }} + > + + + + {pendingInternal + ? 'Mark as internal organization?' + : 'Remove internal organization?'} + + + {pendingInternal + ? 'Platform admins will be treated as real members here — assignable, counted in compliance, and notified. Only enable this for Comp AI-operated orgs, never a customer organization.' + : 'Platform admins will be excluded from this organization again — removed from assignments, compliance counts, and notifications.'} + + + + Cancel + + {pendingInternal ? 'Mark as internal' : 'Remove internal'} + + + + ); } diff --git a/apps/app/src/app/(app)/[orgId]/layout.tsx b/apps/app/src/app/(app)/[orgId]/layout.tsx index b284e75be..ea58ead24 100644 --- a/apps/app/src/app/(app)/[orgId]/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/layout.tsx @@ -1,22 +1,16 @@ import { getFeatureFlags } from '@/app/posthog'; import { APP_AWS_ORG_ASSETS_BUCKET, s3Client } from '@/app/s3'; +import { OrgInternalProvider } from '@/components/org-internal-context'; import { TriggerTokenProvider } from '@/components/trigger-token-provider'; import { serverApi } from '@/lib/api-server'; -import { - canAccessApp, - canAccessAuditorView, - parseRolesString, -} from '@/lib/permissions'; -import { - resolveCustomRolePermissions, - resolveUserPermissions, -} from '@/lib/permissions.server'; +import { canAccessApp, canAccessAuditorView, parseRolesString } from '@/lib/permissions'; +import { resolveCustomRolePermissions, resolveUserPermissions } from '@/lib/permissions.server'; +import { getSignedUrl } from '@/lib/s3-presigner'; import type { OrganizationFromMe } from '@/types'; import { auth } from '@/utils/auth'; import { GetObjectCommand } from '@aws-sdk/client-s3'; -import { getSignedUrl } from '@/lib/s3-presigner'; -import { OrganizationIdentifier, ServerFeatureFlagsProvider } from '@trycompai/analytics'; import { db, Role } from '@db/server'; +import { OrganizationIdentifier, ServerFeatureFlagsProvider } from '@trycompai/analytics'; import dynamic from 'next/dynamic'; import { cookies, headers } from 'next/headers'; import { redirect } from 'next/navigation'; @@ -170,14 +164,8 @@ export default async function Layout({ // audit:read — built-in `auditor` role OR a custom role with explicit // audit:read. Resolve the custom-role permissions once so we don't // second-guess the owner/admin's implicit all-permissions in the UI. - const customRolePermissions = await resolveCustomRolePermissions( - member.role, - requestedOrgId, - ); - const auditorViewVisible = canAccessAuditorView( - member.role, - customRolePermissions, - ); + const customRolePermissions = await resolveCustomRolePermissions(member.role, requestedOrgId); + const auditorViewVisible = canAccessAuditorView(member.role, customRolePermissions); // User data for navbar const user = { @@ -193,25 +181,27 @@ export default async function Layout({ > - - {children} - + + + {children} + + diff --git a/apps/app/src/app/api/people/agent-devices/route.ts b/apps/app/src/app/api/people/agent-devices/route.ts index 8e5d3aa87..32d5ca0e2 100644 --- a/apps/app/src/app/api/people/agent-devices/route.ts +++ b/apps/app/src/app/api/people/agent-devices/route.ts @@ -1,15 +1,13 @@ -import { db } from '@db/server'; -import { NextResponse } from 'next/server'; -import { requireApiPermission } from '@/lib/permissions.server'; -import { - daysSinceCheckIn, - getDeviceComplianceStatus, -} from '@trycompai/utils/devices'; import type { CheckDetails, DeviceWithChecks, SourceCompliance, } from '@/app/(app)/[orgId]/people/devices/types'; +import { getOrgIsInternal } from '@/lib/org-participation'; +import { requireApiPermission } from '@/lib/permissions.server'; +import { db } from '@db/server'; +import { daysSinceCheckIn, getDeviceComplianceStatus } from '@trycompai/utils/devices'; +import { NextResponse } from 'next/server'; /** Maps the DB `DeviceSource` enum to the frontend source discriminant. */ function mapSource(source: string): DeviceWithChecks['source'] { @@ -27,12 +25,13 @@ export async function GET(req: Request) { if (ctx instanceof NextResponse) return ctx; const { organizationId } = ctx; + const orgIsInternal = await getOrgIsInternal(organizationId); const devices = await db.device.findMany({ where: { organizationId, member: { deactivated: false, - NOT: { user: { role: 'admin' } }, + ...(orgIsInternal ? {} : { NOT: { user: { role: 'admin' } } }), }, }, include: { diff --git a/apps/app/src/app/api/people/fleet-hosts/route.ts b/apps/app/src/app/api/people/fleet-hosts/route.ts index 4046f585b..abe6e9ac2 100644 --- a/apps/app/src/app/api/people/fleet-hosts/route.ts +++ b/apps/app/src/app/api/people/fleet-hosts/route.ts @@ -1,8 +1,9 @@ +import type { Host } from '@/app/(app)/[orgId]/people/devices/types'; import { getFleetInstance } from '@/lib/fleet'; +import { getOrgIsInternal } from '@/lib/org-participation'; +import { requireApiPermission } from '@/lib/permissions.server'; import { db } from '@db/server'; import { NextResponse } from 'next/server'; -import { requireApiPermission } from '@/lib/permissions.server'; -import type { Host } from '@/app/(app)/[orgId]/people/devices/types'; const MDM_POLICY_ID = -9999; @@ -15,11 +16,12 @@ export async function GET(req: Request) { const fleet = await getFleetInstance(); + const orgIsInternal = await getOrgIsInternal(organizationId); const employees = await db.member.findMany({ where: { organizationId, deactivated: false, - NOT: { user: { role: 'admin' } }, + ...(orgIsInternal ? {} : { NOT: { user: { role: 'admin' } } }), }, include: { user: true }, }); @@ -70,48 +72,43 @@ export async function GET(req: Request) { } } - const data: Host[] = devices.map( - (device: { data: { host: Host } }, index: number) => { - const host = device.data.host; - const platform = host.platform?.toLowerCase(); - const osVersion = host.os_version?.toLowerCase(); - const isMacOS = - platform === 'darwin' || - platform === 'macos' || - platform === 'osx' || - osVersion?.includes('mac'); - return { - ...host, - user_name: userNames[index], - member_id: memberIds[index], - policies: [ - ...(host.policies || []), - ...(isMacOS - ? [ - { - id: MDM_POLICY_ID, - name: 'MDM Enabled', - response: host.mdm?.connected_to_fleet ? 'pass' : 'fail', - }, - ] - : []), - ].map((policy) => { - const policyResult = resultIndex.get( - `${userIds[index]}:${policy.id}`, - ); - return { - ...policy, - response: - policy.response === 'pass' || - policyResult?.fleetPolicyResponse === 'pass' - ? 'pass' - : 'fail', - attachments: policyResult?.attachments || [], - }; - }), - }; - }, - ); + const data: Host[] = devices.map((device: { data: { host: Host } }, index: number) => { + const host = device.data.host; + const platform = host.platform?.toLowerCase(); + const osVersion = host.os_version?.toLowerCase(); + const isMacOS = + platform === 'darwin' || + platform === 'macos' || + platform === 'osx' || + osVersion?.includes('mac'); + return { + ...host, + user_name: userNames[index], + member_id: memberIds[index], + policies: [ + ...(host.policies || []), + ...(isMacOS + ? [ + { + id: MDM_POLICY_ID, + name: 'MDM Enabled', + response: host.mdm?.connected_to_fleet ? 'pass' : 'fail', + }, + ] + : []), + ].map((policy) => { + const policyResult = resultIndex.get(`${userIds[index]}:${policy.id}`); + return { + ...policy, + response: + policy.response === 'pass' || policyResult?.fleetPolicyResponse === 'pass' + ? 'pass' + : 'fail', + attachments: policyResult?.attachments || [], + }; + }), + }; + }); return NextResponse.json({ data }); } diff --git a/apps/app/src/components/SelectAssignee.tsx b/apps/app/src/components/SelectAssignee.tsx index 0dcece026..83206c72a 100644 --- a/apps/app/src/components/SelectAssignee.tsx +++ b/apps/app/src/components/SelectAssignee.tsx @@ -1,7 +1,9 @@ +import { useOrgIsInternal } from '@/components/org-internal-context'; +import { isOrgParticipant } from '@/lib/org-participation-rule'; import { authClient } from '@/utils/auth-client'; +import { Member, User } from '@db'; import { Avatar, AvatarFallback, AvatarImage } from '@trycompai/ui/avatar'; import { Select, SelectContent, SelectItem, SelectTrigger } from '@trycompai/ui/select'; -import { Member, User } from '@db'; import { UserIcon } from 'lucide-react'; import { useEffect, useState } from 'react'; @@ -21,13 +23,14 @@ export const SelectAssignee = ({ withTitle = true, }: SelectAssigneeProps) => { const { data: activeMember } = authClient.useActiveMember(); - // Exclude platform admins from assignee selection + const orgIsInternal = useOrgIsInternal(); + // Exclude platform admins from assignee selection — except in internal + // (platform-operated) orgs, where they are real members. Uses the shared + // participation rule so the UI stays in sync with the backend. const assignees = rawAssignees - .filter((a) => a.user.role !== 'admin') + .filter((a) => isOrgParticipant(a.user.role, { orgIsInternal })) .sort((a, b) => - (a.user.name || a.user.email || '').localeCompare( - b.user.name || b.user.email || '', - ), + (a.user.name || a.user.email || '').localeCompare(b.user.name || b.user.email || ''), ); const [selectedAssignee, setSelectedAssignee] = useState<(Member & { user: User }) | null>(null); @@ -124,11 +127,7 @@ export const SelectAssignee = ({
{assignees.map((assignee) => ( - +
(false); + +export function OrgInternalProvider({ + isInternal, + children, +}: { + isInternal: boolean; + children: ReactNode; +}) { + return ( + {children} + ); +} + +export function useOrgIsInternal(): boolean { + return useContext(OrgIsInternalContext); +} diff --git a/apps/app/src/lib/compliance.ts b/apps/app/src/lib/compliance.ts index d916451fb..ddc41a422 100644 --- a/apps/app/src/lib/compliance.ts +++ b/apps/app/src/lib/compliance.ts @@ -1,7 +1,9 @@ import 'server-only'; -import { BUILT_IN_ROLE_OBLIGATIONS } from '@trycompai/auth'; import { db } from '@db/server'; +import { BUILT_IN_ROLE_OBLIGATIONS } from '@trycompai/auth'; +import { getOrgIsInternal } from './org-participation'; +import { PLATFORM_ADMIN_ROLE, isOrgParticipant } from './org-participation-rule'; import { type UserPermissions, canAccessApp, @@ -51,9 +53,7 @@ async function filterMembersByPermission( .filter((r) => r.permissions) .map((r) => { const parsed = - typeof r.permissions === 'string' - ? JSON.parse(r.permissions) - : r.permissions; + typeof r.permissions === 'string' ? JSON.parse(r.permissions) : r.permissions; return [r.name, parsed as Record]; }), ); @@ -81,6 +81,11 @@ export async function filterComplianceMembers( ): Promise { if (members.length === 0) return []; + // The internal flag only changes a platform admin's participation, so skip the + // extra query entirely when the list has no platform admins (the common case). + const hasPlatformAdmin = members.some((m) => m.user?.role === PLATFORM_ADMIN_ROLE); + const orgIsInternal = hasPlatformAdmin ? await getOrgIsInternal(organizationId) : false; + const builtInRoleNames = new Set(Object.keys(BUILT_IN_ROLE_OBLIGATIONS)); const allRoleNames = new Set(); @@ -101,9 +106,8 @@ export async function filterComplianceMembers( }); obligationMap = Object.fromEntries( dbRoles.map((r) => { - const obligations = typeof r.obligations === 'string' - ? JSON.parse(r.obligations) - : (r.obligations || {}); + const obligations = + typeof r.obligations === 'string' ? JSON.parse(r.obligations) : r.obligations || {}; return [r.name, obligations as Record]; }), ); @@ -111,8 +115,9 @@ export async function filterComplianceMembers( return memberRoles .filter(({ member, roleNames }) => { - // Platform admins are excluded — they join customer orgs to debug - if (member.user?.role === 'admin') return false; + // Platform admins are excluded — they join customer orgs to debug — unless + // this is an internal (platform-operated) org where they are real members. + if (!isOrgParticipant(member.user?.role, { orgIsInternal })) return false; for (const name of roleNames) { // DB override wins, but only if `compliance` is explicitly set — // otherwise fall back to the hardcoded built-in default. diff --git a/apps/app/src/lib/org-participation-rule.test.ts b/apps/app/src/lib/org-participation-rule.test.ts new file mode 100644 index 000000000..1bcb35d80 --- /dev/null +++ b/apps/app/src/lib/org-participation-rule.test.ts @@ -0,0 +1,58 @@ +import { + PLATFORM_ADMIN_ROLE as AUTH_PLATFORM_ADMIN_ROLE, + isOrgParticipant as authIsOrgParticipant, +} from '@trycompai/auth/participation'; +import { describe, expect, it } from 'vitest'; +import { isOrgParticipant, PLATFORM_ADMIN_ROLE } from './org-participation-rule'; + +describe('isOrgParticipant', () => { + it('excludes platform admins in a customer org', () => { + expect(isOrgParticipant(PLATFORM_ADMIN_ROLE, { orgIsInternal: false })).toBe(false); + }); + + it('includes platform admins in an internal org', () => { + expect(isOrgParticipant(PLATFORM_ADMIN_ROLE, { orgIsInternal: true })).toBe(true); + }); + + it('includes non-admin roles in any org', () => { + expect(isOrgParticipant('user', { orgIsInternal: false })).toBe(true); + expect(isOrgParticipant('owner', { orgIsInternal: false })).toBe(true); + }); + + it('treats null/undefined roles as participants (not platform admins)', () => { + expect(isOrgParticipant(null, { orgIsInternal: false })).toBe(true); + expect(isOrgParticipant(undefined, { orgIsInternal: false })).toBe(true); + }); + + it('includes everyone in an internal org', () => { + expect(isOrgParticipant('admin', { orgIsInternal: true })).toBe(true); + expect(isOrgParticipant('user', { orgIsInternal: true })).toBe(true); + expect(isOrgParticipant(null, { orgIsInternal: true })).toBe(true); + }); +}); + +// Drift guard: this app-local rule is a deliberate dependency-free mirror of +// `@trycompai/auth/participation` (the app can't import the auth index from +// Trigger.dev-bundled files). Fail CI if the two ever diverge. +describe('org-participation rule stays in sync with @trycompai/auth', () => { + const roles: Array = [ + 'admin', + 'owner', + 'user', + 'auditor', + '', + null, + undefined, + ]; + + it('matches the canonical predicate for every role × internal flag', () => { + expect(PLATFORM_ADMIN_ROLE).toBe(AUTH_PLATFORM_ADMIN_ROLE); + for (const role of roles) { + for (const orgIsInternal of [true, false]) { + expect(isOrgParticipant(role, { orgIsInternal })).toBe( + authIsOrgParticipant(role, { orgIsInternal }), + ); + } + } + }); +}); diff --git a/apps/app/src/lib/org-participation-rule.ts b/apps/app/src/lib/org-participation-rule.ts new file mode 100644 index 000000000..e3cebdf8d --- /dev/null +++ b/apps/app/src/lib/org-participation-rule.ts @@ -0,0 +1,23 @@ +/** + * Pure, dependency-free org-participation rule. + * + * This is a deliberate mirror of `packages/auth/src/participation.ts`. The app + * cannot import `@trycompai/auth` from files that end up in the Trigger.dev + * bundle (that package pulls in better-auth, which the deploy pipeline can't + * bundle — see policy-acknowledgment-digest-helpers.ts for the same pattern). + * Keeping this rule free of imports lets both RSC/server code and Trigger.dev + * tasks share one implementation. KEEP IN SYNC with the auth package version. + * + * Platform admins (`User.role === 'admin'`) are Comp AI staff embedded in + * customer orgs for support; they are excluded from an org's business logic + * UNLESS the org is internal (platform-operated, e.g. Comp AI's own org). + */ +export const PLATFORM_ADMIN_ROLE = 'admin'; + +export function isOrgParticipant( + userRole: string | null | undefined, + { orgIsInternal }: { orgIsInternal: boolean }, +): boolean { + if (orgIsInternal) return true; + return userRole !== PLATFORM_ADMIN_ROLE; +} diff --git a/apps/app/src/lib/org-participation.ts b/apps/app/src/lib/org-participation.ts new file mode 100644 index 000000000..f205b12b6 --- /dev/null +++ b/apps/app/src/lib/org-participation.ts @@ -0,0 +1,21 @@ +import 'server-only'; + +import { db } from '@db/server'; + +/** + * App-side server helper for the org-participation rule. This module imports + * `@db/server`, so it must only be used from server code. The pure predicate + * lives in `./org-participation-rule` (dependency-free) — import + * `isOrgParticipant` from there directly so client/Trigger.dev bundles never + * pull `@db/server` in transitively. See `packages/auth/src/participation.ts` + * for the canonical rule. + */ + +/** Whether an org is platform-operated ("internal", e.g. Comp AI's own org). */ +export async function getOrgIsInternal(organizationId: string): Promise { + const org = await db.organization.findUnique({ + where: { id: organizationId }, + select: { isInternal: true }, + }); + return org?.isInternal ?? false; +} diff --git a/apps/app/src/test-utils/setup.ts b/apps/app/src/test-utils/setup.ts index db80fa28a..db4803924 100644 --- a/apps/app/src/test-utils/setup.ts +++ b/apps/app/src/test-utils/setup.ts @@ -2,6 +2,24 @@ import '@testing-library/jest-dom/vitest'; import { afterAll, afterEach, beforeAll, vi } from 'vitest'; +// jsdom does not implement PointerEvent, which base-ui components (e.g. Switch) +// dispatch on interaction. Provide a minimal shim so pointer-driven tests work +// instead of throwing "PointerEvent is not defined". +if (typeof globalThis.PointerEvent === 'undefined') { + class PointerEventShim extends MouseEvent { + readonly pointerId: number; + readonly pointerType: string; + + constructor(type: string, params: PointerEventInit = {}) { + super(type, params); + this.pointerId = params.pointerId ?? 0; + this.pointerType = params.pointerType ?? ''; + } + } + + globalThis.PointerEvent = PointerEventShim as unknown as typeof PointerEvent; +} + // Stub `server-only` so modules that use the marker can still be imported // in jsdom test runs (the package throws on client environments by design). vi.mock('server-only', () => ({})); diff --git a/apps/app/src/trigger/tasks/onboarding/generate-vendor-mitigation.ts b/apps/app/src/trigger/tasks/onboarding/generate-vendor-mitigation.ts index f7f2662fa..6e8d60af1 100644 --- a/apps/app/src/trigger/tasks/onboarding/generate-vendor-mitigation.ts +++ b/apps/app/src/trigger/tasks/onboarding/generate-vendor-mitigation.ts @@ -1,3 +1,4 @@ +import { isOrgParticipant } from '@/lib/org-participation-rule'; import { VendorStatus, db } from '@db/server'; import { logger, metadata, queue, tags, task, tasks } from '@trigger.dev/sdk'; import axios from 'axios'; @@ -49,12 +50,27 @@ export const generateVendorMitigation = task({ // platform admins are hidden from the assignee UI, so skip them too. let assigneeUpdate: { assigneeId: string | null } | Record = {}; if (authorId) { - const author = await db.member.findFirst({ - where: { id: authorId, organizationId }, - include: { user: { select: { role: true } } }, - }); + const [author, org] = await Promise.all([ + db.member.findFirst({ + where: { id: authorId, organizationId, deactivated: false }, + include: { user: { select: { role: true } } }, + }), + db.organization.findUnique({ + where: { id: organizationId }, + select: { isInternal: true }, + }), + ]); + // Only assign when the author is a real member of THIS org and is a + // participant (fail closed if the lookup missed — never write an unknown + // or cross-org member id). assigneeUpdate = { - assigneeId: author?.user.role === 'admin' ? null : authorId, + assigneeId: + author && + isOrgParticipant(author.user.role, { + orgIsInternal: org?.isInternal ?? false, + }) + ? authorId + : null, }; } diff --git a/apps/app/src/trigger/tasks/task/policy-acknowledgment-digest-helpers.ts b/apps/app/src/trigger/tasks/task/policy-acknowledgment-digest-helpers.ts index da2608801..8cea31d1d 100644 --- a/apps/app/src/trigger/tasks/task/policy-acknowledgment-digest-helpers.ts +++ b/apps/app/src/trigger/tasks/task/policy-acknowledgment-digest-helpers.ts @@ -2,6 +2,7 @@ * Helper types and pure filter function for the policy acknowledgment digest. * Extracted from the scheduled task for testability. */ +import { isOrgParticipant } from '@/lib/org-participation-rule'; import type { PolicyVisibility } from '@db'; // Inlined from @trycompai/auth to avoid pulling that package into the Trigger.dev bundle. @@ -34,6 +35,7 @@ export async function filterDigestMembersByCompliance( db: ComplianceFilterDb, members: T[], organizationId: string, + orgIsInternal = false, ): Promise { if (members.length === 0) return []; @@ -58,9 +60,7 @@ export async function filterDigestMembersByCompliance( obligationMap = Object.fromEntries( dbRoles.map((r) => { const obligations = - typeof r.obligations === 'string' - ? JSON.parse(r.obligations) - : r.obligations || {}; + typeof r.obligations === 'string' ? JSON.parse(r.obligations) : r.obligations || {}; return [r.name, obligations as { compliance?: boolean }]; }), ); @@ -68,8 +68,9 @@ export async function filterDigestMembersByCompliance( return parsed .filter(({ member, roleNames }) => { - // Platform admins are excluded — matches the @/lib/compliance behavior. - if (member.user?.role === 'admin') return false; + // Platform admins are excluded — matches the @/lib/compliance behavior — + // unless this is an internal (platform-operated) org. + if (!isOrgParticipant(member.user?.role, { orgIsInternal })) return false; for (const name of roleNames) { // DB override wins, but only if `compliance` is explicitly set — // otherwise fall back to the hardcoded built-in default. diff --git a/apps/app/src/trigger/tasks/task/policy-acknowledgment-digest.ts b/apps/app/src/trigger/tasks/task/policy-acknowledgment-digest.ts index 3d8c293a4..b22e1868f 100644 --- a/apps/app/src/trigger/tasks/task/policy-acknowledgment-digest.ts +++ b/apps/app/src/trigger/tasks/task/policy-acknowledgment-digest.ts @@ -18,10 +18,7 @@ import { } from './policy-acknowledgment-digest-helpers'; const getPortalBase = () => - (process.env.NEXT_PUBLIC_PORTAL_URL ?? 'https://portal.trycomp.ai').replace( - /\/+$/, - '', - ); + (process.env.NEXT_PUBLIC_PORTAL_URL ?? 'https://portal.trycomp.ai').replace(/\/+$/, ''); // Skip orgs that look abandoned — same threshold weekly-task-reminder uses so // we don't keep hitting dead addresses and burning domain reputation. @@ -43,9 +40,7 @@ export const policyAcknowledgmentDigest = schedules.task({ maxDuration: 1000 * 60 * 15, // 15 minutes run: async () => { const inactivityCutoff = new Date(); - inactivityCutoff.setDate( - inactivityCutoff.getDate() - ORG_INACTIVITY_DAYS, - ); + inactivityCutoff.setDate(inactivityCutoff.getDate() - ORG_INACTIVITY_DAYS); const organizations = await db.organization.findMany({ where: { @@ -72,6 +67,7 @@ export const policyAcknowledgmentDigest = schedules.task({ select: { id: true, name: true, + isInternal: true, policy: { where: { status: 'published', @@ -124,6 +120,7 @@ export const policyAcknowledgmentDigest = schedules.task({ db as unknown as ComplianceFilterDb, org.members, org.id, + org.isInternal, ); if (complianceMembers.length === 0) continue; @@ -150,9 +147,7 @@ export const policyAcknowledgmentDigest = schedules.task({ if (pendingByMember.length === 0) continue; // One unsubscribe query per org, batched across members. - const emailsWithPending = pendingByMember.map( - (p) => p.member.user.email, - ); + const emailsWithPending = pendingByMember.map((p) => p.member.user.email); const unsubscribedEmails = await getUnsubscribedEmails( db, emailsWithPending, @@ -188,10 +183,7 @@ export const policyAcknowledgmentDigest = schedules.task({ // Render all emails and group by primaryOrgId for batch sending. let emailsSent = 0; let emailsFailed = 0; - const emailsByOrg = new Map< - string, - Array<{ to: string; subject: string; html: string }> - >(); + const emailsByOrg = new Map>(); for (const entry of rollup.values()) { const subject = computePolicyAcknowledgmentDigestSubject(entry.orgs); diff --git a/apps/app/src/trigger/tasks/task/policy-schedule.ts b/apps/app/src/trigger/tasks/task/policy-schedule.ts index 444df004e..8856f4a1c 100644 --- a/apps/app/src/trigger/tasks/task/policy-schedule.ts +++ b/apps/app/src/trigger/tasks/task/policy-schedule.ts @@ -1,3 +1,4 @@ +import { isOrgParticipant } from '@/lib/org-participation-rule'; import { db } from '@db/server'; import { Novu } from '@novu/api'; import { logger, schedules } from '@trigger.dev/sdk'; @@ -30,13 +31,10 @@ export const policySchedule = schedules.task({ select: { id: true, name: true, + isInternal: true, members: { where: { deactivated: false, - OR: [ - { user: { role: { not: 'admin' } } }, - { role: { contains: 'owner' } }, - ], }, select: { user: { @@ -44,6 +42,7 @@ export const policySchedule = schedules.task({ id: true, name: true, email: true, + role: true, }, }, }, @@ -127,21 +126,33 @@ export const policySchedule = schedules.task({ } >(); const addRecipients = ( - users: Array<{ user: { id: string; email: string; name?: string } }>, + members: Array<{ + user: { + id: string; + email: string; + name?: string; + role?: string | null; + }; + }>, policy: (typeof overduePolicies)[number], ) => { - for (const entry of users) { + // Exclude platform admins (Comp AI staff) unless the org is internal — + // the single participation rule (no per-member owner carve-out). + const orgIsInternal = policy.organization?.isInternal ?? false; + for (const entry of members) { const user = entry.user; - if (user && user.email && user.id) { - const key = `${user.id}-${policy.id}`; - if (!recipientsMap.has(key)) { - recipientsMap.set(key, { - email: user.email, - userId: user.id, - name: user.name ?? '', - policy, - }); - } + if (!user?.email || !user.id) continue; + if (!isOrgParticipant(user.role, { orgIsInternal })) { + continue; + } + const key = `${user.id}-${policy.id}`; + if (!recipientsMap.has(key)) { + recipientsMap.set(key, { + email: user.email, + userId: user.id, + name: user.name ?? '', + policy, + }); } } }; diff --git a/apps/app/src/trigger/tasks/task/task-schedule.ts b/apps/app/src/trigger/tasks/task/task-schedule.ts index 872b3cbd2..84bf0a551 100644 --- a/apps/app/src/trigger/tasks/task/task-schedule.ts +++ b/apps/app/src/trigger/tasks/task/task-schedule.ts @@ -1,3 +1,4 @@ +import { isOrgParticipant } from '@/lib/org-participation-rule'; import { db } from '@db/server'; import { Novu } from '@novu/api'; import { logger, schedules } from '@trigger.dev/sdk'; @@ -33,13 +34,10 @@ export const taskSchedule = schedules.task({ select: { id: true, name: true, + isInternal: true, members: { where: { deactivated: false, - OR: [ - { user: { role: { not: 'admin' } } }, - { role: { contains: 'owner' } }, - ], }, select: { user: { @@ -47,6 +45,7 @@ export const taskSchedule = schedules.task({ id: true, name: true, email: true, + role: true, }, }, }, @@ -196,21 +195,33 @@ export const taskSchedule = schedules.task({ } >(); const addRecipients = ( - users: Array<{ user: { id: string; email: string; name?: string } }>, + members: Array<{ + user: { + id: string; + email: string; + name?: string; + role?: string | null; + }; + }>, task: (typeof allUpdatedTasks)[number], ) => { - for (const entry of users) { + // Exclude platform admins (Comp AI staff) unless the org is internal — + // the single participation rule (no per-member owner carve-out). + const orgIsInternal = task.organization?.isInternal ?? false; + for (const entry of members) { const user = entry.user; - if (user && user.email && user.id) { - const key = `${user.id}-${task.id}`; - if (!recipientsMap.has(key)) { - recipientsMap.set(key, { - email: user.email, - userId: user.id, - name: user.name ?? '', - task, - }); - } + if (!user?.email || !user.id) continue; + if (!isOrgParticipant(user.role, { orgIsInternal })) { + continue; + } + const key = `${user.id}-${task.id}`; + if (!recipientsMap.has(key)) { + recipientsMap.set(key, { + email: user.email, + userId: user.id, + name: user.name ?? '', + task, + }); } } }; @@ -238,7 +249,12 @@ export const taskSchedule = schedules.task({ : ('todo' as const); // Check if user is unsubscribed - const isUnsubscribed = await isUserUnsubscribed(db, recipient.email, 'taskReminders', recipient.task.organizationId); + const isUnsubscribed = await isUserUnsubscribed( + db, + recipient.email, + 'taskReminders', + recipient.task.organizationId, + ); if (isUnsubscribed) { logger.info( diff --git a/apps/app/src/trigger/tasks/task/weekly-task-reminder.ts b/apps/app/src/trigger/tasks/task/weekly-task-reminder.ts index f1466eac6..6e49fb534 100644 --- a/apps/app/src/trigger/tasks/task/weekly-task-reminder.ts +++ b/apps/app/src/trigger/tasks/task/weekly-task-reminder.ts @@ -1,3 +1,4 @@ +import { isOrgParticipant } from '@/lib/org-participation-rule'; import { db } from '@db/server'; import { logger, schedules } from '@trigger.dev/sdk'; import { sendWeeklyTaskDigestEmailTask } from '../email/weekly-task-digest-email'; @@ -36,13 +37,10 @@ export const weeklyTaskReminder = schedules.task({ select: { id: true, name: true, + isInternal: true, members: { where: { deactivated: false, - OR: [ - { user: { role: { not: 'admin' } } }, - { role: { contains: 'owner' } }, - ], }, select: { id: true, @@ -51,6 +49,7 @@ export const weeklyTaskReminder = schedules.task({ id: true, name: true, email: true, + role: true, }, }, }, @@ -58,7 +57,9 @@ export const weeklyTaskReminder = schedules.task({ }, }); - logger.info(`Found ${organizations.length} active organizations to process (skipped orgs with no sessions in ${ORG_INACTIVITY_DAYS} days)`); + logger.info( + `Found ${organizations.length} active organizations to process (skipped orgs with no sessions in ${ORG_INACTIVITY_DAYS} days)`, + ); // Build email payloads for all members with TODO tasks const emailPayloads = []; @@ -97,6 +98,16 @@ export const weeklyTaskReminder = schedules.task({ continue; } + // Exclude platform admins (Comp AI staff) unless the org is internal — + // the single participation rule (no per-member owner carve-out). + if ( + !isOrgParticipant(member.user.role, { + orgIsInternal: org.isInternal, + }) + ) { + continue; + } + emailPayloads.push({ payload: { email: member.user.email, diff --git a/packages/auth/package.json b/packages/auth/package.json index f561f7c08..cbe88d748 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -13,6 +13,10 @@ "types": "./dist/permissions.d.ts", "default": "./dist/permissions.js" }, + "./participation": { + "types": "./dist/participation.d.ts", + "default": "./dist/participation.js" + }, "./server": { "types": "./dist/server.d.ts", "default": "./dist/server.js" diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 489deab2a..465fbba06 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -1,23 +1,30 @@ export { + BUILT_IN_ROLE_OBLIGATIONS, + BUILT_IN_ROLE_PERMISSIONS, + PRIVILEGED_ROLES, + RESTRICTED_ROLES, + ROLE_HIERARCHY, ac, - statement, - owner, admin, + allRoles, auditor, - employee, contractor, - allRoles, - ROLE_HIERARCHY, - RESTRICTED_ROLES, + employee, isRestrictedRole, - PRIVILEGED_ROLES, - BUILT_IN_ROLE_PERMISSIONS, - BUILT_IN_ROLE_OBLIGATIONS, + owner, + parseRoleObligations, + parseRolePermissions, + statement, type RoleName, type RoleObligations, type RolePermissions, - parseRolePermissions, - parseRoleObligations, } from './permissions'; -export { createAuthServer, type CreateAuthServerOptions, type AuthServer } from './server'; +export { createAuthServer, type AuthServer, type CreateAuthServerOptions } from './server'; + +export { + PLATFORM_ADMIN_ROLE, + isExcludedFromOrgParticipation, + isOrgParticipant, + type OrgParticipationContext, +} from './participation'; diff --git a/packages/auth/src/participation.ts b/packages/auth/src/participation.ts new file mode 100644 index 000000000..ae48fd624 --- /dev/null +++ b/packages/auth/src/participation.ts @@ -0,0 +1,55 @@ +/** + * Organization participation rules. + * + * A *platform admin* is a global Comp AI staff account (`User.role === 'admin'`) + * that can enter any organization for support/debugging. By default such an + * account is NOT a genuine participant of the customer orgs it enters, so it is + * excluded from that org's business logic: it cannot be an assignee/approver, is + * not counted toward compliance progress, and does not receive org-scoped + * notifications. + * + * The single exception is an *internal* organization — one operated by the + * platform itself (e.g. Comp AI's own org). There, the platform admins ARE the + * real members and must be able to run compliance like any other org. + * + * `isOrgParticipant` is the single source of truth for that rule. Do NOT + * reintroduce inline `user.role === 'admin'` participation checks anywhere — + * call this instead so the internal-org exception stays consistent. + * + * SCOPE: this governs *participation* only. It must never be used to decide + * platform-admin access or privileges — those stay with PlatformAdminGuard / + * `isPlatformAdmin`, which are unaffected by an org being internal. + */ +export const PLATFORM_ADMIN_ROLE = 'admin'; + +export interface OrgParticipationContext { + /** Whether the organization is platform-operated (e.g. Comp AI's own org). */ + orgIsInternal: boolean; +} + +/** + * Returns true when `userRole` should be treated as a real participant of an + * organization with the given context. + * + * @param userRole The global `User.role` value (e.g. `'admin'` for platform + * admins, `'user'`/null otherwise). This is NOT the org-scoped member role. + */ +export function isOrgParticipant( + userRole: string | null | undefined, + { orgIsInternal }: OrgParticipationContext, +): boolean { + if (orgIsInternal) return true; + return userRole !== PLATFORM_ADMIN_ROLE; +} + +/** + * Inverse of {@link isOrgParticipant} — true when the user must be excluded from + * the org's participation-level business logic. Convenience for the many call + * sites whose existing shape is "if (isPlatformAdmin) exclude". + */ +export function isExcludedFromOrgParticipation( + userRole: string | null | undefined, + ctx: OrgParticipationContext, +): boolean { + return !isOrgParticipant(userRole, ctx); +} diff --git a/packages/db/prisma/migrations/20260708120000_add_organization_is_internal/migration.sql b/packages/db/prisma/migrations/20260708120000_add_organization_is_internal/migration.sql new file mode 100644 index 000000000..ad838a588 --- /dev/null +++ b/packages/db/prisma/migrations/20260708120000_add_organization_is_internal/migration.sql @@ -0,0 +1,6 @@ +-- Mark platform-operated ("internal") organizations, e.g. Comp AI's own org. +-- When true, platform admins are treated as real participants of the org +-- (assignable, counted in compliance, notified). Defaults to false so every +-- existing and future customer org keeps excluding platform-admin staff. +ALTER TABLE "Organization" + ADD COLUMN "isInternal" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/db/prisma/schema/organization.prisma b/packages/db/prisma/schema/organization.prisma index b1cf46f42..64d51d49d 100644 --- a/packages/db/prisma/schema/organization.prisma +++ b/packages/db/prisma/schema/organization.prisma @@ -9,6 +9,10 @@ model Organization { website String? onboardingCompleted Boolean @default(false) hasAccess Boolean @default(false) + // Internal (platform-operated) organization — e.g. Comp AI's own org. + // When true, platform admins are treated as real participants of this org + // (assignable, counted in compliance, notified). Off for all customer orgs. + isInternal Boolean @default(false) advancedModeEnabled Boolean @default(false) evidenceApprovalEnabled Boolean @default(false) deviceAgentStepEnabled Boolean @default(true)