Skip to content

Commit 8789275

Browse files
authored
[AJ-1843] Refactor import requirements (#4861)
1 parent 5d39ceb commit 8789275

12 files changed

+272
-221
lines changed

src/import-data/ImportData.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import * as Utils from 'src/libs/utils';
1515
import { notifyDataImportProgress } from 'src/workspace-data/import-jobs';
1616
import { WorkspaceInfo } from 'src/workspaces/utils';
1717

18+
import { getImportSource } from './import-sources';
1819
import {
1920
BagItImportRequest,
2021
CatalogDatasetImportRequest,
@@ -27,7 +28,6 @@ import {
2728
} from './import-types';
2829
import { ImportDataDestination } from './ImportDataDestination';
2930
import { ImportDataOverview } from './ImportDataOverview';
30-
import { getImportSource } from './protected-data-utils';
3131
import { useImportRequest } from './useImportRequest';
3232

3333
export interface ImportDataProps {

src/import-data/ImportDataDestination.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@ import NewWorkspaceModal from 'src/workspaces/NewWorkspaceModal/NewWorkspaceModa
2121
import { WorkspaceInfo } from 'src/workspaces/utils';
2222
import { WorkspacePolicies } from 'src/workspaces/WorkspacePolicies/WorkspacePolicies';
2323

24+
import { getRequiredCloudPlatform, requiresSecurityMonitoring } from './import-requirements';
2425
import { ImportRequest, TemplateWorkspaceInfo } from './import-types';
25-
import { buildDestinationWorkspaceFilter, getCloudPlatformRequiredForImport } from './import-utils';
26-
import { isProtectedSource } from './protected-data-utils';
26+
import { buildDestinationWorkspaceFilter } from './import-utils';
2727

2828
const styles = {
2929
card: {
@@ -107,7 +107,7 @@ export const ImportDataDestination = (props: ImportDataDestinationProps): ReactN
107107
onImport,
108108
} = props;
109109

110-
const isProtectedData = isProtectedSource(importRequest);
110+
const importRequiresSecurityMonitoring = requiresSecurityMonitoring(importRequest);
111111

112112
// Some import types are finished in a single request.
113113
// For most though, the import request starts a background task that takes time to complete.
@@ -189,13 +189,14 @@ export const ImportDataDestination = (props: ImportDataDestinationProps): ReactN
189189
buildDestinationWorkspaceFilter(importRequest, { requiredAuthorizationDomain })
190190
);
191191

192-
// disable import into existing workspaces if data is marked as protected but no protected workspaces are available
193-
const disableExportIntoExisting = isProtectedData && workspacesToImportTo.length === 0;
192+
// disable import into existing workspaces if no suitable workspaces are available
193+
// TODO: this should apply no matter the exact import requirements
194+
const disableExportIntoExisting = importRequiresSecurityMonitoring && workspacesToImportTo.length === 0;
194195

195196
const renderSelectExistingWorkspace = () =>
196197
h(Fragment, [
197198
h2({ style: styles.title }, [selectExistingWorkspacePrompt]),
198-
isProtectedData &&
199+
importRequiresSecurityMonitoring &&
199200
div({ style: { marginTop: '0.5rem', marginBottom: '0.5rem', lineHeight: '1.5' } }, [
200201
' You may only import into workspaces that have additional security monitoring enabled.',
201202
]),
@@ -219,7 +220,9 @@ export const ImportDataDestination = (props: ImportDataDestinationProps): ReactN
219220
h(WorkspacePolicies, {
220221
workspace: selectedWorkspace,
221222
noCheckboxes: true,
222-
endingNotice: isProtectedData ? div(['Importing this data may add additional access controls']) : undefined,
223+
endingNotice: importRequiresSecurityMonitoring
224+
? div(['Importing this data may add additional access controls'])
225+
: undefined,
223226
}),
224227
div({ style: { display: 'flex', alignItems: 'center', marginTop: '1rem' } }, [
225228
h(ButtonSecondary, { onClick: () => setMode(undefined), style: { marginLeft: 'auto' } }, ['Back']),
@@ -350,10 +353,10 @@ export const ImportDataDestination = (props: ImportDataDestinationProps): ReactN
350353
isCreateOpen &&
351354
h(NewWorkspaceModal, {
352355
requiredAuthDomain: requiredAuthorizationDomain,
353-
cloudPlatform: getCloudPlatformRequiredForImport(importRequest),
356+
cloudPlatform: getRequiredCloudPlatform(importRequest),
354357
renderNotice: () => {
355358
const children: ReactNode[] = [];
356-
if (isProtectedData) {
359+
if (importRequiresSecurityMonitoring) {
357360
children.push(
358361
div({ style: { paddingBottom: importMayTakeTime ? '1.0rem' : 0 } }, [
359362
'Importing controlled access data will apply any additional access controls associated with the data to this workspace.',
@@ -365,7 +368,7 @@ export const ImportDataDestination = (props: ImportDataDestinationProps): ReactN
365368
}
366369
return children.length > 0 ? h(Fragment, children) : undefined;
367370
},
368-
requireEnhancedBucketLogging: isProtectedData,
371+
requireEnhancedBucketLogging: importRequiresSecurityMonitoring,
369372
waitForServices: {
370373
wds: true,
371374
},

src/import-data/ImportDataOverview.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import { div, h, h2, h3 } from 'react-hyperscript-helpers';
33
import colors from 'src/libs/colors';
44
import * as Style from 'src/libs/style';
55

6+
import { requiresSecurityMonitoring } from './import-requirements';
67
import { ImportRequest } from './import-types';
7-
import { isProtectedSource } from './protected-data-utils';
88

99
const styles = {
1010
container: {
@@ -49,7 +49,7 @@ export interface ImportDataOverviewProps {
4949
export const ImportDataOverview = (props: ImportDataOverviewProps): ReactNode => {
5050
const { importRequest } = props;
5151

52-
const isProtectedData = isProtectedSource(importRequest);
52+
const importRequiresSecurityMonitoring = requiresSecurityMonitoring(importRequest);
5353

5454
return div({ style: styles.card }, [
5555
h2({ style: styles.title }, [getTitleForImportRequest(importRequest)]),
@@ -61,7 +61,7 @@ export const ImportDataOverview = (props: ImportDataOverviewProps): ReactNode =>
6161
h3({ style: { fontSize: 16 } }, ['Dataset security requirements:']),
6262
div(
6363
{ style: { marginTop: '1rem' } },
64-
isProtectedData
64+
importRequiresSecurityMonitoring
6565
? ['The data you have selected requires additional security monitoring.']
6666
: [
6767
'The data you just chose to import to Terra will be made available to you within a workspace of your choice where you can then perform analysis.',
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { genericPfbImportRequest } from './__fixtures__/import-request-fixtures';
2+
import { getRequiredCloudPlatform } from './import-requirements';
23
import { ImportRequest } from './import-types';
3-
import { getCloudPlatformRequiredForImport } from './import-utils';
44

55
type FeaturePreviewExports = typeof import('src/libs/feature-previews');
66

@@ -12,18 +12,18 @@ jest.mock(
1212
})
1313
);
1414

15-
// Note that behavior of getRequiredCloudPlatformForImport when the feature flag is set to false is tested
15+
// Note that behavior of getRequiredCloudPlatform when the feature flag is set to false is tested
1616
// in import-utils.test.ts. This file only tests behavior when the feature flag is set to true.
1717
// Since `jest.mock` is global for any given file, the test below is given this new file.
18-
describe('getRequiredCloudPlatformForImport', () => {
18+
describe('getRequiredCloudPlatform', () => {
1919
it('should respect the feature flag for PFB imports', async () => {
2020
// Arrange
2121
const importRequest: ImportRequest = genericPfbImportRequest;
2222

2323
// Act
24-
const cloudPlatform = getCloudPlatformRequiredForImport(importRequest);
24+
const requiredCloudPlatform = getRequiredCloudPlatform(importRequest);
2525

2626
// Assert
27-
expect(cloudPlatform).toBeUndefined();
27+
expect(requiredCloudPlatform).toBeUndefined();
2828
});
2929
});
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { CloudProvider } from 'src/workspaces/utils';
2+
3+
import {
4+
anvilPfbImportRequests,
5+
azureTdrSnapshotImportRequest,
6+
biodataCatalystPfbImportRequests,
7+
gcpTdrSnapshotImportRequest,
8+
gcpTdrSnapshotReferenceImportRequest,
9+
genericPfbImportRequest,
10+
protectedGcpTdrSnapshotImportRequest,
11+
protectedGcpTdrSnapshotReferenceImportRequest,
12+
} from './__fixtures__/import-request-fixtures';
13+
import { getRequiredCloudPlatform, requiresSecurityMonitoring } from './import-requirements';
14+
import { ImportRequest } from './import-types';
15+
16+
describe('cloud provider requirements', () => {
17+
describe('getRequiredCloudPlatform', () => {
18+
it.each([
19+
{
20+
importRequest: genericPfbImportRequest,
21+
expectedCloudPlatform: 'GCP',
22+
},
23+
{
24+
importRequest: azureTdrSnapshotImportRequest,
25+
expectedCloudPlatform: 'AZURE',
26+
},
27+
{
28+
importRequest: gcpTdrSnapshotImportRequest,
29+
expectedCloudPlatform: 'GCP',
30+
},
31+
] as {
32+
importRequest: ImportRequest;
33+
expectedCloudPlatform: CloudProvider | undefined;
34+
}[])('returns cloud provider required for import', async ({ importRequest, expectedCloudPlatform }) => {
35+
// Act
36+
const requiredCloudPlatform = getRequiredCloudPlatform(importRequest);
37+
38+
// Assert
39+
expect(requiredCloudPlatform).toBe(expectedCloudPlatform);
40+
});
41+
});
42+
});
43+
44+
describe('security monitoring requirements', () => {
45+
const importsExpectedToRequireSecurityMonitoring: ImportRequest[] = [
46+
// AnVIL
47+
...anvilPfbImportRequests,
48+
// BioData Catalyst
49+
...biodataCatalystPfbImportRequests,
50+
// Protected TDR snapshots
51+
protectedGcpTdrSnapshotImportRequest,
52+
protectedGcpTdrSnapshotReferenceImportRequest,
53+
];
54+
55+
const importsExpectedToNotRequireSecurityMonitoring: ImportRequest[] = [
56+
genericPfbImportRequest,
57+
{ type: 'entities', url: new URL('https://example.com/file.json') },
58+
gcpTdrSnapshotImportRequest,
59+
gcpTdrSnapshotReferenceImportRequest,
60+
];
61+
62+
describe('isProtectedSource', () => {
63+
it.each(importsExpectedToRequireSecurityMonitoring)('$url should require security monitoring', (importRequest) => {
64+
// Act
65+
const importRequiresSecurityMonitoring = requiresSecurityMonitoring(importRequest);
66+
67+
// Assert
68+
expect(importRequiresSecurityMonitoring).toBe(true);
69+
});
70+
71+
it.each(importsExpectedToNotRequireSecurityMonitoring)(
72+
'$url should not require security monitoring',
73+
(importRequest) => {
74+
// Act
75+
const importRequiresSecurityMonitoring = requiresSecurityMonitoring(importRequest);
76+
77+
// Assert
78+
expect(importRequiresSecurityMonitoring).toBe(false);
79+
}
80+
);
81+
});
82+
});
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { Snapshot } from 'src/libs/ajax/DataRepo';
2+
import { isFeaturePreviewEnabled } from 'src/libs/feature-previews';
3+
import { ENABLE_AZURE_PFB_IMPORT } from 'src/libs/feature-previews-config';
4+
import { CloudProvider } from 'src/workspaces/utils';
5+
6+
import { urlMatchesSource, UrlSource } from './import-sources';
7+
import { ImportRequest } from './import-types';
8+
9+
export const getRequiredCloudPlatform = (importRequest: ImportRequest): CloudProvider | undefined => {
10+
switch (importRequest.type) {
11+
case 'tdr-snapshot-export':
12+
case 'tdr-snapshot-reference':
13+
const tdrCloudPlatformToCloudProvider: Record<Snapshot['cloudPlatform'], CloudProvider> = {
14+
azure: 'AZURE',
15+
gcp: 'GCP',
16+
};
17+
return tdrCloudPlatformToCloudProvider[importRequest.snapshot.cloudPlatform];
18+
case 'pfb':
19+
// restrict PFB imports to GCP unless the user has the right feature flag enabled
20+
return isFeaturePreviewEnabled(ENABLE_AZURE_PFB_IMPORT) ? undefined : 'GCP';
21+
default:
22+
return undefined;
23+
}
24+
};
25+
26+
// These must be kept in sync with CWDS' twds.data-import.sources setting.
27+
// https://github.com/DataBiosphere/terra-workspace-data-service/blob/main/service/src/main/resources/application.yml
28+
const sourcesRequiringSecurityMonitoring: UrlSource[] = [
29+
// AnVIL production
30+
{ type: 'http', host: 'service.prod.anvil.gi.ucsc.edu' },
31+
{ type: 's3', bucket: 'edu-ucsc-gi-platform-anvil-prod-storage-anvilprod.us-east-1' },
32+
// AnVIL development
33+
{ type: 'http', host: 'service.anvil.gi.ucsc.edu' },
34+
{ type: 's3', bucket: 'edu-ucsc-gi-platform-anvil-dev-storage-anvildev.us-east-1' },
35+
// BioData Catalyst
36+
{ type: 'http', host: 'gen3.biodatacatalyst.nhlbi.nih.gov' },
37+
{ type: 's3', bucket: 'gen3-biodatacatalyst-nhlbi-nih-gov-pfb-export' },
38+
{ type: 's3', bucket: 'gen3-theanvil-io-pfb-export' },
39+
];
40+
41+
/**
42+
* Determine if a PFB file requires security monitoring.
43+
*/
44+
const pfbRequiresSecurityMonitoring = (pfbUrl: URL): boolean => {
45+
return sourcesRequiringSecurityMonitoring.some((source) => urlMatchesSource(pfbUrl, source));
46+
};
47+
48+
/**
49+
* Determine if a TDR snapshot requires security monitoring.
50+
*/
51+
const snapshotRequiresSecurityMonitoring = (snapshot: Snapshot): boolean => {
52+
return snapshot.source.some((source) => source.dataset.secureMonitoringEnabled);
53+
};
54+
55+
/**
56+
* Determine whether an import requires security monitoring.
57+
*/
58+
export const requiresSecurityMonitoring = (importRequest: ImportRequest): boolean => {
59+
switch (importRequest.type) {
60+
case 'pfb':
61+
return pfbRequiresSecurityMonitoring(importRequest.url);
62+
case 'tdr-snapshot-export':
63+
case 'tdr-snapshot-reference':
64+
return snapshotRequiresSecurityMonitoring(importRequest.snapshot);
65+
default:
66+
return false;
67+
}
68+
};
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import {
2+
anvilPfbImportRequests,
3+
biodataCatalystPfbImportRequests,
4+
genericPfbImportRequest,
5+
} from './__fixtures__/import-request-fixtures';
6+
import { getImportSource, ImportSource, urlMatchesSource, UrlSource } from './import-sources';
7+
8+
describe('urlMatchesSource', () => {
9+
it.each([
10+
{ url: new URL('https://example.com/file.txt'), shouldMatch: true },
11+
{ url: new URL('https://subdomain.example.com/file.txt'), shouldMatch: true },
12+
{ url: new URL('https://subdomainexample.com/file.txt'), shouldMatch: false },
13+
{ url: new URL('https://example-example.com/file.txt'), shouldMatch: false },
14+
] as { url: URL; shouldMatch: boolean }[])(
15+
'matches HTTP sources by hostname ($url, $shouldMatch)',
16+
({ url, shouldMatch }) => {
17+
// Arrange
18+
const source: UrlSource = { type: 'http', host: 'example.com' };
19+
20+
// Act
21+
const matches = urlMatchesSource(url, source);
22+
23+
// Assert
24+
expect(matches).toBe(shouldMatch);
25+
}
26+
);
27+
});
28+
29+
describe('getImportSource', () => {
30+
it.each([
31+
...anvilPfbImportRequests.map((importRequest) => ({ importUrl: importRequest.url, expectedSource: 'anvil' })),
32+
...[genericPfbImportRequest, ...biodataCatalystPfbImportRequests].map((importRequest) => ({
33+
importUrl: importRequest.url,
34+
expectedSource: '',
35+
})),
36+
] as {
37+
importUrl: URL;
38+
expectedSource: ImportSource;
39+
}[])('identifies import source ($importUrl)', ({ importUrl, expectedSource }) => {
40+
// Act
41+
const source = getImportSource(importUrl);
42+
43+
// Assert
44+
expect(source).toBe(expectedSource);
45+
});
46+
});

src/import-data/import-sources.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
export type UrlSource = { type: 'http'; host: string } | { type: 's3'; bucket: string };
2+
3+
/**
4+
* Determine if a PFB file is considered protected data.
5+
*/
6+
export const urlMatchesSource = (url: URL, source: UrlSource): boolean => {
7+
switch (source.type) {
8+
case 'http':
9+
// Match the hostname or subdomains of protected hosts.
10+
return url.hostname === source.host || url.hostname.endsWith(`.${source.host}`);
11+
12+
case 's3':
13+
// S3 supports multiple URL formats
14+
// https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html
15+
return (
16+
url.hostname === `${source.bucket}.s3.amazonaws.com` ||
17+
(url.hostname === 's3.amazonaws.com' && url.pathname.startsWith(`/${source.bucket}/`))
18+
);
19+
20+
default:
21+
// Use TypeScript to verify that all cases have been handled.
22+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
23+
const exhaustiveGuard: never = source;
24+
return false;
25+
}
26+
};
27+
28+
export type ImportSource = 'anvil' | '';
29+
30+
/**
31+
* This method identifies an import source. Currently it only identifies AnVIL Explorer.
32+
*/
33+
export const getImportSource = (url: URL): ImportSource => {
34+
const anvilSources = [
35+
// AnVIL production
36+
'service.prod.anvil.gi.ucsc.edu',
37+
'edu-ucsc-gi-platform-anvil-prod-storage-anvilprod.us-east-1',
38+
// AnVIL development
39+
'service.anvil.gi.ucsc.edu',
40+
'edu-ucsc-gi-platform-anvil-dev-storage-anvildev.us-east-1',
41+
];
42+
if (anvilSources.some((path) => url.href.includes(path))) {
43+
return 'anvil';
44+
}
45+
return '';
46+
};

0 commit comments

Comments
 (0)