Merge pull request #7665 from Nerdinacan/authnz

Authnz UI and associated api
This commit is contained in:
Dannon
2019-05-20 11:45:27 -04:00
committed by GitHub
28 changed files with 1494 additions and 10 deletions
+10 -1
View File
@@ -1,7 +1,11 @@
module.exports = {
extends: ["eslint:recommended", "plugin:vue/essential"], // airbnb-base, eventually
env: {
browser: true
browser: true,
commonjs: true,
es6: true,
node: true,
mocha: true
},
parserOptions: { parser: "babel-eslint" },
plugins: ["html"],
@@ -16,5 +20,10 @@ module.exports = {
// properties: "always"
// }
// ]
},
globals: {
// chai tests
assert: true,
expect: true
}
};
@@ -0,0 +1,29 @@
import BInput from "bootstrap-vue/es/components/form-input/form-input";
import { createMask } from "imask";
export default {
extends: BInput,
props: {
mask: {
type: String,
required: false,
default: ""
}
},
computed: {
masker() {
return createMask({
mask: this.mask
});
}
},
methods: {
getFormatted(value) {
const result = this.stringifyValue(value);
if (this.mask.length) {
return this.masker.resolve(result);
}
return result;
}
}
};
@@ -0,0 +1,87 @@
import sinon from "sinon";
import flushPromises from "flush-promises";
import { shallowMount, createLocalVue } from "@vue/test-utils";
import { default as CloudAuth, __RewireAPI__ as rewire } from "./CloudAuth";
import CloudAuthItem from "./CloudAuthItem";
import { Credential } from "./model";
import _l from "utils/localization";
import BootstrapVue from "bootstrap-vue";
// test data
import listCredentials from "./testdata/listCredentials.json";
const localVue = createLocalVue();
localVue.use(BootstrapVue);
localVue.filter("localize", value => _l(value));
describe("CloudAuth component", () => {
let stub, wrapper;
let mockSvc = {
listCredentials: async () => null
};
rewire.__Rewire__("svc", mockSvc);
beforeEach(async () => {
let creds = listCredentials.map(Credential.create);
stub = sinon.stub(mockSvc, "listCredentials").resolves(creds);
wrapper = shallowMount(CloudAuth, { localVue });
await flushPromises();
});
afterEach(() => {
if (stub) {
stub.restore();
}
});
describe("initialization", () => {
it("should render the initial list", () => {
assert(wrapper);
assert(wrapper.contains(CloudAuthItem));
assert(wrapper.vm.items.length == 2);
assert(wrapper.vm.filteredItems.length == 2);
});
});
describe("text filter", () => {
it("should show filter result by text match", () => {
let results;
wrapper.vm.filter = "aws";
results = wrapper.vm.filteredItems;
assert(wrapper.contains(CloudAuthItem));
assert(results.length == 1, `Wrong number of items: ${results.length}`);
wrapper.vm.filter = "azure";
results = wrapper.vm.filteredItems;
assert(results.length == 1, `Wrong number of items: ${results.length}`);
wrapper.vm.filter = "";
results = wrapper.vm.filteredItems;
assert(results.length == 2, `Wrong number of items: ${results.length}`);
});
});
describe("create button", () => {
it("clicking create button should add a blank key", () => {
let results = wrapper.vm.filteredItems;
assert(wrapper.contains(CloudAuthItem));
assert(results.length == 2, `Wrong number of items: ${results.length}`);
let button = wrapper.find("button[name=createNewKey]");
assert(button);
button.trigger("click");
results = wrapper.vm.filteredItems;
assert(results.length == 3, `Wrong number of items: ${results.length}`);
let blank = results.find(i => i.id == null);
assert(blank, "missing blank key");
assert(blank.id == null);
});
});
});
@@ -0,0 +1,378 @@
<template>
<section class="cloud-auth">
<header>
<b-alert dismissible fade variant="warning" :show="errorMessage !== null" @dismissed="errorMessage = null">
{{ errorMessage }}
</b-alert>
<hgroup class="cloud-auth-title">
<h1>Manage Cloud Authorization</h1>
<nav class="operations">
<ul>
<li class="cloudKeyHelp">
<a
:class="{ active: showHelp }"
@click.prevent="showHelp = !showHelp"
v-b-tooltip.hover
aria-label="Instructions"
title="Instructions"
>
<span>Instructions</span>
</a>
</li>
<li class="cloudKeyFilter">
<a
:class="{ active: showFilter }"
@click.prevent="showFilter = !showFilter"
v-b-tooltip.hover
aria-label="Filter Results"
title="Filter Results"
>
<span>Filter</span>
</a>
</li>
<li class="createCloudKey">
<a
@click.prevent="onCreate"
v-b-tooltip.hover
aria-label="Create New Key"
title="Create New Key"
>
<span>Create New Key</span>
</a>
</li>
</ul>
</nav>
</hgroup>
<transition name="fade">
<hgroup v-if="showHelp">
<p>See the online <a href="https://galaxyproject.org/cloud/authnz/">Documentation</a>.</p>
</hgroup>
</transition>
<transition name="fade">
<hgroup v-if="showFilter">
<b-form-group :description="filterDescription">
<b-form-input type="text" placeholder="Filter" v-model="filter" />
</b-form-group>
</hgroup>
</transition>
<b-button-group class="mb-4">
<button name="createNewKey" @click="onCreate">
Create New Authorization Key
</button>
</b-button-group>
</header>
<div class="scroll-container">
<b-list-group>
<transition-group name="fade">
<cloud-auth-item
v-for="credential in filteredItems"
:key="credential.counter"
:credential="credential"
class="mb-1"
@delete="onDelete"
@save="onSave"
@expand="expand(credential, $event)"
/>
</transition-group>
</b-list-group>
</div>
<b-modal
v-model="hasDoomed"
centered
id="deleteCredentialModal"
ref="deleteModal"
title="Delete Key?"
size="sm"
@ok="deleteKey"
@cancel="doomedItem = null"
>
</b-modal>
</section>
</template>
<script>
import Vue from "vue";
import BootstrapVue from "bootstrap-vue";
import CloudAuthItem from "./CloudAuthItem";
import { Credential } from "./model";
import svc from "./model/service";
Vue.use(BootstrapVue);
export default {
components: {
CloudAuthItem
},
data() {
return {
items: [],
filter: "",
showHelp: true,
showFilter: false,
loading: false,
doomedItem: null,
errorMessage: null
};
},
computed: {
filteredItems() {
return this.items.filter(o => o.match(this.filter));
},
filterDescription() {
return `${this.filteredItems.length} matches out of ${this.items.length} items`;
},
deleteButtonVariant() {
return this.showDeleted ? "primary" : "secondary";
},
hasDoomed: {
get() {
return this.doomedItem !== null;
},
// This setter is here because vue-bootstrap modal
// tries to set this property for unfathomable reasons
set() {}
}
},
watch: {
showDeleted(deleted) {
this.loadCredentials({ deleted });
}
},
methods: {
loadCredentials(params = {}) {
this.loading = true;
svc.listCredentials(params)
.then(items => (this.items = items))
.catch(this.setError("Unable to load cloud keys."))
.finally(() => (this.loading = false));
},
onCreate() {
const newItem = Credential.create({ expanded: true });
this.addItem(newItem);
},
onSave(item) {
if (!item.valid) {
return;
}
item.loading = true;
svc.saveCredential(item)
.then(result => {
item.id = result.id;
item.updateState();
})
.catch(this.setError("Unable to save key."))
.finally(() => (item.loading = false));
},
onDelete(doomed) {
this.doomedItem = doomed;
if (doomed.id) {
this.$refs.deleteModal.show();
} else {
this.removeItem(doomed);
this.doomedItem = null;
}
},
deleteKey() {
// Called when the modal is closed with an "OK"
svc.deleteCredential(this.doomedItem)
.then(() => this.removeItem(this.doomedItem))
.catch(this.setError("Unable to delete cloud key."))
.finally(() => {
this.doomedItem = null;
});
},
expand(credential, { expanded }) {
credential.expanded = expanded;
if (expanded) {
this.items.filter(i => i !== credential).forEach(i => (i.expanded = false));
}
},
addItem(item) {
this.items = [...this.items, item];
},
removeItem(item) {
this.items = this.items.filter(o => o !== item);
},
setError(msg) {
return err => {
this.errorMessage = msg;
console.warn(err);
};
}
},
created() {
this.loadCredentials();
}
};
</script>
<style lang="scss">
@import "~bootstrap/scss/functions";
@import "~bootstrap/scss/variables";
@import "~bootstrap/scss/mixins";
@import "~bootstrap/scss/utilities/spacing";
@import "scss/theme/blue.scss";
@import "scss/mixins";
// TODO: build reusable icon menu? Maybe make into a component?
// The existing one has lots of reusability problems
.operations {
margin-bottom: 0;
ul {
@include list_reset();
display: flex;
li:not(:first-child) {
@extend .ml-2;
}
}
}
// General Layout
.cloud-auth {
// header sticks, bottom part scrolls
@include scrollingListLayout("header", ".scroll-container");
// title left, icons right
.cloud-auth-title {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
// top icon menu
.operations {
li {
a,
a::before {
font-size: 1rem;
color: $gray-400;
}
a.active::before,
a:hover::before {
color: $brand-primary;
}
}
.cloudKeyHelp a {
@include fontawesome($fa-var-info-circle);
}
.cloudKeyFilter a {
@include fontawesome($fa-var-filter);
}
.createCloudKey a {
@include fontawesome($fa-var-plus);
}
}
}
}
// Single list item
.cloud-auth-key {
header hgroup {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
> * {
margin-bottom: 0;
}
}
form {
@extend .my-3;
@extend .pt-3;
// removes wierd double arrows on select
.custom-select {
background: none;
}
// Undo butchering from base.css so side-by-side labels work
.form-row {
display: flex;
input,
select {
max-width: none;
}
label {
font-weight: 400;
}
}
// button list at bottom of form
footer {
display: flex;
flex-direction: row;
justify-content: flex-end;
@extend .pt-3;
button:not(:first-child) {
@extend .ml-1;
}
}
}
// icon menu
.operations {
.save a {
@include fontawesome($fa-var-save);
}
.delete a {
@include fontawesome($fa-var-times);
}
.details a {
@include fontawesome($fa-var-window-minimize);
&:hover {
@include fontawesome($fa-var-window-maximize);
}
}
}
// reverse icons when key is expanded
&.expanded .details a {
@include fontawesome($fa-var-window-maximize);
&:hover {
@include fontawesome($fa-var-window-minimize);
}
}
}
// Transitions
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s;
}
.fade-enter,
.fade-leave-to {
opacity: 0;
}
// Delete modal
#deleteCredentialModal {
.modal-body {
display: none;
}
.modal-dialog {
max-width: 300px;
}
}
</style>
@@ -0,0 +1,95 @@
<template>
<b-list-group-item class="cloud-auth-key" :class="statusClasses" :variant="variant" :button="!expanded">
<header>
<hgroup>
<h4 @click.prevent="expand()">{{ credential.title }}</h4>
<nav class="operations">
<ul>
<transition name="fade">
<li class="save" v-if="expanded && credential.dirty">
<a
@click.prevent="$emit('save', credential)"
v-b-tooltip.hover
aria-label="Save Key"
title="Save Key"
>
<span>Save Key</span>
</a>
</li>
</transition>
<li class="delete">
<a
@click.prevent="$emit('delete', credential)"
v-b-tooltip.hover
aria-label="Delete Key"
title="Delete Key"
>
<span>Delete Key</span>
</a>
</li>
<li class="details">
<a
@click.prevent="expand()"
v-b-tooltip.hover
aria-label="Show Details"
title="Show Details"
>
<span>Details</span>
</a>
</li>
</ul>
</nav>
</hgroup>
</header>
<credential-form
v-if="expanded"
class="border-top"
v-model="credential"
@click.self="expand()"
@save="$emit('save', credential)"
@delete="$emit('delete', credential)"
/>
</b-list-group-item>
</template>
<script>
import { Credential } from "./model";
import CredentialForm from "./CredentialForm";
export default {
components: {
CredentialForm
},
props: {
credential: { type: Credential, required: true }
},
computed: {
statusClasses() {
const { expanded, valid, dirty, loading } = this.credential;
return { loading, expanded, valid, dirty, collapsed: !expanded };
},
variant() {
if (this.expanded) {
return "";
}
if (this.credential.dirty) {
return "warning";
}
if (!this.credential.valid) {
return "danger";
}
return "primary";
},
expanded() {
return this.credential.expanded;
}
},
methods: {
expand(forceState) {
const expanded = forceState !== undefined ? forceState : !this.expanded;
this.$emit("expand", { expanded });
}
}
};
</script>
@@ -0,0 +1,44 @@
<template>
<div>
<b-form-group
v-for="(field, fieldName) in fieldDefs"
:key="fieldName"
:label="field.label || fieldName"
:label-for="'config-' + fieldName"
label-cols-lg="3"
:state="config.fieldValid(fieldName)"
:invalid-feedback="config.errorMessage(fieldName)"
>
<masked-input
:id="'config-' + fieldName"
v-model="config[fieldName]"
:mask="field.mask"
:placeholder="field.placeholder"
:maxlength="field.maxlength || Math.Infinity"
:state="config.fieldValid(fieldName)"
trim
/>
</b-form-group>
</div>
</template>
<script>
import MaskedInput from "components/MaskedInput";
export default {
components: {
MaskedInput
},
props: {
value: { type: Object, required: true }
},
computed: {
config() {
return this.value;
},
fieldDefs() {
return this.value.constructor.fields;
}
}
};
</script>
@@ -0,0 +1,108 @@
<template>
<b-form :validated="credential.isValid">
<!-- user label -->
<b-form-group label="Description" label-for="credentialDescription" label-cols-lg="3">
<b-form-input
id="credentialDescription"
v-model="credential.description"
:state="credential.fieldValid('description')"
placeholder="Description (optional)"
trim
/>
</b-form-group>
<!-- google-openidconnect -->
<b-form-group
label="Identity Provider"
label-for="identityProvider"
label-cols-lg="3"
v-if="identityProviders.length > 1"
>
<b-form-select
id="identityProvider"
v-model="credential.authn_id"
:state="credential.fieldValid('authn_id')"
:options="identityProviders"
/>
</b-form-group>
<!-- aws/azure, etc -->
<b-form-group label="Resource Provider" label-for="resourceProvider" label-cols-lg="3">
<b-form-select
id="resourceProvider"
v-model="credential.resourceProvider"
:state="credential.fieldValid('provider')"
:options="resourceProviderOptions"
/>
</b-form-group>
<credential-config v-model="credential.config" />
<footer class="border-top">
<b-button variant="secondary" @click.prevent="$emit('delete', credential)">
Delete Key
</b-button>
<b-button
aria-label="Save Key"
:variant="saveButtonVariant"
:disabled="saveButtonDisabled"
@click.prevent="$emit('save', credential)"
>
{{ saveButtonTitle }}
</b-button>
</footer>
</b-form>
</template>
<script>
import { Credential, ResourceProviders } from "./model";
import { getIdentityProviders } from "./model/service";
import CredentialConfig from "./CredentialConfig";
export default {
components: {
CredentialConfig
},
props: {
value: { type: Credential, required: true }
},
data() {
return {
identityProviders: []
};
},
computed: {
credential() {
return this.value;
},
// transformed for bootstrap component
resourceProviderOptions() {
return Array.from(ResourceProviders.entries()).map(([value, o]) => ({ value, text: o.label }));
},
config() {
return this.credential.config;
},
loading() {
return this.credential.loading;
},
saveButtonDisabled() {
return !(this.credential.valid && this.credential.dirty);
},
saveButtonVariant() {
return this.saveButtonDisabled ? "secondary" : "primary";
},
saveButtonTitle() {
return this.loading ? "Saving Key..." : "Save Key";
}
},
created() {
getIdentityProviders().then(result => {
if (!this.credential.authn_id && result.length == 1) {
this.credential.authn_id = result[0].authn_id;
this.credential.updateState();
}
this.identityProviders = result;
});
}
};
</script>
@@ -0,0 +1 @@
export { default as CloudAuth } from "./CloudAuth.vue";
@@ -0,0 +1,27 @@
import { safeAssign } from "utils/safeAssign";
import { BaseModel } from "./BaseModel";
export class AwsConfig extends BaseModel {
constructor(props = {}) {
super();
this.role_arn = "";
safeAssign(this, props);
this.updateState();
}
}
AwsConfig.setValidator(function(model) {
const errors = {};
if (!model.role_arn.length) {
errors.role_arn = "Missing role_arn";
}
return errors;
});
AwsConfig.fields = {
role_arn: {
label: "Role ARN",
description: "The Amazon resource name (ARN) of the role to be assumed by Galaxy.",
placeholder: "arn:aws:iam::XXXXXXXXXXXX:role/XXXXXXXXXXX"
}
};
@@ -0,0 +1,22 @@
import { AwsConfig } from "./AwsConfig";
describe("AwsConfig", () => {
it("should instantiate", () => {
let instance = new AwsConfig();
assert(instance);
assert(!instance.dirty);
assert(!instance.valid);
});
it("should validate role_arn", () => {
let instance = new AwsConfig();
instance.role_arn = "abc";
assert(instance.fieldValid("role_arn"));
});
it("should invalidate role_arn", () => {
let instance = new AwsConfig();
instance.role_arn = "";
assert(!instance.fieldValid("role_arn"));
});
});
@@ -0,0 +1,61 @@
import { safeAssign } from "utils/safeAssign";
import { BaseModel } from "./BaseModel";
export class AzureConfig extends BaseModel {
constructor(props = {}) {
super();
this.tenant_id = "";
this.client_id = "";
this.client_secret = "";
safeAssign(this, props);
this.updateState();
}
}
AzureConfig.setValidator(function(model) {
const errors = {};
if (model.tenant_id.length < 36) {
errors.tenant_id = "Tenant ID too short";
}
if (!model.tenant_id) {
errors.tenant_id = "Missing Tenant ID";
}
if (model.client_id.length < 36) {
errors.client_id = "Client ID too short";
}
if (!model.client_id) {
errors.client_id = "Missing client ID";
}
if (!model.client_secret) {
errors.client_secret = "Missing secret";
}
return errors;
});
AzureConfig.fields = {
tenant_id: {
label: "Tenant ID",
mask: "********-****-****-****-************",
placeholder: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
maxlength: 36,
description: "Your Tenant ID (or Directory ID) on Azure."
},
client_id: {
label: "Client ID",
mask: "********-****-****-****-************",
placeholder: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
maxlength: 36,
description: "The Client ID (or Application ID) you defined for Galaxy on your Azure directory."
},
client_secret: {
label: "Client Secret",
mask: "",
placeholder: "Client Secret",
description:
"A secret string you obtained from Azure portal that Galaxy can use to prove its identity when requesting tokens to access your resources."
}
};
@@ -0,0 +1,58 @@
import { AzureConfig } from "./AzureConfig";
describe("AzureConfig", () => {
it("should instantiate", () => {
let instance = new AzureConfig();
assert(instance);
assert(!instance.dirty);
assert(!instance.valid);
});
describe("client_secret", () => {
it("should validate client_secret", () => {
let instance = new AzureConfig();
instance.client_secret = "abc";
assert(instance.fieldValid("client_secret"));
});
it("should invalidate client_secret", () => {
let instance = new AzureConfig();
instance.client_secret = "";
assert(!instance.fieldValid("client_secret"));
});
});
describe("tenant_id", () => {
it("should validate tenant_id", () => {
let validTenantId = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx";
let instance = new AzureConfig();
instance.tenant_id = validTenantId;
assert(instance.fieldValid("tenant_id"));
});
it("should invalidate tenant_id", () => {
let instance = new AzureConfig();
instance.tenant_id = "";
assert(!instance.fieldValid("tenant_id"));
instance.tenant_id = "asdfa";
assert(!instance.fieldValid("tenant_id"));
});
});
describe("client_id", () => {
it("should validate client_id", () => {
let validClientId = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx";
let instance = new AzureConfig();
instance.client_id = validClientId;
assert(instance.fieldValid("client_id"));
});
it("should invalidate client_id", () => {
let instance = new AzureConfig();
instance.client_id = "";
assert(!instance.fieldValid("client_id"));
instance.client_id = "asdf";
assert(!instance.fieldValid("client_id"));
});
});
});
@@ -0,0 +1,102 @@
const lastState = new WeakMap();
const validators = new WeakMap();
const transients = new WeakMap();
const counter = new WeakMap();
// Helps with Vue for-loop keys
let instanceCounter = 0;
export class BaseModel {
constructor() {
counter.set(this, instanceCounter++);
}
/* object ID */
get counter() {
return counter.get(this);
}
/* Dirty state tracking */
get dirty() {
return lastState.get(this) != this.state;
}
get clean() {
return !this.dirty;
}
get state() {
// build state JSON ignoring transient fields
const tFields = this.transientFields;
return JSON.stringify(this, function(key) {
if (!tFields.has(key)) {
return this[key];
}
return undefined;
});
}
get lastState() {
return lastState.get(this);
}
get transientFields() {
const key = this.constructor;
if (!transients.has(key)) {
transients.set(key, new Set());
}
return transients.get(key);
}
updateState() {
lastState.set(this, this.state);
}
// Setting a property name as transient for a class means its
// state will be caclulated without regard to that propety
static setTransient(...fieldNames) {
const klass = this; // this will be a class
if (!transients.has(klass)) {
transients.set(klass, new Set());
}
const fields = transients.get(klass);
fieldNames.forEach(fieldName => fields.add(fieldName));
transients.set(klass, fields);
}
/* Validation */
get valid() {
return Object.keys(this.validationErrors).length == 0;
}
get validationErrors() {
return this.constructor.validate(this);
}
errorMessage(field) {
if (field in this.validationErrors) {
return this.validationErrors[field];
}
return "errmessage";
}
fieldValid(fieldName) {
return !(fieldName in this.validationErrors);
}
static validate(model) {
if (validators.has(this)) {
const validator = validators.get(this);
return validator(model);
}
throw new Error("Missing validator");
}
static setValidator(validationFunction) {
validators.set(this, validationFunction);
}
}
@@ -0,0 +1,104 @@
import { safeAssign } from "utils/safeAssign";
import { ResourceProviders } from "./ResourceProviders";
import { BaseModel } from "./BaseModel";
export class Credential extends BaseModel {
constructor(props = {}) {
super();
this.id = null;
this.description = "";
this.authn_id = null; // identity provider
this.provider = null; // resource provider
// transient props, exclude from state
this.expanded = false;
this.loading = false;
// populate props
const options = Object.assign({}, Credential.defaults, props);
safeAssign(this, options);
// init nested config
this.config = new this.configClass(options.config);
// initialize state
this.updateState();
}
get title() {
return this.description.length ? this.description : this.provider;
}
get valid() {
return super.valid && this.config.valid;
}
// Alias for provider also changes config object when updated
// Set this when updating in the UI
get resourceProvider() {
return this.provider;
}
set resourceProvider(newProvider) {
this.provider = newProvider;
this.config = new this.configClass({});
}
// Polymorphic config class
get configClass() {
return ResourceProviders.get(this.provider).klass;
}
// Methods
match(searchText = "") {
// TODO: more robust object matching?
return searchText.length ? this.title.includes(searchText) : true;
}
// Statics
static get defaults() {
return {
authn_id: null,
provider: "aws",
expanded: false
};
}
static create(props = {}) {
return new Credential(props);
}
}
/**
* Transient fields do not factor into the state
* for purposes of dirty tracking
*/
Credential.setTransient("expanded", "loading");
/**
* A validator function's job is to return an object
* where the keys are field names and the values are
* error messages.
*/
Credential.setValidator(function(model) {
const errors = {};
if (!model.provider) {
errors.provider = "Provider must be set";
}
if (!model.authn_id) {
errors.authn_id = "Please pick an identity provider";
}
if (!model.config.valid) {
errors.config = "Invalid config object";
}
return errors;
});
@@ -0,0 +1,106 @@
import { Credential } from "./Credential";
describe("Credential model", () => {
describe("basic model props", () => {
it("should exist", () => {
assert(Credential);
});
it("should build with defaults", () => {
let instance = new Credential();
assert(instance);
});
it("should build with props", () => {
let description = "i am the test description";
let props = { description };
let instance = new Credential(props);
assert(instance);
assert(instance.description == description);
});
it("default config object should be AWS", () => {
let instance = new Credential();
assert("role_arn" in instance.config, "Missing role_arn prop");
assert(instance.config.constructor.name == "AwsConfig");
});
it("should load a different config object given the right provider", () => {
let provider = "azure";
let props = { provider };
let instance = new Credential(props);
assert(instance.provider == provider);
assert(instance.config.constructor.name == "AzureConfig");
});
it("should dynamically switch config objects as resourceProvider is changed", () => {
let instance = new Credential();
assert(instance.provider == "aws", "Should default to AWS config' ");
instance.resourceProvider = "azure";
assert(instance.config.constructor.name == "AzureConfig", "Should have been AzureConfig");
instance.resourceProvider = "aws";
assert(instance.config.constructor.name == "AwsConfig", "Should be AWS Config again");
});
});
describe("dirty state", () => {
it("should flag as dirty when a prop is changed", () => {
let instance = new Credential();
instance.description = "foo";
assert(instance.dirty);
});
it("should flag as clean when a prop is restored", () => {
let instance = new Credential();
instance.description = "foo";
assert(instance.dirty);
instance.description = "";
assert(!instance.dirty);
});
it("should not flag as dirty when a transient prop is changed", () => {
let instance = new Credential();
instance.loading = true;
assert(!instance.dirty);
});
});
describe("validation", () => {
it("a new object should be invalid", () => {
let instance = new Credential();
assert(!instance.valid);
});
it("it should become valid when props are assigned", () => {
let instance = new Credential();
instance.authn_id = "floob";
instance.resourceProvider = "aws";
instance.config.role_arn = "abc";
assert(instance.valid);
});
it("it should be valid when initialized with correct props", () => {
let props = {
authn_id: "asdfasdf",
provider: "aws",
config: {
role_arn: "floobar"
}
};
let instance = new Credential(props);
assert(instance.valid);
});
it("should be invalid when bad props assigned", () => {
let props = {
authn_id: "asdfasdf",
provider: "aws",
config: {
role_arn: ""
}
};
let instance = new Credential(props);
assert(!instance.valid);
});
});
});
@@ -0,0 +1,29 @@
import { safeAssign } from "utils/safeAssign";
export class IdentityProvider {
constructor(props = {}) {
this.id = "";
this.provider = "";
safeAssign(this, props);
}
// Aliases
get authn_id() {
return this.id;
}
get text() {
return this.provider;
}
get value() {
return this.id;
}
// Statics
static create(props = {}) {
return new IdentityProvider(props);
}
}
@@ -0,0 +1,14 @@
import { AwsConfig } from "./AwsConfig";
import { AzureConfig } from "./AzureConfig";
export const ResourceProviders = new Map();
ResourceProviders.set("aws", {
klass: AwsConfig,
label: "Amazon Web Services (AWS)"
});
ResourceProviders.set("azure", {
klass: AzureConfig,
label: "Microsoft Azure"
});
@@ -0,0 +1,3 @@
export { Credential } from "./Credential";
export { IdentityProvider } from "./IdentityProvider";
export { ResourceProviders } from "./ResourceProviders";
@@ -0,0 +1,78 @@
/**
* Data retrieval/storage for the auth keys
*/
import axios from "axios";
import { Credential, IdentityProvider } from "./index";
import { getRootFromIndexLink } from "onload";
const getUrl = path => getRootFromIndexLink() + path;
export async function listCredentials() {
const url = getUrl("api/cloud/authz");
const response = await axios.get(url);
if (response.status != 200) {
throw new Error("Unexpected response from listing.");
}
return response.data.map(Credential.create);
}
export async function getCredential(id) {
const url = getUrl("api/cloud/authz/${id}");
const response = await axios.get(url);
if (response.status != 200) {
throw new Error("Unexpected response loading key.");
}
return Credential.create(response.data);
}
export async function saveCredential(newItem) {
const model = Credential.create(newItem);
const response = await saveOrUpdate(model);
if (response.status != 200) {
throw new Error("Save failure.");
}
return Credential.create(response.data);
}
async function saveOrUpdate(model) {
return model.id
? axios.put(getUrl(`api/cloud/authz/${model.id}`), model)
: axios.post(getUrl("api/cloud/authz"), model);
}
export async function deleteCredential(doomed) {
const model = Credential.create(doomed);
if (model.id) {
const url = getUrl(`api/cloud/authz/${doomed.id}`);
const response = await axios.delete(url);
if (response.status != 200) {
throw new Error("Delete failure.");
}
}
return model;
}
// Memoize results (basically never changes)
let identityProviders;
export async function getIdentityProviders() {
if (!identityProviders) {
const url = getUrl("authnz");
const response = await axios.get(url);
if (response.status != 200) {
throw new Error("Unable to load identity providers");
}
identityProviders = response.data.map(IdentityProvider.create);
}
return identityProviders;
}
export default {
listCredentials,
getCredential,
saveCredential,
deleteCredential,
getIdentityProviders
};
@@ -0,0 +1,32 @@
[
{
"authn_id": "f2db41e1fa331b3e",
"user_id": "f2db41e1fa331b3e",
"description": "aws description",
"last_update": "2019-04-04 09:13:16.135032",
"last_activity": "2019-04-04 09:13:16.135042",
"create_time": "2019-04-04 16:13:16.135755",
"provider": "aws",
"model_class": "CloudAuthz",
"config": {
"role_arn": "floobadooba"
},
"id": "a7db2fac67043c7e"
},
{
"authn_id": "f2db41e1fa331b3e",
"user_id": "f2db41e1fa331b3e",
"description": "azure description",
"last_update": "2019-04-03 20:21:51.350748",
"last_activity": "2019-04-03 20:21:51.350804",
"create_time": "2019-04-04 03:21:51.351508",
"provider": "azure",
"model_class": "CloudAuthz",
"config": {
"client_secret": "dannon likes Nickleback",
"client_id": "22222222-2222-2222-2222-222222222222",
"tenant_id": "11111111-1111-1111-1111-111111111111"
},
"id": "b472e2eb553fa0d1"
}
]
@@ -38,6 +38,7 @@ import DatasetEditAttributes from "mvc/dataset/dataset-edit-attributes";
import Citations from "components/Citations.vue";
import DisplayStructure from "components/DisplayStructured.vue";
import Vue from "vue";
import { CloudAuth } from "components/User/CloudAuth";
/** Routes */
export const getAnalysisRouter = Galaxy =>
@@ -47,6 +48,7 @@ export const getAnalysisRouter = Galaxy =>
"(/)root*": "home",
"(/)tours(/)(:tour_id)": "show_tours",
"(/)user(/)": "show_user",
"(/)user(/)cloud_auth": "show_cloud_auth",
"(/)user(/)(:form_id)": "show_user_form",
"(/)pages(/)create(/)": "show_pages_create",
"(/)pages(/)edit(/)": "show_pages_edit",
@@ -75,18 +77,19 @@ export const getAnalysisRouter = Galaxy =>
"(/)datasets/error": "show_dataset_error"
},
require_login: ["show_user", "show_user_form", "show_workflows"],
require_login: ["show_user", "show_user_form", "show_workflows", "show_cloud_auth"],
authenticate: function(args, name) {
const Galaxy = getGalaxyInstance();
return (Galaxy.user && Galaxy.user.id) || this.require_login.indexOf(name) == -1;
},
_display_vue_helper: function(component, props) {
_display_vue_helper: function(component, props = {}) {
const instance = Vue.extend(component);
const vm = document.createElement("div");
this.page.display(vm);
new instance(props).$mount(vm);
const container = document.createElement("div");
this.page.display(container);
const vm = new instance(props).$mount(container);
return vm;
},
show_tours: function(tour_id) {
@@ -109,6 +112,10 @@ export const getAnalysisRouter = Galaxy =>
this.page.display(new FormWrapper.View(_.extend(model.get(form_id), { active_tab: "user" })));
},
show_cloud_auth: function() {
this._display_vue_helper(CloudAuth);
},
show_visualizations: function(action_id) {
var activeTab = action_id == "list_published" ? "shared" : "user";
this.page.display(
@@ -90,6 +90,15 @@ var Model = Backbone.Model.extend({
submit_title: "Create a new key",
submit_icon: "fa-check"
},
cloud_auth: {
title: _l("Manage Cloud Authorization"),
description: _l(
"Add or modify the configuration that grants Galaxy to access your cloud-based resources."
),
icon: "fa-cloud",
submit_title: "Create a new key",
submit_icon: "fa-check"
},
toolbox_filters: {
title: _l("Manage Toolbox filters"),
description: _l("Customize your Toolbox by displaying or omitting sets of Tools."),
@@ -174,6 +183,7 @@ var View = Backbone.View.extend({
self._addLink("permissions");
self._addLink("make_data_private");
self._addLink("api_key");
self._addLink("cloud_auth");
if (config.enable_openid) {
self._addLink("genomespace");
}
+11
View File
@@ -0,0 +1,11 @@
// Object.assign, but props transferred must exist in target
export function safeAssign(target, source = {}) {
if (!(source instanceof Object)) {
console.warn("safeAssign expected an object, instead got:", source);
source = {};
}
Object.keys(source)
.filter(prop => target.hasOwnProperty(prop))
.forEach(prop => (target[prop] = source[prop]));
}
+52
View File
@@ -1,3 +1,7 @@
$fa-font-path: "~font-awesome/fonts/";
@import "~font-awesome/scss/font-awesome.scss";
@import "~font-awesome/scss/_mixins";
// Utility mixin expands to container edges
@mixin fill() {
position: relative;
@@ -29,3 +33,51 @@
display: none;
}
}
@mixin scrollMe() {
overflow-x: hidden;
overflow-y: scroll;
&::-webkit-scrollbar {
display: none;
}
}
// top selector fixed, bottom list scrolls
@mixin scrollingListLayout($fixedTop, $listRegion) {
@include fill();
display: flex;
flex-direction: column;
#{$fixedTop} {
flex-grow: 0;
}
#{$listRegion} {
flex-grow: 1;
@include scrollMe();
}
}
// Removes browser formatting of lists
@mixin list_reset() {
list-style: none;
padding: 0 0 0 0;
margin: 0 0 0 0;
}
// font awesome as a mixin
// icon variables come from ~font-awesome/scss/_variables.scss
@mixin fontawesome($icon) {
@include fa-icon();
&:before {
content: $icon;
}
span {
// Span for text readers
// <a><span>Label</span></a>
@include sr-only();
}
}
+2
View File
@@ -23,8 +23,10 @@
"bootstrap-vue": "^2.0.0-rc.16",
"d3": "3",
"decode-uri-component": "^0.2.0",
"flush-promises": "^1.0.2",
"font-awesome": "^4.7.0",
"handsontable": "^2.0.0",
"imask": "^4.1.5",
"imports-loader": "^0.8.0",
"jquery": "2",
"jquery-migrate": "~1.4",
+4 -1
View File
@@ -162,7 +162,10 @@ let buildconfig = {
loader: "sass-loader",
options: {
sourceMap: true,
includePaths: ["galaxy/style/scss"]
includePaths: [
"galaxy/style/scss",
path.resolve(__dirname, './node_modules')
]
}
}
]
+10
View File
@@ -5530,6 +5530,11 @@ flatted@^2.0.0:
resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.0.tgz#55122b6536ea496b4b44893ee2608141d10d9916"
integrity sha512-R+H8IZclI8AAkSBRQJLVOsxwAoHd6WC40b4QTNWIjzAa6BXOBfQcM587MXDTVPeYaopFNWHUFLx7eNmHDSxMWg==
flush-promises@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/flush-promises/-/flush-promises-1.0.2.tgz#4948fd58f15281fed79cbafc86293d5bb09b2ced"
integrity sha512-G0sYfLQERwKz4+4iOZYQEZVpOt9zQrlItIxQAAYAWpfby3gbHrx0osCHz5RLl/XoXevXk0xoN4hDFky/VV9TrA==
flush-write-stream@^1.0.0:
version "1.0.3"
resolved "https://registry.yarnpkg.com/flush-write-stream/-/flush-write-stream-1.0.3.tgz#c5d586ef38af6097650b49bc41b55fabb19f35bd"
@@ -6480,6 +6485,11 @@ ignore@^4.0.6:
resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc"
integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==
imask@^4.1.5:
version "4.1.5"
resolved "https://registry.yarnpkg.com/imask/-/imask-4.1.5.tgz#9942993ece68b7b33d03e12781a7401b1207e52c"
integrity sha512-n9e3KkJxe6YZKueT02I8/tXpC0vzxmSzoBB5BPi6OPq0rp7qOZfXN5777Dkw6WEYRswlsxm+7pl7WocsG0LWyA==
immer@1.10.0:
version "1.10.0"
resolved "https://registry.yarnpkg.com/immer/-/immer-1.10.0.tgz#bad67605ba9c810275d91e1c2a47d4582e98286d"
+5 -3
View File
@@ -44,7 +44,8 @@ class CloudAuthzsSerializer(base.ModelSerializer):
'authn_id',
'last_update',
'last_activity',
'create_time'
'create_time',
'description'
])
def add_serializers(self):
@@ -63,7 +64,8 @@ class CloudAuthzsSerializer(base.ModelSerializer):
'authn_id' : lambda i, k, **c: self.app.security.encode_id(i.authn_id),
'last_update' : lambda i, k, **c: str(i.last_update),
'last_activity': lambda i, k, **c: str(i.last_activity),
'create_time' : lambda i, k, **c: str(i.create_time)
'create_time' : lambda i, k, **c: str(i.create_time),
'description' : lambda i, k, **c: str(i.description)
})
@@ -80,7 +82,7 @@ class CloudAuthzsDeserializer(base.ModelDeserializer):
'authn_id': self.deserialize_and_validate_authn_id,
'provider': self.default_deserializer,
'config': self.default_deserializer,
'deleted': self.default_deserializer
'description': self.default_deserializer
})
def deserialize_and_validate_authn_id(self, item, key, val, **context):