Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .claude/skills/check-results-service/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -43,6 +48,7 @@ function source(slug: string, connected: boolean, name = slug) {
connectionId: connected ? `conn_${slug}` : null,
lastSyncAt: null,
nextSyncAt: null,
category,
};
}

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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);
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<IntegrationCategory>([
'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.
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 };
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }],
};
}
Expand All @@ -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([
Expand All @@ -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',
);
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import type { Prisma } from '@db';
import {
registry,
type IntegrationCategory,
type IntegrationCheck,
type IntegrationManifest,
type TaskTemplateId,
Expand All @@ -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'). */
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -110,6 +120,7 @@ export class CheckResultsService {
connectionId: connection?.id ?? null,
lastSyncAt: connection?.lastSyncAt?.toISOString() ?? null,
nextSyncAt: connection?.nextSyncAt?.toISOString() ?? null,
category: manifest.category,
};
});
}
Expand All @@ -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,
Expand Down
Loading
Loading