diff --git a/apps/api/src/routes/agents/schemas.ts b/apps/api/src/routes/agents/schemas.ts index 9c72299cd2..2ffbca1760 100644 --- a/apps/api/src/routes/agents/schemas.ts +++ b/apps/api/src/routes/agents/schemas.ts @@ -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(), diff --git a/apps/api/src/routes/devices/index.ts b/apps/api/src/routes/devices/index.ts index cf2249a17f..fa7f51cc3f 100644 --- a/apps/api/src/routes/devices/index.ts +++ b/apps/api/src/routes/devices/index.ts @@ -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(); @@ -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); diff --git a/apps/api/src/routes/devices/posture.test.ts b/apps/api/src/routes/devices/posture.test.ts new file mode 100644 index 0000000000..2e15ecea8e --- /dev/null +++ b/apps/api/src/routes/devices/posture.test.ts @@ -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(); + 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(); + 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(); + }); +}); diff --git a/apps/api/src/routes/devices/posture.ts b/apps/api/src/routes/devices/posture.ts new file mode 100644 index 0000000000..cda90b162d --- /dev/null +++ b/apps/api/src/routes/devices/posture.ts @@ -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 } }); + } +); diff --git a/apps/api/src/routes/devices/scope.ts b/apps/api/src/routes/devices/scope.ts new file mode 100644 index 0000000000..aee1748501 --- /dev/null +++ b/apps/api/src/routes/devices/scope.ts @@ -0,0 +1,49 @@ +import { and, eq, inArray, type SQL } from 'drizzle-orm'; +import { devices } from '../../db/schema'; +import type { UserPermissions } from '../../services/permissions'; + +/** + * Shared tenant-narrowing for fleet-wide device aggregate endpoints + * (GET /devices/stats, GET /devices/management-posture/*). + * + * Semantics (the device list's DEFAULT behavior): + * - org narrowing via auth.orgCondition; + * - optional ?orgId narrows further and 403s when inaccessible; + * - site-restricted users narrowed to their allowedSiteIds — an EMPTY + * allowlist yields an empty result set, deliberately indistinguishable + * from an empty fleet (what the device list would show them). + * + * Extracted so a future change to any of these rules cannot silently apply + * to one aggregate endpoint and not the other. + */ +export type DeviceScopeResult = + | { scope: SQL | undefined } + | { emptyAllowlist: true } + | { forbidden: true }; + +export function buildDeviceScope( + auth: { + orgCondition: (col: typeof devices.orgId) => SQL | undefined; + canAccessOrg: (orgId: string) => boolean; + }, + permissions: UserPermissions | undefined, + orgId: string | undefined +): DeviceScopeResult { + const conditions: SQL[] = []; + + const orgFilter = auth.orgCondition(devices.orgId); + if (orgFilter) conditions.push(orgFilter); + + if (orgId) { + if (!auth.canAccessOrg(orgId)) return { forbidden: true }; + conditions.push(eq(devices.orgId, orgId)); + } + + const allowedSiteIds = permissions?.allowedSiteIds; + if (allowedSiteIds) { + if (allowedSiteIds.length === 0) return { emptyAllowlist: true }; + conditions.push(inArray(devices.siteId, allowedSiteIds)); + } + + return { scope: conditions.length > 0 ? and(...conditions) : undefined }; +} diff --git a/apps/api/src/routes/devices/stats.ts b/apps/api/src/routes/devices/stats.ts index 816b3fd355..2f07428b65 100644 --- a/apps/api/src/routes/devices/stats.ts +++ b/apps/api/src/routes/devices/stats.ts @@ -1,10 +1,11 @@ import { Hono } from 'hono'; import { zValidator } from '../../lib/validation'; -import { and, eq, inArray, sql, type SQL } from 'drizzle-orm'; +import { and, eq, sql, type SQL } from 'drizzle-orm'; import { db } from '../../db'; import { devices } from '../../db/schema'; import { authMiddleware, requireScope, requirePermission } from '../../middleware/auth'; import { PERMISSIONS, type UserPermissions } from '../../services/permissions'; +import { buildDeviceScope } from './scope'; import { z } from 'zod'; export const statsRoutes = new Hono(); @@ -43,31 +44,24 @@ statsRoutes.get( const query = c.req.valid('query'); const permissions = c.get('permissions') as UserPermissions | undefined; + // Tenant narrowing shared with the posture-report endpoints (scope.ts): + // orgCondition + optional ?orgId (403 when inaccessible) + site allowlist. + 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: { total: 0, online: 0, offline: 0, byStatus: {} } }); + } + // Ephemeral Quick Support devices live in the hidden 'quick_support' org, // which stays in accessibleOrgIds for RLS — exclude them from tech-facing counts. const conditions: SQL[] = [ sql`${devices.status} != 'decommissioned'`, eq(devices.isEphemeral, false), ]; - - const orgFilter = auth.orgCondition(devices.orgId); - if (orgFilter) { - conditions.push(orgFilter); - } - - if (query.orgId) { - if (!auth.canAccessOrg(query.orgId)) { - return c.json({ error: 'Access to this organization denied' }, 403); - } - conditions.push(eq(devices.orgId, query.orgId)); - } - - const allowedSiteIds = permissions?.allowedSiteIds; - if (allowedSiteIds) { - if (allowedSiteIds.length === 0) { - return c.json({ data: { total: 0, online: 0, offline: 0, byStatus: {} } }); - } - conditions.push(inArray(devices.siteId, allowedSiteIds)); + if (scoped.scope) { + conditions.push(scoped.scope); } const rows = await db diff --git a/apps/api/src/services/managementPostureReport.integration.test.ts b/apps/api/src/services/managementPostureReport.integration.test.ts new file mode 100644 index 0000000000..846d238be0 --- /dev/null +++ b/apps/api/src/services/managementPostureReport.integration.test.ts @@ -0,0 +1,273 @@ +import { describe, expect, it } from 'vitest'; +import { eq, inArray } from 'drizzle-orm'; +import { db, withSystemDbAccessContext } from '../db'; +import { partners, organizations, sites, devices } from '../db/schema'; +import { + getManagementPostureSummary, + getPostureDetections, + getPostureCoverage, + getPostureDevices, +} from './managementPostureReport'; + +/** + * Real-Postgres integration test for the fleet posture report (#3244). + * + * THE MIXED FIXTURE IS THE TEST. One org contains all four populations at + * once — never-scanned, scanned-stale, scanned-clean (empty array AND absent + * key), scanned-with-a-detection. Any single-population fixture passes + * against the collapsed one-query LEFT JOIN LATERAL form this design had to + * correct (which makes a never-scanned device read as verified-clean), so + * only this fixture actually guards the two-query split. + * + * Seeded per test — the shared integration setup TRUNCATEs on beforeEach. + */ + +const hasDb = !!process.env.DATABASE_URL; + +const daysAgo = (n: number) => new Date(Date.now() - n * 24 * 60 * 60 * 1000).toISOString(); + +function posture(collectedAt: string, categories: Record) { + return { + collectedAt, + scanDurationMs: 100, + categories, + identity: { + joinType: 'none', azureAdJoined: false, domainJoined: false, + workplaceJoined: false, source: 'test', + }, + }; +} + +/** Two partners, two orgs; org 1 carries the mixed fixture, org 2 (a different + * partner) exists to prove scoping. Returns the org ids. + * + * Seeded as ONE system-context transaction PER PARTNER SUBTREE. + * withSystemDbAccessContext wraps its callback in a single transaction, and + * the partner-export lock hierarchy (2026-07-18/21 migrations) forbids + * acquiring a NEW partner lock after an organization lock inside one + * transaction — so partner2's org cannot be inserted in the same tx that + * already touched partner1's org ("partner export lock hierarchy violation"). + * Same shape as contractRenewal.integration.test.ts (one subtree per tx). */ +async function seedMixedFixture(): Promise<{ org1: string; org2: string }> { + const sfx = Math.random().toString(36).slice(2, 8); + + const subtree1 = await withSystemDbAccessContext(async () => { + const [p1] = await db.insert(partners) + .values({ name: `PostureP1 ${sfx}`, slug: `posture-p1-${sfx}`, type: 'msp', plan: 'pro', status: 'active' }) + .returning({ id: partners.id }); + const [o1] = await db.insert(organizations) + .values({ partnerId: p1!.id, name: 'Posture Org 1', slug: `posture-o1-${sfx}` }) + .returning({ id: organizations.id }); + const [s1] = await db.insert(sites) + .values({ orgId: o1!.id, name: 'HQ' }) + .returning({ id: sites.id }); + return { orgId: o1!.id, siteId: s1!.id }; + }); + + const subtree2 = await withSystemDbAccessContext(async () => { + const [p2] = await db.insert(partners) + .values({ name: `PostureP2 ${sfx}`, slug: `posture-p2-${sfx}`, type: 'msp', plan: 'pro', status: 'active' }) + .returning({ id: partners.id }); + const [o2] = await db.insert(organizations) + .values({ partnerId: p2!.id, name: 'Posture Org 2', slug: `posture-o2-${sfx}` }) + .returning({ id: organizations.id }); + const [s2] = await db.insert(sites) + .values({ orgId: o2!.id, name: 'HQ' }) + .returning({ id: sites.id }); + return { orgId: o2!.id, siteId: s2!.id }; + }); + + const base = (host: string, orgId: string, siteId: string) => ({ + orgId, + siteId, + agentId: `posture-${sfx}-${host}`, + hostname: host, + osType: 'windows' as const, + osVersion: '10.0', + architecture: 'x64', + agentVersion: '1.0.0', + }); + + // Org 1's devices — one transaction, one org, so any export-state trigger + // stays within partner1's lock subtree. + await withSystemDbAccessContext(() => + db.insert(devices).values([ + // A — NEVER SCANNED: management_posture IS NULL. Must land in + // neverScanned, never in scannedNoneDetected ("clean"). + { ...base('pst-a-never', subtree1.orgId, subtree1.siteId) }, + // B — STALE scan (40d old) that still carries a detection. + { ...base('pst-b-stale', subtree1.orgId, subtree1.siteId), + managementPosture: posture(daysAgo(40), { rmm: [{ name: 'Datto RMM', status: 'active' }] }) }, + // C — fresh scan, rmm key present but EMPTY ARRAY => scanned, none + // detected. (Also carries a remoteAccess detection to prove category + // isolation.) + { ...base('pst-c-empty', subtree1.orgId, subtree1.siteId), + managementPosture: posture(daysAgo(1), { rmm: [], remoteAccess: [{ name: 'ScreenConnect', status: 'active' }] }) }, + // D — fresh scan, rmm key ABSENT entirely => scanned, none detected. + { ...base('pst-d-absent', subtree1.orgId, subtree1.siteId), + managementPosture: posture(daysAgo(1), {}) }, + // E — fresh scan with detections: active Datto + unknown NinjaOne. + { ...base('pst-e-detected', subtree1.orgId, subtree1.siteId), + managementPosture: posture(daysAgo(1), { rmm: [ + { name: 'Datto RMM', status: 'active' }, + { name: 'NinjaOne', status: 'unknown' }, + ] }) }, + // F — fresh scan listing the SAME product/status twice (two services). + // Must count the device ONCE. + { ...base('pst-f-dupe', subtree1.orgId, subtree1.siteId), + managementPosture: posture(daysAgo(1), { rmm: [ + { name: 'NinjaOne', status: 'installed', serviceName: 'svc-1' }, + { name: 'NinjaOne', status: 'installed', serviceName: 'svc-2' }, + ] }) }, + // H — decommissioned device with a detection: excluded from the fleet. + { ...base('pst-h-decom', subtree1.orgId, subtree1.siteId), status: 'decommissioned' as const, + managementPosture: posture(daysAgo(1), { rmm: [{ name: 'Datto RMM', status: 'active' }] }) }, + ]) + ); + + // Org 2 (different partner) — separate transaction for the same lock- + // hierarchy reason as the subtree seeding above. Must never leak into an + // org-1 scope. + await withSystemDbAccessContext(() => + db.insert(devices).values([ + { ...base('pst-g-other-org', subtree2.orgId, subtree2.siteId), + managementPosture: posture(daysAgo(1), { rmm: [{ name: 'Atera', status: 'active' }] }) }, + ]) + ); + + return { org1: subtree1.orgId, org2: subtree2.orgId }; +} + +const opts = (org1: string) => ({ + category: 'rmm' as const, + stalenessDays: 7, + scope: eq(devices.orgId, org1), +}); + +describe('management posture report — mixed fixture against real Postgres', () => { + it.runIf(hasDb)('partitions the fleet: neverScanned + stale + freshClean + freshDetected == total', async () => { + const { org1 } = await seedMixedFixture(); + const summary = await withSystemDbAccessContext(() => getManagementPostureSummary(opts(org1))); + + expect(summary.orgs).toHaveLength(1); + const org = summary.orgs[0]!; + expect(org.orgId).toBe(org1); + + // 6 live devices (decommissioned H excluded). + expect(org.totalDevices).toBe(6); + // A only — the never-scanned device is reported as UNKNOWN, not clean. + expect(org.neverScanned).toBe(1); + // B only — scanned but outside the 7-day window. + expect(org.stale).toBe(1); + // C (empty array) and D (absent key) BOTH land in scannedNoneDetected, + // NOT in neverScanned. + expect(org.scannedNoneDetected).toBe(2); + // B, E and F carry detections; only E and F are fresh. + expect(org.detectedDevices).toBe(3); + expect(org.freshDetectedDevices).toBe(2); + + // The partition the plan requires: never + stale + fresh-clean + + // fresh-detected == total, nothing double-counted or dropped. + // (B is the stale device; C and D are the fresh-clean ones.) + expect( + org.neverScanned + org.stale + org.scannedNoneDetected + org.freshDetectedDevices + ).toBe(org.totalDevices); + }); + + it.runIf(hasDb)('reports per-product/status rows with distinct-device counts and freshness', async () => { + const { org1 } = await seedMixedFixture(); + const rows = await withSystemDbAccessContext(() => getPostureDetections(opts(org1))); + + const byKey = new Map(rows.map((r) => [`${r.product}|${r.status}`, r])); + + // Datto RMM active: devices B (stale) + E (fresh) => count 2, fresh 1. + expect(byKey.get('Datto RMM|active')).toMatchObject({ deviceCount: 2, freshDeviceCount: 1 }); + // NinjaOne installed: device F lists it twice => counted ONCE. + expect(byKey.get('NinjaOne|installed')).toMatchObject({ deviceCount: 1, freshDeviceCount: 1 }); + // 'unknown' status is neither dropped nor merged. + expect(byKey.get('NinjaOne|unknown')).toMatchObject({ deviceCount: 1, freshDeviceCount: 1 }); + // active vs installed reported separately — no merged NinjaOne row. + expect(byKey.has('NinjaOne|active')).toBe(false); + // Decommissioned H contributes nothing beyond B+E, and org-2's Atera + // never appears in an org-1 scope. + expect(byKey.has('Atera|active')).toBe(false); + + // Staleness boundary invariant. + for (const r of rows) { + expect(r.freshDeviceCount).toBeLessThanOrEqual(r.deviceCount); + } + }); + + it.runIf(hasDb)('category isolation: the remoteAccess report sees ScreenConnect, the rmm report does not', async () => { + const { org1 } = await seedMixedFixture(); + const ra = await withSystemDbAccessContext(() => + getPostureDetections({ ...opts(org1), category: 'remoteAccess' }) + ); + expect(ra).toHaveLength(1); + expect(ra[0]).toMatchObject({ product: 'ScreenConnect', status: 'active', deviceCount: 1 }); + + // In the remoteAccess report device C is DETECTED, and the coverage + // denominators reflect that category, not rmm's. + const cov = await withSystemDbAccessContext(() => + getPostureCoverage({ ...opts(org1), category: 'remoteAccess' }) + ); + expect(cov[0]).toMatchObject({ totalDevices: 6, neverScanned: 1, detectedDevices: 1 }); + }); + + it.runIf(hasDb)('org scoping: an org-1 scope never includes org-2 devices, and vice versa', async () => { + const { org1, org2 } = await seedMixedFixture(); + + const summary2 = await withSystemDbAccessContext(() => + getManagementPostureSummary({ category: 'rmm', stalenessDays: 7, scope: eq(devices.orgId, org2) }) + ); + expect(summary2.orgs).toHaveLength(1); + expect(summary2.orgs[0]!.orgId).toBe(org2); + expect(summary2.orgs[0]!.totalDevices).toBe(1); + expect(summary2.orgs[0]!.products).toEqual([ + { product: 'Atera', status: 'active', deviceCount: 1, freshDeviceCount: 1 }, + ]); + + // A partner-style roll-up over partner 1's accessible orgs must not + // include the other partner's org. + const rollup = await withSystemDbAccessContext(() => + getManagementPostureSummary({ category: 'rmm', stalenessDays: 7, scope: inArray(devices.orgId, [org1]) }) + ); + expect(rollup.orgs.map((o) => o.orgId)).toEqual([org1]); + expect(rollup.orgs[0]!.products.some((p) => p.product === 'Atera')).toBe(false); + }); + + it.runIf(hasDb)('drill-down lists the devices behind a count, honoring the status filter', async () => { + const { org1 } = await seedMixedFixture(); + + const all = await withSystemDbAccessContext(() => + getPostureDevices({ ...opts(org1), product: 'NinjaOne', limit: 50, offset: 0 }) + ); + expect(all.total).toBe(2); // E (unknown) + F (installed) + expect(all.devices.map((d) => d.hostname).sort()).toEqual(['pst-e-detected', 'pst-f-dupe']); + // F lists the product twice but appears once. + expect(all.devices.filter((d) => d.hostname === 'pst-f-dupe')).toHaveLength(1); + + const installedOnly = await withSystemDbAccessContext(() => + getPostureDevices({ ...opts(org1), product: 'NinjaOne', detectionStatus: 'installed', limit: 50, offset: 0 }) + ); + expect(installedOnly.total).toBe(1); + expect(installedOnly.devices[0]).toMatchObject({ + hostname: 'pst-f-dupe', + detectionStatus: 'installed', + }); + expect(installedOnly.devices[0]!.collectedAt).toBeTruthy(); + }); + + it.runIf(hasDb)('a widened staleness window moves the stale device into fresh counts', async () => { + const { org1 } = await seedMixedFixture(); + + const wide = await withSystemDbAccessContext(() => + getManagementPostureSummary({ ...opts(org1), stalenessDays: 90 }) + ); + const org = wide.orgs[0]!; + expect(org.stale).toBe(0); + expect(org.freshDetectedDevices).toBe(3); // B joins E and F + const datto = org.products.find((p) => p.product === 'Datto RMM' && p.status === 'active')!; + expect(datto).toMatchObject({ deviceCount: 2, freshDeviceCount: 2 }); + }); +}); diff --git a/apps/api/src/services/managementPostureReport.test.ts b/apps/api/src/services/managementPostureReport.test.ts new file mode 100644 index 0000000000..1ed1be9057 --- /dev/null +++ b/apps/api/src/services/managementPostureReport.test.ts @@ -0,0 +1,219 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { sql } from 'drizzle-orm'; + +vi.mock('../db', () => ({ + db: { + execute: vi.fn(), + }, +})); + +import { db } from '../db'; +import { + MANAGEMENT_POSTURE_CATEGORIES, + getManagementPostureSummary, + getPostureDetections, + getPostureCoverage, + getPostureDevices, + isManagementPostureCategory, +} from './managementPostureReport'; + +const executeMock = vi.mocked(db.execute); + +/** Serialize a drizzle SQL tree (safely, despite cycles) so tests can assert + * structural properties of the generated SQL. */ +function sqlToString(node: unknown): string { + const seen = new Set(); + return JSON.stringify(node, (_k, v) => { + if (typeof v === 'object' && v !== null) { + if (seen.has(v)) return '[circular]'; + seen.add(v); + } + return v; + }); +} + +beforeEach(() => { + executeMock.mockReset(); +}); + +describe('isManagementPostureCategory', () => { + it('accepts every ingest category and rejects everything else', () => { + for (const c of MANAGEMENT_POSTURE_CATEGORIES) { + expect(isManagementPostureCategory(c)).toBe(true); + } + expect(isManagementPostureCategory('rmm; DROP TABLE devices')).toBe(false); + expect(isManagementPostureCategory('')).toBe(false); + }); +}); + +describe('getManagementPostureSummary', () => { + it('issues TWO separate queries — detections (lateral) and coverage (no lateral)', async () => { + executeMock.mockResolvedValue([] as never); + + await getManagementPostureSummary({ category: 'rmm', stalenessDays: 7, scope: undefined }); + + expect(executeMock).toHaveBeenCalledTimes(2); + const first = sqlToString(executeMock.mock.calls[0]![0]); + const second = sqlToString(executeMock.mock.calls[1]![0]); + + // Query (a): detections explode the category array via CROSS JOIN LATERAL. + expect(first).toContain('CROSS JOIN LATERAL'); + expect(first).toContain('count(DISTINCT'); + // Never a LEFT JOIN — the collapsed one-query form this design forbids. + expect(first).not.toContain('LEFT JOIN'); + + // Query (b): coverage denominators computed WITHOUT any lateral join, and + // never-scanned split from scanned-none-detected. + expect(second).not.toContain('JOIN'); + expect(second).toContain('never_scanned'); + expect(second).toContain('scanned_none_detected'); + }); + + it('scopes both queries with the caller-supplied conditions', async () => { + executeMock.mockResolvedValue([] as never); + + await getManagementPostureSummary({ + category: 'rmm', + stalenessDays: 7, + scope: sql`SCOPE_SENTINEL`, + }); + + for (const call of executeMock.mock.calls) { + expect(sqlToString(call[0])).toContain('SCOPE_SENTINEL'); + } + }); + + it('assembles per-org products under their coverage denominators, plus totals', async () => { + executeMock + .mockResolvedValueOnce([ + { org_id: 'org-1', product: 'Datto RMM', status: 'active', device_count: 3, fresh_device_count: 2 }, + { org_id: 'org-1', product: 'Datto RMM', status: 'installed', device_count: 1, fresh_device_count: 1 }, + { org_id: 'org-2', product: 'NinjaOne', status: 'unknown', device_count: 2, fresh_device_count: 0 }, + ] as never) + .mockResolvedValueOnce([ + { org_id: 'org-1', total_devices: 10, never_scanned: 2, stale: 1, scanned_none_detected: 4, detected_devices: 4, fresh_detected_devices: 3 }, + { org_id: 'org-2', total_devices: 5, never_scanned: 0, stale: 2, scanned_none_detected: 3, detected_devices: 2, fresh_detected_devices: 0 }, + { org_id: 'org-3', total_devices: 4, never_scanned: 4, stale: 0, scanned_none_detected: 0, detected_devices: 0, fresh_detected_devices: 0 }, + ] as never); + + const summary = await getManagementPostureSummary({ category: 'rmm', stalenessDays: 7, scope: undefined }); + + expect(summary.category).toBe('rmm'); + expect(summary.stalenessDays).toBe(7); + expect(summary.orgs).toHaveLength(3); + + const org1 = summary.orgs.find((o) => o.orgId === 'org-1')!; + expect(org1.products).toEqual([ + { product: 'Datto RMM', status: 'active', deviceCount: 3, freshDeviceCount: 2 }, + { product: 'Datto RMM', status: 'installed', deviceCount: 1, freshDeviceCount: 1 }, + ]); + expect(org1.neverScanned).toBe(2); + + // 'unknown' detections are neither dropped nor merged into another status. + const org2 = summary.orgs.find((o) => o.orgId === 'org-2')!; + expect(org2.products).toEqual([ + { product: 'NinjaOne', status: 'unknown', deviceCount: 2, freshDeviceCount: 0 }, + ]); + + // An org with zero detections still appears WITH its denominators — a + // never-scanned fleet must never vanish from the report. + const org3 = summary.orgs.find((o) => o.orgId === 'org-3')!; + expect(org3.products).toEqual([]); + expect(org3.neverScanned).toBe(4); + + expect(summary.totals).toEqual({ + totalDevices: 19, + neverScanned: 6, + stale: 3, + scannedNoneDetected: 7, + detectedDevices: 6, + freshDetectedDevices: 3, + }); + }); + + it('throws (rather than silently dropping) if a detection has no coverage row', async () => { + executeMock + .mockResolvedValueOnce([ + { org_id: 'org-ghost', product: 'Atera', status: 'active', device_count: 1, fresh_device_count: 1 }, + ] as never) + .mockResolvedValueOnce([] as never); + + await expect( + getManagementPostureSummary({ category: 'rmm', stalenessDays: 7, scope: undefined }) + ).rejects.toThrow(/coverage row/); + }); +}); + +describe('getPostureDetections', () => { + it('coerces counts to numbers and passes category + staleness as bind params', async () => { + executeMock.mockResolvedValueOnce([ + { org_id: 'org-1', product: 'Level', status: 'active', device_count: '4', fresh_device_count: '1' }, + ] as never); + + const rows = await getPostureDetections({ category: 'remoteAccess', stalenessDays: 30, scope: undefined }); + + expect(rows).toEqual([ + { orgId: 'org-1', product: 'Level', status: 'active', deviceCount: 4, freshDeviceCount: 1 }, + ]); + const q = sqlToString(executeMock.mock.calls[0]![0]); + expect(q).toContain('remoteAccess'); + expect(q).toContain('30'); + // Category rides a bind param (with a cast), never string interpolation. + expect(q).not.toContain("'remoteAccess'"); + }); +}); + +describe('getPostureCoverage', () => { + it('classifies never-scanned separately from scanned-none-detected', async () => { + executeMock.mockResolvedValueOnce([ + { org_id: 'org-1', total_devices: '6', never_scanned: '1', stale: '1', scanned_none_detected: '2', detected_devices: '2', fresh_detected_devices: '2' }, + ] as never); + + const rows = await getPostureCoverage({ category: 'rmm', stalenessDays: 7, scope: undefined }); + + expect(rows).toEqual([ + { + orgId: 'org-1', totalDevices: 6, neverScanned: 1, stale: 1, + scannedNoneDetected: 2, detectedDevices: 2, freshDetectedDevices: 2, + }, + ]); + const q = sqlToString(executeMock.mock.calls[0]![0]); + expect(q).toContain('IS NULL'); + expect(q).toContain('IS NOT NULL'); + }); +}); + +describe('getPostureDevices', () => { + it('returns total + mapped device rows and filters on the product bind param', async () => { + executeMock + .mockResolvedValueOnce([{ total: '2' }] as never) + .mockResolvedValueOnce([ + { + id: 'dev-1', org_id: 'org-1', site_id: 'site-1', hostname: 'PC-01', + display_name: null, status: 'online', os_type: 'windows', + last_seen_at: new Date('2026-08-01T00:00:00Z'), + collected_at: '2026-08-07T12:00:00Z', + detection_status: 'active', detection_version: '1.2.3', + }, + ] as never); + + const result = await getPostureDevices({ + category: 'rmm', stalenessDays: 7, scope: undefined, + product: 'ScreenConnect', detectionStatus: 'active', limit: 50, offset: 0, + }); + + expect(result.total).toBe(2); + expect(result.devices).toEqual([ + { + id: 'dev-1', orgId: 'org-1', siteId: 'site-1', hostname: 'PC-01', + displayName: null, status: 'online', osType: 'windows', + lastSeenAt: '2026-08-01T00:00:00.000Z', + collectedAt: '2026-08-07T12:00:00Z', + detectionStatus: 'active', detectionVersion: '1.2.3', + }, + ]); + for (const call of executeMock.mock.calls) { + expect(sqlToString(call[0])).toContain('ScreenConnect'); + } + }); +}); diff --git a/apps/api/src/services/managementPostureReport.ts b/apps/api/src/services/managementPostureReport.ts new file mode 100644 index 0000000000..c90a1be9c8 --- /dev/null +++ b/apps/api/src/services/managementPostureReport.ts @@ -0,0 +1,325 @@ +import { sql, type SQL } from 'drizzle-orm'; +import { db } from '../db'; +import { devices } from '../db/schema'; +import { + MANAGEMENT_POSTURE_CATEGORIES, + type ManagementPostureCategory, +} from '../routes/agents/schemas'; + +export { MANAGEMENT_POSTURE_CATEGORIES, type ManagementPostureCategory }; + +/** + * Fleet management-posture report (#3244). + * + * Aggregates the existing `devices.management_posture` jsonb (written by the + * agent's mgmtdetect scan) into a fleet-level migration/decommission report. + * The jsonb stays the single source of truth — no denormalized detections + * table, no new columns. + * + * CRITICAL — two queries, not one. The detection roll-up and the coverage + * denominators must NOT be computed in a single GROUP BY with a LEFT JOIN + * LATERAL: that collapses never-scanned, scanned-with-category-absent, and + * scanned-with-empty-array devices into one identical (product NULL, + * status NULL) group, so a never-scanned device becomes indistinguishable + * from a verified-clean one — precisely the failure this report exists to + * prevent (an endpoint stranding when the incumbent agent is uninstalled on + * a machine whose Breeze enrollment was never verified). Detections use a + * CROSS JOIN LATERAL; coverage is computed separately with no lateral join. + * See docs/superpowers/specs/onboarding-signup/ + * 2026-08-08-fleet-migration-posture-report-design.md. + */ + +export function isManagementPostureCategory(v: string): v is ManagementPostureCategory { + return (MANAGEMENT_POSTURE_CATEGORIES as readonly string[]).includes(v); +} + +export interface PostureDetectionRow { + orgId: string; + product: string; + /** 'active' | 'installed' | 'unknown' — passed through verbatim, never merged. */ + status: string; + /** Distinct devices carrying this product/status (a duplicate entry in one + * device's posture array counts the device once). */ + deviceCount: number; + /** Subset of deviceCount whose posture scan is within the staleness window. */ + freshDeviceCount: number; +} + +export interface PostureCoverageRow { + orgId: string; + totalDevices: number; + /** management_posture IS NULL — status UNKNOWN, never "clean". */ + neverScanned: number; + /** Scanned, but the scan is older than the staleness window. */ + stale: number; + /** Scanned with the category absent OR an empty array — verified none detected + * (regardless of scan age; cross-reference `stale` for freshness). */ + scannedNoneDetected: number; +} + +export interface OrgPostureSummary extends PostureCoverageRow { + /** Distinct devices with >=1 detection in this category (any status). */ + detectedDevices: number; + /** Subset of detectedDevices with a fresh scan. */ + freshDetectedDevices: number; + products: Array>; +} + +export interface PostureSummary { + category: ManagementPostureCategory; + stalenessDays: number; + totals: { + totalDevices: number; + neverScanned: number; + stale: number; + scannedNoneDetected: number; + detectedDevices: number; + freshDetectedDevices: number; + }; + orgs: OrgPostureSummary[]; +} + +interface QueryOpts { + category: ManagementPostureCategory; + stalenessDays: number; + /** Additional scope conditions (org narrowing, site allowlist, …), built by + * the caller with Drizzle helpers over the `devices` columns. Required so a + * forgotten scope is a compile-time-visible choice, not a silent default. */ + scope: SQL | undefined; +} + +/** Base liveness predicate shared by every query in this report. Mirrors the + * device list / stats conventions: decommissioned devices and ephemeral + * Quick Support devices are not part of the managed fleet. */ +function liveDevices(scope: SQL | undefined): SQL { + const base = sql`${devices.status} != 'decommissioned' AND ${devices.isEphemeral} = false`; + return scope ? sql`${base} AND (${scope})` : base; +} + +function freshPredicate(stalenessDays: number): SQL { + return sql`(${devices.managementPosture}->>'collectedAt')::timestamptz > now() - make_interval(days => ${stalenessDays}::int)`; +} + +function categoryArray(category: ManagementPostureCategory): SQL { + // ::text — the bare bind param is ambiguous between the (jsonb, text) and + // (jsonb, int) overloads of the -> operator. + return sql`COALESCE(${devices.managementPosture}->'categories'->${category}::text, '[]'::jsonb)`; +} + +type Row = Record; + +async function execute(query: SQL): Promise { + const result = await db.execute(query); + return result as unknown as Row[]; +} + +/** + * Query (a): one row per (org, product, status) with distinct-device counts. + * Devices with no detections in the category simply produce no rows here — + * the coverage query supplies their denominators. + */ +export async function getPostureDetections(opts: QueryOpts): Promise { + const rows = await execute(sql` + SELECT ${devices.orgId} AS org_id, + e.value->>'name' AS product, + e.value->>'status' AS status, + count(DISTINCT ${devices.id})::int AS device_count, + (count(DISTINCT ${devices.id}) FILTER ( + WHERE ${freshPredicate(opts.stalenessDays)} + ))::int AS fresh_device_count + FROM ${devices} + CROSS JOIN LATERAL jsonb_array_elements(${categoryArray(opts.category)}) e + WHERE ${liveDevices(opts.scope)} + GROUP BY 1, 2, 3 + ORDER BY 1, 2, 3 + `); + return rows.map((r) => ({ + orgId: String(r.org_id), + product: String(r.product), + status: String(r.status), + deviceCount: Number(r.device_count), + freshDeviceCount: Number(r.fresh_device_count), + })); +} + +/** + * Query (b): coverage denominators per org, computed WITHOUT any lateral join + * so the three no-detection populations stay distinguishable. Also counts the + * distinct detected devices per org (via EXISTS, still not a join) so the + * summary can partition the fleet. + */ +export async function getPostureCoverage(opts: QueryOpts): Promise> { + const hasDetection = sql`jsonb_array_length(${categoryArray(opts.category)}) > 0`; + const rows = await execute(sql` + SELECT ${devices.orgId} AS org_id, + count(*)::int AS total_devices, + (count(*) FILTER (WHERE ${devices.managementPosture} IS NULL))::int AS never_scanned, + (count(*) FILTER (WHERE ${devices.managementPosture} IS NOT NULL + AND NOT (${freshPredicate(opts.stalenessDays)})))::int AS stale, + (count(*) FILTER (WHERE ${devices.managementPosture} IS NOT NULL + AND NOT (${hasDetection})))::int AS scanned_none_detected, + (count(*) FILTER (WHERE ${devices.managementPosture} IS NOT NULL + AND ${hasDetection}))::int AS detected_devices, + (count(*) FILTER (WHERE ${devices.managementPosture} IS NOT NULL + AND ${hasDetection} AND ${freshPredicate(opts.stalenessDays)}))::int AS fresh_detected_devices + FROM ${devices} + WHERE ${liveDevices(opts.scope)} + GROUP BY 1 + ORDER BY 1 + `); + return rows.map((r) => ({ + orgId: String(r.org_id), + totalDevices: Number(r.total_devices), + neverScanned: Number(r.never_scanned), + stale: Number(r.stale), + scannedNoneDetected: Number(r.scanned_none_detected), + detectedDevices: Number(r.detected_devices), + freshDetectedDevices: Number(r.fresh_detected_devices), + })); +} + +/** + * The summary endpoint's payload: coverage denominators are always present for + * every org that has devices, and a detection count is never emitted without + * them. An org with detections but no coverage row cannot happen (coverage + * scans the same base population); if it ever did, the org would be dropped + * rather than shown without denominators, so we throw instead — loudly wrong + * beats quietly wrong here. + */ +export async function getManagementPostureSummary(opts: QueryOpts): Promise { + const [detections, coverage] = await Promise.all([ + getPostureDetections(opts), + getPostureCoverage(opts), + ]); + + const byOrg = new Map(); + for (const c of coverage) { + byOrg.set(c.orgId, { ...c, products: [] }); + } + for (const d of detections) { + const org = byOrg.get(d.orgId); + if (!org) { + // See doc comment: never emit a detection count without denominators. + throw new Error( + `posture summary invariant violated: detections for org ${d.orgId} without a coverage row` + ); + } + org.products.push({ + product: d.product, + status: d.status, + deviceCount: d.deviceCount, + freshDeviceCount: d.freshDeviceCount, + }); + } + + const totals = { + totalDevices: 0, + neverScanned: 0, + stale: 0, + scannedNoneDetected: 0, + detectedDevices: 0, + freshDetectedDevices: 0, + }; + for (const org of byOrg.values()) { + totals.totalDevices += org.totalDevices; + totals.neverScanned += org.neverScanned; + totals.stale += org.stale; + totals.scannedNoneDetected += org.scannedNoneDetected; + totals.detectedDevices += org.detectedDevices; + totals.freshDetectedDevices += org.freshDetectedDevices; + } + + return { + category: opts.category, + stalenessDays: opts.stalenessDays, + totals, + orgs: [...byOrg.values()], + }; +} + +export interface PostureDeviceRow { + id: string; + orgId: string; + siteId: string; + hostname: string; + displayName: string | null; + status: string; + osType: string; + lastSeenAt: string | null; + /** Posture scan timestamp (ISO) — null only if the jsonb lacks collectedAt. */ + collectedAt: string | null; + /** Detection status for the requested product on this device. */ + detectionStatus: string; + detectionVersion: string | null; +} + +/** + * Drill-down behind a summary count: the devices carrying `product` in the + * requested category (optionally narrowed to one detection status). + */ +export async function getPostureDevices(opts: QueryOpts & { + product: string; + detectionStatus?: string; + limit: number; + offset: number; +}): Promise<{ devices: PostureDeviceRow[]; total: number }> { + const statusFilter = opts.detectionStatus ?? null; + const matchesProduct = sql` + SELECT e.value->>'status' AS status, e.value->>'version' AS version + FROM jsonb_array_elements(${categoryArray(opts.category)}) e + WHERE e.value->>'name' = ${opts.product} + AND (${statusFilter}::text IS NULL OR e.value->>'status' = ${statusFilter}::text) + `; + + const [countRows, rows] = await Promise.all([ + execute(sql` + SELECT count(*)::int AS total + FROM ${devices} + WHERE ${liveDevices(opts.scope)} + AND EXISTS (${matchesProduct}) + `), + execute(sql` + SELECT ${devices.id} AS id, + ${devices.orgId} AS org_id, + ${devices.siteId} AS site_id, + ${devices.hostname} AS hostname, + ${devices.displayName} AS display_name, + ${devices.status} AS status, + ${devices.osType} AS os_type, + ${devices.lastSeenAt} AS last_seen_at, + ${devices.managementPosture}->>'collectedAt' AS collected_at, + det.status AS detection_status, + det.version AS detection_version + FROM ${devices} + CROSS JOIN LATERAL ( + ${matchesProduct} + ORDER BY CASE e.value->>'status' + WHEN 'active' THEN 0 WHEN 'installed' THEN 1 ELSE 2 END + LIMIT 1 + ) det + WHERE ${liveDevices(opts.scope)} + ORDER BY ${devices.hostname}, ${devices.id} + LIMIT ${opts.limit} OFFSET ${opts.offset} + `), + ]); + + return { + total: Number(countRows[0]?.total ?? 0), + devices: rows.map((r) => ({ + id: String(r.id), + orgId: String(r.org_id), + siteId: String(r.site_id), + hostname: String(r.hostname), + displayName: r.display_name == null ? null : String(r.display_name), + status: String(r.status), + osType: String(r.os_type), + lastSeenAt: r.last_seen_at == null ? null : new Date(r.last_seen_at as string | Date).toISOString(), + collectedAt: r.collected_at == null ? null : String(r.collected_at), + detectionStatus: String(r.detection_status), + detectionVersion: r.detection_version == null ? null : String(r.detection_version), + })), + }; +} diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index 61437bb3fb..e031035dbc 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -48,6 +48,11 @@ export default defineConfig({ // beforeAll), so the unit runner's no-DB environment fails the suite on // connect. Belongs to vitest.integration.config.ts. 'src/routes/agents/changes.integration.test.ts', + // Fleet posture report real-DB test (#3244): the mixed + // never-scanned/stale/clean/detected fixture needs real Postgres (the + // shared integration setup + system DB context), so the no-DB unit + // runner must not pick it up. Belongs to vitest.integration.config.ts. + 'src/services/managementPostureReport.integration.test.ts', // Patch ingest status-transition real-DB test (#2725): imports // `__tests__/integration/setup` (real postgres pool + autoMigrate), so // the no-DB unit runner would fail it on connect. Belongs to diff --git a/apps/api/vitest.integration.config.ts b/apps/api/vitest.integration.config.ts index fd8434f57d..b8e99620a8 100644 --- a/apps/api/vitest.integration.config.ts +++ b/apps/api/vitest.integration.config.ts @@ -223,6 +223,11 @@ export default defineConfig({ // The mocked list suite returns whatever rows it is handed regardless // of the predicate and cannot test this at all. 'src/routes/enrollmentKeysExpiredFilter.integration.test.ts', + // Co-located real-DB integration test for the fleet posture report + // (#3244): the mixed never-scanned/stale/clean/detected fixture that + // guards the two-query split — a mocked unit test cannot catch the + // collapsed LEFT JOIN LATERAL form that reads never-scanned as clean. + 'src/services/managementPostureReport.integration.test.ts', ], exclude: [ // Uses fresh request-pool modules and manages its own temporary role; diff --git a/apps/docs/src/content/docs/features/management-posture.mdx b/apps/docs/src/content/docs/features/management-posture.mdx index 66e53c1583..2d8f98cd96 100644 --- a/apps/docs/src/content/docs/features/management-posture.mdx +++ b/apps/docs/src/content/docs/features/management-posture.mdx @@ -96,6 +96,20 @@ If posture data has not yet been collected for a device, the Management tab disp --- +## Fleet Posture report + +Beyond the per-device tab, the **Fleet Posture** page (sidebar → Reporting → Fleet Posture, or `/devices/posture`) aggregates posture across the whole fleet — the primary surface for RMM migrations and decommissions: + +- **Counts by detected product and status** for a chosen category (RMM by default), with the device list behind each count one click away. +- **Coverage denominators next to every count**: total devices, never scanned, stale scans, and scanned-with-nothing-detected. A device that has never reported posture is shown as **unknown** — never as clean. "0 devices still run the old RMM" only means "safe to uninstall" when the scans backing it are fresh. +- **Per-organization migration progress**: enrolled in Breeze, running both agents (the healthy mid-migration state), Breeze-only (verified clean), and unknown. +- **Orphaned remote-access agents flagged as a security finding.** ScreenConnect survives a ConnectWise Automate uninstall; Splashtop survives Atera and Syncro uninstalls. These are called out separately as a standing exposure, not migration housekeeping. +- **CSV export**, so the report can go to the customer as evidence of a completed migration. + +The fleet report is served by two aggregate endpoints (see the API reference below) — one request for the whole fleet instead of one call per device. + +--- + ## Data freshness - Posture is collected every **15 minutes** as part of the agent heartbeat cycle. @@ -109,8 +123,12 @@ If posture data has not yet been collected for a device, the Management tab disp | Method | Path | Description | |---|---|---| | GET | `/api/v1/devices/:id/management-posture` | Get the management posture snapshot for a device | +| GET | `/api/v1/devices/management-posture/summary` | Fleet-wide counts by product/status plus coverage denominators. Query: `orgId?`, `category?` (default `rmm`), `stalenessDays?` (default 7) | +| GET | `/api/v1/devices/management-posture/devices` | Devices carrying a given product — the drill-down behind a summary count. Query: `product`, `category?`, `status?`, `orgId?`, `page?`, `limit?` | | PUT | `/api/v1/agents/:id/management/posture` | (Agent only) Submit a posture scan result | +The summary response reports, per organization: `totalDevices`, `neverScanned`, `stale`, `scannedNoneDetected`, `detectedDevices`, `freshDetectedDevices`, and a `products` array of `{ product, status, deviceCount, freshDeviceCount }`. Devices that have never reported posture are counted in `neverScanned` — they are **unknown**, not clean. + ### Response fields — `GET /api/v1/devices/:id/management-posture` | Field | Type | Description | diff --git a/apps/web/src/components/devices/DeviceManagementTab.tsx b/apps/web/src/components/devices/DeviceManagementTab.tsx index c7d47053b5..c6a64f7f80 100644 --- a/apps/web/src/components/devices/DeviceManagementTab.tsx +++ b/apps/web/src/components/devices/DeviceManagementTab.tsx @@ -19,9 +19,14 @@ import { fetchWithAuth } from "../../stores/auth"; import { useTranslation } from "react-i18next"; import "../../lib/i18n"; -// ── Types ──────────────────────────────────────────────────────────── +import { + CATEGORY_LABELS, + STATUS_BADGE, + type CategoryKey, + type DetectionStatus, +} from "../../lib/postureCategories"; -type DetectionStatus = "active" | "installed" | "unknown"; +// ── Types ──────────────────────────────────────────────────────────── type Detection = { name: string; @@ -49,19 +54,6 @@ type IdentityStatus = { source: string; }; -type CategoryKey = - | "mdm" - | "rmm" - | "remoteAccess" - | "endpointSecurity" - | "policyEngine" - | "backup" - | "identityMfa" - | "siem" - | "dnsFiltering" - | "zeroTrustVpn" - | "patchManagement"; - type ManagementPosture = { collectedAt: string; scanDurationMs: number; @@ -79,20 +71,6 @@ type PostureResponse = { // ── Constants ──────────────────────────────────────────────────────── -const CATEGORY_LABELS: Record = { - mdm: "MDM", - rmm: "RMM", - remoteAccess: "Remote Access", - endpointSecurity: "Endpoint Security", - policyEngine: "Policy Engine", - backup: "Backup", - identityMfa: "Identity / MFA", - siem: "SIEM", - dnsFiltering: "DNS Filtering", - zeroTrustVpn: "Zero Trust / VPN", - patchManagement: "Patch Management", -}; - const CATEGORY_ORDER: CategoryKey[] = [ "mdm", "endpointSecurity", @@ -115,12 +93,6 @@ const JOIN_TYPE_LABELS: Record = { none: "Not Joined", }; -const STATUS_BADGE: Record = { - active: "bg-emerald-500/20 text-emerald-700 border-emerald-500/40", - installed: "bg-blue-500/20 text-blue-700 border-blue-500/40", - unknown: "bg-gray-500/20 text-gray-600 border-gray-500/30", -}; - // ── Helpers ────────────────────────────────────────────────────────── function formatDateTime(value: string): string { diff --git a/apps/web/src/components/devices/FleetPostureReport.test.tsx b/apps/web/src/components/devices/FleetPostureReport.test.tsx new file mode 100644 index 0000000000..a0aa900d5a --- /dev/null +++ b/apps/web/src/components/devices/FleetPostureReport.test.tsx @@ -0,0 +1,278 @@ +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import FleetPostureReport from './FleetPostureReport'; +import { fetchWithAuth } from '../../stores/auth'; + +vi.mock('../../stores/auth', () => ({ + fetchWithAuth: vi.fn(), + registerOrgIdProvider: vi.fn(), +})); + +vi.mock('../../stores/orgStore', () => ({ + useOrgStore: (selector: (s: unknown) => unknown) => + selector({ + organizations: [ + { id: 'org-1', name: 'Acme Corp' }, + { id: 'org-2', name: 'Globex' }, + ], + currentOrgId: null, + }), +})); + +const fetchWithAuthMock = vi.mocked(fetchWithAuth); + +const makeJsonResponse = (payload: unknown, ok = true, status = ok ? 200 : 500): Response => + ({ + ok, + status, + statusText: ok ? 'OK' : 'ERROR', + json: vi.fn().mockResolvedValue(payload), + }) as unknown as Response; + +const emptyTotals = { + totalDevices: 0, + neverScanned: 0, + stale: 0, + scannedNoneDetected: 0, + detectedDevices: 0, + freshDetectedDevices: 0, +}; + +function summaryPayload(overrides?: { orgs?: unknown[]; totals?: Partial }) { + return { + data: { + category: 'rmm', + stalenessDays: 7, + totals: { ...emptyTotals, ...(overrides?.totals ?? {}) }, + orgs: overrides?.orgs ?? [], + }, + }; +} + +const raEmpty = { + data: { category: 'remoteAccess', stalenessDays: 7, totals: { ...emptyTotals }, orgs: [] }, +}; + +function mockSummaries(main: unknown, remoteAccess: unknown = raEmpty) { + fetchWithAuthMock.mockImplementation(async (url: string) => { + if (url.includes('category=remoteAccess')) return makeJsonResponse(remoteAccess); + if (url.includes('/management-posture/summary')) return makeJsonResponse(main); + throw new Error(`unexpected fetch ${url}`); + }); +} + +beforeEach(() => { + fetchWithAuthMock.mockReset(); + window.location.hash = ''; +}); + +describe('FleetPostureReport', () => { + it('renders per-org detections with coverage denominators and fresh counts', async () => { + mockSummaries( + summaryPayload({ + totals: { + totalDevices: 10, neverScanned: 2, stale: 1, + scannedNoneDetected: 4, detectedDevices: 3, freshDetectedDevices: 2, + }, + orgs: [ + { + orgId: 'org-1', totalDevices: 10, neverScanned: 2, stale: 1, + scannedNoneDetected: 4, detectedDevices: 3, freshDetectedDevices: 2, + products: [ + { product: 'Datto RMM', status: 'active', deviceCount: 3, freshDeviceCount: 2 }, + ], + }, + ], + }) + ); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('posture-org-section')).toBeTruthy(); + }); + + // Org resolved to its name, product row present with the fresh-of-total + // annotation (never a bare count). + expect(screen.getByText('Acme Corp')).toBeTruthy(); + expect(screen.getByText('Datto RMM')).toBeTruthy(); + expect(screen.getAllByText(/2[^0-9]+3/).length).toBeGreaterThan(0); + + // Progress chips PARTITION the fleet from the fresh counts: stale-clean + // devices must NOT read as "verified clean". both=freshDetected(2), + // breezeOnly = 10 total - 2 never - 1 stale - 2 freshDetected = 5, + // unknown = never+stale = 3; 2+5+3 == 10. + expect(screen.getByTestId('posture-progress-both').textContent).toContain('2'); + expect(screen.getByTestId('posture-progress-breeze-only').textContent).toContain('5'); + expect(screen.getByTestId('posture-progress-unknown').textContent).toContain('3'); + + // Both summary requests went to the new aggregate endpoint. + const urls = fetchWithAuthMock.mock.calls.map((c) => c[0] as string); + expect(urls.some((u) => u.includes('/devices/management-posture/summary?category=rmm&stalenessDays=7'))).toBe(true); + expect(urls.some((u) => u.includes('category=remoteAccess'))).toBe(true); + }); + + it('never renders a bare zero: shows the unknown-devices caveat when detections are 0 but scans are missing/stale', async () => { + mockSummaries( + summaryPayload({ + totals: { + totalDevices: 12, neverScanned: 5, stale: 7, + scannedNoneDetected: 0, detectedDevices: 0, freshDetectedDevices: 0, + }, + orgs: [ + { + orgId: 'org-1', totalDevices: 12, neverScanned: 5, stale: 7, + scannedNoneDetected: 0, detectedDevices: 0, freshDetectedDevices: 0, + products: [], + }, + ], + }) + ); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('posture-zero-caveat')).toBeTruthy(); + }); + expect(screen.getByTestId('posture-zero-caveat').textContent).toContain('12'); + }); + + it('calls out orphaned remote-access agents as a security finding', async () => { + mockSummaries( + summaryPayload(), + { + data: { + category: 'remoteAccess', + stalenessDays: 7, + totals: { ...emptyTotals, totalDevices: 4, detectedDevices: 1, freshDetectedDevices: 1 }, + orgs: [ + { + orgId: 'org-2', totalDevices: 4, neverScanned: 0, stale: 0, + scannedNoneDetected: 3, detectedDevices: 1, freshDetectedDevices: 1, + products: [ + { product: 'ScreenConnect', status: 'active', deviceCount: 1, freshDeviceCount: 1 }, + ], + }, + ], + }, + } + ); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('posture-orphan-callout')).toBeTruthy(); + }); + const callout = screen.getByTestId('posture-orphan-callout'); + expect(callout.textContent).toContain('ScreenConnect'); + expect(callout.textContent).toContain('Globex'); + }); + + it('drills down into the device list behind a count', async () => { + mockSummaries( + summaryPayload({ + totals: { + totalDevices: 3, neverScanned: 0, stale: 0, + scannedNoneDetected: 1, detectedDevices: 2, freshDetectedDevices: 2, + }, + orgs: [ + { + orgId: 'org-1', totalDevices: 3, neverScanned: 0, stale: 0, + scannedNoneDetected: 1, detectedDevices: 2, freshDetectedDevices: 2, + products: [ + { product: 'NinjaOne', status: 'installed', deviceCount: 2, freshDeviceCount: 2 }, + ], + }, + ], + }) + ); + fetchWithAuthMock.mockImplementation(async (url: string) => { + if (url.includes('/management-posture/devices')) { + expect(url).toContain('product=NinjaOne'); + expect(url).toContain('status=installed'); + // The drill-down must pin the org whose count it explains — in + // All-organizations mode nothing else scopes the request. + expect(url).toContain('orgId=org-1'); + return makeJsonResponse({ + data: { + total: 1, + page: 1, + limit: 50, + devices: [ + { + id: 'dev-1', orgId: 'org-1', hostname: 'PC-01', displayName: null, + status: 'online', osType: 'windows', lastSeenAt: null, + collectedAt: '2026-08-07T12:00:00Z', + detectionStatus: 'installed', detectionVersion: '5.0', + }, + ], + }, + }); + } + if (url.includes('category=remoteAccess')) return makeJsonResponse(raEmpty); + return makeJsonResponse( + summaryPayload({ + orgs: [ + { + orgId: 'org-1', totalDevices: 3, neverScanned: 0, stale: 0, + scannedNoneDetected: 1, detectedDevices: 2, freshDetectedDevices: 2, + products: [ + { product: 'NinjaOne', status: 'installed', deviceCount: 2, freshDeviceCount: 2 }, + ], + }, + ], + }) + ); + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('posture-product-row')).toBeTruthy(); + }); + fireEvent.click(screen.getByTestId('posture-product-row')); + + await waitFor(() => { + expect(screen.getAllByTestId('posture-drilldown-row')).toHaveLength(1); + }); + expect(screen.getByText('PC-01')).toBeTruthy(); + expect(screen.getByText('5.0')).toBeTruthy(); + }); + + it('keeps the main report when the best-effort remote-access fetch rejects, with an explicit unavailable note', async () => { + fetchWithAuthMock.mockImplementation(async (url: string) => { + if (url.includes('category=remoteAccess')) throw new Error('network down'); + return makeJsonResponse( + summaryPayload({ + totals: { totalDevices: 2 }, + orgs: [ + { + orgId: 'org-1', totalDevices: 2, neverScanned: 0, stale: 0, + scannedNoneDetected: 2, detectedDevices: 0, freshDetectedDevices: 0, + products: [], + }, + ], + }) + ); + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('posture-org-section')).toBeTruthy(); + }); + // Main report rendered; missing security findings surfaced, not silent. + expect(screen.getByTestId('posture-orphan-unavailable')).toBeTruthy(); + expect(screen.queryByTestId('posture-orphan-callout')).toBeNull(); + }); + + it('shows a friendly error with retry when the summary request fails', async () => { + fetchWithAuthMock.mockResolvedValue(makeJsonResponse({}, false)); + + render(); + + await waitFor(() => { + expect(screen.getByText(/retry/i)).toBeTruthy(); + }); + }); +}); diff --git a/apps/web/src/components/devices/FleetPostureReport.tsx b/apps/web/src/components/devices/FleetPostureReport.tsx new file mode 100644 index 0000000000..0f886ad288 --- /dev/null +++ b/apps/web/src/components/devices/FleetPostureReport.tsx @@ -0,0 +1,683 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + AlertTriangle, + ChevronDown, + ChevronRight, + Download, + Loader2, + RefreshCw, + ShieldAlert, +} from "lucide-react"; + +import { friendlyFetchError } from "../../lib/utils"; +import { formatDateTime as formatUserDateTime } from "@/lib/dateTimeFormat"; +import { fetchWithAuth } from "../../stores/auth"; +import { useOrgStore } from "../../stores/orgStore"; +import { toCsv } from "../../lib/csvExport"; +import { downloadBlob } from "../../lib/downloadBlob"; +import { useTranslation } from "react-i18next"; +import "../../lib/i18n"; +import { useHashState } from "../../lib/useHashState"; +import { + CATEGORY_LABELS, + STATUS_BADGE, + isCategoryKey, + type CategoryKey, +} from "../../lib/postureCategories"; + +// ── Types (mirror GET /devices/management-posture/summary) ─────────── + +type ProductRow = { + product: string; + status: string; + deviceCount: number; + freshDeviceCount: number; +}; + +type OrgSummary = { + orgId: string; + totalDevices: number; + neverScanned: number; + stale: number; + scannedNoneDetected: number; + detectedDevices: number; + freshDetectedDevices: number; + products: ProductRow[]; +}; + +type Summary = { + category: CategoryKey; + stalenessDays: number; + totals: { + totalDevices: number; + neverScanned: number; + stale: number; + scannedNoneDetected: number; + detectedDevices: number; + freshDetectedDevices: number; + }; + orgs: OrgSummary[]; +}; + +type DrillDevice = { + id: string; + orgId: string; + hostname: string; + displayName: string | null; + status: string; + osType: string; + lastSeenAt: string | null; + collectedAt: string | null; + detectionStatus: string; + detectionVersion: string | null; +}; + +// ── Constants (same conventions as DeviceManagementTab) ────────────── + +const CATEGORY_ORDER: CategoryKey[] = [ + "rmm", + "remoteAccess", + "mdm", + "endpointSecurity", + "policyEngine", + "backup", + "identityMfa", + "siem", + "dnsFiltering", + "zeroTrustVpn", + "patchManagement", +]; + +const WINDOW_OPTIONS = [7, 14, 30, 90]; + +/** + * Remote-access products that ship as a separately-installed component of a + * competing RMM and SURVIVE that RMM's uninstall (the plan's definition of an + * orphan risk): ScreenConnect outlives a ConnectWise Automate uninstall; + * Splashtop outlives Atera and Syncro uninstalls. Deliberately NOT every + * remote-access detection — flagging an MSP's own sanctioned TeamViewer/ + * AnyDesk on every visit would train users to ignore the banner. A true + * per-device orphan check (RA present AND its parent RMM absent) needs a + * server-side cross-category query — follow-up on #3244. + */ +const ORPHAN_RISK_REMOTE_ACCESS = new Set(["ScreenConnect", "Splashtop"]); + +function statusBadgeClass(status: string): string { + return STATUS_BADGE[(status as keyof typeof STATUS_BADGE)] ?? STATUS_BADGE.unknown; +} + +function formatDateTime(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return formatUserDateTime(date, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +// ── Drill-down device list ─────────────────────────────────────────── + +function DrillDownList({ + category, + product, + status, + stalenessDays, + orgId, + orgNames, +}: { + category: CategoryKey; + product: string; + status: string; + stalenessDays: number; + /** The org whose count this drill-down explains. Passed explicitly: in + * "All organizations" mode fetchWithAuth injects no orgId, and without it + * the list would span every accessible org under one org's heading. */ + orgId: string; + orgNames: Map; +}) { + const { t } = useTranslation("devices"); + const [devices, setDevices] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(); + + const fetchPage = useCallback( + async (nextPage: number, append: boolean) => { + setLoading(true); + setError(undefined); + try { + const params = new URLSearchParams({ + category, + product, + status, + orgId, + stalenessDays: String(stalenessDays), + page: String(nextPage), + limit: "50", + }); + const response = await fetchWithAuth( + `/devices/management-posture/devices?${params.toString()}` + ); + if (!response.ok) throw new Error(`${response.status} ${response.statusText}`); + const body = await response.json(); + setTotal(body.data.total ?? 0); + setDevices((prev) => (append ? [...prev, ...body.data.devices] : body.data.devices)); + setPage(nextPage); + } catch (err) { + setError(friendlyFetchError(err)); + } finally { + setLoading(false); + } + }, + [category, product, status, stalenessDays, orgId] + ); + + useEffect(() => { + fetchPage(1, false); + }, [fetchPage]); + + return ( +
+ + + + + + + + + + + {devices.map((d) => ( + + + + + + + ))} + +
{t("fleetPosture.colHostname")}{t("fleetPosture.colOrganization")}{t("fleetPosture.colDetectedVersion")}{t("fleetPosture.colScannedAt")}
+ + {d.displayName || d.hostname} + + {orgNames.get(d.orgId) ?? d.orgId}{d.detectionVersion ?? "—"} + {d.collectedAt ? formatDateTime(d.collectedAt) : "—"} +
+ {loading && ( +
+ + {t("fleetPosture.loading")} +
+ )} + {/* Errors render inline so an already-loaded page of rows survives a + failed "Load more"; retrying refetches only the failed page. */} + {error && !loading && ( +
+ {error} + +
+ )} + {!loading && !error && devices.length < total && ( + + )} +
+ ); +} + +// ── Main component ─────────────────────────────────────────────────── + +export default function FleetPostureReport() { + const { t } = useTranslation("devices"); + const organizations = useOrgStore((s) => s.organizations); + const currentOrgId = useOrgStore((s) => s.currentOrgId); + + // SSR-safe hash-derived tab state (#2421): starts from the default and + // adopts the hash pre-paint. isCategoryKey does an own-property check — a + // plain `raw in CATEGORY_LABELS` would also match inherited keys like + // '#toString' and wedge the page on an invalid category. + const [category, setCategory] = useHashState("rmm", (raw) => + isCategoryKey(raw) ? raw : undefined + ); + const [stalenessDays, setStalenessDays] = useState(7); + const [summary, setSummary] = useState(); + const [remoteAccess, setRemoteAccess] = useState(); + const [raUnavailable, setRaUnavailable] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(); + const [expanded, setExpanded] = useState(); + // Out-of-order guard: rapid category/window switches fire overlapping + // fetches; only the latest request may commit state, otherwise a slower + // older response would render under the newer selection. + const requestSeq = useRef(0); + + const orgNames = new Map(organizations.map((o) => [o.id, o.name])); + + const fetchSummary = useCallback(async () => { + const seq = ++requestSeq.current; + setLoading(true); + setError(undefined); + setExpanded(undefined); + try { + const query = (cat: CategoryKey) => + `/devices/management-posture/summary?category=${cat}&stalenessDays=${stalenessDays}`; + // The orphaned-remote-access callout is best-effort; the main report + // must not fail because of it — so its rejection is swallowed here and + // surfaced as an explicit "findings unavailable" note instead. + const [main, ra] = await Promise.all([ + fetchWithAuth(query(category)), + category === "remoteAccess" + ? Promise.resolve(undefined) + : fetchWithAuth(query("remoteAccess")).catch(() => undefined), + ]); + if (seq !== requestSeq.current) return; + if (!main.ok) throw new Error(`${main.status} ${main.statusText}`); + const mainData = (await main.json()).data; + const raData = ra && ra.ok ? (await ra.json()).data : undefined; + if (seq !== requestSeq.current) return; + setSummary(mainData); + setRemoteAccess(raData); + // Missing security findings must be visible, not silent — this report + // is the only place the orphaned-RA exposure surfaces. + setRaUnavailable(category !== "remoteAccess" && raData === undefined); + } catch (err) { + if (seq !== requestSeq.current) return; + setError(friendlyFetchError(err)); + setSummary(undefined); + } finally { + if (seq === requestSeq.current) setLoading(false); + } + }, [category, stalenessDays, currentOrgId]); + + useEffect(() => { + fetchSummary(); + }, [fetchSummary]); + + const selectCategory = (next: CategoryKey) => { + setCategory(next); + if (typeof window !== "undefined") window.location.hash = next; + }; + + const exportCsv = () => { + if (!summary) return; + const header = [ + "Organization", + "Category", + "Metric", + "Detection status", + "Devices", + `Fresh (<= ${summary.stalenessDays}d)`, + ]; + const rows: (string | number)[][] = []; + for (const org of summary.orgs) { + const orgName = orgNames.get(org.orgId) ?? org.orgId; + rows.push([orgName, summary.category, "Total devices", "", org.totalDevices, ""]); + rows.push([orgName, summary.category, "Never scanned (posture unknown)", "", org.neverScanned, ""]); + rows.push([orgName, summary.category, "Stale scan", "", org.stale, ""]); + rows.push([orgName, summary.category, "Scanned, none detected", "", org.scannedNoneDetected, ""]); + rows.push([orgName, summary.category, "Detected devices", "", org.detectedDevices, org.freshDetectedDevices]); + for (const p of org.products) { + rows.push([orgName, summary.category, p.product, p.status, p.deviceCount, p.freshDeviceCount]); + } + } + const csv = toCsv(header, rows); + const blob = new Blob([csv], { type: "text/csv;charset=utf-8" }); + downloadBlob(blob, `fleet-posture-${summary.category}-${new Date().toISOString().slice(0, 10)}.csv`); + }; + + const orphanProducts = + remoteAccess?.orgs.flatMap((o) => + o.products + .filter((p) => ORPHAN_RISK_REMOTE_ACCESS.has(p.product)) + .map((p) => ({ orgId: o.orgId, ...p })) + ) ?? []; + + return ( +
+
+
+

{t("fleetPosture.title")}

+

{t("fleetPosture.subtitle")}

+
+
+ + + + + + +
+
+ + {loading && ( +
+ + {t("fleetPosture.loading")} +
+ )} + + {error && !loading && ( +
+

{error}

+ +
+ )} + + {summary && !loading && !error && ( + <> + {/* Coverage denominators — posture age is part of the answer. A zero + detection count is only meaningful next to these. */} +
+ + + + 0 ? "warn" : undefined} + hint={summary.totals.stale > 0 ? t("fleetPosture.staleHint") : undefined} + /> + 0 ? "warn" : undefined} + hint={summary.totals.neverScanned > 0 ? t("fleetPosture.neverScannedHint") : undefined} + /> +
+ + {/* Never render a bare zero: 0 detections with unknown devices is a + different fact from 0 with everything fresh. */} + {summary.totals.detectedDevices === 0 && + (summary.totals.neverScanned > 0 || summary.totals.stale > 0) && ( +
+ + + {t("fleetPosture.zeroCaveat", { + unknown: summary.totals.neverScanned + summary.totals.stale, + })} + +
+ )} + + {raUnavailable && ( +

+ {t("fleetPosture.securityUnavailable")} +

+ )} + + {orphanProducts.length > 0 && category !== "remoteAccess" && ( +
+
+ + {t("fleetPosture.securityTitle")} +
+

+ {t("fleetPosture.securityBody")} +

+
    + {orphanProducts.map((p) => ( +
  • + + {p.status} + + {p.product} + + · {orgNames.get(p.orgId) ?? p.orgId} ·{" "} + {t("fleetPosture.deviceCount", { count: p.deviceCount })} + +
  • + ))} +
+
+ )} + + {summary.orgs.map((org) => ( +
+
+

{orgNames.get(org.orgId) ?? org.orgId}

+ {/* Per-org migration progress. The three non-enrolled chips + PARTITION the fleet (fresh-detected + fresh-clean + + unknown == total): the raw coverage buckets overlap on + stale devices (a stale-clean scan is NOT "verified + clean"), so both/Breeze-only are derived from the fresh + counts only and every stale device counts as unknown. */} +
+ + {t("fleetPosture.progressEnrolled", { count: org.totalDevices })} + + + {t("fleetPosture.progressBoth", { count: org.freshDetectedDevices })} + + + {t("fleetPosture.progressBreezeOnly", { + count: + org.totalDevices - org.neverScanned - org.stale - org.freshDetectedDevices, + })} + + 0 ? "text-amber-600" : undefined} + data-testid="posture-progress-unknown" + > + {t("fleetPosture.progressUnknown", { count: org.neverScanned + org.stale })} + +
+
+ {org.products.length === 0 ? ( +

+ {t("fleetPosture.noDetections", { category: CATEGORY_LABELS[summary.category] })}{" "} + {org.neverScanned + org.stale > 0 && + t("fleetPosture.zeroCaveat", { unknown: org.neverScanned + org.stale })} +

+ ) : ( + + + + + + + + + + + {org.products.map((p) => { + const key = `${org.orgId}|${p.product}|${p.status}`; + const isOpen = expanded === key; + return ( + setExpanded(isOpen ? undefined : key)} + product={p} + category={summary.category} + stalenessDays={summary.stalenessDays} + orgId={org.orgId} + orgNames={orgNames} + /> + ); + })} + +
{t("fleetPosture.colProduct")}{t("fleetPosture.colDetectionStatus")}{t("fleetPosture.colDevices")}{t("fleetPosture.colFresh")} +
+ )} +
+ ))} + + )} +
+ ); +} + +function StatCard({ + label, + value, + hint, + tone, +}: { + label: string; + value: number; + hint?: string; + tone?: "warn"; +}) { + return ( +
+
{label}
+
{value}
+ {hint &&
{hint}
} +
+ ); +} + +function FragmentRow({ + isOpen, + onToggle, + product, + category, + stalenessDays, + orgId, + orgNames, +}: { + isOpen: boolean; + onToggle: () => void; + product: ProductRow; + category: CategoryKey; + stalenessDays: number; + orgId: string; + orgNames: Map; +}) { + const { t } = useTranslation("devices"); + return ( + <> + + {product.product} + + + {product.status} + + + {product.deviceCount} + + {/* Posture age next to every count — a count with 0 fresh scans is + not evidence the product is still (or no longer) there. */} + + {t("fleetPosture.freshOf", { + fresh: product.freshDeviceCount, + total: product.deviceCount, + })} + + + + {isOpen ? : } + + + {isOpen && ( + + + + + + )} + + ); +} diff --git a/apps/web/src/components/layout/Sidebar.tsx b/apps/web/src/components/layout/Sidebar.tsx index af4f0db756..1aa4552113 100644 --- a/apps/web/src/components/layout/Sidebar.tsx +++ b/apps/web/src/components/layout/Sidebar.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { LayoutDashboard, Monitor, + Radar, FileCode, Bell, ShieldAlert, @@ -275,6 +276,9 @@ export const navSections: NavSection[] = [ items: [ { name: 'Reports', labelKey: 'nav.reports', href: '/reports', icon: FileText, requiredPermission: { resource: 'reports', action: 'read' } }, { name: 'Analytics', labelKey: 'nav.analytics', href: '/analytics', icon: BarChart3, requiredPermission: { resource: 'reports', action: 'read' } }, + // Fleet migration/decommission posture report (#3244) — backed by + // GET /devices/management-posture/summary, which enforces devices:read. + { name: 'Fleet Posture', labelKey: 'nav.fleetPosture', href: '/devices/posture', icon: Radar, requiredPermission: { resource: 'devices', action: 'read' } }, { name: 'Audit Trail', labelKey: 'nav.auditTrail', href: '/audit', icon: FileText, requiredPermission: { resource: 'audit', action: 'read' } }, { name: 'Event Logs', labelKey: 'nav.eventLogs', href: '/logs', icon: ScrollText, requiredPermission: { resource: 'audit', action: 'read' } }, ], diff --git a/apps/web/src/lib/postureCategories.ts b/apps/web/src/lib/postureCategories.ts new file mode 100644 index 0000000000..618673900e --- /dev/null +++ b/apps/web/src/lib/postureCategories.ts @@ -0,0 +1,48 @@ +/** + * Management-posture category/status constants shared by the per-device + * Management tab and the fleet posture report. Keys mirror the API's + * MANAGEMENT_POSTURE_CATEGORIES (apps/api/src/routes/agents/schemas.ts) — + * the ingest enum is the source of truth; add new categories there first. + * + * Labels are product/technical vocabulary (RMM, MDM, SIEM…) and deliberately + * not i18n'd, matching the original DeviceManagementTab convention. + */ + +export type DetectionStatus = "active" | "installed" | "unknown"; + +export type CategoryKey = + | "mdm" + | "rmm" + | "remoteAccess" + | "endpointSecurity" + | "policyEngine" + | "backup" + | "identityMfa" + | "siem" + | "dnsFiltering" + | "zeroTrustVpn" + | "patchManagement"; + +export const CATEGORY_LABELS: Record = { + mdm: "MDM", + rmm: "RMM", + remoteAccess: "Remote Access", + endpointSecurity: "Endpoint Security", + policyEngine: "Policy Engine", + backup: "Backup", + identityMfa: "Identity / MFA", + siem: "SIEM", + dnsFiltering: "DNS Filtering", + zeroTrustVpn: "Zero Trust / VPN", + patchManagement: "Patch Management", +}; + +export const STATUS_BADGE: Record = { + active: "bg-emerald-500/20 text-emerald-700 border-emerald-500/40", + installed: "bg-blue-500/20 text-blue-700 border-blue-500/40", + unknown: "bg-gray-500/20 text-gray-600 border-gray-500/30", +}; + +export function isCategoryKey(value: string): value is CategoryKey { + return Object.prototype.hasOwnProperty.call(CATEGORY_LABELS, value); +} diff --git a/apps/web/src/locales/de-DE/common.json b/apps/web/src/locales/de-DE/common.json index a5a2f2996f..4f832e5670 100644 --- a/apps/web/src/locales/de-DE/common.json +++ b/apps/web/src/locales/de-DE/common.json @@ -1,5 +1,6 @@ { "nav": { + "fleetPosture": "Flottenstatus", "dashboard": "Dashboard", "devices": "Geräte", "alerts": "Warnmeldungen", diff --git a/apps/web/src/locales/de-DE/devices.json b/apps/web/src/locales/de-DE/devices.json index 4f7f83e7b7..4f54513fb7 100644 --- a/apps/web/src/locales/de-DE/devices.json +++ b/apps/web/src/locales/de-DE/devices.json @@ -2236,5 +2236,49 @@ "title": "Mögliche doppelte Registrierung", "unknownDevice": "das vorherige Gerät", "viewOldDevice": "Altes Gerät anzeigen" + }, + "fleetPosture": { + "securityUnavailable": "Sicherheitsbefunde zum Fernzugriff konnten für diese Ansicht nicht geladen werden.", + "title": "Flottenstatus-Bericht", + "subtitle": "Auf welchen Endpunkten läuft noch konkurrierende Verwaltungssoftware — und wo kann sie sicher außer Betrieb genommen werden.", + "labelCategory": "Kategorie", + "labelWindow": "Aktualitätsfenster", + "windowDays_one": "{{count}} Tag", + "windowDays_other": "{{count}} Tage", + "refresh": "Aktualisieren", + "exportCsv": "CSV exportieren", + "loading": "Flottenstatus wird geladen…", + "retry": "Erneut versuchen", + "totalDevices": "Registrierte Geräte", + "detected": "Konkurrenzprodukt erkannt", + "cleanFresh": "Gescannt, nichts erkannt", + "stale": "Veraltete Scans", + "neverScanned": "Nie gescannt", + "staleHint": "Scan ist älter als das Aktualitätsfenster.", + "neverScannedHint": "Status unbekannt — Registrierung prüfen, bevor etwas deinstalliert wird.", + "freshOf": "{{fresh}} aktuell von {{total}}", + "zeroCaveat": "Keine Erkennungen, aber {{unknown}} Gerät(e) ohne aktuellen Scan — vor Abschluss der Migration prüfen.", + "securityTitle": "Verwaiste Fernzugriffs-Agenten", + "securityBody": "Diese Fernzugriffs-Tools werden unabhängig vom RMM installiert und überstehen dessen Deinstallation. Ein unbeaufsichtigter Fernzugriffs-Agent, den niemand überwacht, ist ein dauerhaftes Risiko — vor Abschluss einer Migration jeden einzeln prüfen.", + "deviceCount_one": "{{count}} Gerät", + "deviceCount_other": "{{count}} Geräte", + "progressEnrolled_one": "{{count}} in Breeze registriert", + "progressEnrolled_other": "{{count}} in Breeze registriert", + "progressBoth_one": "{{count}} mit beiden (mitten in der Migration)", + "progressBoth_other": "{{count}} mit beiden (mitten in der Migration)", + "progressBreezeOnly_one": "{{count}} nur Breeze (geprüft sauber)", + "progressBreezeOnly_other": "{{count}} nur Breeze (geprüft sauber)", + "progressUnknown_one": "{{count}} unbekannt (nie gescannt oder veraltet)", + "progressUnknown_other": "{{count}} unbekannt (nie gescannt oder veraltet)", + "noDetections": "Keine {{category}}-Produkte auf gescannten Geräten erkannt.", + "colProduct": "Produkt", + "colDetectionStatus": "Erkennungsstatus", + "colDevices": "Geräte", + "colFresh": "Aktuelle Scans", + "colHostname": "Rechnername", + "colOrganization": "Organisation", + "colDetectedVersion": "Erkannte Version", + "colScannedAt": "Letzter Scan", + "loadMore": "Mehr laden" } } diff --git a/apps/web/src/locales/en/common.json b/apps/web/src/locales/en/common.json index 94564c9e05..500fcfe5ed 100644 --- a/apps/web/src/locales/en/common.json +++ b/apps/web/src/locales/en/common.json @@ -1,5 +1,6 @@ { "nav": { + "fleetPosture": "Fleet Posture", "dashboard": "Dashboard", "devices": "Devices", "alerts": "Alerts", diff --git a/apps/web/src/locales/en/devices.json b/apps/web/src/locales/en/devices.json index 1b0d1e7f8a..2ef50e13b8 100644 --- a/apps/web/src/locales/en/devices.json +++ b/apps/web/src/locales/en/devices.json @@ -2236,5 +2236,49 @@ "title": "Possible duplicate enrollment", "unknownDevice": "the previous device", "viewOldDevice": "View old device" + }, + "fleetPosture": { + "securityUnavailable": "Remote-access security findings could not be loaded for this view.", + "title": "Fleet Posture Report", + "subtitle": "Which endpoints still run competing management tooling — and whether it is safe to decommission it there.", + "labelCategory": "Category", + "labelWindow": "Freshness window", + "windowDays_one": "{{count}} day", + "windowDays_other": "{{count}} days", + "refresh": "Refresh", + "exportCsv": "Export CSV", + "loading": "Loading fleet posture…", + "retry": "Retry", + "totalDevices": "Enrolled devices", + "detected": "Competing product detected", + "cleanFresh": "Scanned, none detected", + "stale": "Stale scans", + "neverScanned": "Never scanned", + "staleHint": "Scan older than the freshness window.", + "neverScannedHint": "Posture unknown — verify enrollment before uninstalling anything.", + "freshOf": "{{fresh}} fresh of {{total}}", + "zeroCaveat": "Zero detections, but {{unknown}} device(s) have no fresh scan — verify them before treating the migration as complete.", + "securityTitle": "Orphaned remote-access agents", + "securityBody": "These remote-access tools install separately from an RMM and survive its uninstall. An unattended remote-access agent that nobody monitors is a standing exposure — review each one before closing out a migration.", + "deviceCount_one": "{{count}} device", + "deviceCount_other": "{{count}} devices", + "progressEnrolled_one": "{{count}} enrolled in Breeze", + "progressEnrolled_other": "{{count}} enrolled in Breeze", + "progressBoth_one": "{{count}} running both (mid-migration)", + "progressBoth_other": "{{count}} running both (mid-migration)", + "progressBreezeOnly_one": "{{count}} Breeze-only (verified clean)", + "progressBreezeOnly_other": "{{count}} Breeze-only (verified clean)", + "progressUnknown_one": "{{count}} unknown (never scanned or stale)", + "progressUnknown_other": "{{count}} unknown (never scanned or stale)", + "noDetections": "No {{category}} products detected on scanned devices.", + "colProduct": "Product", + "colDetectionStatus": "Detection status", + "colDevices": "Devices", + "colFresh": "Fresh scans", + "colHostname": "Host name", + "colOrganization": "Organization", + "colDetectedVersion": "Detected version", + "colScannedAt": "Last scan", + "loadMore": "Load more" } } diff --git a/apps/web/src/locales/es-419/common.json b/apps/web/src/locales/es-419/common.json index 9e5100bb5c..8584ca4d36 100644 --- a/apps/web/src/locales/es-419/common.json +++ b/apps/web/src/locales/es-419/common.json @@ -1,5 +1,6 @@ { "nav": { + "fleetPosture": "Postura de la flota", "dashboard": "Panel", "devices": "Dispositivos", "alerts": "Alertas", diff --git a/apps/web/src/locales/es-419/devices.json b/apps/web/src/locales/es-419/devices.json index b3a79c6c4b..ae74ea8601 100644 --- a/apps/web/src/locales/es-419/devices.json +++ b/apps/web/src/locales/es-419/devices.json @@ -2236,5 +2236,49 @@ "title": "Posible inscripción duplicada", "unknownDevice": "el dispositivo anterior", "viewOldDevice": "Ver dispositivo antiguo" + }, + "fleetPosture": { + "securityUnavailable": "No se pudieron cargar los hallazgos de seguridad de acceso remoto para esta vista.", + "title": "Informe de postura de la flota", + "subtitle": "Qué endpoints aún ejecutan herramientas de administración de la competencia y dónde es seguro retirarlas.", + "labelCategory": "Categoría", + "labelWindow": "Ventana de vigencia", + "windowDays_one": "{{count}} día", + "windowDays_other": "{{count}} días", + "refresh": "Actualizar", + "exportCsv": "Exportar CSV", + "loading": "Cargando postura de la flota…", + "retry": "Reintentar", + "totalDevices": "Dispositivos inscritos", + "detected": "Producto de la competencia detectado", + "cleanFresh": "Escaneado, nada detectado", + "stale": "Escaneos obsoletos", + "neverScanned": "Nunca escaneado", + "staleHint": "Escaneo anterior a la ventana de vigencia.", + "neverScannedHint": "Postura desconocida: verifique la inscripción antes de desinstalar nada.", + "freshOf": "{{fresh}} vigentes de {{total}}", + "zeroCaveat": "Cero detecciones, pero {{unknown}} dispositivo(s) sin escaneo vigente: verifíquelos antes de dar la migración por completa.", + "securityTitle": "Agentes de acceso remoto huérfanos", + "securityBody": "Estas herramientas de acceso remoto se instalan aparte del RMM y sobreviven a su desinstalación. Un agente de acceso remoto desatendido que nadie supervisa es una exposición permanente: revise cada uno antes de cerrar una migración.", + "deviceCount_one": "{{count}} dispositivo", + "deviceCount_other": "{{count}} dispositivos", + "progressEnrolled_one": "{{count}} inscrito en Breeze", + "progressEnrolled_other": "{{count}} inscritos en Breeze", + "progressBoth_one": "{{count}} con ambos (en plena migración)", + "progressBoth_other": "{{count}} con ambos (en plena migración)", + "progressBreezeOnly_one": "{{count}} solo Breeze (verificado limpio)", + "progressBreezeOnly_other": "{{count}} solo Breeze (verificado limpio)", + "progressUnknown_one": "{{count}} desconocido (nunca escaneado u obsoleto)", + "progressUnknown_other": "{{count}} desconocidos (nunca escaneados u obsoletos)", + "noDetections": "No se detectaron productos de {{category}} en los dispositivos escaneados.", + "colProduct": "Producto", + "colDetectionStatus": "Estado de detección", + "colDevices": "Dispositivos", + "colFresh": "Escaneos vigentes", + "colHostname": "Nombre de host", + "colOrganization": "Organización", + "colDetectedVersion": "Versión detectada", + "colScannedAt": "Último escaneo", + "loadMore": "Cargar más" } } diff --git a/apps/web/src/locales/fr-CA/common.json b/apps/web/src/locales/fr-CA/common.json index 233c017dc8..382b5e4def 100644 --- a/apps/web/src/locales/fr-CA/common.json +++ b/apps/web/src/locales/fr-CA/common.json @@ -1,5 +1,6 @@ { "nav": { + "fleetPosture": "Posture du parc", "dashboard": "Tableau de bord", "devices": "Dispositifs", "alerts": "Alertes", diff --git a/apps/web/src/locales/fr-CA/devices.json b/apps/web/src/locales/fr-CA/devices.json index 8d1868d338..a74eb157eb 100644 --- a/apps/web/src/locales/fr-CA/devices.json +++ b/apps/web/src/locales/fr-CA/devices.json @@ -2236,5 +2236,49 @@ "title": "Inscription potentiellement en double", "unknownDevice": "l’appareil précédent", "viewOldDevice": "Voir l’ancien appareil" + }, + "fleetPosture": { + "securityUnavailable": "Impossible de charger les constats de sécurité d'accès distant pour cette vue.", + "title": "Rapport de posture du parc", + "subtitle": "Quels terminaux exécutent encore un outil de gestion concurrent — et où sa mise hors service est-elle sûre.", + "labelCategory": "Catégorie", + "labelWindow": "Fenêtre de fraîcheur", + "windowDays_one": "{{count}} jour", + "windowDays_other": "{{count}} jours", + "refresh": "Actualiser", + "exportCsv": "Exporter en CSV", + "loading": "Chargement de la posture du parc…", + "retry": "Réessayer", + "totalDevices": "Appareils inscrits", + "detected": "Produit concurrent détecté", + "cleanFresh": "Analysé, rien détecté", + "stale": "Analyses obsolètes", + "neverScanned": "Jamais analysé", + "staleHint": "Analyse antérieure à la fenêtre de fraîcheur.", + "neverScannedHint": "Posture inconnue — vérifiez l'inscription avant toute désinstallation.", + "freshOf": "{{fresh}} récents sur {{total}}", + "zeroCaveat": "Zéro détection, mais {{unknown}} appareil(s) sans analyse récente — vérifiez-les avant de considérer la migration comme terminée.", + "securityTitle": "Agents d'accès distant orphelins", + "securityBody": "Ces outils d'accès distant s'installent indépendamment du RMM et survivent à sa désinstallation. Un agent d'accès distant laissé sans surveillance constitue une exposition permanente — examinez chacun avant de clore une migration.", + "deviceCount_one": "{{count}} appareil", + "deviceCount_other": "{{count}} appareils", + "progressEnrolled_one": "{{count}} inscrit dans Breeze", + "progressEnrolled_other": "{{count}} inscrits dans Breeze", + "progressBoth_one": "{{count}} avec les deux (migration en cours)", + "progressBoth_other": "{{count}} avec les deux (migration en cours)", + "progressBreezeOnly_one": "{{count}} Breeze uniquement (vérifié propre)", + "progressBreezeOnly_other": "{{count}} Breeze uniquement (vérifiés propres)", + "progressUnknown_one": "{{count}} inconnu (jamais analysé ou obsolète)", + "progressUnknown_other": "{{count}} inconnus (jamais analysés ou obsolètes)", + "noDetections": "Aucun produit {{category}} détecté sur les appareils analysés.", + "colProduct": "Produit", + "colDetectionStatus": "Statut de détection", + "colDevices": "Appareils", + "colFresh": "Analyses récentes", + "colHostname": "Nom d'hôte", + "colOrganization": "Organisation", + "colDetectedVersion": "Version détectée", + "colScannedAt": "Dernière analyse", + "loadMore": "Charger plus" } } diff --git a/apps/web/src/locales/fr-FR/common.json b/apps/web/src/locales/fr-FR/common.json index ab0e60a50b..3994bc1297 100644 --- a/apps/web/src/locales/fr-FR/common.json +++ b/apps/web/src/locales/fr-FR/common.json @@ -1,5 +1,6 @@ { "nav": { + "fleetPosture": "Posture du parc", "dashboard": "Tableau de bord", "devices": "Dispositifs", "alerts": "Alertes", diff --git a/apps/web/src/locales/fr-FR/devices.json b/apps/web/src/locales/fr-FR/devices.json index 4515f7d1bf..13f433239e 100644 --- a/apps/web/src/locales/fr-FR/devices.json +++ b/apps/web/src/locales/fr-FR/devices.json @@ -2236,5 +2236,49 @@ "title": "Inscription potentiellement en double", "unknownDevice": "l’appareil précédent", "viewOldDevice": "Voir l’ancien appareil" + }, + "fleetPosture": { + "securityUnavailable": "Impossible de charger les constats de sécurité d'accès distant pour cette vue.", + "title": "Rapport de posture du parc", + "subtitle": "Quels terminaux exécutent encore un outil de gestion concurrent — et où sa mise hors service est-elle sûre.", + "labelCategory": "Catégorie", + "labelWindow": "Fenêtre de fraîcheur", + "windowDays_one": "{{count}} jour", + "windowDays_other": "{{count}} jours", + "refresh": "Actualiser", + "exportCsv": "Exporter en CSV", + "loading": "Chargement de la posture du parc…", + "retry": "Réessayer", + "totalDevices": "Appareils inscrits", + "detected": "Produit concurrent détecté", + "cleanFresh": "Analysé, rien détecté", + "stale": "Analyses obsolètes", + "neverScanned": "Jamais analysé", + "staleHint": "Analyse antérieure à la fenêtre de fraîcheur.", + "neverScannedHint": "Posture inconnue — vérifiez l'inscription avant toute désinstallation.", + "freshOf": "{{fresh}} récents sur {{total}}", + "zeroCaveat": "Zéro détection, mais {{unknown}} appareil(s) sans analyse récente — vérifiez-les avant de considérer la migration comme terminée.", + "securityTitle": "Agents d'accès distant orphelins", + "securityBody": "Ces outils d'accès distant s'installent indépendamment du RMM et survivent à sa désinstallation. Un agent d'accès distant laissé sans surveillance constitue une exposition permanente — examinez chacun avant de clore une migration.", + "deviceCount_one": "{{count}} appareil", + "deviceCount_other": "{{count}} appareils", + "progressEnrolled_one": "{{count}} inscrit dans Breeze", + "progressEnrolled_other": "{{count}} inscrits dans Breeze", + "progressBoth_one": "{{count}} avec les deux (migration en cours)", + "progressBoth_other": "{{count}} avec les deux (migration en cours)", + "progressBreezeOnly_one": "{{count}} Breeze uniquement (vérifié propre)", + "progressBreezeOnly_other": "{{count}} Breeze uniquement (vérifiés propres)", + "progressUnknown_one": "{{count}} inconnu (jamais analysé ou obsolète)", + "progressUnknown_other": "{{count}} inconnus (jamais analysés ou obsolètes)", + "noDetections": "Aucun produit {{category}} détecté sur les appareils analysés.", + "colProduct": "Produit", + "colDetectionStatus": "Statut de détection", + "colDevices": "Appareils", + "colFresh": "Analyses récentes", + "colHostname": "Nom d'hôte", + "colOrganization": "Organisation", + "colDetectedVersion": "Version détectée", + "colScannedAt": "Dernière analyse", + "loadMore": "Charger plus" } } diff --git a/apps/web/src/locales/it-IT/common.json b/apps/web/src/locales/it-IT/common.json index 645432b177..b9e842aabd 100644 --- a/apps/web/src/locales/it-IT/common.json +++ b/apps/web/src/locales/it-IT/common.json @@ -1,5 +1,6 @@ { "nav": { + "fleetPosture": "Postura della flotta", "dashboard": "Dashboard", "devices": "Dispositivi", "alerts": "Avvisi", diff --git a/apps/web/src/locales/it-IT/devices.json b/apps/web/src/locales/it-IT/devices.json index 6daa742d34..06310eed70 100644 --- a/apps/web/src/locales/it-IT/devices.json +++ b/apps/web/src/locales/it-IT/devices.json @@ -2236,5 +2236,49 @@ "title": "Possibile registrazione duplicata", "unknownDevice": "il dispositivo precedente", "viewOldDevice": "Visualizza il vecchio dispositivo" + }, + "fleetPosture": { + "securityUnavailable": "Impossibile caricare i rilievi di sicurezza sull'accesso remoto per questa vista.", + "title": "Report sulla postura della flotta", + "subtitle": "Quali endpoint eseguono ancora strumenti di gestione concorrenti — e dove è sicuro dismetterli.", + "labelCategory": "Categoria", + "labelWindow": "Finestra di attualità", + "windowDays_one": "{{count}} giorno", + "windowDays_other": "{{count}} giorni", + "refresh": "Aggiorna", + "exportCsv": "Esporta CSV", + "loading": "Caricamento postura della flotta…", + "retry": "Riprova", + "totalDevices": "Dispositivi registrati", + "detected": "Prodotto concorrente rilevato", + "cleanFresh": "Scansionato, nulla rilevato", + "stale": "Scansioni obsolete", + "neverScanned": "Mai scansionato", + "staleHint": "Scansione precedente alla finestra di attualità.", + "neverScannedHint": "Postura sconosciuta: verificare la registrazione prima di disinstallare qualsiasi cosa.", + "freshOf": "{{fresh}} recenti su {{total}}", + "zeroCaveat": "Zero rilevamenti, ma {{unknown}} dispositivo/i senza scansione recente: verificarli prima di considerare completata la migrazione.", + "securityTitle": "Agenti di accesso remoto orfani", + "securityBody": "Questi strumenti di accesso remoto si installano separatamente dall'RMM e sopravvivono alla sua disinstallazione. Un agente di accesso remoto incustodito che nessuno monitora è un'esposizione permanente: esaminarli uno a uno prima di chiudere una migrazione.", + "deviceCount_one": "{{count}} dispositivo", + "deviceCount_other": "{{count}} dispositivi", + "progressEnrolled_one": "{{count}} registrato in Breeze", + "progressEnrolled_other": "{{count}} registrati in Breeze", + "progressBoth_one": "{{count}} con entrambi (migrazione in corso)", + "progressBoth_other": "{{count}} con entrambi (migrazione in corso)", + "progressBreezeOnly_one": "{{count}} solo Breeze (verificato pulito)", + "progressBreezeOnly_other": "{{count}} solo Breeze (verificati puliti)", + "progressUnknown_one": "{{count}} sconosciuto (mai scansionato oppure obsoleto)", + "progressUnknown_other": "{{count}} sconosciuti (mai scansionati oppure obsoleti)", + "noDetections": "Nessun prodotto {{category}} rilevato sui dispositivi scansionati.", + "colProduct": "Prodotto", + "colDetectionStatus": "Stato del rilevamento", + "colDevices": "Dispositivi", + "colFresh": "Scansioni recenti", + "colHostname": "Nome host", + "colOrganization": "Organizzazione", + "colDetectedVersion": "Versione rilevata", + "colScannedAt": "Ultima scansione", + "loadMore": "Carica altro" } } diff --git a/apps/web/src/locales/pt-BR/common.json b/apps/web/src/locales/pt-BR/common.json index 16472cce00..0aac710940 100644 --- a/apps/web/src/locales/pt-BR/common.json +++ b/apps/web/src/locales/pt-BR/common.json @@ -1,5 +1,6 @@ { "nav": { + "fleetPosture": "Postura da frota", "dashboard": "Painel", "devices": "Dispositivos", "alerts": "Alertas", diff --git a/apps/web/src/locales/pt-BR/devices.json b/apps/web/src/locales/pt-BR/devices.json index 42b95444b9..e89df6c397 100644 --- a/apps/web/src/locales/pt-BR/devices.json +++ b/apps/web/src/locales/pt-BR/devices.json @@ -2236,5 +2236,49 @@ "title": "Possível registro duplicado", "unknownDevice": "o dispositivo anterior", "viewOldDevice": "Ver dispositivo antigo" + }, + "fleetPosture": { + "securityUnavailable": "Não foi possível carregar os achados de segurança de acesso remoto para esta visualização.", + "title": "Relatório de postura da frota", + "subtitle": "Quais endpoints ainda executam ferramentas de gerenciamento concorrentes — e onde é seguro desativá-las.", + "labelCategory": "Categoria", + "labelWindow": "Janela de atualidade", + "windowDays_one": "{{count}} dia", + "windowDays_other": "{{count}} dias", + "refresh": "Atualizar", + "exportCsv": "Exportar CSV", + "loading": "Carregando postura da frota…", + "retry": "Tentar novamente", + "totalDevices": "Dispositivos inscritos", + "detected": "Produto concorrente detectado", + "cleanFresh": "Verificado, nada detectado", + "stale": "Verificações desatualizadas", + "neverScanned": "Nunca verificado", + "staleHint": "Verificação anterior à janela de atualidade.", + "neverScannedHint": "Postura desconhecida — confirme a inscrição antes de desinstalar qualquer coisa.", + "freshOf": "{{fresh}} recentes de {{total}}", + "zeroCaveat": "Zero detecções, mas {{unknown}} dispositivo(s) sem verificação recente — confirme antes de considerar a migração concluída.", + "securityTitle": "Agentes de acesso remoto órfãos", + "securityBody": "Essas ferramentas de acesso remoto são instaladas separadamente do RMM e sobrevivem à sua desinstalação. Um agente de acesso remoto sem supervisão é uma exposição permanente — revise cada um antes de encerrar uma migração.", + "deviceCount_one": "{{count}} dispositivo", + "deviceCount_other": "{{count}} dispositivos", + "progressEnrolled_one": "{{count}} inscrito no Breeze", + "progressEnrolled_other": "{{count}} inscritos no Breeze", + "progressBoth_one": "{{count}} com ambos (migração em andamento)", + "progressBoth_other": "{{count}} com ambos (migração em andamento)", + "progressBreezeOnly_one": "{{count}} somente Breeze (verificado limpo)", + "progressBreezeOnly_other": "{{count}} somente Breeze (verificados limpos)", + "progressUnknown_one": "{{count}} desconhecido (nunca verificado ou desatualizado)", + "progressUnknown_other": "{{count}} desconhecidos (nunca verificados ou desatualizados)", + "noDetections": "Nenhum produto de {{category}} detectado nos dispositivos verificados.", + "colProduct": "Produto", + "colDetectionStatus": "Status da detecção", + "colDevices": "Dispositivos", + "colFresh": "Verificações recentes", + "colHostname": "Nome do host", + "colOrganization": "Organização", + "colDetectedVersion": "Versão detectada", + "colScannedAt": "Última verificação", + "loadMore": "Carregar mais" } } diff --git a/apps/web/src/pages/devices/posture.astro b/apps/web/src/pages/devices/posture.astro new file mode 100644 index 0000000000..b5d90187b4 --- /dev/null +++ b/apps/web/src/pages/devices/posture.astro @@ -0,0 +1,13 @@ +--- +import DashboardLayout from '../../layouts/DashboardLayout.astro'; +import FleetPostureReport from '../../components/devices/FleetPostureReport'; +import Breadcrumbs from '../../components/layout/Breadcrumbs'; +--- + + + + +