Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ConnectionRepository } from '../repositories/connection.repository';
import { CredentialVaultService } from '../services/credential-vault.service';
import { OAuthCredentialsService } from '../services/oauth-credentials.service';
import { IntegrationSyncLoggerService } from '../services/integration-sync-logger.service';
import { GenericDeviceSyncService } from '../services/generic-device-sync.service';
import { GenericEmployeeSyncService } from '../services/generic-employee-sync.service';
import { DynamicIntegrationRepository } from '../repositories/dynamic-integration.repository';
import { CheckRunRepository } from '../repositories/check-run.repository';
Expand Down Expand Up @@ -104,6 +105,9 @@ describe('SyncController - Google Workspace employees', () => {
useValue: { logSync: jest.fn() },
},
{ provide: GenericEmployeeSyncService, useValue: {} },
// Required by SyncController's constructor; the suite could not
// instantiate the controller without it.
{ provide: GenericDeviceSyncService, useValue: {} },
{ provide: DynamicIntegrationRepository, useValue: {} },
{ provide: CheckRunRepository, useValue: {} },
],
Expand Down Expand Up @@ -306,7 +310,8 @@ describe('SyncController - Google Workspace employees', () => {
expect(result.skipped).toBe(0);
expect(mockedDb.member.update).toHaveBeenCalledWith({
where: { id: 'mem_back' },
data: { deactivated: false, isActive: true },
// offboardDate is cleared on reactivation (97636c4ea).
data: { deactivated: false, isActive: true, offboardDate: null },
});
});

Expand Down

This file was deleted.

23 changes: 0 additions & 23 deletions apps/api/src/integration-platform/controllers/sync-ou-filter.ts

This file was deleted.

88 changes: 42 additions & 46 deletions apps/api/src/integration-platform/controllers/sync.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,14 @@ import { GenericDeviceSyncService } from '../services/generic-device-sync.servic
import { DynamicIntegrationRepository } from '../repositories/dynamic-integration.repository';
import { CheckRunRepository } from '../repositories/check-run.repository';
import { createCheckContext } from '@trycompai/integration-platform';
import { filterUsersByOrgUnits } from './sync-ou-filter';
import {
createBearerTokenClient,
isGoogleWorkspaceUserInScope,
isGoogleWorkspaceUserSelectedBySyncTerms,
parseGoogleWorkspaceCheckUserFilter,
resolveEffectiveSyncFilterMode,
resolveGoogleWorkspaceUserFilter,
} from '@trycompai/integration-platform';

interface GoogleWorkspaceUser {
id: string;
Expand Down Expand Up @@ -285,57 +292,46 @@ export class SyncController {
unknown
>;

// Filter by organizational unit if configured
const targetOrgUnits = Array.isArray(syncVariables.target_org_units)
? (syncVariables.target_org_units as string[])
: undefined;
const ouFilteredUsers = filterUsersByOrgUnits(users, targetOrgUnits);

if (targetOrgUnits && targetOrgUnits.length > 0) {
this.logger.log(
`Google Workspace OU filter kept ${ouFilteredUsers.length}/${users.length} users (OUs: ${targetOrgUnits.join(', ')})`,
);
}
// One implementation of "who is in scope" shared with the Google Workspace
// checks (packages/integration-platform). This controller used to
// re-implement the OU + email rules, so sync and the access review could
// silently disagree about the population.
//
// Two stages, matching the previous shape exactly:
// scopedUsers — org unit / group / domain, suspended users RETAINED
// because offboarding below needs to see them.
// filteredUsers — the include/exclude selection actually imported.
const filterConfig = await resolveGoogleWorkspaceUserFilter({
client: createBearerTokenClient(accessToken, (message) =>
this.logger.warn(message),
),
config: parseGoogleWorkspaceCheckUserFilter(
syncVariables as Record<string, string | string[]>,
),
});

const rawSyncFilterMode = syncVariables.sync_user_filter_mode;
const syncFilterMode: GoogleWorkspaceSyncFilterMode =
typeof rawSyncFilterMode === 'string' &&
GOOGLE_WORKSPACE_SYNC_FILTER_MODES.has(
rawSyncFilterMode as GoogleWorkspaceSyncFilterMode,
)
? (rawSyncFilterMode as GoogleWorkspaceSyncFilterMode)
: 'all';
const excludedTerms = parseSyncFilterTerms(
syncVariables.sync_excluded_emails,
);
const includedTerms = parseSyncFilterTerms(
syncVariables.sync_included_emails,
const scopedUsers = users.filter((user) =>
isGoogleWorkspaceUserInScope(user, filterConfig),
);
const effectiveSyncFilterMode = resolveEffectiveSyncFilterMode(filterConfig);
const excludedTerms = filterConfig.excludedTerms;

let effectiveSyncFilterMode = syncFilterMode;
if (syncFilterMode === 'include' && includedTerms.length === 0) {
if (effectiveSyncFilterMode !== (filterConfig.userFilterMode ?? 'all')) {

@cubic-dev-ai cubic-dev-ai Bot Sep 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: When sync_user_filter_mode contains an unsupported value, this condition logs that an empty list caused the fallback. Emit this warning only for include mode with an empty include list.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/integration-platform/controllers/sync.controller.ts, line 319:

<comment>When `sync_user_filter_mode` contains an unsupported value, this condition logs that an empty list caused the fallback. Emit this warning only for `include` mode with an empty include list.</comment>

<file context>
@@ -285,57 +292,46 @@ export class SyncController {
 
-    let effectiveSyncFilterMode = syncFilterMode;
-    if (syncFilterMode === 'include' && includedTerms.length === 0) {
+    if (effectiveSyncFilterMode !== (filterConfig.userFilterMode ?? 'all')) {
       this.logger.warn(
-        `Google Workspace sync for org ${organizationId} is set to include mode, but include list is empty. Falling back to all users.`,
</file context>
Suggested change
if (effectiveSyncFilterMode !== (filterConfig.userFilterMode ?? 'all')) {
if (
filterConfig.userFilterMode === 'include' &&
filterConfig.includedTerms.length === 0
) {
Fix with cubic

this.logger.warn(
`Google Workspace sync for org ${organizationId} is set to include mode, but include list is empty. Falling back to all users.`,
`Google Workspace sync for org ${organizationId} requested "${filterConfig.userFilterMode}" mode with an empty list. Falling back to all users.`,
);
effectiveSyncFilterMode = 'all';
}

const filteredUsers = ouFilteredUsers.filter((user) => {
const email = user.primaryEmail.toLowerCase();

if (effectiveSyncFilterMode === 'exclude' && excludedTerms.length > 0) {
return !matchesSyncFilterTerms(email, excludedTerms);
}

if (effectiveSyncFilterMode === 'include') {
return matchesSyncFilterTerms(email, includedTerms);
}

return true;
});
const filteredUsers = scopedUsers.filter((user) =>
isGoogleWorkspaceUserSelectedBySyncTerms(user, filterConfig),
);

this.logger.log(
`Google Workspace sync filter mode "${effectiveSyncFilterMode}" kept ${filteredUsers.length}/${ouFilteredUsers.length} users`,
`Google Workspace scope kept ${scopedUsers.length}/${users.length} users ` +
`(OUs: ${filterConfig.targetOrgUnits?.join(', ') || 'all'}, ` +
`groups: ${filterConfig.targetGroups?.join(', ') || 'all'}, ` +
`domains: ${filterConfig.targetDomains?.join(', ') || 'all'}); ` +
`filter mode "${effectiveSyncFilterMode}" kept ${filteredUsers.length}/${scopedUsers.length}`,
);

// Active users to import/reactivate are based on the selected filter mode
Expand All @@ -349,12 +345,12 @@ export class SyncController {
activeUsers.map((u) => u.primaryEmail.toLowerCase()),
);
const allSuspendedEmails = new Set(
ouFilteredUsers
scopedUsers
.filter((u) => u.suspended)
.map((u) => u.primaryEmail.toLowerCase()),
);
const allActiveEmails = new Set(
ouFilteredUsers
scopedUsers
.filter((u) => !u.suspended)
.map((u) => u.primaryEmail.toLowerCase()),
);
Expand Down Expand Up @@ -493,7 +489,7 @@ export class SyncController {
});

const deactivationGwDomains = new Set(
ouFilteredUsers.map((u) => u.primaryEmail.split('@')[1]?.toLowerCase()),
scopedUsers.map((u) => u.primaryEmail.split('@')[1]?.toLowerCase()),

@cubic-dev-ai cubic-dev-ai Bot Sep 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When target_groups is set, this converts a user-level group scope into a domain-wide deactivation boundary. The loop can therefore offboard every non-privileged same-domain member outside the selected group; use exact scoped member identities or disable deletion outside a known authoritative scope.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/integration-platform/controllers/sync.controller.ts, line 492:

<comment>When `target_groups` is set, this converts a user-level group scope into a domain-wide deactivation boundary. The loop can therefore offboard every non-privileged same-domain member outside the selected group; use exact scoped member identities or disable deletion outside a known authoritative scope.</comment>

<file context>
@@ -493,7 +489,7 @@ export class SyncController {
 
     const deactivationGwDomains = new Set(
-      ouFilteredUsers.map((u) => u.primaryEmail.split('@')[1]?.toLowerCase()),
+      scopedUsers.map((u) => u.primaryEmail.split('@')[1]?.toLowerCase()),
     );
     const deactivationSuspendedEmails =
</file context>
Fix with cubic

);
const deactivationSuspendedEmails =
effectiveSyncFilterMode === 'include'
Expand Down
56 changes: 56 additions & 0 deletions frameworks/cmmc-level-2/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# CMMC Level 2 — framework definition

CMMC 2.0 Level 2: 110 practices for protecting Controlled Unclassified
Information (CUI), aligned to NIST SP 800-171 Rev 2.

## Contents

| File | What it is |
|---|---|
| `cmmc-level-2.import.json` | The import payload. Matches `ImportFrameworkDto`. |
| `import-cmmc.ts` | Inserts the payload directly via Prisma. |
| `generator/practices.py` | The 110 practices: NIST id, title, requirement statement. |
| `generator/build.py` | Builds the payload (requirements, controls, policies, tasks + index links). |
| `generator/add_content.py` | Adds TipTap policy body content. |

## What it contains

- 110 requirements across 14 families (AC 22, AT 3, AU 9, CM 9, IA 11,
IR 3, MA 6, MP 9, PS 2, PE 6, RA 3, CA 4, SC 16, SI 7)
- 36 control templates — every requirement covered by at least one
- 14 policy templates (one per family) with body content
- 25 task templates
- Identifiers use CMMC practice format, e.g. `AC.L2-3.1.1`

## Importing

Preferred — the API, which is the supported path:

POST /v1/framework-editor/framework/import

It sits behind `PlatformAdminGuard`, so it needs a browser session from a
user whose `User.role = 'admin'`.

Fallback used here, when no session is available (e.g. headless):

cd packages/db && bun scripts/import-cmmc.ts <path-to>/cmmc-level-2.import.json

`import-cmmc.ts` mirrors `FrameworkExportService.import()` in
`apps/api/src/framework-editor/framework/framework-export.service.ts` —
same entities, same link rows, one transaction. If that service changes,
re-check this script. It refuses to run if a framework with the same name
already exists.

## Regenerating

python3 generator/build.py && python3 generator/add_content.py

@cubic-dev-ai cubic-dev-ai Bot Sep 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The documented "Regenerating" workflow cannot regenerate the committed framework. generator/build.py hardcodes the output path /private/tmp/claude-501/-Users-chris-Code-comp/a0fe0e29-c24d-4821-b750-359c9a908dc2/scratchpad/cmmc/payload.json, and generator/add_content.py reads and writes that same machine/user-specific scratchpad path (including a sys.path.insert on it). Neither writes to the committed frameworks/cmmc-level-2/cmmc-level-2.import.json, and the absolute path will not exist on a fresh checkout or any other machine. So running the documented python3 generator/build.py && python3 generator/add_content.py produces no usable/committed output; the regeneration flow is effectively broken. Point both scripts at a repo-relative path to cmmc-level-2.import.json.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At frameworks/cmmc-level-2/README.md, line 46:

<comment>The documented "Regenerating" workflow cannot regenerate the committed framework. `generator/build.py` hardcodes the output path `/private/tmp/claude-501/-Users-chris-Code-comp/a0fe0e29-c24d-4821-b750-359c9a908dc2/scratchpad/cmmc/payload.json`, and `generator/add_content.py` reads and writes that same machine/user-specific scratchpad path (including a `sys.path.insert` on it). Neither writes to the committed `frameworks/cmmc-level-2/cmmc-level-2.import.json`, and the absolute path will not exist on a fresh checkout or any other machine. So running the documented `python3 generator/build.py && python3 generator/add_content.py` produces no usable/committed output; the regeneration flow is effectively broken. Point both scripts at a repo-relative path to `cmmc-level-2.import.json`.</comment>

<file context>
@@ -0,0 +1,56 @@
+
+## Regenerating
+
+    python3 generator/build.py && python3 generator/add_content.py
+
+(The generator writes to the path hardcoded at the bottom of `build.py`.)
</file context>
Fix with cubic


(The generator writes to the path hardcoded at the bottom of `build.py`.)

## Accuracy

Requirement statements paraphrase NIST SP 800-171 Rev 2. Family counts and
practice identifiers were checked against the published structure and total
110. The control groupings, policies, and tasks are an editorial layer, not
part of the standard. **Verify the requirement text against the official
NIST publication before relying on this for an actual assessment.**
Loading