diff --git a/.claude/skills/check-results-service/SKILL.md b/.claude/skills/check-results-service/SKILL.md index f79904ebff..5caf3db7e7 100644 --- a/.claude/skills/check-results-service/SKILL.md +++ b/.claude/skills/check-results-service/SKILL.md @@ -52,6 +52,36 @@ Notes: - Empty array = "no data" (source not bound/connected, or never really ran). Never throws for "no results". +## Person-scoped results: the shape contract + +Checks about PEOPLE (employee access, 2FA/MFA, training) follow a standard emission shape — +this section is the canonical definition of it: + +- **One row per person** — never a single aggregate row with a roster buried in `evidence`. +- **`resourceType: 'user'`** (exactly this string). +- **`resourceId` = the person's email, lowercased + trimmed.** Fallback `username || id` + only when the provider genuinely exposes no email (such rows won't join to members — + acceptable, still visible in evidence views). +- **`evidence`** carries what the provider knows: `email`, `name`, `role`, `isAdmin`, + `status`, `lastLogin`, plus a `checkedAt` timestamp. +- **Access/inventory rows always emit as pass** (having access is information, not a + violation); compliance-gate checks (2FA, training) pass/fail per person; error paths + (bad creds, missing scopes) stay org-level rows. + +So a feature joining check results to org members does exactly this — no parsing, no AI: + +```ts +const rows = await checkResults.getLatestResultsForTask({ + organizationId, taskTemplateId, sourceSlug, resourceType: 'user', +}); +const forMember = rows.filter((r) => r.resourceId === member.email.toLowerCase()); +``` + +If a source returns zero `'user'` rows, its check hasn't been normalized to the standard +yet (or genuinely has no per-person data) — render that as "no per-person data from this +source", and fix the CHECK to emit the shape above. Never work around it by parsing +aggregate evidence in the feature. + ## The envelope you get back ```ts diff --git a/apps/api/src/integration-platform/controllers/two-factor-source.controller.spec.ts b/apps/api/src/integration-platform/controllers/two-factor-source.controller.spec.ts index 2abc178348..a7180c5ddf 100644 --- a/apps/api/src/integration-platform/controllers/two-factor-source.controller.spec.ts +++ b/apps/api/src/integration-platform/controllers/two-factor-source.controller.spec.ts @@ -33,7 +33,12 @@ function makeController() { return new TwoFactorSourceController(mockCheckResults as never); } -function source(slug: string, connected: boolean, name = slug) { +function source( + slug: string, + connected: boolean, + name = slug, + category = 'Identity & Access', +) { return { slug, name, @@ -43,6 +48,7 @@ function source(slug: string, connected: boolean, name = slug) { connectionId: connected ? `conn_${slug}` : null, lastSyncAt: null, nextSyncAt: null, + category, }; } @@ -99,6 +105,19 @@ describe('TwoFactorSourceController.setTwoFactorSource', () => { expect(mockOrgUpdate).not.toHaveBeenCalled(); }); + it('rejects a connected, bound provider that is not an identity provider', async () => { + // A source can be bound to the 2FA task and connected, yet not be an identity + // provider (so it can't align to the People roster) — it must not be settable. + mockCheckResults.listSourcesBoundToTask.mockResolvedValue([ + source('google-workspace', true, 'Google Workspace', 'Identity & Access'), + source('github', true, 'GitHub', 'Development'), + ]); + await expect( + makeController().setTwoFactorSource(ORG, { provider: 'github' }), + ).rejects.toBeInstanceOf(HttpException); + expect(mockOrgUpdate).not.toHaveBeenCalled(); + }); + it('sets a valid, connected, bound provider', async () => { mockCheckResults.listSourcesBoundToTask.mockResolvedValue([ source('google-workspace', true), @@ -163,16 +182,18 @@ describe('TwoFactorSourceController.setTwoFactorSource', () => { }); describe('TwoFactorSourceController.getAvailableTwoFactorSources', () => { - it('returns bound sources with connection state (without the internal checkId)', async () => { + it('offers only identity-provider sources, without internal fields', async () => { mockCheckResults.listSourcesBoundToTask.mockResolvedValue([ - source('google-workspace', true, 'Google Workspace'), - source('github', false, 'GitHub'), + source('google-workspace', true, 'Google Workspace', 'Identity & Access'), + // Bound to the 2FA task but not an identity provider — must be excluded. + source('github', false, 'GitHub', 'Development'), ]); const { providers } = await makeController().getAvailableTwoFactorSources(ORG); - expect(providers.map((p) => p.slug)).toEqual(['google-workspace', 'github']); + expect(providers.map((p) => p.slug)).toEqual(['google-workspace']); expect(providers[0]).not.toHaveProperty('checkId'); + expect(providers[0]).not.toHaveProperty('category'); expect(providers[0].connected).toBe(true); }); }); diff --git a/apps/api/src/integration-platform/controllers/two-factor-source.controller.ts b/apps/api/src/integration-platform/controllers/two-factor-source.controller.ts index deba61f724..b335a833c2 100644 --- a/apps/api/src/integration-platform/controllers/two-factor-source.controller.ts +++ b/apps/api/src/integration-platform/controllers/two-factor-source.controller.ts @@ -17,13 +17,27 @@ import { } from '@nestjs/swagger'; import { IsOptional, IsString } from 'class-validator'; import { db } from '@db'; -import { TASK_TEMPLATES } from '@trycompai/integration-platform'; +import { + TASK_TEMPLATES, + type IntegrationCategory, +} from '@trycompai/integration-platform'; import { HybridAuthGuard } from '../../auth/hybrid-auth.guard'; import { PermissionGuard } from '../../auth/permission.guard'; import { RequirePermission } from '../../auth/require-permission.decorator'; import { OrganizationId } from '../../auth/auth-context.decorator'; import { CheckResultsService } from '../services/check-results.service'; +/** + * Only identity-provider integrations are meaningful per-employee 2FA sources: + * they cover the whole workforce and key each person by email, so their results + * align with the People roster. Gating on category (rather than a hand-maintained + * list of slugs) means any future IdP in this category qualifies automatically, + * while non-identity integrations that merely expose a 2FA check do not. + */ +const TWO_FA_SOURCE_CATEGORIES = new Set([ + 'Identity & Access', +]); + // Body for POST /v1/integrations/sync/two-factor-source. Pass a provider slug to // set the org's 2FA source, or null/omit to clear it. Class (not inline type) so // swagger + the ValidationPipe whitelist accept it. @@ -123,10 +137,14 @@ export class TwoFactorSourceController { } if (provider) { - const sources = await this.checkResults.listSourcesBoundToTask( - organizationId, - TASK_TEMPLATES.twoFactorAuth, - ); + // Mirror the selector: only identity-provider sources are acceptable + // (see TWO_FA_SOURCE_CATEGORIES / getAvailableTwoFactorSources). + const sources = ( + await this.checkResults.listSourcesBoundToTask( + organizationId, + TASK_TEMPLATES.twoFactorAuth, + ) + ).filter((s) => TWO_FA_SOURCE_CATEGORIES.has(s.category)); const source = sources.find((s) => s.slug === provider); if (!source) { throw new HttpException( @@ -174,16 +192,20 @@ export class TwoFactorSourceController { organizationId, TASK_TEMPLATES.twoFactorAuth, ); - // Expose only what the selector needs (drop the internal checkId). - const providers = sources.map((s) => ({ - slug: s.slug, - name: s.name, - logoUrl: s.logoUrl, - connected: s.connected, - connectionId: s.connectionId, - lastSyncAt: s.lastSyncAt, - nextSyncAt: s.nextSyncAt, - })); + // Offer only identity-provider sources (see TWO_FA_SOURCE_CATEGORIES) — an + // integration that merely exposes a 2FA check but isn't a workforce identity + // provider doesn't map cleanly onto the People roster, so it's not shown. + const providers = sources + .filter((s) => TWO_FA_SOURCE_CATEGORIES.has(s.category)) + .map((s) => ({ + slug: s.slug, + name: s.name, + logoUrl: s.logoUrl, + connected: s.connected, + connectionId: s.connectionId, + lastSyncAt: s.lastSyncAt, + nextSyncAt: s.nextSyncAt, + })); return { providers }; } diff --git a/apps/api/src/integration-platform/repositories/check-run.repository.ts b/apps/api/src/integration-platform/repositories/check-run.repository.ts index 8d360adbe6..f58c46a750 100644 --- a/apps/api/src/integration-platform/repositories/check-run.repository.ts +++ b/apps/api/src/integration-platform/repositories/check-run.repository.ts @@ -340,6 +340,10 @@ export class CheckRunRepository { checkRunId: run.id, ...(resourceType ? { resourceType } : {}), }, + // Deterministic order — without it Postgres may return the same run's + // rows in a different order per query, which breaks consumers that key + // UI state off row identity/position. + orderBy: { id: 'asc' }, }); return { run, results }; } diff --git a/apps/api/src/integration-platform/services/check-results.service.spec.ts b/apps/api/src/integration-platform/services/check-results.service.spec.ts index 05a89dac1f..ea92e96b62 100644 --- a/apps/api/src/integration-platform/services/check-results.service.spec.ts +++ b/apps/api/src/integration-platform/services/check-results.service.spec.ts @@ -30,11 +30,12 @@ function makeService() { ); } -function boundManifest(id: string, name = id) { +function boundManifest(id: string, name = id, category = 'Identity & Access') { return { id, name, logoUrl: null, + category, checks: [{ id: 'two-factor-auth', taskMapping: TASK_TEMPLATES.twoFactorAuth }], }; } @@ -57,9 +58,9 @@ beforeEach(() => { describe('CheckResultsService.listSourcesBoundToTask', () => { it('returns only manifests bound to the task, with connection state + checkId', async () => { mockGetActiveManifests.mockReturnValue([ - boundManifest('google-workspace', 'Google Workspace'), + boundManifest('google-workspace', 'Google Workspace', 'Identity & Access'), unboundManifest('slack'), - boundManifest('github', 'GitHub'), + boundManifest('github', 'GitHub', 'Development'), ]); mockConnRepo.findActiveBySlugsAndOrg.mockResolvedValue( new Map([ @@ -80,8 +81,14 @@ describe('CheckResultsService.listSourcesBoundToTask', () => { connected: true, connectionId: 'c1', checkId: 'two-factor-auth', + // Category surfaces from the manifest so features can gate on it. + category: 'Identity & Access', }); expect(sources.find((s) => s.slug === 'github')?.connected).toBe(false); + // Category flows through for every bound source. + expect(sources.find((s) => s.slug === 'github')?.category).toBe( + 'Development', + ); }); }); diff --git a/apps/api/src/integration-platform/services/check-results.service.ts b/apps/api/src/integration-platform/services/check-results.service.ts index 313a42d94e..fe5f0c4c26 100644 --- a/apps/api/src/integration-platform/services/check-results.service.ts +++ b/apps/api/src/integration-platform/services/check-results.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import type { Prisma } from '@db'; import { registry, + type IntegrationCategory, type IntegrationCheck, type IntegrationManifest, type TaskTemplateId, @@ -18,6 +19,8 @@ import { ConnectionRepository } from '../repositories/connection.repository'; * (e.g. with a zod schema) and reads only the fields it understands. */ export interface CheckResultRow { + /** Database id of this result row — unique and stable; safe as a UI key. */ + resultId: string; /** Provider-native identifier for the resource (email, bucket ARN, repo, …). */ resourceId: string; /** Kind of resource the check produced (e.g. 'user', 'bucket'). */ @@ -44,6 +47,13 @@ export interface CheckSourceInfo { connectionId: string | null; lastSyncAt: string | null; nextSyncAt: string | null; + /** + * The integration's catalog category (e.g. 'Identity & Access', 'Development'). + * Descriptive manifest metadata — features that only make sense for certain + * kinds of source (e.g. the People-tab 2FA column, which wants identity + * providers) filter on this. + */ + category: IntegrationCategory; } /** @@ -110,6 +120,7 @@ export class CheckResultsService { connectionId: connection?.id ?? null, lastSyncAt: connection?.lastSyncAt?.toISOString() ?? null, nextSyncAt: connection?.nextSyncAt?.toISOString() ?? null, + category: manifest.category, }; }); } @@ -136,6 +147,7 @@ export class CheckResultsService { if (!latest) return []; return latest.results.map((r) => ({ + resultId: r.id, resourceId: r.resourceId, resourceType: r.resourceType, passed: r.passed, diff --git a/apps/api/src/people/people-access.service.spec.ts b/apps/api/src/people/people-access.service.spec.ts new file mode 100644 index 0000000000..cebc43fe62 --- /dev/null +++ b/apps/api/src/people/people-access.service.spec.ts @@ -0,0 +1,151 @@ +jest.mock('@db', () => ({ db: { member: { findFirst: jest.fn() } } })); + +import { NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import type { + CheckResultRow, + CheckResultsService, +} from '../integration-platform/services/check-results.service'; +import { PeopleAccessService } from './people-access.service'; + +const memberFindFirst = db.member.findFirst as jest.Mock; + +function row(partial: Partial): CheckResultRow { + return { + resultId: 'icr_1', + resourceId: 'org', + resourceType: 'organization', + passed: true, + title: 'Access List', + description: null, + evidence: null, + collectedAt: new Date('2026-07-01T00:00:00Z'), + runId: 'run_1', + connectionId: 'conn_1', + ...partial, + }; +} + +const SOURCE = { + slug: 'google-workspace', + name: 'Google Workspace', + logoUrl: 'https://logo', + connected: true, + connectionId: 'conn_1', + checkId: 'employee-access', +}; + +describe('PeopleAccessService.getMemberAccess', () => { + const checkResults = { + listSourcesBoundToTask: jest.fn(), + getLatestResultsByCheck: jest.fn(), + }; + const service = new PeopleAccessService(checkResults as unknown as CheckResultsService); + + beforeEach(() => { + jest.clearAllMocks(); + memberFindFirst.mockResolvedValue({ id: 'mem_1', user: { email: 'Jane@X.com ' } }); + checkResults.listSourcesBoundToTask.mockResolvedValue([SOURCE]); + }); + + it('404s when the member is not in this organization', async () => { + memberFindFirst.mockResolvedValue(null); + await expect(service.getMemberAccess('org_1', 'mem_x')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('matches per-user rows by lowercased email and builds display entries', async () => { + checkResults.getLatestResultsByCheck.mockResolvedValue([ + row({ + resourceType: 'user', + resourceId: 'jane@x.com', + description: 'Jane has access to Google Workspace as Super Admin', + evidence: { + email: 'jane@x.com', + name: 'Jane', + role: 'Super Admin', + isAdmin: true, + orgUnit: null, + checkedAt: '2026-07-01T00:00:00Z', + roles: ['Super Admin'], + }, + }), + row({ resourceType: 'user', resourceId: 'other@x.com' }), + ]); + + const { sources } = await service.getMemberAccess('org_1', 'mem_1'); + + expect(sources).toHaveLength(1); + expect(sources[0].matchType).toBe('matched'); + expect(sources[0].entries).toHaveLength(1); + const entry = sources[0].entries[0]; + expect(entry.id).toBe('icr_1'); + expect(entry.summary).toBe('Jane has access to Google Workspace as Super Admin'); + // Primitive evidence values become labeled fields; nulls, arrays, and + // timestamp keys are excluded; raw evidence is passed through for auditors. + expect(entry.fields).toEqual({ + Email: 'jane@x.com', + Name: 'Jane', + Role: 'Super Admin', + 'Is Admin': 'true', + }); + expect(entry.raw).toMatchObject({ email: 'jane@x.com' }); + }); + + it('reports not-matched when per-user rows exist but none for this member', async () => { + checkResults.getLatestResultsByCheck.mockResolvedValue([ + row({ resourceType: 'user', resourceId: 'other@x.com' }), + ]); + + const { sources } = await service.getMemberAccess('org_1', 'mem_1'); + + expect(sources[0].matchType).toBe('not-matched'); + expect(sources[0].entries).toHaveLength(0); + }); + + it('reports no-person-data when the check ran but emits no user rows', async () => { + checkResults.getLatestResultsByCheck.mockResolvedValue([ + row({ resourceType: 'organization', resourceId: 'google-workspace' }), + ]); + + const { sources } = await service.getMemberAccess('org_1', 'mem_1'); + + expect(sources[0].matchType).toBe('no-person-data'); + expect(sources[0].lastCheckedAt).toBe('2026-07-01T00:00:00.000Z'); + }); + + it('reports no-data when the check has never really run', async () => { + checkResults.getLatestResultsByCheck.mockResolvedValue([]); + + const { sources } = await service.getMemberAccess('org_1', 'mem_1'); + + expect(sources[0].matchType).toBe('no-data'); + expect(sources[0].lastCheckedAt).toBeNull(); + }); + + it('skips sources that are not connected', async () => { + checkResults.listSourcesBoundToTask.mockResolvedValue([ + SOURCE, + { ...SOURCE, slug: 'slack', connected: false, connectionId: null }, + ]); + checkResults.getLatestResultsByCheck.mockResolvedValue([]); + + const { sources } = await service.getMemberAccess('org_1', 'mem_1'); + + expect(sources.map((s) => s.slug)).toEqual(['google-workspace']); + expect(checkResults.getLatestResultsByCheck).toHaveBeenCalledTimes(1); + }); + + it('never matches when the member has no email', async () => { + memberFindFirst.mockResolvedValue({ id: 'mem_1', user: { email: null } }); + checkResults.getLatestResultsByCheck.mockResolvedValue([ + row({ resourceType: 'user', resourceId: 'jane@x.com' }), + ]); + + const { sources } = await service.getMemberAccess('org_1', 'mem_1'); + + expect(sources[0].matchType).toBe('not-matched'); + expect(sources[0].entries).toHaveLength(0); + }); +}); diff --git a/apps/api/src/people/people-access.service.ts b/apps/api/src/people/people-access.service.ts new file mode 100644 index 0000000000..1c5e39fbc3 --- /dev/null +++ b/apps/api/src/people/people-access.service.ts @@ -0,0 +1,138 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { TASK_TEMPLATES } from '@trycompai/integration-platform'; +import { + CheckResultsService, + type CheckResultRow, +} from '../integration-platform/services/check-results.service'; + +/** One access record for the member from one integration's check results. */ +export interface MemberAccessEntry { + /** The result row's database id — unique and stable; safe as a React key. */ + id: string; + summary: string; + /** Human-readable label -> value pairs pulled from the row's evidence. */ + fields: Record; + /** The raw result evidence, for auditors. */ + raw: unknown; +} + +/** One integration's access information for a single member. */ +export interface MemberAccessSource { + slug: string; + name: string; + logoUrl: string | null; + /** + * matched — per-user rows for this member's email were found + * not-matched — the source reports per-user rows, but none for this member + * no-person-data — the source's check ran but emits no per-user rows (its + * vendor API has no roster, or the check isn't normalized) + * no-data — the source's access check has never really run + */ + matchType: 'matched' | 'not-matched' | 'no-person-data' | 'no-data'; + entries: MemberAccessEntry[]; + lastCheckedAt: string | null; +} + +/** Evidence keys that duplicate row-level info or are noise in a field list. */ +const HIDDEN_EVIDENCE_KEYS = new Set(['checkedAt', 'fetchedAt', 'reviewedAt', 'raw']); +const MAX_FIELDS = 12; + +/** camelCase / snake_case -> "Title Case" label. */ +function labelize(key: string): string { + return key + .replace(/_/g, ' ') + .replace(/([a-z\d])([A-Z])/g, '$1 $2') + .replace(/^./, (c) => c.toUpperCase()); +} + +/** + * Flatten a result row into a display entry. Only primitive evidence values + * become fields — nested objects/arrays stay visible in `raw`. + */ +function toEntry(row: CheckResultRow): MemberAccessEntry { + const fields: Record = {}; + if (row.evidence && typeof row.evidence === 'object' && !Array.isArray(row.evidence)) { + for (const [key, value] of Object.entries(row.evidence)) { + if (Object.keys(fields).length >= MAX_FIELDS) break; + if (HIDDEN_EVIDENCE_KEYS.has(key) || value == null) continue; + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + fields[labelize(key)] = String(value); + } + } + } + return { + id: row.resultId, + summary: row.description ?? row.title, + fields, + raw: row.evidence, + }; +} + +/** + * Aggregates a member's access across every connected integration whose check + * is bound to the Employee Access evidence task. Read-only consumer of the + * universal CheckResultsService. + * + * Matching is purely deterministic: person-scoped checks emit one row per + * person with resourceType 'user' and resourceId = lowercased email (see the + * check-results-service skill), so a member's access is exactly the rows whose + * resourceId equals their email. No evidence parsing, no AI. + */ +@Injectable() +export class PeopleAccessService { + constructor(private readonly checkResults: CheckResultsService) {} + + async getMemberAccess(organizationId: string, memberId: string) { + const member = await db.member.findFirst({ + where: { id: memberId, organizationId }, + select: { id: true, user: { select: { email: true } } }, + }); + if (!member) throw new NotFoundException('Member not found'); + const memberEmail = (member.user.email ?? '').toLowerCase().trim(); + + const sources = await this.checkResults.listSourcesBoundToTask( + organizationId, + TASK_TEMPLATES.employeeAccess, + ); + + const access: MemberAccessSource[] = await Promise.all( + sources + .filter((s) => s.connected && s.connectionId) + .map(async (s) => { + const results = await this.checkResults.getLatestResultsByCheck({ + organizationId, + connectionId: s.connectionId as string, + checkId: s.checkId, + }); + const userRows = results.filter((r) => r.resourceType === 'user'); + const memberRows = memberEmail + ? userRows.filter((r) => r.resourceId.toLowerCase().trim() === memberEmail) + : []; + const lastCheckedAt = results.length + ? new Date( + Math.max(...results.map((r) => new Date(r.collectedAt).getTime())), + ).toISOString() + : null; + const matchType: MemberAccessSource['matchType'] = + results.length === 0 + ? 'no-data' + : memberRows.length > 0 + ? 'matched' + : userRows.length > 0 + ? 'not-matched' + : 'no-person-data'; + return { + slug: s.slug, + name: s.name, + logoUrl: s.logoUrl, + matchType, + entries: memberRows.map(toEntry), + lastCheckedAt, + } satisfies MemberAccessSource; + }), + ); + + return { memberId, sources: access }; + } +} diff --git a/apps/api/src/people/people.controller.spec.ts b/apps/api/src/people/people.controller.spec.ts index 7e471dddfb..c259c07d2e 100644 --- a/apps/api/src/people/people.controller.spec.ts +++ b/apps/api/src/people/people.controller.spec.ts @@ -1,6 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { PeopleService } from './people.service'; import { PeopleInviteService } from './people-invite.service'; +import { PeopleAccessService } from './people-access.service'; import { AttachmentsService } from '../attachments/attachments.service'; import type { AuthContext } from '../auth/types'; import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; @@ -90,6 +91,10 @@ describe('PeopleController', () => { deleteAttachment: jest.fn(), }; + const mockPeopleAccessService = { + getMemberAccess: jest.fn(), + }; + const mockGuard = { canActivate: jest.fn().mockReturnValue(true) }; const mockAuthContext: AuthContext = { @@ -108,6 +113,7 @@ describe('PeopleController', () => { providers: [ { provide: PeopleService, useValue: mockPeopleService }, { provide: PeopleInviteService, useValue: mockPeopleInviteService }, + { provide: PeopleAccessService, useValue: mockPeopleAccessService }, { provide: AttachmentsService, useValue: mockAttachmentsService }, ], }) diff --git a/apps/api/src/people/people.controller.ts b/apps/api/src/people/people.controller.ts index 781993a741..176c09dd16 100644 --- a/apps/api/src/people/people.controller.ts +++ b/apps/api/src/people/people.controller.ts @@ -42,6 +42,7 @@ import { UploadAttachmentDto } from '../attachments/upload-attachment.dto'; import { AttachmentEntityType } from '@db'; import { PeopleService } from './people.service'; import { PeopleInviteService } from './people-invite.service'; +import { PeopleAccessService } from './people-access.service'; import { GET_ALL_PEOPLE_RESPONSES } from './schemas/get-all-people.responses'; import { CREATE_MEMBER_RESPONSES } from './schemas/create-member.responses'; import { BULK_CREATE_MEMBERS_RESPONSES } from './schemas/bulk-create-members.responses'; @@ -62,6 +63,7 @@ export class PeopleController { constructor( private readonly peopleService: PeopleService, private readonly peopleInviteService: PeopleInviteService, + private readonly peopleAccessService: PeopleAccessService, private readonly attachmentsService: AttachmentsService, ) {} @@ -373,6 +375,37 @@ export class PeopleController { }; } + @Get(':id/access') + @RequirePermission('member', 'read') + @ApiOperation({ + summary: "Member's access across connected integrations", + description: + 'Aggregates the latest Employee Access check results from every connected integration bound to the Employee Access task, matched to the member by email.', + }) + @ApiParam(PEOPLE_PARAMS.memberId) + async getMemberAccess( + @Param('id') memberId: string, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const data = await this.peopleAccessService.getMemberAccess( + organizationId, + memberId, + ); + + return { + data, + authType: authContext.authType, + ...(authContext.userId && + authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; + } + @Get(':id/training-videos') @RequirePermission('member', 'read') @ApiOperation({ summary: 'Get training video completions for a member' }) diff --git a/apps/api/src/people/people.module.ts b/apps/api/src/people/people.module.ts index 9590239747..fcd735b374 100644 --- a/apps/api/src/people/people.module.ts +++ b/apps/api/src/people/people.module.ts @@ -1,16 +1,18 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; +import { IntegrationPlatformModule } from '../integration-platform/integration-platform.module'; import { AttachmentsModule } from '../attachments/attachments.module'; import { TimelinesModule } from '../timelines/timelines.module'; import { FleetService } from '../lib/fleet.service'; import { PeopleController } from './people.controller'; import { PeopleService } from './people.service'; import { PeopleInviteService } from './people-invite.service'; +import { PeopleAccessService } from './people-access.service'; @Module({ - imports: [AuthModule, AttachmentsModule, TimelinesModule], + imports: [AuthModule, AttachmentsModule, TimelinesModule, IntegrationPlatformModule], controllers: [PeopleController], - providers: [PeopleService, PeopleInviteService, FleetService], + providers: [PeopleService, PeopleInviteService, PeopleAccessService, FleetService], exports: [PeopleService], }) export class PeopleModule {} diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx index c526b494e7..70f5f91b86 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx @@ -18,6 +18,7 @@ import { EmployeeBackgroundCheck } from './EmployeeBackgroundCheck'; import { EmployeeDetails } from './EmployeeDetails'; import { EmployeeDevice } from './EmployeeDevice'; import { EmployeePageHeader } from './EmployeePageHeader'; +import { EmployeeAccess } from './EmployeeAccess'; import { EmployeePolicies } from './EmployeePolicies'; import { EmployeeHipaaTraining, EmployeeTrainingVideos } from './EmployeeTraining'; import { isAuditorOnly } from './isAuditorOnly'; @@ -29,6 +30,7 @@ type EmployeeTab = | 'training' | 'hipaa' | 'device' + | 'access' | 'offboarding' | 'background-check'; @@ -85,6 +87,7 @@ export function Employee({ 'training', ...(hasHipaaFramework ? (['hipaa'] as EmployeeTab[]) : []), 'device', + 'access', ...(showBackgroundCheck ? (['background-check'] as EmployeeTab[]) : []), ...(employee.offboardDate ? (['offboarding'] as EmployeeTab[]) : []), ]; @@ -154,6 +157,7 @@ export function Employee({ Training Videos {hasHipaaFramework && HIPAA Training} Device + Access {showBackgroundCheck && ( Background Check )} @@ -181,6 +185,9 @@ export function Employee({ /> )} + + + ({ mockGet: vi.fn() })); + +vi.mock('@/lib/api-client', () => ({ apiClient: { get: mockGet } })); +vi.mock('next/link', () => ({ + default: ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ), +})); +vi.mock('next/image', () => ({ + default: (props: { src: string; alt: string }) => {props.alt}, +})); + +const source = (overrides: object) => ({ + slug: 'google-workspace', + name: 'Google Workspace', + logoUrl: null, + matchType: 'matched', + entries: [{ id: 'run_1:0', summary: 'Super Admin', fields: { Role: 'Super Admin' }, raw: {} }], + lastCheckedAt: '2026-07-01T00:00:00Z', + ...overrides, +}); + +beforeEach(() => vi.clearAllMocks()); + +describe('EmployeeAccess', () => { + it('lists integrations with the member access summary and match state', async () => { + mockGet.mockResolvedValue({ + data: { data: { memberId: 'mem_1', sources: [source({}), source({ slug: 'okta', name: 'Okta', matchType: 'not-matched', entries: [] })] } }, + }); + + render(); + + await waitFor(() => expect(screen.getByText('Google Workspace')).toBeInTheDocument()); + expect(screen.getByText('Super Admin')).toBeInTheDocument(); + expect(screen.getByText('Access found')).toBeInTheDocument(); + expect(screen.getByText('No match for this member')).toBeInTheDocument(); + }); + + it('labels sources whose checks produce no per-person rows', async () => { + mockGet.mockResolvedValue({ + data: { + data: { + memberId: 'mem_3', + sources: [source({ matchType: 'no-person-data', entries: [] })], + }, + }, + }); + + render(); + + await waitFor(() => expect(screen.getByText('No per-person data')).toBeInTheDocument()); + }); + + it('expands a matched source to show fields and the raw record', async () => { + mockGet.mockResolvedValue({ + data: { + data: { + memberId: 'mem_4', + sources: [ + source({ + entries: [ + { id: 'run_1:0', summary: 'Editor seat', fields: { Role: 'Editor' }, raw: { role: 'editor' } }, + ], + }), + ], + }, + }, + }); + + render(); + + await waitFor(() => expect(screen.getByText('Google Workspace')).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: /Google Workspace/ })); + + expect(screen.getByText('Role')).toBeInTheDocument(); + expect(screen.getByText('Editor')).toBeInTheDocument(); + expect(screen.getByText('Raw record')).toBeInTheDocument(); + }); + + it('falls back to a neutral badge for an unknown matchType instead of crashing', async () => { + mockGet.mockResolvedValue({ + data: { + data: { + memberId: 'mem_5', + // A matchType this build doesn't know (API contract drift). + sources: [source({ matchType: 'something-new', entries: [] })], + }, + }, + }); + + render(); + + await waitFor(() => expect(screen.getByText('Google Workspace')).toBeInTheDocument()); + expect(screen.getByText('Check not run yet')).toBeInTheDocument(); + }); + + it('shows the connect empty state when no integration reports access', async () => { + mockGet.mockResolvedValue({ data: { data: { memberId: 'mem_2', sources: [] } } }); + + render(); + + await waitFor(() => + expect( + screen.getByText('No connected integrations report employee access yet.'), + ).toBeInTheDocument(), + ); + expect(screen.getByText('Browse integrations →')).toHaveAttribute( + 'href', + '/org_1/integrations', + ); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeAccess.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeAccess.tsx new file mode 100644 index 0000000000..336c789da0 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeAccess.tsx @@ -0,0 +1,204 @@ +'use client'; + +import Image from 'next/image'; +import Link from 'next/link'; +import { useState } from 'react'; +import useSWR from 'swr'; + +import { apiClient } from '@/lib/api-client'; +import { + Badge, + Card, + CardContent, + CardHeader, + CardTitle, + Skeleton, + Stack, + Text, +} from '@trycompai/design-system'; +import { ChevronDown, ChevronUp } from '@trycompai/design-system/icons'; + +interface MemberAccessEntry { + /** Stable unique id from the API — safe as a React key. */ + id: string; + summary: string; + fields: Record; + /** The raw result evidence, for auditors. */ + raw: unknown; +} + +interface MemberAccessSource { + slug: string; + name: string; + logoUrl: string | null; + matchType: 'matched' | 'not-matched' | 'no-person-data' | 'no-data'; + entries: MemberAccessEntry[]; + lastCheckedAt: string | null; +} + +interface MemberAccessResponse { + data: { memberId: string; sources: MemberAccessSource[] }; +} + +function useMemberAccess(memberId: string) { + return useSWR( + memberId ? ['member-access', memberId] : null, + async () => { + const response = await apiClient.get( + `/v1/people/${memberId}/access`, + ); + if (response.error || !response.data?.data) { + throw new Error(response.error || 'Failed to fetch access'); + } + return response.data.data; + }, + { revalidateOnFocus: false }, + ); +} + +const MATCH_BADGE: Record< + MemberAccessSource['matchType'], + { label: string; variant: 'accent' | 'secondary' | 'outline' } +> = { + matched: { label: 'Access found', variant: 'accent' }, + 'not-matched': { label: 'No match for this member', variant: 'secondary' }, + 'no-person-data': { label: 'No per-person data', variant: 'outline' }, + 'no-data': { label: 'Check not run yet', variant: 'outline' }, +}; + +function SourceRow({ source }: { source: MemberAccessSource }) { + const [expanded, setExpanded] = useState(false); + // TypeScript's union is compile-time only — matchType arrives as deserialized + // JSON, so an API value this build doesn't know yet must not crash the row. + const badge = MATCH_BADGE[source.matchType] ?? MATCH_BADGE['no-data']; + const canExpand = source.entries.length > 0; + + return ( +
+ + + {expanded && ( +
+ + {/* Keyed by the API's stable entry id: each entry contains an + uncontrolled
, so reconciliation must follow the + entry, not its position, when the list reorders. */} + {source.entries.map((entry) => ( +
+
+ {Object.entries(entry.fields).map(([label, value]) => ( +
+ + {label} + + {value} +
+ ))} +
+ {/* Raw record for auditors; scrolls inside itself, never the page. */} + {entry.raw != null && ( +
+ + Raw record + +
+                      {JSON.stringify(entry.raw, null, 2)}
+                    
+
+ )} +
+ ))} + {source.lastCheckedAt && ( + + Last checked {new Date(source.lastCheckedAt).toLocaleString()} + + )} + +
+ )} +
+ ); +} + +/** + * The member's access across every connected integration bound to the + * Employee Access evidence task, matched by email. Read-only. + */ +export function EmployeeAccess({ + memberId, + organizationId, +}: { + memberId: string; + organizationId: string; +}) { + const { data, error, isLoading } = useMemberAccess(memberId); + + return ( + + + Access + + + {isLoading ? ( + + ) : error ? ( + + Couldn't load access information. Try refreshing the page. + + ) : !data || data.sources.length === 0 ? ( + + + No connected integrations report employee access yet. + + + Browse integrations → + + + ) : ( + + + What this person can access in your connected tools, from each + integration's latest Employee Access check. + + {data.sources.map((source) => ( + + ))} + + )} + + + ); +} diff --git a/packages/integration-platform/src/manifests/google-workspace/__tests__/employee-access.test.ts b/packages/integration-platform/src/manifests/google-workspace/__tests__/employee-access.test.ts new file mode 100644 index 0000000000..a25eca1f9a --- /dev/null +++ b/packages/integration-platform/src/manifests/google-workspace/__tests__/employee-access.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'bun:test'; +import { employeeAccessCheck } from '../checks/employee-access'; +import type { CheckContext, CheckResult, CheckVariableValues } from '../../../types'; +import type { GoogleWorkspaceUser } from '../types'; + +const makeUser = (overrides: Partial & { primaryEmail: string }): GoogleWorkspaceUser => ({ + id: `id_${overrides.primaryEmail}`, + name: { givenName: 'Test', familyName: 'User', fullName: 'Test User' }, + isAdmin: false, + isDelegatedAdmin: false, + isEnrolledIn2Sv: true, + isEnforcedIn2Sv: true, + suspended: false, + archived: false, + creationTime: '2024-01-01T00:00:00Z', + lastLoginTime: '2026-01-01T00:00:00Z', + orgUnitPath: '/', + ...overrides, +}); + +async function runCheck( + users: GoogleWorkspaceUser[], + variables: CheckVariableValues = {}, +): Promise<{ passed: CheckResult[]; failed: CheckResult[] }> { + const passed: CheckResult[] = []; + const failed: CheckResult[] = []; + + const ctx: CheckContext = { + accessToken: 'tok', + credentials: {}, + variables, + connectionId: 'conn_1', + organizationId: 'org_1', + metadata: {}, + log: () => {}, + pass: (result) => { + passed.push(result as CheckResult); + }, + fail: (result) => { + failed.push(result as CheckResult); + }, + fetch: (async (path: string): Promise => { + if (path.includes('/roles')) { + return { items: [{ roleId: 'r1', roleName: 'Groups Admin' }] } as unknown as T; + } + if (path.includes('/roleassignments')) { + return { + items: users + .filter((u) => u.isDelegatedAdmin) + .map((u) => ({ roleId: 'r1', assignedTo: u.id })), + } as unknown as T; + } + if (path.includes('/users')) { + return { kind: 'k', users } as unknown as T; + } + throw new Error(`Unexpected fetch: ${path}`); + }) as CheckContext['fetch'], + fetchAllPages: (async () => []) as CheckContext['fetchAllPages'], + graphql: (async () => ({})) as CheckContext['graphql'], + } as CheckContext; + + await employeeAccessCheck.run(ctx); + return { passed, failed }; +} + +describe('employeeAccessCheck per-user emission', () => { + it('emits one user row per person, keyed by lowercased email', async () => { + const users = [ + makeUser({ primaryEmail: 'Admin@Example.com', isAdmin: true }), + makeUser({ primaryEmail: 'person@example.com' }), + ]; + + const { passed, failed } = await runCheck(users); + + expect(failed).toHaveLength(0); + expect(passed).toHaveLength(2); + expect(passed.every((r) => r.resourceType === 'user')).toBe(true); + expect(passed.map((r) => r.resourceId).sort()).toEqual([ + 'admin@example.com', + 'person@example.com', + ]); + }); + + it('carries role details in each row evidence', async () => { + const users = [ + makeUser({ primaryEmail: 'admin@example.com', isAdmin: true }), + makeUser({ primaryEmail: 'delegated@example.com', isDelegatedAdmin: true }), + makeUser({ primaryEmail: 'person@example.com' }), + ]; + + const { passed } = await runCheck(users); + + const byEmail = new Map(passed.map((r) => [r.resourceId, r])); + expect((byEmail.get('admin@example.com')?.evidence as { role: string }).role).toBe( + 'Super Admin', + ); + expect((byEmail.get('delegated@example.com')?.evidence as { role: string }).role).toBe( + 'Delegated Admin', + ); + expect((byEmail.get('person@example.com')?.evidence as { role: string }).role).toBe('User'); + expect((byEmail.get('person@example.com')?.evidence as { email: string }).email).toBe( + 'person@example.com', + ); + }); + + it('excludes suspended users by default (same filter as employee sync)', async () => { + const users = [ + makeUser({ primaryEmail: 'active@example.com' }), + makeUser({ primaryEmail: 'gone@example.com', suspended: true }), + ]; + + const { passed } = await runCheck(users); + + expect(passed.map((r) => r.resourceId)).toEqual(['active@example.com']); + }); + + it('emits a single org-level summary row when no users match the filters', async () => { + const users = [makeUser({ primaryEmail: 'gone@example.com', suspended: true })]; + + const { passed, failed } = await runCheck(users); + + expect(failed).toHaveLength(0); + expect(passed).toHaveLength(1); + expect(passed[0].resourceType).toBe('organization'); + expect(passed[0].resourceId).toBe('google-workspace'); + }); +}); diff --git a/packages/integration-platform/src/manifests/google-workspace/checks/employee-access.ts b/packages/integration-platform/src/manifests/google-workspace/checks/employee-access.ts index 6f9c87fa61..c80ff73ccf 100644 --- a/packages/integration-platform/src/manifests/google-workspace/checks/employee-access.ts +++ b/packages/integration-platform/src/manifests/google-workspace/checks/employee-access.ts @@ -176,27 +176,42 @@ export const employeeAccessCheck: IntegrationCheck = { }; }); - // Group users by role for summary + // Group users by role for the summary log const superAdmins = activeUsers.filter((u) => u.isAdmin); const delegatedAdmins = activeUsers.filter((u) => u.isDelegatedAdmin && !u.isAdmin); - const regularUsers = activeUsers.filter((u) => !u.isAdmin && !u.isDelegatedAdmin); - - // Pass with the full employee list as evidence - ctx.pass({ - title: 'Employee Access List', - resourceType: 'organization', - resourceId: 'google-workspace', - description: `Retrieved ${activeUsers.length} employees from Google Workspace (${superAdmins.length} super admins, ${delegatedAdmins.length} delegated admins, ${regularUsers.length} regular users)`, - evidence: { - totalUsers: activeUsers.length, - superAdminCount: superAdmins.length, - delegatedAdminCount: delegatedAdmins.length, - regularUserCount: regularUsers.length, - reviewedAt: new Date().toISOString(), - employees: employeeList, - }, - }); - ctx.log('Google Workspace Employee Access check complete'); + const checkedAt = new Date().toISOString(); + + // No users after filtering is still a completed review — emit one org-level + // row so the run never stores zero results (which would read as "no evidence"). + if (employeeList.length === 0) { + ctx.pass({ + title: 'Employee Access List', + resourceType: 'organization', + resourceId: 'google-workspace', + description: `No active users matched the configured filters (${allUsers.length} total user records inspected)`, + evidence: { totalUsers: 0, inspectedUsers: allUsers.length, checkedAt }, + }); + ctx.log('Google Workspace Employee Access check complete: 0 users after filtering'); + return; + } + + // One row per person (resourceType 'user', resourceId = lowercased email) so + // person-scoped features can join results to org members by email. Access is + // an inventory, not a violation — every person row emits as pass; error paths + // keep their org-level rows. + for (const employee of employeeList) { + ctx.pass({ + title: 'Employee Access', + resourceType: 'user', + resourceId: employee.email.toLowerCase().trim(), + description: `${employee.name} has access to Google Workspace as ${employee.role}`, + evidence: { ...employee, checkedAt }, + }); + } + + ctx.log( + `Google Workspace Employee Access check complete: ${employeeList.length} users (${superAdmins.length} super admins, ${delegatedAdmins.length} delegated admins)`, + ); }, };