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
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@
"^@db$": "<rootDir>/../prisma/index",
"^@/(.*)$": "<rootDir>/$1",
"^\\./sku-definitions\\.js$": "<rootDir>/../../../packages/billing/src/sku-definitions.ts",
"^@trycompai/auth/participation$": "<rootDir>/../../../packages/auth/src/participation.ts",
"^@trycompai/auth$": "<rootDir>/../../../packages/auth/src/index.ts",
"^@trycompai/billing$": "<rootDir>/../../../packages/billing/src/index.ts",
"^@trycompai/company$": "<rootDir>/../../../packages/company/src/index.ts",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ export class AdminOrganizationsService {
onboardingCompleted: true,
website: true,
backgroundCheckStepEnabled: true,
isInternal: true,
members: {
where: { isActive: true, deactivated: false },
select: {
Expand Down
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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;
}
12 changes: 7 additions & 5 deletions apps/api/src/comments/comment-mention-notifier.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 },
});
Expand Down
7 changes: 7 additions & 0 deletions apps/api/src/isms/documents/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { deriveContextOfOrganization } from './context';
import { deriveInterestedParties } from './interested-parties';
import { deriveRequirements } from './requirements';
import { deriveObjectives } from './objectives';
import { seedRolesIfMissing } from './roles';
import { deriveNarrativeForType, isNarrativeType } from './registry';
import type { IsmsPlatformData } from './types';

Expand Down Expand Up @@ -248,6 +249,12 @@ export async function runDerivation({
await generateObjectives({ tx, documentId, data });
return;
}
if (type === 'roles_and_responsibilities') {
// Idempotent seed only — never a destructive replace, so member assignments
// (IsmsRoleAssignment) and customer edits survive every regenerate.
await seedRolesIfMissing({ tx, documentId, memberCount: data.memberCount });
return;
}
if (isNarrativeType(type)) {
await generateNarrative({ tx, documentId, type, data });
return;
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/isms/documents/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { buildContextSections } from './context';
import { buildInterestedPartiesSections } from './interested-parties';
import { buildRequirementsSections } from './requirements';
import { buildObjectivesSections } from './objectives';
import { buildRolesSections } from './roles';
import {
buildScopeSections,
deriveScopeNarrative,
Expand All @@ -31,6 +32,7 @@ const EXPORT_SECTION_BUILDERS: Record<
interested_parties_register: buildInterestedPartiesSections,
interested_parties_requirements: buildRequirementsSections,
objectives_plan: buildObjectivesSections,
roles_and_responsibilities: buildRolesSections,
isms_scope: buildScopeSections,
leadership_commitment: buildLeadershipSections,
};
Expand Down
106 changes: 106 additions & 0 deletions apps/api/src/isms/documents/roles-defaults.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type { SeedRoleDefinition } from './types';

/**
* The four seeded ISMS governance roles (clause 5.3) and their pre-filled
* default text. Drafted from the reference document
* ("05a - ISMS Roles, Responsibilities & Authorities") so a customer with no ISO
* expertise gets a workable baseline; every field is editable in the app.
*
* Order is the render/seed order. `roleKey` is the idempotency key for seeding.
*/
export const SEED_ROLE_DEFINITIONS: SeedRoleDefinition[] = [
{
roleKey: 'top_management',
name: 'Top Management',
description:
'The executive leadership accountable for the ISMS. Top management sets the direction of the information security programme and provides the mandate and resources for it to operate.',
responsibilities:
'Approve the ISMS scope, information security policy, and objectives; provide the resources the ISMS needs; direct and support the people contributing to the ISMS; promote continual improvement; and accept or escalate residual risk.',
authorities:
'Approve the ISMS and accept residual risk on behalf of the organisation.',
authorityGrantedBy: 'The executive office and the Board of Directors',
requiredCompetence:
'Executive understanding of the organisation, its risk appetite, and its legal and regulatory obligations, sufficient to direct the ISMS and be accountable for it.',
},
{
roleKey: 'spo',
name: 'Security & Privacy Owner (SPO)',
description:
'The person who owns and operates the ISMS day to day and is the focal point for information security and privacy across the organisation.',
responsibilities:
'Operate the ISMS day to day — scope, Statement of Applicability, risk, vendor, policy, training, incident, and audit programmes; ensure the ISMS conforms to ISO/IEC 27001; report ISMS performance to top management; and assign control, risk, and policy owners.',
authorities:
'Direct the security and privacy programme; assign control, risk, and policy owners; and approve exceptions within the organisation’s risk appetite.',
authorityGrantedBy: 'Top Management',
requiredCompetence:
'Working knowledge of ISO/IEC 27001 and the organisation’s control environment, with the experience to operate an ISMS and make risk-based decisions within the agreed risk appetite.',
},
{
roleKey: 'deputy_spo',
name: 'Deputy Security & Privacy Owner',
description:
'The documented backup for the Security & Privacy Owner, ensuring the ISMS remains covered during the SPO’s absence.',
responsibilities:
'Provide documented backup for the SPO; act with the SPO’s authority during the SPO’s absence; and participate in incident response and management reviews as required.',
authorities: 'Act with the SPO’s authority during the SPO’s absence.',
authorityGrantedBy: 'Top Management',
requiredCompetence:
'Sufficient familiarity with the ISMS and the organisation’s controls to stand in for the SPO and make interim risk-based decisions.',
},
{
roleKey: 'internal_auditor',
name: 'Internal Auditor',
description:
'The person or party responsible for independently auditing the ISMS to confirm it conforms to ISO/IEC 27001 and is effectively implemented and maintained.',
responsibilities:
'Plan and conduct internal audits of the ISMS at the intervals defined in the audit programme; report findings and nonconformities; and maintain independence and impartiality from the areas audited.',
authorities:
'Access the information, systems, and personnel needed to conduct the audit, and report findings directly to top management.',
authorityGrantedBy: 'Top Management',
requiredCompetence:
'Knowledge of ISO/IEC 27001 auditing (e.g. ISO 19011 principles) and the independence to audit the ISMS objectively; where in-house audit competence is not held, it is engaged externally.',
},
];

/** roleKey values of the seeded roles, for validation and lookups. */
export const SEED_ROLE_KEYS: string[] = SEED_ROLE_DEFINITIONS.map(
(role) => role.roleKey,
);

/**
* The two governance rows that appear only in the generated document (never as
* editable role cards): the per-artifact operational owners and the whole
* workforce. Holders/text are fixed.
*/
export const AUTO_DOC_ROLE_ROWS = [
{
name: 'Control / asset / risk / policy owners',
holders: 'Identified in the platform per policy, control, risk, task, and vendor.',
responsibilities:
'Implement, operate, and evidence the specific controls, policies, risks, and evidence tasks assigned to them. Operational ownership is assigned at the artifact level in Comp AI.',
authority: 'Make operational decisions for their assigned item. Authority granted by the SPO.',
},
{
name: 'All personnel and contractors',
holders: 'All workforce members.',
responsibilities:
'Comply with the policy set; complete required training; protect company data on personal devices; and report incidents and near-misses.',
authority: 'Use approved tools and identity flows. Granted on engagement.',
},
] as const;

/**
* Non-removable note reproduced verbatim in the generated document (§2). Clarifies
* that Comp AI application-access levels are not ISMS governance roles.
*/
export const APPLICATION_ACCESS_NOTE =
'Comp AI application-access levels (Owner / Admin / Auditor / Employee / Contractor) are not ISMS governance roles. The ISMS governance roles are defined below.';

/** The application-access levels and what they grant (generated document §2). */
export const APPLICATION_ACCESS_LEVELS: string[] = [
'Owner — the account creator.',
'Admin — administrative access to the GRC platform.',
'Auditor — read access provided to external auditors.',
'Employee — access to the employee portal.',
'Contractor — limited access for engaged contractors.',
];
137 changes: 137 additions & 0 deletions apps/api/src/isms/documents/roles-export-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { db } from '@db';
import type { Prisma } from '@db';
import type { IsmsTeamSizeBand, OperationalOwnershipRow } from './types';
import { teamSizeBand } from './roles';

/**
* Extra data the Roles document (5.3) needs at export time but that isn't on the
* document's own rows: display names for assigned members (assignments store a
* plain memberId, no FK), the live per-artifact operational owners, and the
* team-size band. Resolved once and frozen into the version snapshot so a
* historical export re-renders byte-faithfully.
*/
export interface RolesExtras {
/** memberId → display name (name, else email, else a placeholder). */
memberNames: Record<string, string>;
operationalOwnership: OperationalOwnershipRow[];
band: IsmsTeamSizeBand;
}

type Client = Prisma.TransactionClient | typeof db;

const OWNER_DISPLAY_CAP = 12;

type NamedAssignee = {
assignee: { user: { name: string | null; email: string | null } | null } | null;
};

function memberDisplayName(user: { name: string | null; email: string | null } | null): string {
return user?.name?.trim() || user?.email?.trim() || 'Unknown member';
}

function dedupeOwners(rows: NamedAssignee[]): string[] {
const seen = new Set<string>();
const names: string[] = [];
for (const row of rows) {
if (!row.assignee?.user) continue;
const name = memberDisplayName(row.assignee.user);
if (seen.has(name)) continue;
seen.add(name);
names.push(name);
}
names.sort((a, b) => a.localeCompare(b));
if (names.length <= OWNER_DISPLAY_CAP) return names;
return [
...names.slice(0, OWNER_DISPLAY_CAP),
`and ${names.length - OWNER_DISPLAY_CAP} more`,
];
}

/** Load the Roles document's export extras for an organization. */
export async function loadRolesExtras({
organizationId,
client,
}: {
organizationId: string;
client?: Client;
}): Promise<RolesExtras> {
const prisma = client ?? db;
const assigneeSelect = {
assignee: { select: { user: { select: { name: true, email: true } } } },
} as const;

const [members, memberCount, policies, risks, tasks, vendors] =
await Promise.all([
prisma.member.findMany({
where: { organizationId },
select: { id: true, user: { select: { name: true, email: true } } },
}),
prisma.member.count({ where: { organizationId, deactivated: false } }),
prisma.policy.findMany({
where: { organizationId, assigneeId: { not: null } },
select: assigneeSelect,
}),
prisma.risk.findMany({
where: { organizationId, assigneeId: { not: null } },
select: assigneeSelect,
}),
prisma.task.findMany({
where: { organizationId, assigneeId: { not: null } },
select: assigneeSelect,
}),
prisma.vendor.findMany({
where: { organizationId, assigneeId: { not: null } },
select: assigneeSelect,
}),
]);

const memberNames: Record<string, string> = {};
for (const member of members) {
memberNames[member.id] = memberDisplayName(member.user);
}

// Controls have no owner field in the platform; their ownership is descriptive
// (assigned per control via their linked tasks). Kept in the matrix per the
// reference document, without enumerating names.
const operationalOwnership: OperationalOwnershipRow[] = [
{
artifact: 'Policies',
assignedWhere: 'Policy assignee / approver in Comp AI',
ownerResponsibility:
'Keep the policy current and accurate; ensure required acknowledgement.',
owners: dedupeOwners(policies),
},
{
artifact: 'Controls',
assignedWhere: 'Control owner in Comp AI',
ownerResponsibility: 'Implement, operate, and evidence the control.',
owners: [],
},
{
artifact: 'Risks',
assignedWhere: 'Risk owner in Comp AI',
ownerResponsibility:
'Assess, treat, and monitor the risk within the risk appetite.',
owners: dedupeOwners(risks),
},
{
artifact: 'Evidence tasks',
assignedWhere: 'Task assignee in Comp AI',
ownerResponsibility: 'Complete and evidence the task by its due date.',
owners: dedupeOwners(tasks),
},
{
artifact: 'Vendors / sub-processors',
assignedWhere: 'Vendor owner in Comp AI',
ownerResponsibility:
'Perform due diligence and ongoing security review; maintain the DPA.',
owners: dedupeOwners(vendors),
},
];

return {
memberNames,
operationalOwnership,
band: teamSizeBand(memberCount),
};
}
Loading
Loading