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
43 changes: 43 additions & 0 deletions apps/api/src/device-agent/device-agent-auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,49 @@ describe('DeviceAgentAuthService', () => {
});
});

it('stamps source="agent" on check-in so an integration-imported row adopted by the agent is counted in People (CS-770)', async () => {
// CS-770: the endpoint agent had adopted a device first imported via an
// integration, but the row stayed source='integration' — so the People
// tab (which only rolls up agent devices) showed the compliant device as
// "Missing". A check-in means the agent is managing the device, so it
// must assert agent ownership to heal the record.
(mockDb.device.findFirst as jest.Mock).mockResolvedValue({
id: 'dev-1',
agentSessionId: 'ses-1',
diskEncryptionEnabled: false,
antivirusEnabled: false,
passwordPolicySet: false,
screenLockEnabled: false,
checkDetails: {},
});
(mockDb.device.update as jest.Mock).mockResolvedValue({
isCompliant: true,
});

await service.checkIn({
userId: 'user-1',
sessionId: 'ses-1',
sessionDeviceAgent: true,
dto: {
deviceId: 'dev-1',
checks: [
{
checkType: 'disk_encryption',
passed: true,
checkedAt: new Date().toISOString(),
},
],
},
});

expect(mockDb.device.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'dev-1' },
data: expect.objectContaining({ source: 'agent' }),
}),
);
});

it('should throw NotFoundException if device not found', async () => {
(mockDb.device.findFirst as jest.Mock).mockResolvedValue(null);

Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/device-agent/device-agent-auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,11 @@ export class DeviceAgentAuthService {
checkDetails: checkDetails as Prisma.InputJsonValue,
isCompliant,
lastCheckIn: new Date(),
// A check-in means the endpoint agent is actively managing this device,
// so assert agent ownership. Heals a row that was imported by an
// integration and adopted by the agent but left source='integration'
// (which hid the compliant device as "Missing" in the People tab).
source: 'agent',
...(dto.agentVersion ? { agentVersion: dto.agentVersion } : {}),
...(sessionIdToLink !== undefined ? { agentSessionId: sessionIdToLink } : {}),
},
Expand Down
27 changes: 27 additions & 0 deletions apps/api/src/device-agent/device-registration.helpers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,33 @@ describe('registerWithSerial — orphan adoption', () => {
});
});

describe('registerWithSerial — claims agent ownership (CS-770)', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('re-stamps an adopted serial-match row as source="agent" so it is not left stuck as an integration import', async () => {
// CS-770: a device imported via an integration (source='integration') then
// adopted by the endpoint agent — matched here by serial for the same
// member — must be re-stamped source='agent'. Otherwise the People tab
// skips it (it only rolls up agent devices) and the compliant device reads
// "Missing" there even though the Device tab still shows it.
(mockDb.device.findUnique as jest.Mock).mockResolvedValue({
id: 'dev_intune',
memberId: member.id,
});
(mockDb.device.update as jest.Mock).mockResolvedValue({ id: 'dev_intune' });

const dto = makeDto();
await registerWithSerial({ member, dto });

expect(mockDb.device.update).toHaveBeenCalledWith({
where: { id: 'dev_intune' },
data: expect.objectContaining({ source: 'agent' }),
});
});
});

describe('registerWithoutSerial — unchanged behavior', () => {
beforeEach(() => {
jest.clearAllMocks();
Expand Down
7 changes: 7 additions & 0 deletions apps/api/src/device-agent/device-registration.helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ function buildUpdateData(dto: RegisterDeviceDto) {
osVersion: dto.osVersion,
hardwareModel: dto.hardwareModel,
agentVersion: dto.agentVersion,
// The endpoint agent is the managing source once it registers a device.
// When it adopts a row previously created by an integration import (matched
// here by serial for the same member), re-stamp it as an agent device.
// Otherwise the row stays source='integration', the People tab skips it
// (it only rolls up agent devices) and a compliant device reads "Missing"
// there while still showing in the Device tab.
source: 'agent' as const,
};
}

Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/isms/documents/data-source.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const mockDb = {
frameworkEditorFramework: { findUnique: jest.fn() },
ismsProfile: { findUnique: jest.fn() },
ismsInterestedParty: { findMany: jest.fn() },
riskAcceptance: { findMany: jest.fn() },
};

jest.mock('@db', () => ({ db: mockDb }));
Expand Down Expand Up @@ -55,6 +56,7 @@ function seedDb({
});
mockDb.ismsProfile.findUnique.mockResolvedValue({ answers: {} });
mockDb.ismsInterestedParty.findMany.mockResolvedValue(parties);
mockDb.riskAcceptance.findMany.mockResolvedValue([]);
}

beforeEach(() => {
Expand Down
186 changes: 173 additions & 13 deletions apps/api/src/isms/documents/data-source.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createHash } from 'node:crypto';
import { db } from '@db';
import type { Prisma } from '@db';
import { parseStoredAnswers } from '../wizard/wizard-schema';
import type { IsmsPlatformData } from './types';

Expand All @@ -16,10 +17,19 @@ const HIGH_IMPACT = ['major', 'severe'];
export async function collectPlatformData({
organizationId,
frameworkId,
client,
}: {
organizationId: string;
frameworkId: string;
/**
* Optional transaction client. The approval flow passes its transaction so
* the drift baseline is read at the SAME point in time as the rows frozen
* into the published version — otherwise a concurrent edit between the two
* reads makes a just-approved document immediately show as stale.
*/
client?: Prisma.TransactionClient;
}): Promise<IsmsPlatformData> {
const dbc = client ?? db;
const [
organization,
frameworkInstances,
Expand All @@ -32,42 +42,72 @@ export async function collectPlatformData({
ownFramework,
profile,
partiesRows,
acceptanceRows,
] = await Promise.all([
db.organization.findUnique({
dbc.organization.findUnique({
where: { id: organizationId },
select: { name: true },
}),
db.frameworkInstance.findMany({
dbc.frameworkInstance.findMany({
where: { organizationId },
select: { framework: { select: { name: true } } },
}),
db.vendor.findMany({
dbc.vendor.findMany({
where: { organizationId },
select: { name: true, category: true, isSubProcessor: true },
select: {
id: true,
name: true,
category: true,
isSubProcessor: true,
// Risk fields feed the Risk Treatment Plan fingerprint (6.1.3).
status: true,
inherentProbability: true,
inherentImpact: true,
residualProbability: true,
residualImpact: true,
treatmentStrategy: true,
treatmentStrategyDescription: true,
assigneeId: true,
assignee: { select: { user: { select: { name: true, email: true } } } },
},
}),
db.member.count({ where: { organizationId, deactivated: false } }),
db.member.groupBy({
dbc.member.count({ where: { organizationId, deactivated: false } }),
dbc.member.groupBy({
by: ['department'],
where: { organizationId, deactivated: false },
_count: { _all: true },
}),
db.device.count({ where: { organizationId } }),
db.risk.findMany({
dbc.device.count({ where: { organizationId } }),
dbc.risk.findMany({
where: { organizationId },
select: { residualLikelihood: true, residualImpact: true },
select: {
id: true,
residualLikelihood: true,
residualImpact: true,
// The remaining fields feed the Risk Treatment Plan fingerprint (6.1.3).
title: true,
category: true,
status: true,
likelihood: true,
impact: true,
treatmentStrategy: true,
treatmentStrategyDescription: true,
assigneeId: true,
assignee: { select: { user: { select: { name: true, email: true } } } },
},
}),
db.employeeTrainingVideoCompletion.count({
dbc.employeeTrainingVideoCompletion.count({
where: { member: { organizationId } },
}),
db.frameworkEditorFramework.findUnique({
dbc.frameworkEditorFramework.findUnique({
where: { id: frameworkId },
select: { name: true },
}),
db.ismsProfile.findUnique({
dbc.ismsProfile.findUnique({
where: { organizationId_frameworkId: { organizationId, frameworkId } },
select: { answers: true },
}),
db.ismsInterestedParty.findMany({
dbc.ismsInterestedParty.findMany({
where: {
document: {
organizationId,
Expand All @@ -77,6 +117,10 @@ export async function collectPlatformData({
},
select: { id: true, name: true, category: true },
}),
dbc.riskAcceptance.findMany({
where: { organizationId },
select: { id: true, riskId: true, vendorId: true },
}),
]);

const frameworkNames = new Set<string>();
Expand Down Expand Up @@ -124,6 +168,11 @@ export async function collectPlatformData({
hasTrainingProgram: trainingCompletionCount > 0,
wizardAnswers: parseStoredAnswers(profile?.answers),
partiesFingerprint: fingerprintParties(partiesRows),
riskTreatmentFingerprint: fingerprintRiskTreatment({
Comment thread
tofikwest marked this conversation as resolved.
risks,
vendors,
acceptances: acceptanceRows,
}),
};
}

Expand All @@ -145,3 +194,114 @@ function fingerprintParties(
.join('');
return createHash('sha256').update(canonical).digest('hex');
}

/**
* Stable, order-insensitive SHA-256 over everything the Risk Treatment Plan
* (6.1.3) renders: non-archived Risk Register rows, vendor risk fields, and
* acceptance events (append-only, so their ids alone capture "a new acceptance
* was recorded"). Same canonicalization as fingerprintParties: JSON-encoded
* rows, sorted, so field boundaries can't collide and row order is irrelevant.
* Two subtleties: (1) archived risks leave the plan, so archiving changes the
* row set (= drift) while later edits to an archived risk stay invisible —
* acceptance rows are filtered to the RENDERED subjects for the same reason;
* (2) the fingerprint carries the rendered owner DISPLAY value (not the id),
* so a member rename that changes the exported owner cell also drifts.
*/
function fingerprintRiskTreatment({
risks,
vendors,
acceptances,
}: {
risks: Array<{
id: string;
title: string;
category: string;
status: string;
likelihood: string;
impact: string;
residualLikelihood: string;
residualImpact: string;
treatmentStrategy: string;
treatmentStrategyDescription: string | null;
assigneeId: string | null;
assignee: { user: { name: string | null; email: string } } | null;
}>;
vendors: Array<{
id: string;
name: string;
category: string;
status: string;
inherentProbability: string;
inherentImpact: string;
residualProbability: string;
residualImpact: string;
treatmentStrategy: string;
treatmentStrategyDescription: string | null;
assigneeId: string | null;
assignee: { user: { name: string | null; email: string } } | null;
}>;
acceptances: Array<{
id: string;
riskId: string | null;
vendorId: string | null;
}>;
}): string {
const ownerDisplay = (
assignee: { user: { name: string | null; email: string } } | null,
): string => (assignee ? assignee.user.name?.trim() || assignee.user.email : '');
const renderedRisks = risks.filter((risk) => risk.status !== 'archived');
const renderedSubjectIds = new Set([
...renderedRisks.map((risk) => risk.id),
...vendors.map((vendor) => vendor.id),
]);
const rows = [
...renderedRisks.map((risk) =>
JSON.stringify([
'risk',
risk.id,
risk.title,
risk.category,
risk.status,
risk.likelihood,
risk.impact,
risk.residualLikelihood,
risk.residualImpact,
risk.treatmentStrategy,
risk.treatmentStrategyDescription ?? '',
risk.assigneeId ?? '',
ownerDisplay(risk.assignee),
]),
),
...vendors.map((vendor) =>
JSON.stringify([
'vendor',
vendor.id,
vendor.name,
vendor.category,
vendor.status,
vendor.inherentProbability,
vendor.inherentImpact,
vendor.residualProbability,
vendor.residualImpact,
vendor.treatmentStrategy,
vendor.treatmentStrategyDescription ?? '',
vendor.assigneeId ?? '',
ownerDisplay(vendor.assignee),
]),
),
...acceptances
.filter((acceptance) =>
renderedSubjectIds.has(acceptance.riskId ?? acceptance.vendorId ?? ''),
)
.map((acceptance) =>
JSON.stringify([
'acceptance',
acceptance.id,
acceptance.riskId ?? '',
acceptance.vendorId ?? '',
]),
),
];
if (rows.length === 0) return '';
return createHash('sha256').update(rows.sort().join('')).digest('hex');
}
1 change: 1 addition & 0 deletions apps/api/src/isms/documents/generate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const data: IsmsPlatformData = {
hasTrainingProgram: true,
wizardAnswers: {},
partiesFingerprint: '',
riskTreatmentFingerprint: '',
};

function registerTable() {
Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/isms/documents/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,11 @@ export async function runDerivation({
await generateNarrative({ tx, documentId, type, data });
return;
}
if (type === 'risk_treatment_plan') {
// Nothing is stored on generate: the plan renders live from the Risk
// Register + Vendors at export/snapshot time (loadRiskTreatmentExtras).
return;
}
if (isNarrativeType(type)) {
await generateNarrative({ tx, documentId, type, data });
return;
Expand Down
Loading
Loading