diff --git a/.changeset/processing-activities-kebab.md b/.changeset/processing-activities-kebab.md new file mode 100644 index 000000000..603a55205 --- /dev/null +++ b/.changeset/processing-activities-kebab.md @@ -0,0 +1,6 @@ +--- +'@transcend-io/cli': minor +'@transcend-io/sdk': minor +--- + +Complete processing-activities RoPA pull/push: create-by-title then update relationships, skip empty `processing-activities: []` on push, and soft-warn unresolved `teamNames`. diff --git a/packages/cli/README.md b/packages/cli/README.md index 952881509..ffc7ee7a1 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -2472,7 +2472,7 @@ The API key permissions for this command vary based on the `resources` argument: | `enrichers` | `enrichers` | The Privacy Request enricher configurations. | View Identity Verification Settings | [DSR Automation -> Identifiers](https://app.transcend.io/privacy-requests/identifiers) | | `dataFlows` | `data-flows` | Consent Manager Data Flow definitions. | View Data Flows | [Consent Management -> Data Flows](https://app.transcend.io/consent-manager/data-flows/approved) | | `businessEntities` | `business-entities` | The business entities in the Data Inventory. | View Data Inventory | [Data Inventory -> Business Entities](https://app.transcend.io/data-map/data-inventory/business-entities) | -| `processingActivities` | `processing-activities` | The processing activities in the Data Inventory. | View Data Inventory | [Data Inventory -> Processing Activities](https://app.transcend.io/data-map/data-inventory/processing-activities) | +| `processingActivities` | `processing-activities` | The processing activities in the Data Inventory (RoPA). Empty `processing-activities: []` does not request Manage Data Map scopes. | View Data Inventory | [Data Inventory -> Processing Activities](https://app.transcend.io/data-map/data-inventory/processing-activities) | | `actions` | `actions` | The privacy request action settings. | View Data Subject Request Settings | [DSR Automation -> Request Settings -> Data Actions](https://app.transcend.io/privacy-requests/settings/data-actions) | | `dataSubjects` | `data-subjects` | The privacy request data subject settings. | View Data Subject Request Settings | [DSR Automation -> Request Settings -> Data Subjects](https://app.transcend.io/privacy-requests/settings/data-subjects) | | `identifiers` | `identifiers` | The privacy request identifier configurations. | View Identity Verification Settings | [DSR Automation -> Identifiers](https://app.transcend.io/privacy-requests/identifiers) | @@ -2674,7 +2674,8 @@ FLAGS #### Scopes -The API key permissions for this command vary based on the resources declared as top-level keys in your [`transcend.yml`](#transcendyml) file: +The API key permissions for this command vary based on the resources declared as top-level keys in your [`transcend.yml`](#transcendyml) file. +Empty arrays (e.g. `processing-activities: []`) do **not** request scopes for that resource. | Resource | Key in `transcend.yml` | Description | Scopes | Link | | ----------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | @@ -2685,7 +2686,7 @@ The API key permissions for this command vary based on the resources declared as | `enrichers` | `enrichers` | The Privacy Request enricher configurations. | Manage Request Identity Verification | [DSR Automation -> Identifiers](https://app.transcend.io/privacy-requests/identifiers) | | `dataFlows` | `data-flows` | Consent Manager Data Flow definitions. | Manage Data Flows | [Consent Management -> Data Flows](https://app.transcend.io/consent-manager/data-flows/approved) | | `businessEntities` | `business-entities` | The business entities in the Data Inventory. | Manage Data Inventory | [Data Inventory -> Business Entities](https://app.transcend.io/data-map/data-inventory/business-entities) | -| `processingActivities` | `processing-activities` | The processing activities in the Data Inventory. | Manage Data Map | [Data Inventory -> Processing Activities](https://app.transcend.io/data-map/data-inventory/processing-activities) | +| `processingActivities` | `processing-activities` | The processing activities in the Data Inventory (RoPA). Empty `processing-activities: []` does not request Manage Data Map scopes. | Manage Data Map | [Data Inventory -> Processing Activities](https://app.transcend.io/data-map/data-inventory/processing-activities) | | `actions` | `actions` | The privacy request action settings. | Manage Data Subject Request Settings | [DSR Automation -> Request Settings -> Data Actions](https://app.transcend.io/privacy-requests/settings/data-actions) | | `dataSubjects` | `data-subjects` | The privacy request data subject settings. | Manage Data Subject Request Settings | [DSR Automation -> Request Settings -> Data Subjects](https://app.transcend.io/privacy-requests/settings/data-subjects) | | `identifiers` | `identifiers` | The privacy request identifier configurations. | Manage Request Identity Verification | [DSR Automation -> Identifiers](https://app.transcend.io/privacy-requests/identifiers) | diff --git a/packages/cli/examples/processing-activities.yml b/packages/cli/examples/processing-activities.yml index 96c2131b7..fa1ef53fb 100644 --- a/packages/cli/examples/processing-activities.yml +++ b/packages/cli/examples/processing-activities.yml @@ -1,4 +1,32 @@ +# Processing activities (RoPA) for Data Inventory. +# Admin UI: https://app.transcend.io/data-map/data-inventory/processing-activities +# +# Pull: +# transcend inventory pull --auth="$TRANSCEND_API_KEY" --resources=processingActivities +# Push: +# transcend inventory push --auth="$TRANSCEND_API_KEY" --resources=processingActivities processing-activities: + # TWDC CCTV RoPA acceptance fixture — round-trips via pull/push + - title: CCTV — Disney Italia Theme Park Entrances + description: On-premise CCTV surveillance… + controllerships: [CONTROLLER] + retentionType: LIMITED + dataProtectionImpactAssessmentStatus: MISSING + dataSubjectTypes: [customer, employee] + teamNames: [Security Operations] + processingSubPurposes: + - purpose: OPERATION_SECURITY + - purpose: ESSENTIAL + dataSubCategories: + - category: OTHER + name: Security-Physical + - category: CONTACT + name: Image + storageRegions: + - country: IT + transferRegions: + - country: EU + - title: Chatbot AI description: >- A product that allows employees to chat with an informational AI Chatbot @@ -26,6 +54,7 @@ processing-activities: name: Interaction Events saaSCategories: - Artificial Intelligence + - title: Marketing Analytics description: Data around how our website is used controllerships: @@ -47,6 +76,7 @@ processing-activities: processingSubPurposes: - purpose: ANALYTICS - purpose: MARKETING + - title: Onboarding description: >- The process of onboarding new hires through a number of interactive @@ -61,6 +91,7 @@ processing-activities: - charlie@acme.com processingSubPurposes: - purpose: HR + - title: Recruiting description: >- Our recruitment pipeline includes the use of several different tools and diff --git a/packages/cli/src/commands/inventory/push/readme.ts b/packages/cli/src/commands/inventory/push/readme.ts index 002e2815d..ca98c1f20 100644 --- a/packages/cli/src/commands/inventory/push/readme.ts +++ b/packages/cli/src/commands/inventory/push/readme.ts @@ -52,7 +52,8 @@ const examples = buildExamples( export default `#### Scopes -The API key permissions for this command vary based on the resources declared as top-level keys in your [\`transcend.yml\`](#transcendyml) file: +The API key permissions for this command vary based on the resources declared as top-level keys in your [\`transcend.yml\`](#transcendyml) file. +Empty arrays (e.g. \`processing-activities: []\`) do **not** request scopes for that resource. ${createPullResourceScopesTable(TR_PUSH_RESOURCE_SCOPE_MAP)} diff --git a/packages/cli/src/lib/docgen/createPullResourceScopesTable.ts b/packages/cli/src/lib/docgen/createPullResourceScopesTable.ts index eadb78a83..21ac7432b 100644 --- a/packages/cli/src/lib/docgen/createPullResourceScopesTable.ts +++ b/packages/cli/src/lib/docgen/createPullResourceScopesTable.ts @@ -64,7 +64,8 @@ const RESOURCE_DOCUMENTATION: Record< ], }, [TranscendPullResource.ProcessingActivities]: { - description: 'The processing activities in the Data Inventory.', + description: + 'The processing activities in the Data Inventory (RoPA). Empty `processing-activities: []` does not request Manage Data Map scopes.', markdownLinks: [ '[Data Inventory -> Processing Activities](https://app.transcend.io/data-map/data-inventory/processing-activities)', ], diff --git a/packages/cli/src/lib/graphql/syncConfigurationToTranscend.ts b/packages/cli/src/lib/graphql/syncConfigurationToTranscend.ts index 29c510f69..3b8fb44ce 100644 --- a/packages/cli/src/lib/graphql/syncConfigurationToTranscend.ts +++ b/packages/cli/src/lib/graphql/syncConfigurationToTranscend.ts @@ -44,6 +44,7 @@ import { GraphQLClient } from 'graphql-request'; /* eslint-disable max-lines */ import { TranscendInput } from '../../codecs.js'; import { logger } from '../../logger.js'; +import { hasTranscendConfigSection } from '../helpers/hasTranscendConfigSection.js'; import { validatePreferenceManagementSlugs } from '../preference-management/validatePreferenceManagementSlugs.js'; import { ensureAllDataSubjectsExist } from './ensureAllDataSubjectsExist.js'; import { syncDataSilos } from './syncDataSilos.js'; @@ -151,7 +152,7 @@ export async function syncConfigurationToTranscend( }) : ({} as { [k in string]: Identifier }), // Grab all data subjects in the organization - dataSilos || dataSubjects || enrichers || processingActivities + dataSilos || dataSubjects || enrichers || hasTranscendConfigSection(processingActivities) ? ensureAllDataSubjectsExist(input, client) : {}, // Grab API keys @@ -551,11 +552,15 @@ export async function syncConfigurationToTranscend( await syncDataSiloDependencies(client, { input: dependencyUpdates, logger }); } - // Update processing activities - if (processingActivities) { - const processingActivitySuccess = await syncProcessingActivities(client, processingActivities, { - logger, - }); + // Update processing activities (skip empty `processing-activities: []`) + if (hasTranscendConfigSection(processingActivities)) { + const processingActivitySuccess = await syncProcessingActivities( + client, + processingActivities!, + { + logger, + }, + ); encounteredError = encounteredError || !processingActivitySuccess; } diff --git a/packages/cli/src/lib/helpers/hasTranscendConfigSection.ts b/packages/cli/src/lib/helpers/hasTranscendConfigSection.ts new file mode 100644 index 000000000..4cc4866d0 --- /dev/null +++ b/packages/cli/src/lib/helpers/hasTranscendConfigSection.ts @@ -0,0 +1,71 @@ +import type { ScopeName } from '@transcend-io/privacy-types'; +import { uniq } from 'lodash-es'; + +import type { TranscendInput } from '../../codecs.js'; +import { TR_PUSH_RESOURCE_SCOPE_MAP, TR_YML_RESOURCE_TO_FIELD_NAME } from '../../constants.js'; +import { TranscendPullResource } from '../../enums.js'; + +/** + * Whether a transcend.yml top-level section should trigger push scopes / sync. + * Empty arrays like `"processing-activities": []` must not request ManageDataMap. + * + * @param value - Top-level YAML section value + * @returns True when the section has content to sync + */ +export function hasTranscendConfigSection(value: unknown): boolean { + if (value === undefined || value === null) { + return false; + } + if (Array.isArray(value)) { + return value.length > 0; + } + if (typeof value === 'object') { + return Object.keys(value).length > 0; + } + return true; +} + +/** + * Remove empty transcend.yml sections before decode / scope derivation. + * Empty arrays like `"action-items": []` still count as present keys and can + * cause unnecessary API scopes or sync attempts. + * + * @param config - Raw YAML object + * @returns Config with empty top-level sections removed + */ +export function stripEmptyTranscendConfigSections( + config: Record, +): Record { + return Object.fromEntries( + Object.entries(config).filter(([, value]) => hasTranscendConfigSection(value)), + ); +} + +/** + * Derive push scopes only for non-empty config sections. + * + * @param input - Decoded transcend.yml input + * @returns Unique ScopeName values required for the push + */ +export function derivePushScopesFromTranscendInput(input: TranscendInput): ScopeName[] { + return uniq( + Object.values(TranscendPullResource).flatMap((resource) => + hasTranscendConfigSection(input[TR_YML_RESOURCE_TO_FIELD_NAME[resource]]) + ? TR_PUSH_RESOURCE_SCOPE_MAP[resource] + : [], + ), + ); +} + +/** + * Summarize which transcend.yml sections are included in a push. + * + * @param input - Decoded transcend.yml input + * @returns Human-readable section summary + */ +export function summarizeTranscendConfigSections(input: TranscendInput): string { + return Object.entries(input) + .filter(([, value]) => hasTranscendConfigSection(value)) + .map(([key, value]) => `${key}: (${Array.isArray(value) ? value.length : 1})`) + .join(', '); +} diff --git a/packages/cli/src/lib/helpers/index.ts b/packages/cli/src/lib/helpers/index.ts index 20b784be9..7e035a255 100644 --- a/packages/cli/src/lib/helpers/index.ts +++ b/packages/cli/src/lib/helpers/index.ts @@ -7,3 +7,4 @@ export * from './collectParquetFilesOrExit.js'; export * from './collectCsvFilesOrExit.js'; export * from './parquetToCsvOneFile.js'; export * from './writeCsv.js'; +export * from './hasTranscendConfigSection.js'; diff --git a/packages/cli/src/lib/helpers/tests/hasTranscendConfigSection.test.ts b/packages/cli/src/lib/helpers/tests/hasTranscendConfigSection.test.ts new file mode 100644 index 000000000..2b79998c8 --- /dev/null +++ b/packages/cli/src/lib/helpers/tests/hasTranscendConfigSection.test.ts @@ -0,0 +1,63 @@ +import { DefaultConsentOption, ScopeName } from '@transcend-io/privacy-types'; +import { describe, expect, it } from 'vitest'; + +import { + derivePushScopesFromTranscendInput, + hasTranscendConfigSection, + stripEmptyTranscendConfigSections, +} from '../hasTranscendConfigSection.js'; + +describe('hasTranscendConfigSection', () => { + it('returns false for empty arrays and empty objects', () => { + expect(hasTranscendConfigSection([])).toBe(false); + expect(hasTranscendConfigSection({})).toBe(false); + expect(hasTranscendConfigSection(undefined)).toBe(false); + expect(hasTranscendConfigSection(null)).toBe(false); + }); + + it('returns true for non-empty arrays and objects', () => { + expect(hasTranscendConfigSection([{ title: 'CCTV' }])).toBe(true); + expect(hasTranscendConfigSection({ unknownRequestPolicy: 'ALLOW' })).toBe(true); + }); +}); + +describe('stripEmptyTranscendConfigSections', () => { + it('removes empty top-level sections', () => { + expect( + stripEmptyTranscendConfigSections({ + purposes: [{ title: 'Advertising' }], + 'processing-activities': [], + 'action-items': [], + }), + ).toEqual({ + purposes: [{ title: 'Advertising' }], + }); + }); +}); + +describe('derivePushScopesFromTranscendInput', () => { + it('does not request ManageDataMap for empty processing-activities arrays', () => { + const scopes = derivePushScopesFromTranscendInput({ + 'processing-activities': [], + purposes: [ + { + trackingType: 'Advertising', + title: 'Advertising', + name: 'Advertising', + 'default-consent': DefaultConsentOption.OptOut, + }, + ], + }); + + expect(scopes).not.toContain(ScopeName.ManageDataMap); + expect(scopes).toContain(ScopeName.ManageConsentManager); + }); + + it('requests ManageDataMap when processing-activities has entries', () => { + const scopes = derivePushScopesFromTranscendInput({ + 'processing-activities': [{ title: 'CCTV — Disney Italia Theme Park Entrances' }], + }); + + expect(scopes).toContain(ScopeName.ManageDataMap); + }); +}); diff --git a/packages/cli/src/lib/readTranscendYaml.ts b/packages/cli/src/lib/readTranscendYaml.ts index a532940a4..75b7456b0 100644 --- a/packages/cli/src/lib/readTranscendYaml.ts +++ b/packages/cli/src/lib/readTranscendYaml.ts @@ -4,6 +4,7 @@ import { decodeCodec, ObjByString } from '@transcend-io/type-utils'; import yaml from 'js-yaml'; import { TranscendInput } from '../codecs.js'; +import { stripEmptyTranscendConfigSections } from './helpers/hasTranscendConfigSection.js'; export const VARIABLE_PARAMETERS_REGEXP = /<>/; export const VARIABLE_PARAMETERS_NAME = 'parameters'; @@ -60,8 +61,15 @@ export function readTranscendYaml(filePath: string, variables: ObjByString = {}) `Also check that there are no extra variables defined in your yaml: ${filePath}`, ); + // Drop empty top-level sections (e.g. `processing-activities: []`) before decode + const loaded = yaml.load(replacedVariables); + const stripped = + loaded && typeof loaded === 'object' && !Array.isArray(loaded) + ? stripEmptyTranscendConfigSections(loaded as Record) + : loaded; + // Validate shape - return decodeCodec(TranscendInput, yaml.load(replacedVariables)); + return decodeCodec(TranscendInput, stripped); } /** diff --git a/packages/cli/src/lib/tests/codebase.test.ts b/packages/cli/src/lib/tests/codebase.test.ts index 89e66fb57..0a67bc4e4 100644 --- a/packages/cli/src/lib/tests/codebase.test.ts +++ b/packages/cli/src/lib/tests/codebase.test.ts @@ -136,6 +136,7 @@ describe('CLI Command Structure', () => { `${implFile} should export ${implExportName} or _${implExportName} (for reserved keywords)`, ).toBe(true); }, + 15_000, ); }); diff --git a/packages/sdk/src/data-inventory/syncProcessingActivities.ts b/packages/sdk/src/data-inventory/syncProcessingActivities.ts index ac9650edf..be5a22a59 100644 --- a/packages/sdk/src/data-inventory/syncProcessingActivities.ts +++ b/packages/sdk/src/data-inventory/syncProcessingActivities.ts @@ -11,6 +11,7 @@ import { mapSeries, type Logger } from '@transcend-io/utils'; import { GraphQLClient } from 'graphql-request'; import { keyBy } from 'lodash-es'; +import { fetchAllTeams } from '../administration/fetchAllTeams.js'; import { makeGraphQLRequest, NOOP_LOGGER } from '../api/makeGraphQLRequest.js'; import { fetchAllProcessingActivities, @@ -85,6 +86,91 @@ export interface ProcessingActivityInput { saaSCategories?: string[]; } +/** + * Map a transcend.yml processing-activity entry to the GraphQL update shape. + * + * @param processingActivity - YAML input + * @param id - Existing processing activity ID + * @returns GraphQL UpdateProcessingActivity input fields + */ +export function toProcessingActivityUpdateInput( + processingActivity: ProcessingActivityInput, + id: string, +): Record { + return { + id, + title: processingActivity.title, + ...(processingActivity.description !== undefined + ? { description: processingActivity.description } + : {}), + ...(processingActivity.securityMeasureDetails !== undefined + ? { securityMeasureDetails: processingActivity.securityMeasureDetails } + : {}), + ...(processingActivity.controllerships !== undefined + ? { controllerships: processingActivity.controllerships } + : {}), + ...(processingActivity.storageRegions !== undefined + ? { storageRegions: processingActivity.storageRegions } + : {}), + ...(processingActivity.transferRegions !== undefined + ? { transferRegions: processingActivity.transferRegions } + : {}), + ...(processingActivity.retentionType !== undefined + ? { retentionType: processingActivity.retentionType } + : {}), + ...(processingActivity.retentionPeriod !== undefined + ? { retentionPeriod: processingActivity.retentionPeriod } + : {}), + ...(processingActivity.dataProtectionImpactAssessmentLink !== undefined + ? { + dataProtectionImpactAssessmentLink: processingActivity.dataProtectionImpactAssessmentLink, + } + : {}), + ...(processingActivity.dataProtectionImpactAssessmentStatus !== undefined + ? { + dataProtectionImpactAssessmentStatus: + processingActivity.dataProtectionImpactAssessmentStatus, + } + : {}), + ...(processingActivity.attributes !== undefined + ? { attributes: processingActivity.attributes } + : {}), + ...(processingActivity.dataSiloTitles !== undefined + ? { dataSiloTitles: processingActivity.dataSiloTitles } + : {}), + ...(processingActivity.dataSubjectTypes !== undefined + ? { dataSubjectTypes: processingActivity.dataSubjectTypes } + : {}), + ...(processingActivity.teamNames !== undefined + ? { teamNames: processingActivity.teamNames } + : {}), + ...(processingActivity.ownerEmails !== undefined + ? { ownerEmails: processingActivity.ownerEmails } + : {}), + ...(processingActivity.dataSubCategories !== undefined + ? { + dataSubCategoryInputs: processingActivity.dataSubCategories.map(({ category, name }) => ({ + category, + name: name ?? '', + })), + } + : {}), + ...(processingActivity.processingSubPurposes !== undefined + ? { + processingPurposeSubCategoryInputs: processingActivity.processingSubPurposes.map( + ({ purpose, name }) => ({ + purpose, + name: name ?? 'Other', + }), + ), + } + : {}), + ...(processingActivity.saaSCategories !== undefined + ? { saaSCategoryTitles: processingActivity.saaSCategories } + : {}), + }; +} + /** * Create a new processing activity, setting only title and description * @@ -151,23 +237,8 @@ async function updateProcessingActivities( await makeGraphQLRequest(client, UPDATE_PROCESSING_ACTIVITIES, { variables: { input: { - processingActivities: processingActivityIdPairs.map( - ([ - { processingSubPurposes, dataSubCategories, saaSCategories, ...processingActivity }, - id, - ]) => ({ - dataSubCategoryInputs: dataSubCategories?.map(({ category, name }) => ({ - category, - name: name ?? '', - })), - processingPurposeSubCategoryInputs: processingSubPurposes?.map(({ purpose, name }) => ({ - purpose, - name: name ?? 'Other', - })), - saaSCategoryTitles: saaSCategories, - ...processingActivity, - id, - }), + processingActivities: processingActivityIdPairs.map(([processingActivity, id]) => + toProcessingActivityUpdateInput(processingActivity, id), ), }, }, @@ -176,7 +247,54 @@ async function updateProcessingActivities( } /** - * Sync the data inventory processing activities + * Soft-warn on teamNames that do not exist in the organization. + * Unresolved names are dropped from the update payload so GraphQL does not hard-fail. + * + * @param inputs - Processing activity YAML inputs + * @param existingTeamNames - Team names already present in the org + * @param logger - Logger + * @returns Inputs with unresolved teamNames filtered out + */ +export function filterUnresolvedTeamNames( + inputs: ProcessingActivityInput[], + existingTeamNames: Set, + logger: Logger, +): ProcessingActivityInput[] { + return inputs.map((input) => { + const teamNames = input.teamNames; + if (!teamNames || teamNames.length === 0) { + return input; + } + + const resolved: string[] = []; + const unresolved: string[] = []; + for (const name of teamNames) { + if (existingTeamNames.has(name)) { + resolved.push(name); + } else { + unresolved.push(name); + } + } + + if (unresolved.length > 0) { + logger.warn( + `Processing activity "${input.title}": unresolved teamNames skipped: "${unresolved.join( + '", "', + )}". Create these teams first or remove them from transcend.yml.`, + ); + } + + return { + ...input, + teamNames: resolved.length > 0 ? resolved : undefined, + }; + }); +} + +/** + * Sync the data inventory processing activities. + * Idempotent by title: creates missing activities (title + description only), + * then updates all relationships via updateProcessingActivities. * * @param client - GraphQL client * @param inputs - Inputs to create @@ -194,18 +312,32 @@ export async function syncProcessingActivities( const { logger = NOOP_LOGGER } = options; let encounteredError = false; + if (inputs.length === 0) { + return true; + } + // Fetch existing logger.info(`Syncing "${inputs.length}" processing activities...`); - const existingProcessingActivities = await fetchAllProcessingActivities(client, { logger }); + const [existingProcessingActivities, existingTeams] = await Promise.all([ + fetchAllProcessingActivities(client, { logger }), + inputs.some((input) => (input.teamNames?.length ?? 0) > 0) + ? fetchAllTeams(client, { logger }) + : Promise.resolve([]), + ]); + + const existingTeamNames = new Set(existingTeams.map((team) => team.name)); + const inputsWithResolvedTeams = filterUnresolvedTeamNames(inputs, existingTeamNames, logger); - // Look up by title + // Look up by title (idempotency key) const processingActivityByTitle: Record> = keyBy( existingProcessingActivities, 'title', ); - // Create new processingActivities - const newProcessingActivities = inputs.filter((input) => !processingActivityByTitle[input.title]); + // Create new processingActivities (CreateProcessingActivityInput: title + description only) + const newProcessingActivities = inputsWithResolvedTeams.filter( + (input) => !processingActivityByTitle[input.title], + ); if (newProcessingActivities.length > 0) { logger.info(`Creating "${newProcessingActivities.length}" new processing activities...`); } @@ -224,21 +356,21 @@ export async function syncProcessingActivities( } }); - // Update all processing activities + // Update all processing activities (relationships: sub-purposes, categories, regions, teams, etc.) try { - logger.info(`Updating "${inputs.length}" processing activities!`); + logger.info(`Updating "${inputsWithResolvedTeams.length}" processing activities!`); await updateProcessingActivities( client, - inputs + inputsWithResolvedTeams .map((input) => [input, processingActivityByTitle[input.title]?.id] as const) .filter((x): x is [ProcessingActivityInput, string] => !!x[1]), { logger }, ); - logger.info(`Successfully synced "${inputs.length}" processingActivities!`); + logger.info(`Successfully synced "${inputsWithResolvedTeams.length}" processingActivities!`); } catch (err) { encounteredError = true; logger.error( - `Failed to sync "${inputs.length}" processingActivities! - ${(err as Error).message}`, + `Failed to sync "${inputsWithResolvedTeams.length}" processingActivities! - ${(err as Error).message}`, ); } diff --git a/packages/sdk/src/data-inventory/tests/syncProcessingActivities.test.ts b/packages/sdk/src/data-inventory/tests/syncProcessingActivities.test.ts new file mode 100644 index 000000000..d3ee376d1 --- /dev/null +++ b/packages/sdk/src/data-inventory/tests/syncProcessingActivities.test.ts @@ -0,0 +1,124 @@ +import { + Controllership, + DataCategoryType, + DataProtectionImpactAssessmentStatus, + ProcessingPurpose, + RetentionType, + IsoCountryCode, +} from '@transcend-io/privacy-types'; +import { describe, expect, it, vi } from 'vitest'; + +import { + filterUnresolvedTeamNames, + toProcessingActivityUpdateInput, + type ProcessingActivityInput, +} from '../syncProcessingActivities.js'; + +describe('toProcessingActivityUpdateInput', () => { + it('maps TWDC CCTV RoPA fields to GraphQL update input', () => { + const input: ProcessingActivityInput = { + title: 'CCTV — Disney Italia Theme Park Entrances', + description: 'On-premise CCTV surveillance…', + controllerships: [Controllership.Controller], + retentionType: RetentionType.Limited, + dataProtectionImpactAssessmentStatus: DataProtectionImpactAssessmentStatus.Missing, + dataSubjectTypes: ['customer', 'employee'], + teamNames: ['Security Operations'], + processingSubPurposes: [ + { purpose: ProcessingPurpose.OperationSecurity }, + { purpose: ProcessingPurpose.Essential }, + ], + dataSubCategories: [ + { category: DataCategoryType.Other, name: 'Security-Physical' }, + { category: DataCategoryType.Contact, name: 'Image' }, + ], + storageRegions: [{ country: IsoCountryCode.IT }], + transferRegions: [{ country: IsoCountryCode.EU }], + }; + + expect(toProcessingActivityUpdateInput(input, 'pa-id-1')).toEqual({ + id: 'pa-id-1', + title: 'CCTV — Disney Italia Theme Park Entrances', + description: 'On-premise CCTV surveillance…', + controllerships: [Controllership.Controller], + retentionType: RetentionType.Limited, + dataProtectionImpactAssessmentStatus: DataProtectionImpactAssessmentStatus.Missing, + dataSubjectTypes: ['customer', 'employee'], + teamNames: ['Security Operations'], + processingPurposeSubCategoryInputs: [ + { purpose: ProcessingPurpose.OperationSecurity, name: 'Other' }, + { purpose: ProcessingPurpose.Essential, name: 'Other' }, + ], + dataSubCategoryInputs: [ + { category: DataCategoryType.Other, name: 'Security-Physical' }, + { category: DataCategoryType.Contact, name: 'Image' }, + ], + storageRegions: [{ country: IsoCountryCode.IT }], + transferRegions: [{ country: IsoCountryCode.EU }], + }); + }); + + it('maps optional fields and defaults sub-purpose name to Other', () => { + const input: ProcessingActivityInput = { + title: 'Recruiting', + securityMeasureDetails: 'Encrypted at rest', + retentionType: RetentionType.StatedPeriod, + retentionPeriod: 90, + dataProtectionImpactAssessmentLink: 'https://example.com', + dataSiloTitles: ['Adobe'], + ownerEmails: ['delilah@acme.com'], + saaSCategories: ['Artificial Intelligence'], + processingSubPurposes: [{ purpose: ProcessingPurpose.HR, name: 'Recruiting' }], + dataSubCategories: [{ category: DataCategoryType.Contact }], + attributes: [{ key: 'Source of Data', values: ['Online Tracking Technologies'] }], + }; + + expect(toProcessingActivityUpdateInput(input, 'pa-id-2')).toEqual({ + id: 'pa-id-2', + title: 'Recruiting', + securityMeasureDetails: 'Encrypted at rest', + retentionType: RetentionType.StatedPeriod, + retentionPeriod: 90, + dataProtectionImpactAssessmentLink: 'https://example.com', + dataSiloTitles: ['Adobe'], + ownerEmails: ['delilah@acme.com'], + saaSCategoryTitles: ['Artificial Intelligence'], + processingPurposeSubCategoryInputs: [{ purpose: ProcessingPurpose.HR, name: 'Recruiting' }], + dataSubCategoryInputs: [{ category: DataCategoryType.Contact, name: '' }], + attributes: [{ key: 'Source of Data', values: ['Online Tracking Technologies'] }], + }); + }); +}); + +describe('filterUnresolvedTeamNames', () => { + it('soft-warns and drops unresolved teamNames', () => { + const warn = vi.fn(); + const result = filterUnresolvedTeamNames( + [ + { + title: 'CCTV', + teamNames: ['Security Operations', 'Missing Team'], + }, + ], + new Set(['Security Operations']), + { warn, info: vi.fn(), error: vi.fn(), debug: vi.fn() }, + ); + + expect(result[0]?.teamNames).toEqual(['Security Operations']); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('unresolved teamNames skipped: "Missing Team"'), + ); + }); + + it('clears teamNames when none resolve', () => { + const warn = vi.fn(); + const result = filterUnresolvedTeamNames( + [{ title: 'CCTV', teamNames: ['Ghost Team'] }], + new Set(), + { warn, info: vi.fn(), error: vi.fn(), debug: vi.fn() }, + ); + + expect(result[0]?.teamNames).toBeUndefined(); + expect(warn).toHaveBeenCalled(); + }); +});