adds feature to download a compliance policy as a json document. upload as json and apply to policy. performs attribute type assertions on values in uploaded policy

This commit is contained in:
Seyi Adebajo 2017-06-01 13:11:26 -07:00 committed by Mars Lan
parent 30f9bf28b2
commit c3de4a5ff7
17 changed files with 311 additions and 21 deletions

View File

@ -1,9 +1,7 @@
import Base from 'ember-simple-auth/authenticators/base';
import Ember from 'ember';
const {
$: { post }
} = Ember;
const { $: { post } } = Ember;
export default Base.extend({
/**
@ -11,7 +9,7 @@ export default Base.extend({
* Resolves with data object returned from successful request.
* @param {String} username username to authenticate with
* @param {String} password matching candidate password for username
* @return {Promise<{Object, String}>}
* @return {Promise}
*/
authenticate: (username, password) =>
Promise.resolve(

View File

@ -8,8 +8,19 @@ import {
nonIdFieldLogicalTypes,
defaultFieldDataTypeClassification
} from 'wherehows-web/constants';
import { isPolicyExpectedShape } from 'wherehows-web/utils/datasets/functions';
const { Component, computed, set, get, setProperties, getWithDefault, isEmpty, String: { htmlSafe } } = Ember;
const {
Component,
computed,
set,
get,
setProperties,
getProperties,
getWithDefault,
isEmpty,
String: { htmlSafe }
} = Ember;
// TODO: DSS-6671 Extract to constants module
const missingTypes = 'Looks like some fields may contain privacy data but do not have a specified `Field Format`?';
@ -153,6 +164,13 @@ export default Component.extend({
label: value ? formatAsCapitalizedStringWithSpaces(value) : '...'
})),
/**
* Caches the policy's modification time in milliseconds
*/
policyModificationTimeInEpoch: computed('complianceInfo', function() {
return getWithDefault(this, 'complianceInfo.modifiedTime', 0) * 1000;
}),
/**
* @type {Boolean} cached boolean flag indicating that fields do contain a `kafka type`
* tracking header.
@ -405,6 +423,38 @@ export default Component.extend({
},
actions: {
/**
* Receives the json representation for compliance and applies each key to the policy
* @param {String} textString string representation for the JSON file
*/
onComplianceJsonUpload(textString) {
const policy = JSON.parse(textString);
if (isPolicyExpectedShape(policy)) {
const currentPolicy = get(this, 'complianceInfo');
return set(this, 'complianceInfo', Object.assign({}, currentPolicy, policy));
}
alert('Received policy in an unexpected format! Please check the provided attributes and try again.');
},
/**
* Handles the compliance policy download action
*/
onComplianceDownloadJson() {
const currentPolicy = get(this, 'complianceInfo');
const policyProps = [
datasetClassificationKey,
policyFieldClassificationKey,
policyComplianceEntitiesKey
].map(name => name.split('.').pop());
const policy = Object.assign({}, getProperties(currentPolicy, policyProps));
const href = `data:text/json;charset=utf-8,${encodeURIComponent(JSON.stringify(policy))}`;
const download = `${get(this, 'datasetName')}_policy.json`;
const anchor = document.createElement('a');
Object.assign(anchor, { download, href });
anchor.click();
},
/**
* When a user updates the identifierFieldType in the DOM, update the backing store
* @param {String} identifierField

View File

@ -0,0 +1,51 @@
import Ember from 'ember';
const { get, computed, Component } = Ember;
export default Component.extend({
classNames: ['nacho-uploader'],
/**
* Handles the change event on the json-upload DOM element
* @param {FileList} files extracts a FileList object from the event object
* @return {void|null}
*/
change({ target: { files = [] } }) {
const hasFiles = !!files.length;
if (hasFiles) {
return this.sendFileAsText(Array.from(files).shift());
}
return null;
},
/**
* Caches a unique id for the HTML Element for file upload
*/
uploadInputId: computed(function() {
return `${get(this, 'elementId')}-uploader`;
}),
/**
* Reads a file as a text string and passes the output to the closure action
* @param {Blob} fileBlob
*/
sendFileAsText(fileBlob) {
const closureAction = get(this, 'attrs.receiveJsonFile');
const reader = new FileReader();
if (typeof closureAction === 'function') {
reader.onload = ({ target: { result } }) => closureAction(result);
reader.readAsText(fileBlob);
}
},
actions: {
/**
* Proxies the user's click interaction on the styled upload button to the file input element
*/
didSelectUpload() {
const uploadInput = this.$(`#${get(this, 'uploadInputId')}`);
return uploadInput.click();
}
}
});

View File

@ -52,7 +52,8 @@ $color-scheme: (
),
blue: (
oxford: rgb(53, 75, 87),
curious: rgb(26, 161, 217)
curious: rgb(26, 161, 217),
eastern: rgb(26, 132, 188)
),
grey: (
light: rgb(237, 237, 237),

View File

@ -16,3 +16,4 @@
@import "nacho/nacho-select";
@import "nacho/nacho-pager";
@import "nacho/nacho-breadcrumbs";
@import "nacho/nacho-uploader";

View File

@ -1,5 +1,8 @@
@import "../abstracts/functions";
@import "../abstracts/mixins";
$item-spacing: 10px;
/**
* Override Bootstrap navigation class, .navbar, ruleset
*/
@ -51,6 +54,19 @@
}
}
/**
* Styles the non primary action bar for an entity
*/
.secondary-actions {
display: flex;
justify-content: flex-end;
align-items: center;
&__action + &__action {
margin-left: $item-spacing;
}
}
/**
* Styles the action bar on a tab component
*/
@ -64,6 +80,15 @@
background-color: #f8f8f8;
z-index: z(dropdown);
max-height: 50px;
&__content {
white-space: nowrap;
vertical-align: top;
}
&__item + &__item {
margin-left: $item-spacing;
}
}
/**

View File

@ -39,3 +39,11 @@
}
}
}
.policy-last-saved {
margin-right: auto;
&__saved {
font-weight: fw(normal, 6);
}
}

View File

@ -6,17 +6,20 @@
);
}
$main-button-color: set-color(blue, eastern);
$button-text-color: set-color(white, base);
@include restyle-define(button, (
// Button Component Variables
restyle-var(height): 32px,
restyle-var(horizontal-padding): 16px,
restyle-var(main-color): $secondary-color,
restyle-var(background-color): $secondary-color,
restyle-var(main-color): $main-button-color,
restyle-var(background-color): $main-button-color,
restyle-var(font-size): 1.7rem,
restyle-var(font-weight): 600,
restyle-var(text-color): $text-color,
restyle-var(text-color): $button-text-color,
restyle-var(border-radius): 2px,
restyle-var(disabled-text): tint($text-color, 70%),
restyle-var(disabled-text): tint($button-text-color, 70%),
background-color: restyle-var(background-color),
border: 0,
@ -50,7 +53,7 @@
restyle-modifiers: (
'with a border': (
border: 1px solid restyle-var(text-color)
border: 1px solid restyle-var(main-color)
),
small: (
restyle-var(font-size): 1.5rem,
@ -76,7 +79,7 @@
),
secondary: (
restyle-var(background-color): transparent,
restyle-var(text-color): $text-invert-color,
restyle-var(text-color): $main-button-color,
restyle-var(box-shadow-size): inset 0 0 0 1px,
restyle-var(box-shadow-size-hover): inset 0 0 0 2px,
restyle-var(box-shadow-size-active): inset 0 0 0 3px,

View File

@ -18,4 +18,10 @@ $height: 40px;
overflow: hidden;
justify-content: flex-end;
}
.search-button {
border: 1px solid $secondary-color;
background-color: $secondary-color;
color: $text-color;
}
}

View File

@ -0,0 +1,10 @@
.nacho-uploader {
@include flex-center;
display: inline-flex;
&__label {
width: 250px;
margin-bottom: 0;
padding-right: 5px;
}
}

View File

@ -3,4 +3,5 @@
display: inline-block;
border-radius: 2px;
padding: 5px;
vertical-align: top;
}

View File

@ -1,7 +1,7 @@
<div class="tab-body">
<section class="action-bar">
<div class="container">
<button class="nacho-button nacho-button--large-inverse"
<div class="container action-bar__content">
<button class="nacho-button nacho-button--large-inverse action-bar__item"
title={{if ownershipIsInvalid
"Need at least two confirmed owners to make changes
and no Invalid users"
@ -11,7 +11,7 @@
Save
</button>
<button class="nacho-button nacho-button--large"
<button class="nacho-button nacho-button--large action-bar__item"
{{action "addOwner" owners}}>
<i class="fa fa-plus" title="Add an Owner">
</i>

View File

@ -13,8 +13,8 @@
</div>
{{else}}
<section class="action-bar">
<div class="container">
<button class="nacho-button nacho-button--large-inverse"
<div class="container action-bar__content">
<button class="nacho-button nacho-button--large-inverse action-bar__item"
title={{unless isDatasetFullyClassified
"Ensure you have provided a yes/no value for all dataset tags"
"Save"}}
@ -23,7 +23,7 @@
Save
</button>
<button class="nacho-button nacho-button--large"
<button class="nacho-button nacho-button--large action-bar__item"
{{action "resetCompliance"}}>
<i class="fa fa-times" title="Start Over">
</i>
@ -39,6 +39,25 @@
</section>
{{/if}}
<div class="secondary-actions">
<div class="policy-last-saved">
Last saved:
<span class="policy-last-saved__saved">
{{if isNewPrivacyPolicy 'Never'
(moment-calendar policyModificationTimeInEpoch sameElse="MMM Do YYYY, h:mm a")}}
</span>
</div>
{{json-upload receiveJsonFile=(action "onComplianceJsonUpload") class="secondary-actions__action"}}
{{#unless isNewPrivacyPolicy}}
<button
{{action "onComplianceDownloadJson"}}
class="nacho-button nacho-button--large-inverse secondary-actions__action">
Download as a JSON file
</button>
{{/unless}}
</div>
{{#if isNewPrivacyPolicy}}
<div class="alert alert-info" role="alert">
<p>

View File

@ -0,0 +1,16 @@
<input
type="file"
accept=".json"
style="display:none"
id="{{uploadInputId}}">
<label for="{{concat elementId '-button'}}" class="nacho-uploader__label">
Have existing metadata from a similar dataset that you want to apply here?
</label>
<button
{{action "didSelectUpload"}}
id="{{concat elementId '-button'}}"
class="nacho-button nacho-button--large-inverse">
Choose File
</button>

View File

@ -3,7 +3,7 @@
<div class="input-group-btn">
<div class="dropdown">
<button type="button"
class="btn btn-default dropdown-toggle nacho-button--large"
class="btn btn-default dropdown-toggle nacho-button--large search-button"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false">
@ -38,7 +38,7 @@
class="form-control nacho-global-search__text-input"}}
<span class="input-group-btn">
<button class="btn btn-default nacho-button--large"
<button class="btn btn-default nacho-button--large search-button"
id="global-search-button"
type="submit" {{action "search"}}>
<span class="glyphicon glyphicon-search" aria-hidden="true"></span>

View File

@ -1,3 +1,5 @@
import { datasetClassifiers } from 'wherehows-web/constants/dataset-classification';
/**
* Builds a default shape for securitySpecification & privacyCompliancePolicy with default / unset values
* for non null properties as per Avro schema
@ -15,4 +17,78 @@ const createInitialComplianceInfo = datasetId => ({
retentionPolicy: { retentionType: '' }
});
export { createInitialComplianceInfo };
/**
*
* @type {{complianceEntities: {type: string, of: {type: string, keys: [*]}}, datasetClassification: {type: string, keys: (*)}, fieldClassification: {type: string}}}
*/
const policyShape = {
complianceEntities: {
type: 'array',
of: {
type: 'object',
keys: [
'identifierField:string',
'identifierType:string',
'isSubject:boolean|object',
'logicalType:string|object|undefined'
]
}
},
datasetClassification: { type: 'object', keys: Object.keys(datasetClassifiers).map(key => `${key}:boolean`) },
fieldClassification: { type: 'object' }
};
/**
* Checks that a policy is valid
* @param candidatePolicy
* @return {Boolean}
*/
const isPolicyExpectedShape = (candidatePolicy = {}) => {
const candidateMatchesShape = policyKey => {
const policyProps = policyShape[policyKey];
const expectedType = policyProps.type;
const policyKeyValue = candidatePolicy[policyKey];
const isValueExpectedType = expectedType === 'array'
? Array.isArray(policyKeyValue)
: typeof policyKeyValue === expectedType;
const typeDeclarations = {
get array() {
return policyProps.of.keys;
},
get object() {
return policyProps.keys;
}
}[expectedType] || [];
if (!policyKeyValue || !isValueExpectedType) {
return false;
}
if (expectedType === 'array') {
return policyKeyValue.every(value => {
if (!value && typeof value !== policyProps.of.type) {
return false;
}
return typeDeclarations.every(typeString => {
const [key, type] = typeString.split(':');
return type.includes(typeof value[key]);
});
});
}
if (expectedType === typeof {}) {
return typeDeclarations.every(typeString => {
const [key, type] = typeString.split(':');
return type.includes(typeof policyKeyValue[key]);
});
}
};
if (typeof candidatePolicy === 'object' && candidatePolicy) {
return Object.keys(policyShape).every(candidateMatchesShape);
}
return false;
};
export { createInitialComplianceInfo, isPolicyExpectedShape };

View File

@ -0,0 +1,25 @@
import { moduleForComponent, test } from 'ember-qunit';
import hbs from 'htmlbars-inline-precompile';
moduleForComponent('json-upload', 'Integration | Component | json upload', {
integration: true
});
test('it renders', function(assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.on('myAction', function(val) { ... });
this.render(hbs`{{json-upload}}`);
assert.equal(this.$().text().trim(), '');
// Template block usage:
this.render(hbs`
{{#json-upload}}
template block text
{{/json-upload}}
`);
assert.equal(this.$().text().trim(), 'template block text');
});