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
17 changes: 14 additions & 3 deletions apps/api/src/routes/agents/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,15 +390,26 @@ export const sensitiveDataCommandTypes = {
// Management Posture
// ============================================

/**
* Category keys of the management-posture ingest payload. Single source of
* truth — reused by the fleet posture report (services/managementPostureReport)
* to validate the `category` query param before it reaches SQL.
*/
export const MANAGEMENT_POSTURE_CATEGORIES = [
'mdm', 'rmm', 'remoteAccess', 'endpointSecurity',
'policyEngine', 'backup', 'identityMfa', 'siem',
'dnsFiltering', 'zeroTrustVpn', 'patchManagement',
] as const;

export type ManagementPostureCategory = (typeof MANAGEMENT_POSTURE_CATEGORIES)[number];

export const managementPostureIngestSchema = z.object({
collectedAt: z.string().datetime(),
scanDurationMs: z.number().int().nonnegative(),
// v4: z.record(enum, …) is exhaustive; agents report only the categories they
// detect, so partialRecord preserves the v3 partial-ingest behavior.
categories: z.partialRecord(
z.enum(['mdm', 'rmm', 'remoteAccess', 'endpointSecurity',
'policyEngine', 'backup', 'identityMfa', 'siem',
'dnsFiltering', 'zeroTrustVpn', 'patchManagement']),
z.enum(MANAGEMENT_POSTURE_CATEGORIES),
z.array(z.object({
name: z.string(),
version: z.string().optional(),
Expand Down
6 changes: 6 additions & 0 deletions apps/api/src/routes/devices/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { networkRoutes } from './network';
import { customFieldValuesRoutes } from './customFieldValues';
import { linksRoutes } from './links';
import { statsRoutes } from './stats';
import { postureRoutes } from './posture';

export const deviceRoutes = new Hono();

Expand Down Expand Up @@ -68,6 +69,11 @@ deviceRoutes.route('/', linksRoutes);
// must not be eaten by the `/:id` matcher in coreRoutes.
deviceRoutes.route('/', statsRoutes);

// Mount the fleet posture report (#3244) BEFORE core routes — the static
// `/management-posture/*` paths must not be eaten by the `/:id` matcher
// (which would read `management-posture` as a device id).
deviceRoutes.route('/', postureRoutes);

// Mount core routes (/, /:id, PATCH /:id, DELETE /:id)
deviceRoutes.route('/', coreRoutes);

Expand Down
221 changes: 221 additions & 0 deletions apps/api/src/routes/devices/posture.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Hono } from 'hono';
import { sql } from 'drizzle-orm';

const { requirePermissionMock } = vi.hoisted(() => ({
requirePermissionMock: vi.fn((resource: string, action: string) => async (c: any, next: any) => {
const restricted = c.req.header('x-site-restricted');
c.set('permissions', {
permissions: [{ resource, action }],
allowedSiteIds:
restricted === 'true' ? ['site-allowed'] : restricted === 'empty' ? [] : undefined,
});
return next();
}),
}));

vi.mock('../../db', () => ({
db: {
execute: vi.fn(),
},
}));

const ACCESSIBLE_ORG_ID = '0d4433c3-6fa5-4bfb-a217-c9d2924e3f01';
const OTHER_ORG_ID = 'a63f79cf-9a10-4f5e-8de3-0a180fa7c882';

vi.mock('../../middleware/auth', () => ({
authMiddleware: vi.fn((c: any, next: any) => {
c.set('auth', {
user: { id: 'user-1' },
scope: 'organization',
orgId: ACCESSIBLE_ORG_ID,
accessibleOrgIds: [ACCESSIBLE_ORG_ID],
canAccessOrg: (orgId: string) => orgId === ACCESSIBLE_ORG_ID,
// Real SQL fragment so tests can assert the condition reaches the scope.
orgCondition: () => sql`ORG_CONDITION_SENTINEL`,
});
return next();
}),
requireScope: vi.fn(() => async (_c: any, next: any) => next()),
requirePermission: requirePermissionMock,
}));

vi.mock('../../services/managementPostureReport', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../services/managementPostureReport')>();
return {
...actual,
getManagementPostureSummary: vi.fn(),
getPostureDevices: vi.fn(),
};
});

import {
getManagementPostureSummary,
getPostureDevices,
} from '../../services/managementPostureReport';
import { postureRoutes } from './posture';

const summaryMock = vi.mocked(getManagementPostureSummary);
const devicesMock = vi.mocked(getPostureDevices);

const EMPTY_SUMMARY = {
category: 'rmm' as const,
stalenessDays: 7,
totals: {
totalDevices: 0, neverScanned: 0, stale: 0,
scannedNoneDetected: 0, detectedDevices: 0, freshDetectedDevices: 0,
},
orgs: [],
};

function sqlToString(node: unknown): string {
const seen = new Set<object>();
return JSON.stringify(node, (_k, v) => {
if (typeof v === 'object' && v !== null) {
if (seen.has(v)) return '[circular]';
seen.add(v);
}
return v;
});
}

function makeApp() {
const app = new Hono();
app.route('/devices', postureRoutes);
return app;
}

beforeEach(() => {
summaryMock.mockReset();
devicesMock.mockReset();
summaryMock.mockResolvedValue(EMPTY_SUMMARY);
devicesMock.mockResolvedValue({ devices: [], total: 0 });
});

describe('GET /devices/management-posture/summary', () => {
it('defaults to category=rmm, stalenessDays=7 and applies the auth org condition', async () => {
const res = await makeApp().request('/devices/management-posture/summary');

expect(res.status).toBe(200);
expect(summaryMock).toHaveBeenCalledTimes(1);
const args = summaryMock.mock.calls[0]![0];
expect(args.category).toBe('rmm');
expect(args.stalenessDays).toBe(7);
expect(sqlToString(args.scope)).toContain('ORG_CONDITION_SENTINEL');
});

it('passes explicit category and stalenessDays through', async () => {
const res = await makeApp().request(
'/devices/management-posture/summary?category=remoteAccess&stalenessDays=30'
);

expect(res.status).toBe(200);
const args = summaryMock.mock.calls[0]![0];
expect(args.category).toBe('remoteAccess');
expect(args.stalenessDays).toBe(30);
});

it('rejects an unknown category with 400 before any query runs', async () => {
const res = await makeApp().request(
"/devices/management-posture/summary?category=rmm';DROP TABLE devices;--"
);

expect(res.status).toBe(400);
expect(summaryMock).not.toHaveBeenCalled();
});

it('403s an inaccessible orgId without calling the service', async () => {
const res = await makeApp().request(
`/devices/management-posture/summary?orgId=${OTHER_ORG_ID}`
);

expect(res.status).toBe(403);
expect(summaryMock).not.toHaveBeenCalled();
});

it('narrows to an accessible orgId', async () => {
const res = await makeApp().request(
`/devices/management-posture/summary?orgId=${ACCESSIBLE_ORG_ID}`
);

expect(res.status).toBe(200);
expect(sqlToString(summaryMock.mock.calls[0]![0].scope)).toContain(ACCESSIBLE_ORG_ID);
});

it('narrows site-restricted users to their allowed sites', async () => {
const res = await makeApp().request('/devices/management-posture/summary', {
headers: { 'x-site-restricted': 'true' },
});

expect(res.status).toBe(200);
expect(sqlToString(summaryMock.mock.calls[0]![0].scope)).toContain('site-allowed');
});

it('returns an all-zero report for an empty site allowlist without querying', async () => {
const res = await makeApp().request('/devices/management-posture/summary', {
headers: { 'x-site-restricted': 'empty' },
});

expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.totals.totalDevices).toBe(0);
expect(body.data.orgs).toEqual([]);
expect(summaryMock).not.toHaveBeenCalled();
});
});

describe('GET /devices/management-posture/devices', () => {
it('requires product', async () => {
const res = await makeApp().request('/devices/management-posture/devices');

expect(res.status).toBe(400);
expect(devicesMock).not.toHaveBeenCalled();
});

it('passes product, status and pagination through with the org scope', async () => {
const res = await makeApp().request(
'/devices/management-posture/devices?product=ScreenConnect&status=active&page=2&limit=25'
);

expect(res.status).toBe(200);
const args = devicesMock.mock.calls[0]![0];
expect(args.product).toBe('ScreenConnect');
expect(args.detectionStatus).toBe('active');
expect(args.limit).toBe(25);
expect(args.offset).toBe(25);
expect(sqlToString(args.scope)).toContain('ORG_CONDITION_SENTINEL');

const body = await res.json();
expect(body.data).toEqual({ devices: [], total: 0, page: 2, limit: 25 });
});

it('rejects an invalid detection status', async () => {
const res = await makeApp().request(
'/devices/management-posture/devices?product=Atera&status=present'
);

expect(res.status).toBe(400);
expect(devicesMock).not.toHaveBeenCalled();
});

it('403s an inaccessible orgId', async () => {
const res = await makeApp().request(
`/devices/management-posture/devices?product=Atera&orgId=${OTHER_ORG_ID}`
);

expect(res.status).toBe(403);
expect(devicesMock).not.toHaveBeenCalled();
});

it('returns an empty page for an empty site allowlist without querying', async () => {
const res = await makeApp().request(
'/devices/management-posture/devices?product=Atera',
{ headers: { 'x-site-restricted': 'empty' } }
);

expect(res.status).toBe(200);
const body = await res.json();
expect(body.data).toEqual({ devices: [], total: 0, page: 1, limit: 50 });
expect(devicesMock).not.toHaveBeenCalled();
});
});
118 changes: 118 additions & 0 deletions apps/api/src/routes/devices/posture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { Hono } from 'hono';
import { z } from 'zod';
import { zValidator } from '../../lib/validation';
import { authMiddleware, requireScope, requirePermission } from '../../middleware/auth';
import { PERMISSIONS, type UserPermissions } from '../../services/permissions';
import { buildDeviceScope } from './scope';
import {
MANAGEMENT_POSTURE_CATEGORIES,
getManagementPostureSummary,
getPostureDevices,
} from '../../services/managementPostureReport';

/**
* Fleet management-posture report routes (#3244).
*
* GET /devices/management-posture/summary — per-org, per-product, per-status
* counts over the whole accessible fleet in ONE request (replaces the
* migration toolkit's N+1 loop over GET /devices/:id/management-posture).
* GET /devices/management-posture/devices — the drill-down behind a count.
*
* Mounted BEFORE coreRoutes so the static `/management-posture/...` paths are
* not eaten by the `/:id` matcher (same convention as /stats, /network).
*
* Scoping mirrors GET /devices/stats: org narrowing via auth.orgCondition,
* optional ?orgId (403 when inaccessible), site-restricted users narrowed to
* their allowedSiteIds (empty allowlist => empty report, deliberately
* indistinguishable from an empty fleet).
*/
export const postureRoutes = new Hono();

postureRoutes.use('*', authMiddleware);

const baseQuerySchema = z.object({
orgId: z.string().guid().optional(),
category: z.enum(MANAGEMENT_POSTURE_CATEGORIES).default('rmm'),
stalenessDays: z.coerce.number().int().min(1).max(365).default(7),
});

const devicesQuerySchema = baseQuerySchema.extend({
product: z.string().min(1).max(255),
status: z.enum(['active', 'installed', 'unknown']).optional(),
page: z.string().optional(),
limit: z.string().optional(),
});

postureRoutes.get(
'/management-posture/summary',
requireScope('organization', 'partner', 'system'),
requirePermission(PERMISSIONS.DEVICES_READ.resource, PERMISSIONS.DEVICES_READ.action),
zValidator('query', baseQuerySchema),
async (c) => {
const auth = c.get('auth');
const query = c.req.valid('query');
const permissions = c.get('permissions') as UserPermissions | undefined;

const scoped = buildDeviceScope(auth, permissions, query.orgId);
if ('forbidden' in scoped) {
return c.json({ error: 'Access to this organization denied' }, 403);
}
if ('emptyAllowlist' in scoped) {
return c.json({
data: {
category: query.category,
stalenessDays: query.stalenessDays,
totals: {
totalDevices: 0, neverScanned: 0, stale: 0,
scannedNoneDetected: 0, detectedDevices: 0, freshDetectedDevices: 0,
},
orgs: [],
},
});
}

const summary = await getManagementPostureSummary({
category: query.category,
stalenessDays: query.stalenessDays,
scope: scoped.scope,
});

return c.json({ data: summary });
}
);

postureRoutes.get(
'/management-posture/devices',
requireScope('organization', 'partner', 'system'),
requirePermission(PERMISSIONS.DEVICES_READ.resource, PERMISSIONS.DEVICES_READ.action),
zValidator('query', devicesQuerySchema),
async (c) => {
const auth = c.get('auth');
const query = c.req.valid('query');
const permissions = c.get('permissions') as UserPermissions | undefined;

const page = Math.max(1, Number.parseInt(query.page ?? '1', 10) || 1);
const limit = Math.min(500, Math.max(1, Number.parseInt(query.limit ?? '50', 10) || 50));
const offset = (page - 1) * limit;

const scoped = buildDeviceScope(auth, permissions, query.orgId);
if ('forbidden' in scoped) {
return c.json({ error: 'Access to this organization denied' }, 403);
}
if ('emptyAllowlist' in scoped) {
return c.json({ data: { devices: [], total: 0, page, limit } });
}

const result = await getPostureDevices({
category: query.category,
stalenessDays: query.stalenessDays,
scope: scoped.scope,
product: query.product,
detectionStatus: query.status,
limit,
offset,
});

return c.json({ data: { ...result, page, limit } });
}
);
Loading