diff --git a/wherehows-web/app/components/dataset-compliance.ts b/wherehows-web/app/components/dataset-compliance.ts index 89076a819e..3a71b99189 100644 --- a/wherehows-web/app/components/dataset-compliance.ts +++ b/wherehows-web/app/components/dataset-compliance.ts @@ -35,7 +35,6 @@ import { singleTagsInChangeSet, tagsForIdentifierField } from 'wherehows-web/constants'; -import { isPolicyExpectedShape } from 'wherehows-web/utils/datasets/compliance-policy'; import { getTagsSuggestions } from 'wherehows-web/utils/datasets/compliance-suggestions'; import { arrayMap, compact, isListUnique, iterateArrayAsync } from 'wherehows-web/utils/array'; import noop from 'wherehows-web/utils/noop'; @@ -67,15 +66,12 @@ import { IdLogicalType, NonIdLogicalType } from 'wherehows-web/constants/dataset import { pick } from 'lodash'; import { trackableEvent, TrackableEventCategory } from 'wherehows-web/constants/analytics/event-tracking'; import { notificationDialogActionFactory } from 'wherehows-web/utils/notifications/notifications'; -import { action } from '@ember-decorators/object'; const { complianceDataException, complianceFieldNotUnique, missingTypes, helpText, - successUploading, - invalidPolicyData, missingPurgePolicy, missingDatasetSecurityClassification } = compliancePolicyStrings; @@ -128,6 +124,9 @@ export default class DatasetCompliance extends Component { schemaFieldNamesMappedToDataTypes: Array>; onReset: () => Promise; onSave: () => Promise; + + onComplianceUpload: (jsonString: string) => void; + notifyOnChangeSetSuggestions: (hasSuggestions: boolean) => void; notifyOnChangeSetRequiresReview: (hasChangeSetDrift: boolean) => void; @@ -816,9 +815,9 @@ export default class DatasetCompliance extends Component { * checked in the function. If criteria is not met, an the returned promise is settled * in a rejected state, otherwise fulfilled * @method - * @return {Promise | void} + * @return {Promise} */ - validateFields(this: DatasetCompliance): Promise | void { + async validateFields(this: DatasetCompliance): Promise { const { notify } = get(this, 'notifications'); const { complianceEntities = [] } = get(this, 'complianceInfo') || {}; const idTypeComplianceEntities = complianceEntities.filter(isTagIdType(get(this, 'complianceDataTypes'))); @@ -891,39 +890,6 @@ export default class DatasetCompliance extends Component { get(this, 'updateEditStepTask').perform(); } - /** - * Receives the json representation for compliance and applies each key to the policy - * @param {string} textString string representation for the JSON file - */ - @action - async onComplianceJsonUpload(this: DatasetCompliance, textString: string): Promise { - const { - complianceInfo, - notifications: { notify } - } = getProperties(this, ['complianceInfo', 'notifications']); - - if (complianceInfo) { - try { - const policy = JSON.parse(textString); - - if (isPolicyExpectedShape(policy)) { - setProperties(complianceInfo, { - complianceEntities: policy.complianceEntities, - datasetClassification: policy.datasetClassification - }); - - notify(NotificationEvent.info, { - content: successUploading - }); - } - } catch (e) { - notify(NotificationEvent.error, { - content: invalidPolicyData - }); - } - } - } - actions: IDatasetComplianceActions = { /** * Action handles wizard step cancellation @@ -1230,6 +1196,14 @@ export default class DatasetCompliance extends Component { anchor.click(); }, + /** + * Receives the json representation for compliance and applies each key to the policy + * @param {string} jsonString string representation for the JSON file + */ + onComplianceJsonUpload(this: DatasetCompliance, jsonString: string): void { + get(this, 'onComplianceUpload')(jsonString); + }, + /** * Updates the source object representing the current datasetClassification map * @param {keyof typeof DatasetClassifiers} classifier the property on the datasetClassification to update diff --git a/wherehows-web/app/components/datasets/containers/dataset-compliance.ts b/wherehows-web/app/components/datasets/containers/dataset-compliance.ts index cd0c4f4703..13e2251e63 100644 --- a/wherehows-web/app/components/datasets/containers/dataset-compliance.ts +++ b/wherehows-web/app/components/datasets/containers/dataset-compliance.ts @@ -1,5 +1,5 @@ import Component from '@ember/component'; -import { get, set, setProperties } from '@ember/object'; +import { get, set, setProperties, getProperties } from '@ember/object'; import ComputedProperty from '@ember/object/computed'; import { inject } from '@ember/service'; import { task } from 'ember-concurrency'; @@ -28,6 +28,10 @@ import { SuggestionIntent } from 'wherehows-web/constants'; import { iterateArrayAsync } from 'wherehows-web/utils/array'; +import validateMetadataObject, { + datasetComplianceMetadataTaxonomy +} from 'wherehows-web/utils/datasets/compliance/metadata-schema'; +import { notificationDialogActionFactory } from 'wherehows-web/utils/notifications/notifications'; /** * Type alias for the response when container data items are batched @@ -52,7 +56,7 @@ type BatchContainerDataResult = Pick< | 'schemaless' >; -const { successUpdating, failedUpdating } = compliancePolicyStrings; +const { successUpdating, failedUpdating, successUploading, invalidPolicyData } = compliancePolicyStrings; export default class DatasetComplianceContainer extends Component { /** @@ -336,4 +340,43 @@ export default class DatasetComplianceContainer extends Component { onSuggestionsComplianceFeedback(uid: string | null = null, feedback: SuggestionIntent) { saveDatasetComplianceSuggestionFeedbackByUrn(get(this, 'urn'), uid, feedback); } + + /** + * Reapplies the uploaded compliance policy to the container property + * @param {string} jsonString string representation for the JSON file + * @memberof DatasetComplianceContainer + */ + @action + onComplianceUpload(this: DatasetComplianceContainer, jsonString: string): void { + const { + complianceInfo, + notifications: { notify } + } = getProperties(this, ['complianceInfo', 'notifications']); + + if (complianceInfo) { + try { + const policy = JSON.parse(jsonString); + + if (validateMetadataObject(policy, datasetComplianceMetadataTaxonomy)) { + const { complianceEntities, datasetClassification } = policy; + const resolvedComplianceInfo = { ...complianceInfo, complianceEntities, datasetClassification }; + const { dialogActions } = notificationDialogActionFactory(); + + set(this, 'complianceInfo', resolvedComplianceInfo); + + notify(NotificationEvent.confirm, { + header: 'Successfully applied uploaded metadata', + content: successUploading, + dialogActions, + dismissButtonText: false, + confirmButtonText: 'Dismiss' + }); + } + } catch (e) { + notify(NotificationEvent.error, { + content: invalidPolicyData + }); + } + } + } } diff --git a/wherehows-web/app/constants/dataset-compliance.ts b/wherehows-web/app/constants/dataset-compliance.ts index 6e4f1ebb6d..5999ce6fa6 100644 --- a/wherehows-web/app/constants/dataset-compliance.ts +++ b/wherehows-web/app/constants/dataset-compliance.ts @@ -34,7 +34,7 @@ const compliancePolicyStrings = { missingTypes: 'Looks like you may have forgotten to specify a `Field Format` for all ID fields?', successUpdating: 'Changes have been successfully saved!', failedUpdating: 'An error occurred while saving.', - successUploading: 'Metadata successfully updated! Please "Save" when ready.', + successUploading: 'Metadata successfully updated! Please confirm and "Save" when ready.', invalidPolicyData: 'Received policy in an unexpected format! Please check the provided attributes and try again.', helpText: { classification: diff --git a/wherehows-web/app/templates/components/datasets/containers/dataset-compliance.hbs b/wherehows-web/app/templates/components/datasets/containers/dataset-compliance.hbs index 66a8187ffe..853d0f342a 100644 --- a/wherehows-web/app/templates/components/datasets/containers/dataset-compliance.hbs +++ b/wherehows-web/app/templates/components/datasets/containers/dataset-compliance.hbs @@ -33,6 +33,7 @@ notifyOnChangeSetRequiresReview=(action "onCompliancePolicyChangeSetDrift") onSave=(action "savePrivacyCompliancePolicy") onReset=(action "resetPrivacyCompliancePolicy") + onComplianceUpload=(action "onComplianceUpload") }} {{/if}} diff --git a/wherehows-web/app/typings/generic.d.ts b/wherehows-web/app/typings/generic.d.ts index 7f6dc30a81..9ea889548c 100644 --- a/wherehows-web/app/typings/generic.d.ts +++ b/wherehows-web/app/typings/generic.d.ts @@ -10,4 +10,12 @@ type StringUnionKeyToValue = { [K in U]: K }; */ type StringEnumKeyToEnumValue = { [K in T]: V }; -export { StringUnionKeyToValue, StringEnumKeyToEnumValue }; +/** + * Describes the index signature for a generic object + * @interface IObject + */ +interface IObject { + [K: string]: T; +} + +export { StringUnionKeyToValue, StringEnumKeyToEnumValue, IObject }; diff --git a/wherehows-web/app/utils/datasets/compliance/metadata-schema.ts b/wherehows-web/app/utils/datasets/compliance/metadata-schema.ts new file mode 100644 index 0000000000..3c45147758 --- /dev/null +++ b/wherehows-web/app/utils/datasets/compliance/metadata-schema.ts @@ -0,0 +1,116 @@ +import { typeOf } from '@ember/utils'; +import { DatasetClassifiers } from 'wherehows-web/constants'; +import { arrayEvery, arrayReduce } from 'wherehows-web/utils/array'; +import { Classification } from 'wherehows-web/constants/datasets/compliance'; +import { IObject } from 'wherehows-web/typings/generic'; + +/** + * Describes the interface for schemas that are used for compliance metadata objects + * @interface IMetadataTaxonomy + */ +interface IMetadataTaxonomy { + readonly '@props': Array; + readonly '@type': string; + + readonly [K: string]: IMetadataTaxonomy | any; +} + +/** + * Defines the shape of the dataset compliance metadata json object using the IMetadataTaxonomy interface + * @type {({'@type': string; '@props': string[]} | {'@type': string; '@props': string[]; securityClassification: {'@type': string; '@props': any[]}})[]} + */ +const datasetComplianceMetadataTaxonomy: Array = [ + { + '@type': 'datasetClassification:object', + '@props': Object.keys(DatasetClassifiers).map(key => `${key}:boolean`) + }, + { + '@type': 'complianceEntities:array', + '@props': [ + 'identifierField:string', + 'identifierType:string|null', + 'securityClassification:string|null', + 'logicalType:string|null', + 'nonOwner:boolean|null', + 'valuePattern:string|null' + ], + securityClassification: { + '@type': 'securityClassification:string', + '@props': Object.values(Classification) + } + } +]; + +/** + * Checks that a value type matches an expected pattern string + * @param {*} value the value to check + * @param {string} expectedTypePattern the pattern string to match against + * @returns {boolean} + */ +const valueEquiv = (value: any, expectedTypePattern: string): boolean => expectedTypePattern.includes(typeOf(value)); + +/** + * Extracts the type key and the pattern string from the string mapping into a tuple pair + * @param {string} objectKeyTypePattern string value consisting of a pair of key/property name and allowed types separated by a colon ":" + * @returns {[string, string]} + */ +const typePatternMap = (objectKeyTypePattern: string): [string, string] => + <[string, string]>objectKeyTypePattern.split(':'); + +/** + * Returns a iteratee bound to an object that checks that a key matches the expected value in the typeMap + * @param {IObject} object the object with keys to check + * @return {(typeMap: string) => boolean} + */ +const keyValueHasMatch = (object: IObject) => (typeMap: string) => { + const [key, typeString] = typePatternMap(typeMap); + return valueEquiv(object[key], typeString); +}; + +/** + * Checks each key on an object matches the expected types in the typeMap + * @param {IObject} object the object with keys to check + * @param {Array} typeMaps the colon delimited type string + * @returns {boolean} + */ +const keysEquiv = (object: IObject, typeMaps: Array): boolean => + arrayEvery(keyValueHasMatch(object))(typeMaps); + +/** + * Checks that a compliance metadata object has a schema that matches the taxonomy / schema provided + * @param {IObject} object an instance of a compliance metadata object + * @param {Array} taxonomy schema shape to check against + * @return {boolean} + */ +const validateMetadataObject = (object: IObject, taxonomy: Array): boolean => { + const rootTypeMaps = taxonomy.map(category => category['@type']); + let isValid = keysEquiv(object, rootTypeMaps); + + if (isValid) { + const downlevelAccumulator = (validity: boolean, typeMap: string): boolean => { + const [key, pattern]: [string, string] = typePatternMap(typeMap); + + if (pattern.includes('object')) { + validity = keysEquiv(object[key], taxonomy.findBy('@type', typeMap)!['@props']); + } + + if (pattern.includes('array') && Array.isArray(object[key])) { + validity = arrayReduce( + (validity: boolean, value: IObject) => + validity && keysEquiv(value, taxonomy.findBy('@type', typeMap)!['@props']), + validity + )(object[key]); + } + + return validity; + }; + + return arrayReduce(downlevelAccumulator, isValid)(rootTypeMaps); + } + + return isValid; +}; + +export default validateMetadataObject; + +export { datasetComplianceMetadataTaxonomy };