ui: Restrict the viewing/editing of certain UI elements based on the users ACLs (#9687)

This commit use the internal authorize endpoint along wiht ember-can to further restrict user access to certain UI features and navigational elements depending on the users ACL token
This commit is contained in:
John Cowen 2021-02-19 16:42:16 +00:00 committed by GitHub
parent 804b24ae6b
commit dc183b1786
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
82 changed files with 1080 additions and 187 deletions

3
.changelog/9687.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:improvement
ui: restrict the viewing/editing of certain UI elements based on the users ACL token
```

View File

@ -128,6 +128,7 @@ token/secret.
| `CONSUL_EXPOSED_COUNT` | (random) | Configure the number of exposed paths that the API returns. | | `CONSUL_EXPOSED_COUNT` | (random) | Configure the number of exposed paths that the API returns. |
| `CONSUL_CHECK_COUNT` | (random) | Configure the number of health checks that the API returns. | | `CONSUL_CHECK_COUNT` | (random) | Configure the number of health checks that the API returns. |
| `CONSUL_OIDC_PROVIDER_COUNT` | (random) | Configure the number of OIDC providers that the API returns. | | `CONSUL_OIDC_PROVIDER_COUNT` | (random) | Configure the number of OIDC providers that the API returns. |
| `CONSUL_RESOURCE_<singular-resource-name>_<access-type>` | true | Configure permissions e.g `CONSUL_RESOURCE_INTENTION_WRITE=false`. |
| `DEBUG_ROUTES_ENDPOINT` | undefined | When using the window.Routes() debug utility ([see utility functions](#browser-debug-utility-functions)), use a URL to pass the route DSL to. %s in the URL will be replaced with the route DSL - http://url.com?routes=%s | | `DEBUG_ROUTES_ENDPOINT` | undefined | When using the window.Routes() debug utility ([see utility functions](#browser-debug-utility-functions)), use a URL to pass the route DSL to. %s in the URL will be replaced with the route DSL - http://url.com?routes=%s |
See `./mock-api` for more details. See `./mock-api` for more details.

View File

@ -0,0 +1,16 @@
import BaseAbility from './base';
import { inject as service } from '@ember/service';
// ACL ability covers all of the ACL things, like tokens, policies, roles and
// auth methods and this therefore should not be deleted once we remove the on
// legacy ACLs related classes
export default class ACLAbility extends BaseAbility {
@service('env') env;
resource = 'acl';
segmented = false;
get canRead() {
return this.env.var('CONSUL_ACLS_ENABLED') && super.canRead;
}
}

View File

@ -0,0 +1,8 @@
import BaseAbility from './base';
import { inject as service } from '@ember/service';
export default class AuthenticateAbility extends BaseAbility {
@service('env') env;
get can() {
return this.env.var('CONSUL_ACLS_ENABLED');
}
}

View File

@ -0,0 +1,83 @@
import { inject as service } from '@ember/service';
import { get } from '@ember/object';
import { Ability } from 'ember-can';
export const ACCESS_READ = 'read';
export const ACCESS_WRITE = 'write';
export const ACCESS_LIST = 'list';
// None of the permission inspection here is namespace aware, this is due to
// the fact that we only have one set of permission from one namespace at one
// time, therefore all the permissions are relevant to the namespace you are
// currently in, when you choose a different namespace we refresh the
// permissions list. This is also fine for permission inspection for single
// items/models as we only request the permissions for the namespace you are
// in, we don't need to recheck that namespace here
export default class BaseAbility extends Ability {
@service('repository/permission') permissions;
// the name of the resource used for this ability in the backend, e.g
// service, key, operator, node
resource = '';
// whether you can ask the backend for a segment for this resource, e.g. you
// can ask for a specific service or KV, but not a specific nspace or token
segmented = true;
generate(action) {
return this.permissions.generate(this.resource, action);
}
generateForSegment(segment) {
// if this ability isn't segmentable just return empty which means we
// won't request the permissions/resources form the backend
if (!this.segmented) {
return [];
}
return [
this.permissions.generate(this.resource, ACCESS_READ, segment),
this.permissions.generate(this.resource, ACCESS_WRITE, segment),
];
}
get canRead() {
if (typeof this.item !== 'undefined') {
const perm = (get(this, 'item.Resources') || []).find(item => item.Access === ACCESS_READ);
if (perm) {
return perm.Allow;
}
}
return this.permissions.has(this.generate(ACCESS_READ));
}
get canList() {
if (typeof this.item !== 'undefined') {
const perm = (get(this, 'item.Resources') || []).find(item => item.Access === ACCESS_LIST);
if (perm) {
return perm.Allow;
}
}
return this.permissions.has(this.generate(ACCESS_LIST));
}
get canWrite() {
if (typeof this.item !== 'undefined') {
const perm = (get(this, 'item.Resources') || []).find(item => item.Access === ACCESS_WRITE);
if (perm) {
return perm.Allow;
}
}
return this.permissions.has(this.generate(ACCESS_WRITE));
}
get canCreate() {
return this.canWrite;
}
get canDelete() {
return this.canWrite;
}
get canUpdate() {
return this.canWrite;
}
}

View File

@ -0,0 +1,9 @@
import BaseAbility from './base';
export default class IntentionAbility extends BaseAbility {
resource = 'intention';
get canWrite() {
return super.canWrite && (typeof this.item === 'undefined' || this.item.IsEditable);
}
}

View File

@ -0,0 +1,13 @@
import BaseAbility, { ACCESS_LIST } from './base';
export default class KVAbility extends BaseAbility {
resource = 'key';
generateForSegment(segment) {
let resources = super.generateForSegment(segment);
if (segment.endsWith('/')) {
resources = resources.concat(this.permissions.generate(this.resource, ACCESS_LIST, segment));
}
return resources;
}
}

View File

@ -0,0 +1,5 @@
import BaseAbility from './base';
export default class NodeAbility extends BaseAbility {
resource = 'node';
}

View File

@ -0,0 +1,17 @@
import BaseAbility from './base';
import { inject as service } from '@ember/service';
export default class NspaceAbility extends BaseAbility {
@service('env') env;
resource = 'operator';
segmented = false;
get canManage() {
return this.canCreate;
}
get canChoose() {
return this.env.var('CONSUL_NSPACES_ENABLED') && this.nspaces.length > 0;
}
}

View File

@ -0,0 +1,7 @@
import BaseAbility from './base';
export default class PermissionAbility extends BaseAbility {
get canRead() {
return this.permissions.permissions.length > 0;
}
}

View File

@ -0,0 +1,5 @@
import BaseAbility from './base';
export default class ServiceInstanceAbility extends BaseAbility {
resource = 'service';
}

View File

@ -0,0 +1,5 @@
import BaseAbility from './base';
export default class ServiceAbility extends BaseAbility {
resource = 'service';
}

View File

@ -0,0 +1,5 @@
import BaseAbility from './base';
export default class SessionAbility extends BaseAbility {
resource = 'session';
}

View File

@ -9,7 +9,7 @@ export default class ApplicationAdapter extends Adapter {
@service('env') env; @service('env') env;
formatNspace(nspace) { formatNspace(nspace) {
if (this.env.env('CONSUL_NSPACES_ENABLED')) { if (this.env.var('CONSUL_NSPACES_ENABLED')) {
return nspace !== '' ? { [NSPACE_QUERY_PARAM]: nspace } : undefined; return nspace !== '' ? { [NSPACE_QUERY_PARAM]: nspace } : undefined;
} }
} }

View File

@ -57,33 +57,4 @@ export default class NspaceAdapter extends Adapter {
DELETE /v1/namespace/${data[SLUG_KEY]} DELETE /v1/namespace/${data[SLUG_KEY]}
`; `;
} }
requestForAuthorize(request, { dc, ns, index }) {
return request`
POST /v1/internal/acl/authorize?${{ dc, ns, index }}
${[
{
Resource: 'operator',
Access: 'write',
},
]}
`;
}
authorize(store, type, id, snapshot) {
return this.rpc(
function(adapter, request, serialized, unserialized) {
return adapter.requestForAuthorize(request, serialized, unserialized);
},
function(serializer, respond, serialized, unserialized) {
// Completely skip the serializer here
return respond(function(headers, body) {
return body;
});
},
snapshot,
type.modelName
);
}
} }

View File

@ -0,0 +1,39 @@
import Adapter from './application';
import { inject as service } from '@ember/service';
export default class PermissionAdapter extends Adapter {
@service('env') env;
requestForAuthorize(request, { dc, ns, permissions = [], index }) {
// the authorize endpoint is slightly different to all others in that it
// ignores an ns parameter, but accepts a Namespace property on each
// resource. Here we hide this different from the rest of the app as
// currently we never need to ask for permissions/resources for mutiple
// different namespaces in one call so here we use the ns param and add
// this to the resources instead of passing through on the queryParameter
if (this.env.var('CONSUL_NSPACES_ENABLED')) {
permissions = permissions.map(item => ({ ...item, Namespace: ns }));
}
return request`
POST /v1/internal/acl/authorize?${{ dc, index }}
${permissions}
`;
}
authorize(store, type, id, snapshot) {
return this.rpc(
function(adapter, request, serialized, unserialized) {
return adapter.requestForAuthorize(request, serialized, unserialized);
},
function(serializer, respond, serialized, unserialized) {
// Completely skip the serializer here
return respond(function(headers, body) {
return body;
});
},
snapshot,
type.modelName
);
}
}

View File

@ -5,6 +5,6 @@
</h1> </h1>
</BlockSlot> </BlockSlot>
<BlockSlot @name="content"> <BlockSlot @name="content">
<ErrorState @error={{@error}} /> <ErrorState @error={{@error}} @allowLogin={{eq @error.status "403"}} />
</BlockSlot> </BlockSlot>
</AppView> </AppView>

View File

@ -32,7 +32,7 @@ as |api|>
<BlockSlot @name="form"> <BlockSlot @name="form">
{{#let api.data as |item|}} {{#let api.data as |item|}}
{{#if item.IsEditable}} {{#if (can 'write intention' item=item)}}
{{#if this.warn}} {{#if this.warn}}
{{#let (changeset-get item 'Action') as |newAction|}} {{#let (changeset-get item 'Action') as |newAction|}}

View File

@ -59,7 +59,7 @@ as |item index|>
More More
</BlockSlot> </BlockSlot>
<BlockSlot @name="menu" as |confirm send keypressClick change|> <BlockSlot @name="menu" as |confirm send keypressClick change|>
{{#if item.IsEditable}} {{#if (can "write intention" item=item)}}
<li role="none"> <li role="none">
<a role="menuitem" tabindex="-1" href={{href-to (or routeName 'dc.intentions.edit') item.ID}}>Edit</a> <a role="menuitem" tabindex="-1" href={{href-to (or routeName 'dc.intentions.edit') item.ID}}>Edit</a>
</li> </li>

View File

@ -11,8 +11,11 @@
as |api| as |api|
> >
<BlockSlot @name="content"> <BlockSlot @name="content">
{{#let (cannot 'write kv' item=api.data) as |disabld|}}
<form onsubmit={{action api.submit}}> <form onsubmit={{action api.submit}}>
<fieldset disabled={{api.disabled}}> <fieldset
{{disabled (or disabld api.disabled)}}
>
{{#if api.isCreate}} {{#if api.isCreate}}
<label class="type-text{{if api.data.error.Key ' has-error'}}"> <label class="type-text{{if api.data.error.Key ' has-error'}}">
<span>Key or folder</span> <span>Key or folder</span>
@ -24,27 +27,47 @@
<div> <div>
<div class="type-toggle"> <div class="type-toggle">
<label> <label>
<input type="checkbox" name="json" checked={{if json 'checked'}} onchange={{action api.change}} /> <input
type="checkbox"
name="json"
{{disabled false}}
checked={{if json 'checked'}}
onchange={{action api.change}}
/>
<span>Code</span> <span>Code</span>
</label> </label>
</div> </div>
<label for="" class="type-text{{if api.data.error.Value ' has-error'}}"> <label for="" class="type-text{{if api.data.error.Value ' has-error'}}">
<span>Value</span> <span>Value</span>
{{#if json}} {{#if json}}
<CodeEditor @name="value" @value={{atob api.data.Value}} @onkeyup={{action api.change "value"}} /> <CodeEditor
@name="value"
@readonly={{or disabld api.disabled}}
@value={{atob api.data.Value}}
@onkeyup={{action api.change "value"}}
/>
{{else}} {{else}}
<textarea autofocus={{not api.isCreate}} name="value" oninput={{action api.change}}>{{atob api.data.Value}}</textarea> <textarea
{{disabled (or disabld api.disabled)}}
autofocus={{not api.isCreate}}
name="value"
oninput={{action api.change}}>{{atob api.data.Value}}</textarea>
{{/if}} {{/if}}
</label> </label>
</div> </div>
{{/if}} {{/if}}
</fieldset> </fieldset>
{{#if api.isCreate}} {{#if api.isCreate}}
{{#if (not disabld)}}
<button type="submit" disabled={{or api.data.isPristine api.data.isInvalid api.disabled}}>Save</button> <button type="submit" disabled={{or api.data.isPristine api.data.isInvalid api.disabled}}>Save</button>
{{/if}}
<button type="reset" onclick={{action oncancel api.data}} disabled={{api.disabled}}>Cancel</button> <button type="reset" onclick={{action oncancel api.data}} disabled={{api.disabled}}>Cancel</button>
{{else}} {{else}}
{{#if (not disabld)}}
<button type="submit" disabled={{or api.data.isInvalid api.disabled}}>Save</button> <button type="submit" disabled={{or api.data.isInvalid api.disabled}}>Save</button>
{{/if}}
<button type="reset" onclick={{action oncancel api.data}} disabled={{api.disabled}}>Cancel</button> <button type="reset" onclick={{action oncancel api.data}} disabled={{api.disabled}}>Cancel</button>
{{#if (not disabld)}}
<ConfirmationDialog @message="Are you sure you want to delete this key?"> <ConfirmationDialog @message="Are you sure you want to delete this key?">
<BlockSlot @name="action" as |confirm|> <BlockSlot @name="action" as |confirm|>
<button data-test-delete type="button" class="type-delete" {{action confirm api.delete}} disabled={{api.disabled}}>Delete</button> <button data-test-delete type="button" class="type-delete" {{action confirm api.delete}} disabled={{api.disabled}}>Delete</button>
@ -54,6 +77,8 @@
</BlockSlot> </BlockSlot>
</ConfirmationDialog> </ConfirmationDialog>
{{/if}} {{/if}}
{{/if}}
</form> </form>
{{/let}}
</BlockSlot> </BlockSlot>
</DataForm> </DataForm>

View File

@ -17,6 +17,7 @@ as |item index|>
More More
</BlockSlot> </BlockSlot>
<BlockSlot @name="menu" as |confirm send keypressClick|> <BlockSlot @name="menu" as |confirm send keypressClick|>
{{#if (can 'write kv' item=item)}}
<li role="none"> <li role="none">
<a data-test-edit role="menuitem" tabindex="-1" href={{href-to (if item.isFolder 'dc.kv.folder' 'dc.kv.edit') item.Key}}>{{if item.isFolder 'View' 'Edit'}}</a> <a data-test-edit role="menuitem" tabindex="-1" href={{href-to (if item.isFolder 'dc.kv.folder' 'dc.kv.edit') item.Key}}>{{if item.isFolder 'View' 'Edit'}}</a>
</li> </li>
@ -55,6 +56,11 @@ as |item index|>
</InformedAction> </InformedAction>
</div> </div>
</li> </li>
{{else}}
<li role="none">
<a data-test-edit role="menuitem" tabindex="-1" href={{href-to (if item.isFolder 'dc.kv.folder' 'dc.kv.edit') item.Key}}>View</a>
</li>
{{/if}}
</BlockSlot> </BlockSlot>
</PopoverMenu> </PopoverMenu>
</BlockSlot> </BlockSlot>

View File

@ -41,6 +41,7 @@
</dd> </dd>
{{/if}} {{/if}}
</dl> </dl>
{{#if (can 'delete session' item=api.data)}}
<ConfirmationDialog @message="Are you sure you want to invalidate this session?"> <ConfirmationDialog @message="Are you sure you want to invalidate this session?">
<BlockSlot @name="action" as |confirm|> <BlockSlot @name="action" as |confirm|>
<button type="button" data-test-delete class="type-delete" {{action confirm api.delete session}} disabled={{api.disabled}}>Invalidate Session</button> <button type="button" data-test-delete class="type-delete" {{action confirm api.delete session}} disabled={{api.disabled}}>Invalidate Session</button>
@ -53,6 +54,7 @@
<button type="button" class="type-cancel" {{action cancel}}>Cancel</button> <button type="button" class="type-cancel" {{action cancel}}>Cancel</button>
</BlockSlot> </BlockSlot>
</ConfirmationDialog> </ConfirmationDialog>
{{/if}}
</div> </div>
</BlockSlot> </BlockSlot>
</DataForm> </DataForm>

View File

@ -50,6 +50,7 @@
</dd> </dd>
</dl> </dl>
</BlockSlot> </BlockSlot>
{{#if (can "delete sessions")}}
<BlockSlot @name="actions"> <BlockSlot @name="actions">
<ConfirmationDialog @message="Are you sure you want to invalidate this session?"> <ConfirmationDialog @message="Are you sure you want to invalidate this session?">
<BlockSlot @name="action" as |confirm|> <BlockSlot @name="action" as |confirm|>
@ -70,5 +71,6 @@
</BlockSlot> </BlockSlot>
</ConfirmationDialog> </ConfirmationDialog>
</BlockSlot> </BlockSlot>
{{/if}}
</ListCollection> </ListCollection>
{{/if}} {{/if}}

View File

@ -62,10 +62,17 @@
</Notification> </Notification>
{{/yield-slot}} {{/yield-slot}}
</State> </State>
{{#if (eq error.status "403")}}
<YieldSlot @name="loaded"> {{#yield-slot name="error"}}
{{yield api}} {{yield api}}
</YieldSlot> {{else}}
<ErrorState @error={{error}} />
{{/yield-slot}}
{{else}}
<YieldSlot @name="loaded">
{{yield api}}
</YieldSlot>
{{/if}}
</State> </State>

View File

@ -18,7 +18,7 @@
{{#if @dc}} {{#if @dc}}
<ul> <ul>
{{#let (or this.nspaces @nspaces) as |nspaces|}} {{#let (or this.nspaces @nspaces) as |nspaces|}}
{{#if (and (env 'CONSUL_NSPACES_ENABLED') (gt nspaces.length 0))}} {{#if (can "choose nspaces" nspaces=nspaces)}}
<li <li
class="nspaces" class="nspaces"
data-test-nspace-menu data-test-nspace-menu
@ -52,7 +52,7 @@
</BlockSlot> </BlockSlot>
</MenuItem> </MenuItem>
{{/each}} {{/each}}
{{#if this.canManageNspaces}} {{#if (can 'manage nspaces')}}
<MenuSeparator /> <MenuSeparator />
<MenuItem <MenuItem
data-test-main-nav-nspaces data-test-main-nav-nspaces
@ -104,18 +104,27 @@
</PopoverMenu> </PopoverMenu>
</li> </li>
{{#if (can "read services")}}
<li data-test-main-nav-services class={{if (is-href 'dc.services' @dc.Name) 'is-active'}}> <li data-test-main-nav-services class={{if (is-href 'dc.services' @dc.Name) 'is-active'}}>
<a href={{href-to 'dc.services' @dc.Name}}>Services</a> <a href={{href-to 'dc.services' @dc.Name}}>Services</a>
</li> </li>
{{/if}}
{{#if (can "read nodes")}}
<li data-test-main-nav-nodes class={{if (is-href 'dc.nodes' @dc.Name) 'is-active'}}> <li data-test-main-nav-nodes class={{if (is-href 'dc.nodes' @dc.Name) 'is-active'}}>
<a href={{href-to 'dc.nodes' @dc.Name}}>Nodes</a> <a href={{href-to 'dc.nodes' @dc.Name}}>Nodes</a>
</li> </li>
{{/if}}
{{#if (can "read kv")}}
<li data-test-main-nav-kvs class={{if (is-href 'dc.kv' @dc.Name) 'is-active'}}> <li data-test-main-nav-kvs class={{if (is-href 'dc.kv' @dc.Name) 'is-active'}}>
<a href={{href-to 'dc.kv' @dc.Name}}>Key/Value</a> <a href={{href-to 'dc.kv' @dc.Name}}>Key/Value</a>
</li> </li>
{{/if}}
{{#if (can "read intentions")}}
<li data-test-main-nav-intentions class={{if (is-href 'dc.intentions' @dc.Name) 'is-active'}}> <li data-test-main-nav-intentions class={{if (is-href 'dc.intentions' @dc.Name) 'is-active'}}>
<a href={{href-to 'dc.intentions' @dc.Name}}>Intentions</a> <a href={{href-to 'dc.intentions' @dc.Name}}>Intentions</a>
</li> </li>
{{/if}}
{{#if (can "read acls")}}
<li role="separator">Access Controls</li> <li role="separator">Access Controls</li>
<li data-test-main-nav-tokens class={{if (is-href 'dc.acls.tokens' @dc.Name) 'is-active'}}> <li data-test-main-nav-tokens class={{if (is-href 'dc.acls.tokens' @dc.Name) 'is-active'}}>
<a href={{href-to 'dc.acls.tokens' @dc.Name}}>Tokens</a> <a href={{href-to 'dc.acls.tokens' @dc.Name}}>Tokens</a>
@ -129,6 +138,7 @@
<li data-test-main-nav-auth-methods class={{if (is-href 'dc.acls.auth-methods' @dc.Name) 'is-active'}}> <li data-test-main-nav-auth-methods class={{if (is-href 'dc.acls.auth-methods' @dc.Name) 'is-active'}}>
<a href={{href-to 'dc.acls.auth-methods' @dc.Name}}>Auth Methods</a> <a href={{href-to 'dc.acls.auth-methods' @dc.Name}}>Auth Methods</a>
</li> </li>
{{/if}}
</ul> </ul>
{{/if}} {{/if}}
@ -175,7 +185,7 @@
<li data-test-main-nav-settings class={{if (is-href 'settings') 'is-active'}}> <li data-test-main-nav-settings class={{if (is-href 'settings') 'is-active'}}>
<a href={{href-to 'settings'}}>Settings</a> <a href={{href-to 'settings'}}>Settings</a>
</li> </li>
{{#if (env 'CONSUL_ACLS_ENABLED')}} {{#if (can 'authenticate')}}
<li data-test-main-nav-auth> <li data-test-main-nav-auth>
<AuthDialog <AuthDialog
@dc={{@dc.Name}} @dc={{@dc.Name}}

View File

@ -2,16 +2,6 @@ import Component from '@glimmer/component';
import { action } from '@ember/object'; import { action } from '@ember/object';
export default class HashiCorpConsul extends Component { export default class HashiCorpConsul extends Component {
// TODO: Right now this is the only place where we need permissions
// but we are likely to need it elsewhere, so probably need a nice helper
get canManageNspaces() {
return (
typeof (this.args.permissions || []).find(function(item) {
return item.Resource === 'operator' && item.Access === 'write' && item.Allow;
}) !== 'undefined'
);
}
@action @action
open() { open() {
this.authForm.focus(); this.authForm.focus();

View File

@ -96,6 +96,7 @@
returns=(set this 'popoverController') returns=(set this 'popoverController')
}} }}
{{on 'click' (fn (optional this.popoverController.show))}} {{on 'click' (fn (optional this.popoverController.show))}}
{{disabled (cannot 'update intention' item=item.Intention)}}
type="button" type="button"
style={{{concat 'top:' @position.y 'px;left:' @position.x 'px;'}}} style={{{concat 'top:' @position.y 'px;left:' @position.x 'px;'}}}
aria-label={{if (eq @type 'deny') 'Add intention' 'View intention'}} aria-label={{if (eq @type 'deny') 'Add intention' 'View intention'}}

View File

@ -5,11 +5,14 @@
background-color: $white; background-color: $white;
padding: 1px 1px; padding: 1px 1px;
&:hover { &:hover {
cursor:pointer; cursor: pointer;
} }
&:active, &:focus { &:active, &:focus {
outline: none; outline: none;
} }
&:disabled {
cursor: default;
}
} }
&.deny .informed-action header::before { &.deny .informed-action header::before {
display: none; display: none;

View File

@ -80,6 +80,9 @@ export function initialize(container) {
.filter(function(item) { .filter(function(item) {
return item.startsWith('dc'); return item.startsWith('dc');
}) })
.filter(function(item) {
return item.endsWith('path');
})
.map(function(item) { .map(function(item) {
return item.replace('._options.path', '').replace(dotRe, '/'); return item.replace('._options.path', '').replace(dotRe, '/');
}) })

View File

@ -27,6 +27,7 @@ export default class Intention extends Model {
@attr('number') CreateIndex; @attr('number') CreateIndex;
@attr('number') ModifyIndex; @attr('number') ModifyIndex;
@attr() Meta; // {} @attr() Meta; // {}
@attr({ defaultValue: () => [] }) Resources; // []
@fragmentArray('intention-permission') Permissions; @fragmentArray('intention-permission') Permissions;
@computed('Meta') @computed('Meta')

View File

@ -20,6 +20,7 @@ export default class Kv extends Model {
@attr('number') CreateIndex; @attr('number') CreateIndex;
@attr('number') ModifyIndex; @attr('number') ModifyIndex;
@attr('string') Session; @attr('string') Session;
@attr({ defaultValue: () => [] }) Resources; // []
@computed('isFolder') @computed('isFolder')
get Kind() { get Kind() {

View File

@ -19,6 +19,7 @@ export default class Node extends Model {
@attr() meta; // {} @attr() meta; // {}
@attr() Meta; // {} @attr() Meta; // {}
@attr() TaggedAddresses; // {lan, wan} @attr() TaggedAddresses; // {lan, wan}
@attr({ defaultValue: () => [] }) Resources; // []
// Services are reshaped to a different shape to what you sometimes get from // Services are reshaped to a different shape to what you sometimes get from
// the response, see models/node.js // the response, see models/node.js
@hasMany('service-instance') Services; // TODO: Rename to ServiceInstances @hasMany('service-instance') Services; // TODO: Rename to ServiceInstances

View File

@ -10,6 +10,7 @@ export default class Nspace extends Model {
@attr('number') SyncTime; @attr('number') SyncTime;
@attr('string', { defaultValue: () => '' }) Description; @attr('string', { defaultValue: () => '' }) Description;
@attr({ defaultValue: () => [] }) Resources; // []
// TODO: Is there some sort of date we can use here // TODO: Is there some sort of date we can use here
@attr('string') DeletedAt; @attr('string') DeletedAt;
@attr({ @attr({

View File

@ -0,0 +1,8 @@
import Model, { attr } from '@ember-data/model';
export default class Permission extends Model {
@attr('string') Resource;
@attr('string') Segment;
@attr('string') Access;
@attr('boolean') Allow;
}

View File

@ -37,6 +37,7 @@ export default class ServiceInstance extends Model {
@fragmentArray('health-check') Checks; @fragmentArray('health-check') Checks;
@attr('number') SyncTime; @attr('number') SyncTime;
@attr() meta; @attr() meta;
@attr({ defaultValue: () => [] }) Resources; // []
// The name is the Name of the Service (the grouping of instances) // The name is the Name of the Service (the grouping of instances)
@alias('Service.Service') Name; @alias('Service.Service') Name;

View File

@ -35,6 +35,7 @@ export default class Service extends Model {
@attr('number') InstanceCount; @attr('number') InstanceCount;
@attr('boolean') ConnectedWithGateway; @attr('boolean') ConnectedWithGateway;
@attr('boolean') ConnectedWithProxy; @attr('boolean') ConnectedWithProxy;
@attr({ defaultValue: () => [] }) Resources; // []
@attr('number') SyncTime; @attr('number') SyncTime;
@attr('number') CreateIndex; @attr('number') CreateIndex;
@attr('number') ModifyIndex; @attr('number') ModifyIndex;

View File

@ -19,4 +19,5 @@ export default class Session extends Model {
@attr('number') ModifyIndex; @attr('number') ModifyIndex;
@attr({ defaultValue: () => [] }) Checks; @attr({ defaultValue: () => [] }) Checks;
@attr({ defaultValue: () => [] }) Resources; // []
} }

View File

@ -0,0 +1,17 @@
import { modifier } from 'ember-modifier';
export default modifier(function enabled($element, [bool], hash) {
if (['input', 'textarea', 'select', 'button'].includes($element.nodeName.toLowerCase())) {
if (bool) {
$element.disabled = bool;
} else {
$element.dataset.disabled = false;
}
return;
}
for (const $el of $element.querySelectorAll('input,textarea')) {
if ($el.dataset.disabled !== 'false') {
$el.disabled = bool;
}
}
});

View File

@ -91,10 +91,16 @@ export const routes = {
intentions: { intentions: {
_options: { path: '/intentions' }, _options: { path: '/intentions' },
edit: { edit: {
_options: { path: '/:intention_id' }, _options: {
path: '/:intention_id',
abilities: ['read intentions'],
},
}, },
create: { create: {
_options: { path: '/create' }, _options: {
path: '/create',
abilities: ['create intentions'],
},
}, },
}, },
// Key/Value // Key/Value
@ -107,10 +113,16 @@ export const routes = {
_options: { path: '/*key/edit' }, _options: { path: '/*key/edit' },
}, },
create: { create: {
_options: { path: '/*key/create' }, _options: {
path: '/*key/create',
abilities: ['create kvs'],
},
}, },
'root-create': { 'root-create': {
_options: { path: '/create' }, _options: {
path: '/create',
abilities: ['create kvs'],
},
}, },
}, },
// ACLs // ACLs

View File

@ -36,7 +36,7 @@ export default Route.extend(WithBlockingActions, {
error: function(e, transition) { error: function(e, transition) {
// TODO: Normalize all this better // TODO: Normalize all this better
let error = { let error = {
status: e.code || '', status: e.code || e.statusCode || '',
message: e.message || e.detail || 'Error', message: e.message || e.detail || 'Error',
}; };
if (e.errors && e.errors[0]) { if (e.errors && e.errors[0]) {

View File

@ -24,6 +24,7 @@ const findActiveNspace = function(nspaces, nspace) {
}; };
export default class DcRoute extends Route { export default class DcRoute extends Route {
@service('repository/dc') repo; @service('repository/dc') repo;
@service('repository/permission') permissionsRepo;
@service('repository/nspace/disabled') nspacesRepo; @service('repository/nspace/disabled') nspacesRepo;
@service('settings') settingsRepo; @service('settings') settingsRepo;
@ -41,11 +42,8 @@ export default class DcRoute extends Route {
nspace = nspace =
app.nspaces.length > 1 ? findActiveNspace(app.nspaces, nspace) : app.nspaces.firstObject; app.nspaces.length > 1 ? findActiveNspace(app.nspaces, nspace) : app.nspaces.firstObject;
let permissions; // When disabled nspaces is [], so nspace is undefined
if (get(token, 'SecretID')) { const permissions = await this.permissionsRepo.findAll(params.dc, get(nspace || {}, 'Name'));
// When disabled nspaces is [], so nspace is undefined
permissions = await this.nspacesRepo.authorize(params.dc, get(nspace || {}, 'Name'));
}
return { return {
dc, dc,
nspace, nspace,
@ -81,7 +79,7 @@ export default class DcRoute extends Route {
const controller = this.controllerFor('application'); const controller = this.controllerFor('application');
Promise.all([ Promise.all([
this.nspacesRepo.findAll(), this.nspacesRepo.findAll(),
this.nspacesRepo.authorize(get(controller, 'dc.Name'), get(controller, 'nspace.Name')), this.permissionsRepo.findAll(get(controller, 'dc.Name'), get(controller, 'nspace.Name')),
]).then(([nspaces, permissions]) => { ]).then(([nspaces, permissions]) => {
if (typeof controller !== 'undefined') { if (typeof controller !== 'undefined') {
controller.setProperties({ controller.setProperties({

View File

@ -6,11 +6,9 @@ import { get } from '@ember/object';
import ascend from 'consul-ui/utils/ascend'; import ascend from 'consul-ui/utils/ascend';
export default class EditRoute extends Route { export default class EditRoute extends Route {
@service('repository/kv') @service('repository/kv') repo;
repo; @service('repository/session') sessionRepo;
@service('repository/permission') permissions;
@service('repository/session')
sessionRepo;
model(params) { model(params) {
const create = const create =
@ -39,7 +37,7 @@ export default class EditRoute extends Route {
// TODO: Consider loading this after initial page load // TODO: Consider loading this after initial page load
if (typeof model.item !== 'undefined') { if (typeof model.item !== 'undefined') {
const session = get(model.item, 'Session'); const session = get(model.item, 'Session');
if (session) { if (session && this.permissions.can('read sessions')) {
return hash({ return hash({
...model, ...model,
...{ ...{

View File

@ -36,15 +36,7 @@ export default class IndexRoute extends Route {
return hash({ return hash({
...model, ...model,
...{ ...{
items: this.repo.findAllBySlug(get(model.parent, 'Key'), dc, nspace).catch(e => { items: this.repo.findAllBySlug(get(model.parent, 'Key'), dc, nspace),
const status = get(e, 'errors.firstObject.status');
switch (status) {
case '403':
return this.transitionTo('dc.acls.tokens');
default:
return this.transitionTo('dc.kv.index');
}
}),
}, },
}); });
}); });
@ -55,7 +47,8 @@ export default class IndexRoute extends Route {
if (e.errors && e.errors[0] && e.errors[0].status == '404') { if (e.errors && e.errors[0] && e.errors[0].status == '404') {
return this.transitionTo('dc.kv.index'); return this.transitionTo('dc.kv.index');
} }
throw e; // let the route above handle the error
return true;
} }
setupController(controller, model) { setupController(controller, model) {

View File

@ -1,12 +1,33 @@
import Route from '@ember/routing/route'; import Route from '@ember/routing/route';
import { get, setProperties } from '@ember/object'; import { get, setProperties } from '@ember/object';
import { inject as service } from '@ember/service';
import HTTPError from 'consul-ui/utils/http/error';
// paramsFor // paramsFor
import { routes } from 'consul-ui/router'; import { routes } from 'consul-ui/router';
import wildcard from 'consul-ui/utils/routing/wildcard'; import wildcard from 'consul-ui/utils/routing/wildcard';
const isWildcard = wildcard(routes); const isWildcard = wildcard(routes);
export default class BaseRoute extends Route { export default class BaseRoute extends Route {
@service('repository/permission') permissions;
/**
* Inspects a custom `abilities` array on the router for this route. Every
* abililty needs to 'pass' for the route not to throw a 403 error. Anything
* more complex then this (say ORs) should use a single ability and perform
* the OR lgic in the test for the ability. Note, this ability check happens
* before any calls to the backend for this model/route.
*/
async beforeModel() {
const abilities = get(routes, `${this.routeName}._options.abilities`) || [];
if (abilities.length > 0) {
if (!abilities.every(ability => this.permissions.can(ability))) {
throw new HTTPError(403);
}
}
}
/** /**
* By default any empty string query parameters should remove the query * By default any empty string query parameters should remove the query
* parameter from the URL. This is the most common behavior if you don't * parameter from the URL. This is the most common behavior if you don't
@ -16,28 +37,29 @@ export default class BaseRoute extends Route {
* queryParameter configuration to configure what is deemed 'empty' * queryParameter configuration to configure what is deemed 'empty'
*/ */
serializeQueryParam(value, key, type) { serializeQueryParam(value, key, type) {
if(typeof value !== 'undefined') { if (typeof value !== 'undefined') {
const empty = get(this, `queryParams.${key}.empty`); const empty = get(this, `queryParams.${key}.empty`);
if(typeof empty === 'undefined') { if (typeof empty === 'undefined') {
// by default any queryParams when an empty string mean undefined, // by default any queryParams when an empty string mean undefined,
// therefore remove the queryParam from the URL // therefore remove the queryParam from the URL
if(value === '') { if (value === '') {
value = undefined; value = undefined;
} }
} else { } else {
const possible = empty[0]; const possible = empty[0];
let actual = value; let actual = value;
if(Array.isArray(actual)) { if (Array.isArray(actual)) {
actual = actual.split(','); actual = actual.split(',');
} }
const diff = possible.filter(item => !actual.includes(item)) const diff = possible.filter(item => !actual.includes(item));
if(diff.length === 0) { if (diff.length === 0) {
value = undefined; value = undefined;
} }
} }
} }
return value; return value;
} }
/** /**
* Set the routeName for the controller so that it is available in the template * Set the routeName for the controller so that it is available in the template
* for the route/controller.. This is mainly used to give a route name to the * for the route/controller.. This is mainly used to give a route name to the
@ -49,6 +71,7 @@ export default class BaseRoute extends Route {
}); });
super.setupController(...arguments); super.setupController(...arguments);
} }
/** /**
* Adds urldecoding to any wildcard route `params` passed into ember `model` * Adds urldecoding to any wildcard route `params` passed into ember `model`
* hooks, plus of course anywhere else where `paramsFor` is used. This means * hooks, plus of course anywhere else where `paramsFor` is used. This means

View File

@ -0,0 +1,3 @@
import Serializer from './application';
export default class PermissionSerializer extends Serializer {}

View File

@ -1,10 +1,15 @@
import Service, { inject as service } from '@ember/service'; import Service, { inject as service } from '@ember/service';
import { assert } from '@ember/debug'; import { assert } from '@ember/debug';
import { typeOf } from '@ember/utils'; import { typeOf } from '@ember/utils';
import { get } from '@ember/object'; import { get, set } from '@ember/object';
import { isChangeset } from 'validated-changeset'; import { isChangeset } from 'validated-changeset';
import HTTPError from 'consul-ui/utils/http/error';
import { ACCESS_READ } from 'consul-ui/abilities/base';
export default class RepositoryService extends Service { export default class RepositoryService extends Service {
@service('store') store;
@service('repository/permission') permissions;
getModelName() { getModelName() {
assert('RepositoryService.getModelName should be overridden', false); assert('RepositoryService.getModelName should be overridden', false);
} }
@ -17,9 +22,59 @@ export default class RepositoryService extends Service {
assert('RepositoryService.getSlugKey should be overridden', false); assert('RepositoryService.getSlugKey should be overridden', false);
} }
// /**
@service('store') * Creates a set of permissions base don a slug, loads in the access
store; * permissions for themand checks/validates
*/
async authorizeBySlug(cb, access, slug, dc, nspace) {
return this.validatePermissions(
cb,
await this.permissions.findBySlug(slug, this.getModelName(), dc, nspace),
access,
dc,
nspace
);
}
/**
* Loads in the access permissions and checks/validates them for a set of
* permissions
*/
async authorizeByPermissions(cb, permissions, access, dc, nspace) {
return this.validatePermissions(
cb,
await this.permissions.authorize(permissions, dc, nspace),
access,
dc,
nspace
);
}
/**
* Checks already loaded permissions for certain access before calling cb to
* return the thing you wanted to check the permissions on
*/
async validatePermissions(cb, permissions, access, dc, nspace) {
// inspect the permissions for this segment/slug remotely, if we have zero
// permissions fire a fake 403 so we don't even request the model/resource
if (permissions.length > 0) {
const permission = permissions.find(item => item.Access === access);
if (permission && permission.Allow === false) {
// TODO: Here we temporarily make a hybrid HTTPError/ember-data HTTP error
// we should eventually use HTTPError's everywhere
const e = new HTTPError(403);
e.errors = [{ status: '403' }];
throw e;
}
}
const item = await cb();
// add the `Resource` information to the record/model so we can inspect
// them in other places like templates etc
if (get(item, 'Resources')) {
set(item, 'Resources', permissions);
}
return item;
}
reconcile(meta = {}) { reconcile(meta = {}) {
// unload anything older than our current sync date/time // unload anything older than our current sync date/time
@ -59,7 +114,7 @@ export default class RepositoryService extends Service {
return this.store.query(this.getModelName(), query); return this.store.query(this.getModelName(), query);
} }
findBySlug(slug, dc, nspace, configuration = {}) { async findBySlug(slug, dc, nspace, configuration = {}) {
const query = { const query = {
dc: dc, dc: dc,
ns: nspace, ns: nspace,
@ -69,7 +124,13 @@ export default class RepositoryService extends Service {
query.index = configuration.cursor; query.index = configuration.cursor;
query.uri = configuration.uri; query.uri = configuration.uri;
} }
return this.store.queryRecord(this.getModelName(), query); return this.authorizeBySlug(
() => this.store.queryRecord(this.getModelName(), query),
ACCESS_READ,
slug,
dc,
nspace
);
} }
create(obj) { create(obj) {

View File

@ -19,7 +19,7 @@ export default class DiscoveryChainService extends RepositoryService {
} }
return super.findBySlug(...arguments).catch(e => { return super.findBySlug(...arguments).catch(e => {
const code = get(e, 'errors.firstObject.status'); const code = get(e, 'errors.firstObject.status');
const body = get(e, 'errors.firstObject.detail').trim(); const body = (get(e, 'errors.firstObject.detail') || '').trim();
switch (code) { switch (code) {
case '500': case '500':
if (datacenter !== null && body.endsWith(ERROR_MESH_DISABLED)) { if (datacenter !== null && body.endsWith(ERROR_MESH_DISABLED)) {

View File

@ -32,6 +32,17 @@ export default class IntentionRepository extends RepositoryService {
return this.managedByCRDs; return this.managedByCRDs;
} }
// legacy intentions are strange that in order to read/write you need access
// to either/or the destination or source
async authorizeBySlug(cb, access, slug, dc, nspace) {
const [, source, , destination] = slug.split(':');
const ability = this.permissions.abilityFor(this.getModelName());
const permissions = ability
.generateForSegment(source)
.concat(ability.generateForSegment(destination));
return this.authorizeByPermissions(cb, permissions, access, dc, nspace);
}
async persist(obj) { async persist(obj) {
const res = await super.persist(...arguments); const res = await super.persist(...arguments);
// if Action is set it means we are an l4 type intention // if Action is set it means we are an l4 type intention

View File

@ -2,6 +2,7 @@ import RepositoryService from 'consul-ui/services/repository';
import isFolder from 'consul-ui/utils/isFolder'; import isFolder from 'consul-ui/utils/isFolder';
import { get } from '@ember/object'; import { get } from '@ember/object';
import { PRIMARY_KEY } from 'consul-ui/models/kv'; import { PRIMARY_KEY } from 'consul-ui/models/kv';
import { ACCESS_LIST } from 'consul-ui/abilities/base';
const modelName = 'kv'; const modelName = 'kv';
export default class KvService extends RepositoryService { export default class KvService extends RepositoryService {
@ -14,31 +15,30 @@ export default class KvService extends RepositoryService {
} }
// this one gives you the full object so key,values and meta // this one gives you the full object so key,values and meta
findBySlug(key, dc, nspace, configuration = {}) { async findBySlug(slug, dc, nspace, configuration = {}) {
if (isFolder(key)) { if (isFolder(slug)) {
// we only use findBySlug for a folder when we are looking to create a
// parent for a key for retriveing something Model shaped. Therefore we
// only use existing records or a fake record with the correct Key,
// which means we don't need to inpsect permissions as its an already
// existing KV or a fake one
// TODO: This very much shouldn't be here, // TODO: This very much shouldn't be here,
// needs to eventually use ember-datas generateId thing // needs to eventually use ember-datas generateId thing
// in the meantime at least our fingerprinter // in the meantime at least our fingerprinter
const id = JSON.stringify([nspace, dc, key]); const id = JSON.stringify([nspace, dc, slug]);
let item = this.store.peekRecord(this.getModelName(), id); let item = this.store.peekRecord(this.getModelName(), id);
if (!item) { if (!item) {
item = this.create({ item = this.create({
Key: key, Key: slug,
Datacenter: dc, Datacenter: dc,
Namespace: nspace, Namespace: nspace,
}); });
} }
return Promise.resolve(item); return item;
} else {
return super.findBySlug(slug, dc, nspace, configuration);
} }
const query = {
id: key,
dc: dc,
ns: nspace,
};
if (typeof configuration.cursor !== 'undefined') {
query.index = configuration.cursor;
}
return this.store.queryRecord(this.getModelName(), query);
} }
// this one only gives you keys // this one only gives you keys
@ -47,37 +47,39 @@ export default class KvService extends RepositoryService {
if (key === '/') { if (key === '/') {
key = ''; key = '';
} }
const query = { return this.authorizeBySlug(
id: key, async () => {
dc: dc, const query = {
ns: nspace, id: key,
separator: '/', dc: dc,
}; ns: nspace,
if (typeof configuration.cursor !== 'undefined') { separator: '/',
query.index = configuration.cursor; };
} if (typeof configuration.cursor !== 'undefined') {
return this.store query.index = configuration.cursor;
.query(this.getModelName(), query)
.then(function(items) {
return items.filter(function(item) {
return key !== get(item, 'Key');
});
})
.catch(e => {
// TODO: Double check this was loose on purpose, its probably as we were unsure of
// type of ember-data error.Status at first, we could probably change this
// to `===` now
if (get(e, 'errors.firstObject.status') == '404') {
// TODO: This very much shouldn't be here,
// needs to eventually use ember-datas generateId thing
// in the meantime at least our fingerprinter
const id = JSON.stringify([dc, key]);
const record = this.store.peekRecord(this.getModelName(), id);
if (record) {
record.unloadRecord();
}
} }
throw e; let items;
}); try {
items = await this.store.query(this.getModelName(), query);
} catch (e) {
if (get(e, 'errors.firstObject.status') === '404') {
// TODO: This very much shouldn't be here,
// needs to eventually use ember-datas generateId thing
// in the meantime at least our fingerprinter
const id = JSON.stringify([dc, key]);
const record = this.store.peekRecord(this.getModelName(), id);
if (record) {
record.unloadRecord();
}
}
throw e;
}
return items.filter(item => key !== get(item, 'Key'));
},
ACCESS_LIST,
key,
dc,
nspace
);
} }
} }

View File

@ -0,0 +1,149 @@
import RepositoryService from 'consul-ui/services/repository';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { runInDebug } from '@ember/debug';
const modelName = 'permission';
// The set of permissions/resources required globally by the UI in order to
// run correctly
const REQUIRED_PERMISSIONS = [
{
Resource: 'operator',
Access: 'write',
},
{
Resource: 'service',
Access: 'read',
},
{
Resource: 'node',
Access: 'read',
},
{
Resource: 'session',
Access: 'read',
},
{
Resource: 'session',
Access: 'write',
},
{
Resource: 'key',
Access: 'read',
},
{
Resource: 'key',
Access: 'write',
},
{
Resource: 'intention',
Access: 'read',
},
{
Resource: 'intention',
Access: 'write',
},
{
Resource: 'acl',
Access: 'read',
},
{
Resource: 'acl',
Access: 'write',
},
];
export default class PermissionService extends RepositoryService {
@service('env') env;
@service('can') _can;
// TODO: move this to the store, if we want it to use ember-data
// currently this overwrites an inherited permissions service (this service)
// which isn't ideal, but if the name of this changes be aware that we'd
// probably have some circular dependency happening here
@tracked permissions = [];
getModelName() {
return modelName;
}
has(permission) {
const keys = Object.keys(permission);
return this.permissions.some(item => {
return keys.every(key => item[key] === permission[key]) && item.Allow === true;
});
}
can(can) {
return this._can.can(can);
}
abilityFor(str) {
return this._can.abilityFor(str);
}
generate(resource, action, segment) {
const req = {
Resource: resource,
Access: action,
};
if (typeof segment !== 'undefined') {
req.Segment = segment;
}
return req;
}
/**
* Requests the access for the defined resources/permissions from the backend.
* If ACLs are disabled, then you have access to everything, hence we check
* that here and only make the request if ACLs are enabled
*/
async authorize(resources, dc, nspace) {
if (!this.env.var('CONSUL_ACLS_ENABLED')) {
return resources.map(item => {
return {
...item,
Allow: true,
};
});
} else {
let permissions = [];
try {
permissions = await this.store.authorize('permission', {
dc: dc,
ns: nspace,
permissions: resources,
});
} catch (e) {
runInDebug(() => console.error(e));
// passthrough
}
return permissions;
}
}
async findBySlug(segment, model, dc, nspace) {
let ability;
try {
ability = this._can.abilityFor(model);
} catch (e) {
return [];
}
const resources = ability.generateForSegment(segment.toString());
// if we get no resources for a segment it means that this
// ability/permission isn't segmentable
if (resources.length === 0) {
return [];
}
return this.authorize(resources, dc, nspace);
}
async findByPermissions(resources, dc, nspace) {
return this.authorize(resources, dc, nspace);
}
async findAll(dc, nspace) {
this.permissions = await this.findByPermissions(REQUIRED_PERMISSIONS, dc, nspace);
return this.permissions;
}
}

View File

@ -1,6 +1,7 @@
import RepositoryService from 'consul-ui/services/repository'; import RepositoryService from 'consul-ui/services/repository';
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
import { set } from '@ember/object'; import { set } from '@ember/object';
import { ACCESS_READ } from 'consul-ui/abilities/base';
const modelName = 'service-instance'; const modelName = 'service-instance';
export default class ServiceInstanceService extends RepositoryService { export default class ServiceInstanceService extends RepositoryService {
@ -19,7 +20,13 @@ export default class ServiceInstanceService extends RepositoryService {
query.index = configuration.cursor; query.index = configuration.cursor;
query.uri = configuration.uri; query.uri = configuration.uri;
} }
return this.store.query(this.getModelName(), query); return this.authorizeBySlug(
async () => this.store.query(this.getModelName(), query),
ACCESS_READ,
slug,
dc,
nspace
);
} }
async findBySlug(serviceId, node, service, dc, nspace, configuration = {}) { async findBySlug(serviceId, node, service, dc, nspace, configuration = {}) {
@ -34,7 +41,13 @@ export default class ServiceInstanceService extends RepositoryService {
query.index = configuration.cursor; query.index = configuration.cursor;
query.uri = configuration.uri; query.uri = configuration.uri;
} }
return this.store.queryRecord(this.getModelName(), query); return this.authorizeBySlug(
async () => this.store.queryRecord(this.getModelName(), query),
ACCESS_READ,
service,
dc,
nspace
);
} }
async findProxyBySlug(serviceId, node, service, dc, nspace, configuration = {}) { async findProxyBySlug(serviceId, node, service, dc, nspace, configuration = {}) {

View File

@ -63,8 +63,8 @@ export default class StoreService extends Store {
}); });
} }
// TODO: This one is only for nspaces and OIDC, should fail nicely if you call it // TODO: This one is only for permissions and OIDC, should fail nicely if you call it
// for anything other than nspaces/OIDC for good DX // for anything other than permissions/OIDC for good DX
authorize(modelName, query = {}) { authorize(modelName, query = {}) {
const adapter = this.adapterFor(modelName); const adapter = this.adapterFor(modelName);
const serializer = this.serializerFor(modelName); const serializer = this.serializerFor(modelName);

View File

@ -6,6 +6,11 @@
border: $decor-border-100; border: $decor-border-100;
outline: none; outline: none;
} }
textarea:disabled + .CodeMirror,
%form-element-text-input:disabled,
%form-element-text-input:read-only {
cursor: not-allowed;
}
%form h2 { %form h2 {
@extend %h200; @extend %h200;
} }

View File

@ -21,7 +21,6 @@ as |source|>
{{#if (not-eq router.currentRouteName 'application')}} {{#if (not-eq router.currentRouteName 'application')}}
<HashicorpConsul <HashicorpConsul
id="wrapper" id="wrapper"
@permissions={{permissions}}
@dcs={{dcs}} @dcs={{dcs}}
@dc={{or dc dcs.firstObject}} @dc={{or dc dcs.firstObject}}
@nspaces={{nspaces}} @nspaces={{nspaces}}

View File

@ -40,7 +40,9 @@ as |sort filters items|}}
<label for="toolbar-toggle"></label> <label for="toolbar-toggle"></label>
</BlockSlot> </BlockSlot>
<BlockSlot @name="actions"> <BlockSlot @name="actions">
{{#if (can 'create intentions')}}
<a data-test-create href="{{href-to 'dc.intentions.create'}}" class="type-create">Create</a> <a data-test-create href="{{href-to 'dc.intentions.create'}}" class="type-create">Create</a>
{{/if}}
</BlockSlot> </BlockSlot>
<BlockSlot @name="toolbar"> <BlockSlot @name="toolbar">

View File

@ -24,9 +24,11 @@
</h1> </h1>
</BlockSlot> </BlockSlot>
<BlockSlot @name="content"> <BlockSlot @name="content">
{{#if session}} {{! if a KV has a session `Session` will always be populated despite any specific session permissions }}
{{#if item.Session}}
<Notice <Notice
@type="warning" @type="warning"
data-test-session-warning
as |notice|> as |notice|>
<notice.Body> <notice.Body>
<p> <p>
@ -42,6 +44,7 @@
@onsubmit={{if (eq parent.Key '/') (transition-to 'dc.kv.index') (transition-to 'dc.kv.folder' parent.Key)}} @onsubmit={{if (eq parent.Key '/') (transition-to 'dc.kv.index') (transition-to 'dc.kv.folder' parent.Key)}}
@parent={{parent}} @parent={{parent}}
/> />
{{! session is slightly different to item.Session as we only have session if you have session:read perms}}
{{#if session}} {{#if session}}
<Consul::LockSession::Form <Consul::LockSession::Form
@item={{session}} @item={{session}}

View File

@ -50,11 +50,13 @@ as |sort filters items|}}
{{/if}} {{/if}}
</BlockSlot> </BlockSlot>
<BlockSlot @name="actions"> <BlockSlot @name="actions">
{{#if (can 'create kvs')}}
{{#if (not-eq parent.Key '/') }} {{#if (not-eq parent.Key '/') }}
<a data-test-create href="{{href-to 'dc.kv.create' parent.Key}}" class="type-create">Create</a> <a data-test-create href="{{href-to 'dc.kv.create' parent.Key}}" class="type-create">Create</a>
{{else}} {{else}}
<a data-test-create href="{{href-to 'dc.kv.root-create'}}" class="type-create">Create</a> <a data-test-create href="{{href-to 'dc.kv.root-create'}}" class="type-create">Create</a>
{{/if}} {{/if}}
{{/if}}
</BlockSlot> </BlockSlot>
<BlockSlot @name="content"> <BlockSlot @name="content">
<DataWriter <DataWriter

View File

@ -18,6 +18,13 @@
This node no longer exists in the catalog. This node no longer exists in the catalog.
</p> </p>
</Notification> </Notification>
{{else if (eq loader.error.status "403")}}
<Notification @sticky={{true}}>
<p data-notification role="alert" class="error notification-update">
<strong>Error!</strong>
You no longer have access to this node
</p>
</Notification>
{{else}} {{else}}
<Notification @sticky={{true}}> <Notification @sticky={{true}}>
<p data-notification role="alert" class="warning notification-update"> <p data-notification role="alert" class="warning notification-update">

View File

@ -1,14 +1,30 @@
<EventSource @src={{sessions}} /> <EventSource @src={{sessions}} />
<div class="tab-section"> <div class="tab-section">
{{#if (gt sessions.length 0)}} {{#if (gt sessions.length 0)}}
<Consul::LockSession::List @items={{sessions}} @onInvalidate={{action send 'invalidateSession'}}/> <Consul::LockSession::List
@items={{sessions}}
@onInvalidate={{action send 'invalidateSession'}}
/>
{{else}} {{else}}
<EmptyState> <EmptyState @allowLogin={{true}}>
<BlockSlot @name="header">
<h2>
Welcome to Lock Sessions
</h2>
</BlockSlot>
<BlockSlot @name="body"> <BlockSlot @name="body">
<p> <p>
There are no Lock Sessions for this Node. For more information, view <a href="{{ env 'CONSUL_DOCS_URL'}}/internals/sessions.html" rel="noopener noreferrer" target="_blank">our documentation</a> Consul provides a session mechanism which can be used to build distributed locks. Sessions act as a binding layer between nodes, health checks, and key/value data. There are currently no lock sessions present, or you may not have permission to view lock sessions.
</p> </p>
</BlockSlot> </BlockSlot>
<BlockSlot @name="actions">
<li class="docs-link">
<a href="{{env 'CONSUL_DOCS_URL'}}/internals/sessions.html" rel="noopener noreferrer" target="_blank">Documentation on sessions</a>
</li>
<li class="learn-link">
<a href="{{env 'CONSUL_DOCS_LEARN_URL'}}/tutorials/consul/distributed-semaphore" rel="noopener noreferrer" target="_blank">Read the guide</a>
</li>
</BlockSlot>
</EmptyState> </EmptyState>
{{/if}} {{/if}}
</div> </div>

View File

@ -21,6 +21,13 @@
This service has been deregistered and no longer exists in the catalog. This service has been deregistered and no longer exists in the catalog.
</p> </p>
</Notification> </Notification>
{{else if (eq loader.error.status "403")}}
<Notification @sticky={{true}}>
<p data-notification role="alert" class="error notification-update">
<strong>Error!</strong>
You no longer have access to this service
</p>
</Notification>
{{else}} {{else}}
<Notification @sticky={{true}}> <Notification @sticky={{true}}>
<p data-notification role="alert" class="warning notification-update"> <p data-notification role="alert" class="warning notification-update">

View File

@ -22,6 +22,13 @@
This service has been deregistered and no longer exists in the catalog. This service has been deregistered and no longer exists in the catalog.
</p> </p>
</Notification> </Notification>
{{else if (eq loader.error.status "403")}}
<Notification @sticky={{true}}>
<p data-notification role="alert" class="error notification-update">
<strong>Error!</strong>
You no longer have access to this service
</p>
</Notification>
{{else}} {{else}}
<Notification @sticky={{true}}> <Notification @sticky={{true}}>
<p data-notification role="alert" class="warning notification-update"> <p data-notification role="alert" class="warning notification-update">

View File

@ -38,9 +38,11 @@ as |api|>
as |sort filters items|}} as |sort filters items|}}
<div class="tab-section"> <div class="tab-section">
{{#if (can 'create intentions')}}
<Portal @target="app-view-actions"> <Portal @target="app-view-actions">
<a data-test-create href={{href-to 'dc.services.show.intentions.create'}} class="type-create">Create</a> <a data-test-create href={{href-to 'dc.services.show.intentions.create'}} class="type-create">Create</a>
</Portal> </Portal>
{{/if}}
{{#if (gt items.length 0) }} {{#if (gt items.length 0) }}
<Consul::Intention::SearchBar <Consul::Intention::SearchBar
@search={{search}} @search={{search}}

View File

@ -1,34 +1,14 @@
[ [
{ ${
"Resource": "acl", http.body.map(item => {
"Access": "read", return JSON.stringify(
"Allow": false Object.assign(
}, item,
{ {
"Resource": "acl", Allow: !!JSON.parse(env(`CONSUL_RESOURCE_${item.Resource.toUpperCase()}_${item.Access.toUpperCase()}`, 'true'))
"Access": "write", }
"Allow": false )
}, );
{ })
"Resource": "operator", }
"Access": "read",
"Allow": true
},
{
"Resource": "operator",
"Access": "write",
"Allow": true
},
{
"Resource": "service",
"Segment": "web",
"Access": "read",
"Allow": true
},
{
"Resource": "service",
"Segment": "web",
"Access": "write",
"Allow": true
}
] ]

View File

@ -88,6 +88,7 @@
"d3-shape": "^2.0.0", "d3-shape": "^2.0.0",
"dayjs": "^1.9.3", "dayjs": "^1.9.3",
"ember-auto-import": "^1.5.3", "ember-auto-import": "^1.5.3",
"ember-can": "^3.0.0",
"ember-changeset-conditional-validations": "^0.6.0", "ember-changeset-conditional-validations": "^0.6.0",
"ember-changeset-validations": "^3.9.0", "ember-changeset-validations": "^3.9.0",
"ember-cli": "~3.20.2", "ember-cli": "~3.20.2",

View File

@ -10,6 +10,20 @@ Feature: dc / intentions / index
Then the url should be /dc-1/intentions Then the url should be /dc-1/intentions
And the title should be "Intentions - Consul" And the title should be "Intentions - Consul"
Then I see 3 intention models on the intentionList component Then I see 3 intention models on the intentionList component
Scenario: Viewing intentions with no write access
Given 1 datacenter model with the value "dc-1"
And 3 intention models
And permissions from yaml
---
intention:
write: false
---
When I visit the intentions page for yaml
---
dc: dc-1
---
Then the url should be /dc-1/intentions
And I don't see create
Scenario: Viewing intentions in the listing live updates Scenario: Viewing intentions in the listing live updates
Given 1 datacenter model with the value "dc-1" Given 1 datacenter model with the value "dc-1"
Given 3 intention models Given 3 intention models

View File

@ -25,6 +25,49 @@ Feature: dc / kvs / edit: KV Viewing
kv: another-key kv: another-key
--- ---
Then I don't see ID on the session Then I don't see ID on the session
Scenario: Viewing a kv with no write access
Given 1 datacenter model with the value "datacenter"
And 1 kv model from yaml
---
Key: key
Session: session-id
---
And permissions from yaml
---
key:
write: false
session:
read: false
---
When I visit the kv page for yaml
---
dc: datacenter
kv: key
---
Then the url should be /datacenter/kv/key/edit
And I don't see create
And I don't see ID on the session
And I see warning on the session
Scenario: Viewing a kv with no read access
Given 1 datacenter model with the value "datacenter"
And 1 kv model from yaml
---
Key: key
---
And permissions from yaml
---
key:
write: false
read: false
---
When I visit the kv page for yaml
---
dc: datacenter
kv: key
---
Then the url should be /datacenter/kv/key/edit
And I see status on the error like "403"
And a GET request wasn't made to "/v1/kv/key?dc=datacenter"
# Make sure we can view KVs that have similar names to sections in the UI # Make sure we can view KVs that have similar names to sections in the UI
Scenario: I have KV called [Page] Scenario: I have KV called [Page]
Given 1 datacenter model with the value "datacenter" Given 1 datacenter model with the value "datacenter"

View File

@ -0,0 +1,27 @@
@setupApplicationTest
Feature: dc / kvs / index
Scenario: Viewing kvs in the listing
Given 1 datacenter model with the value "dc-1"
And 3 kv models
When I visit the kvs page for yaml
---
dc: dc-1
---
Then the url should be /dc-1/kv
And the title should be "Key/Value - Consul"
Then I see 3 kv models
Scenario: Viewing kvs with no write access
Given 1 datacenter model with the value "dc-1"
And 3 kv models
And permissions from yaml
---
key:
write: false
---
When I visit the kvs page for yaml
---
dc: dc-1
---
Then the url should be /dc-1/kv
And I don't see create

View File

@ -119,3 +119,44 @@ Feature: dc / services / show: Show Service
--- ---
# The Metrics dashboard should use the Service.Name not the ID # The Metrics dashboard should use the Service.Name not the ID
And I see href on the metricsAnchor like "https://something.com?service-0&dc1" And I see href on the metricsAnchor like "https://something.com?service-0&dc1"
Scenario: With no access to service
Given 1 datacenter model with the value "dc1"
And permissions from yaml
---
service:
read: false
---
When I visit the service page for yaml
---
dc: dc1
service: service-0
---
Then I see status on the error like "403"
Scenario: When access is removed from a service
Given 1 datacenter model with the value "dc1"
And 1 node models
And 1 service model from yaml
And a network latency of 100
And permissions from yaml
---
service:
read: true
---
When I visit the service page for yaml
---
dc: dc1
service: service-0
---
And I click instances on the tabs
And I see 1 instance model
Given permissions from yaml
---
service:
read: false
---
# authorization requests are not blocking so we just wait until the next
# service blocking query responds
Then pause until I see the text "no longer have access" in "[data-notification]"
And "[data-notification]" has the "error" class
And I see status on the error like "403"

View File

@ -0,0 +1,33 @@
@setupApplicationTest
Feature: navigation-links: Main Navigation link visibility
Scenario: No read access to Key/Values
Given 1 datacenter model with the value "dc-1"
And the url "/v1/internal/acl/authorize" responds with from yaml
---
body:
- Resource: operator
Access: write
Allow: true
- Resource: service
Access: read
Allow: true
- Resource: node
Access: read
Allow: true
- Resource: key
Access: read
Allow: false
- Resource: intention
Access: read
Allow: true
- Resource: acl
Access: read
Allow: true
---
When I visit the services page for yaml
---
dc: dc-1
---
Then I see services on the navigation
Then I don't see kvs on the navigation

View File

@ -0,0 +1,10 @@
import steps from '../../steps';
// step definitions that are shared between features should be moved to the
// tests/acceptance/steps/steps.js file
export default function(assert) {
return steps(assert).then('I should find a file', function() {
assert.ok(true, this.step);
});
}

View File

@ -0,0 +1,10 @@
import steps from './steps';
// step definitions that are shared between features should be moved to the
// tests/acceptance/steps/steps.js file
export default function(assert) {
return steps(assert).then('I should find a file', function() {
assert.ok(true, this.step);
});
}

View File

@ -0,0 +1,27 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { env } from '../../../env';
const shouldHaveNspace = function(nspace) {
return typeof nspace !== 'undefined' && env('CONSUL_NSPACES_ENABLED');
};
module('Integration | Adapter | permission', function(hooks) {
setupTest(hooks);
const dc = 'dc-1';
const undefinedNspace = 'default';
[undefinedNspace, 'team-1', undefined].forEach(nspace => {
test('requestForAuthorize returns the correct url/method', function(assert) {
const adapter = this.owner.lookup('adapter:permission');
const client = this.owner.lookup('service:client/http');
// authorize endpoint doesn't need an ns sending on the query param
const expected = `POST /v1/internal/acl/authorize?dc=${dc}${
shouldHaveNspace(nspace) ? `` : ``
}`;
const actual = adapter.requestForAuthorize(client.requestParams.bind(client), {
dc: dc,
ns: nspace,
});
assert.equal(`${actual.method} ${actual.url}`, expected);
});
});
});

View File

@ -155,7 +155,9 @@ export default {
radiogroup radiogroup
) )
), ),
service: create(service(visitable, clickable, attribute, collection, text, consulIntentionList, tabgroup)), service: create(
service(visitable, clickable, attribute, collection, text, consulIntentionList, tabgroup)
),
instance: create( instance: create(
instance( instance(
visitable, visitable,
@ -182,7 +184,7 @@ export default {
) )
), ),
kvs: create(kvs(visitable, creatable, consulKvList)), kvs: create(kvs(visitable, creatable, consulKvList)),
kv: create(kv(visitable, attribute, submitable, deletable, cancelable, clickable)), kv: create(kv(visitable, attribute, isPresent, submitable, deletable, cancelable, clickable)),
acls: create(acls(visitable, deletable, creatable, clickable, attribute, collection)), acls: create(acls(visitable, deletable, creatable, clickable, attribute, collection)),
acl: create(acl(visitable, submitable, deletable, cancelable, clickable)), acl: create(acl(visitable, submitable, deletable, cancelable, clickable)),
policies: create(policies(visitable, creatable, consulPolicyList, popoverSelect)), policies: create(policies(visitable, creatable, consulPolicyList, popoverSelect)),

View File

@ -1,4 +1,4 @@
export default function(visitable, attribute, submitable, deletable, cancelable) { export default function(visitable, attribute, present, submitable, deletable, cancelable) {
return { return {
visit: visitable(['/:dc/kv/:kv/edit', '/:dc/kv/create'], function(str) { visit: visitable(['/:dc/kv/:kv/edit', '/:dc/kv/create'], function(str) {
// this will encode the parts of the key path but means you can no longer // this will encode the parts of the key path but means you can no longer
@ -12,6 +12,7 @@ export default function(visitable, attribute, submitable, deletable, cancelable)
...cancelable(), ...cancelable(),
...deletable(), ...deletable(),
session: { session: {
warning: present('[data-test-session-warning]'),
ID: attribute('data-test-session', '[data-test-session]'), ID: attribute('data-test-session', '[data-test-session]'),
...deletable({}, '[data-test-session]'), ...deletable({}, '[data-test-session]'),
}, },

View File

@ -97,7 +97,7 @@ export default function({
const clipboard = function() { const clipboard = function() {
return window.localStorage.getItem('clipboard'); return window.localStorage.getItem('clipboard');
}; };
models(library, create); models(library, create, setCookie);
http(library, respondWith, setCookie); http(library, respondWith, setCookie);
visit(library, pages, utils.setCurrentPage, reset); visit(library, pages, utils.setCurrentPage, reset);
click(library, utils.find, helpers.click); click(library, utils.find, helpers.click);

View File

@ -7,6 +7,9 @@ export default function(scenario, respondWith, set) {
}); });
}) })
.given(['the url "$endpoint" responds with from yaml\n$yaml'], function(url, data) { .given(['the url "$endpoint" responds with from yaml\n$yaml'], function(url, data) {
if (typeof data.body !== 'string') {
data.body = JSON.stringify(data.body);
}
respondWith(url, data); respondWith(url, data);
}) })
.given('a network latency of $number', function(number) { .given('a network latency of $number', function(number) {

View File

@ -1,4 +1,4 @@
export default function(scenario, create, win = window, doc = document) { export default function(scenario, create, set, win = window, doc = document) {
scenario scenario
.given(['an external edit results in $number $model model[s]?'], function(number, model) { .given(['an external edit results in $number $model model[s]?'], function(number, model) {
return create(number, model); return create(number, model);
@ -21,9 +21,20 @@ export default function(scenario, create, win = window, doc = document) {
}); });
}) })
.given(['ui_config from yaml\n$yaml'], function(data) { .given(['ui_config from yaml\n$yaml'], function(data) {
// this one doesn't interact with the api therefore you don't need to use
// setCookie/set. Ideally setCookie should probably use doc.cookie also so
// there is no difference between these
doc.cookie = `CONSUL_UI_CONFIG=${JSON.stringify(data)}`; doc.cookie = `CONSUL_UI_CONFIG=${JSON.stringify(data)}`;
}) })
.given(['the local datacenter is "$value"'], function(value) { .given(['the local datacenter is "$value"'], function(value) {
doc.cookie = `CONSUL_DATACENTER_LOCAL=${value}`; doc.cookie = `CONSUL_DATACENTER_LOCAL=${value}`;
})
.given(['permissions from yaml\n$yaml'], function(data) {
Object.entries(data).forEach(([key, value]) => {
const resource = `CONSUL_RESOURCE_${key.toUpperCase()}`;
Object.entries(value).forEach(([key, value]) => {
set(`${resource}_${key.toUpperCase()}`, value);
});
});
}); });
} }

View File

@ -0,0 +1,12 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Adapter | permission', function(hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function(assert) {
let adapter = this.owner.lookup('adapter:permission');
assert.ok(adapter);
});
});

View File

@ -0,0 +1,14 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { run } from '@ember/runloop';
module('Unit | Model | permission', function(hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function(assert) {
let store = this.owner.lookup('service:store');
let model = run(() => store.createRecord('permission', {}));
assert.ok(model);
});
});

View File

@ -0,0 +1,23 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Serializer | permission', function(hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function(assert) {
let store = this.owner.lookup('service:store');
let serializer = store.serializerFor('permission');
assert.ok(serializer);
});
test('it serializes records', function(assert) {
let store = this.owner.lookup('service:store');
let record = store.createRecord('permission', {});
let serializedRecord = record.serialize();
assert.ok(serializedRecord);
});
});

View File

@ -0,0 +1,12 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Service | permission', function(hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function(assert) {
let service = this.owner.lookup('service:repository/permission');
assert.ok(service);
});
});

View File

@ -4303,6 +4303,13 @@ babel-plugin-ember-modules-api-polyfill@^3.2.0:
dependencies: dependencies:
ember-rfc176-data "^0.3.16" ember-rfc176-data "^0.3.16"
babel-plugin-ember-modules-api-polyfill@^3.2.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/babel-plugin-ember-modules-api-polyfill/-/babel-plugin-ember-modules-api-polyfill-3.2.1.tgz#715252ffde309da36fb32cd6a9bad5c6b61edd33"
integrity sha512-7k4gM0VLAMjoWVxLBDqavH/Dn4mBfzqTuQmtGmZgsdQ4SYVEJ7dewUVeqWBVn5v3QspW4VSoeXh4rHPPlp/rPw==
dependencies:
ember-rfc176-data "^0.3.16"
babel-plugin-emotion@^10.0.20, babel-plugin-emotion@^10.0.27: babel-plugin-emotion@^10.0.20, babel-plugin-emotion@^10.0.27:
version "10.0.33" version "10.0.33"
resolved "https://registry.yarnpkg.com/babel-plugin-emotion/-/babel-plugin-emotion-10.0.33.tgz#ce1155dcd1783bbb9286051efee53f4e2be63e03" resolved "https://registry.yarnpkg.com/babel-plugin-emotion/-/babel-plugin-emotion-10.0.33.tgz#ce1155dcd1783bbb9286051efee53f4e2be63e03"
@ -7720,6 +7727,15 @@ ember-basic-dropdown@^3.0.10:
ember-maybe-in-element "^2.0.1" ember-maybe-in-element "^2.0.1"
ember-truth-helpers "2.1.0" ember-truth-helpers "2.1.0"
ember-can@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/ember-can/-/ember-can-3.0.0.tgz#86988e2ec35ece0ac7b28f7c436228485911528a"
integrity sha512-URgfnVIe2O6bkgC1OAKGuKwbNklwONpT4MbQM17g7v0IKoHW+k6mL1iGQzBIPdjTBtlwRJNAl6nddYE8T5UVig==
dependencies:
ember-cli-babel "^7.13.2"
ember-cli-htmlbars "^4.2.1"
ember-inflector "^3.0.1"
ember-changeset-conditional-validations@^0.6.0: ember-changeset-conditional-validations@^0.6.0:
version "0.6.0" version "0.6.0"
resolved "https://registry.yarnpkg.com/ember-changeset-conditional-validations/-/ember-changeset-conditional-validations-0.6.0.tgz#78369ad3af0aea338e00a9bdf1b622fb512d9a00" resolved "https://registry.yarnpkg.com/ember-changeset-conditional-validations/-/ember-changeset-conditional-validations-0.6.0.tgz#78369ad3af0aea338e00a9bdf1b622fb512d9a00"
@ -7824,6 +7840,38 @@ ember-cli-babel@^6.0.0, ember-cli-babel@^6.0.0-beta.4, ember-cli-babel@^6.11.0,
ember-cli-version-checker "^2.1.2" ember-cli-version-checker "^2.1.2"
semver "^5.5.0" semver "^5.5.0"
ember-cli-babel@^7.13.2:
version "7.23.1"
resolved "https://registry.yarnpkg.com/ember-cli-babel/-/ember-cli-babel-7.23.1.tgz#d1517228ede08a5d4b045c78a7429728e956b30b"
integrity sha512-qYggmt3hRs6QJ6cRkww3ahMpyP8IEV2KFrIRO/Z6hu9MkE/8Y28Xd5NjQl6fPV3oLoG0vwuHzhNe3Jr7Wec8zw==
dependencies:
"@babel/core" "^7.12.0"
"@babel/helper-compilation-targets" "^7.12.0"
"@babel/plugin-proposal-class-properties" "^7.10.4"
"@babel/plugin-proposal-decorators" "^7.10.5"
"@babel/plugin-transform-modules-amd" "^7.10.5"
"@babel/plugin-transform-runtime" "^7.12.0"
"@babel/plugin-transform-typescript" "^7.12.0"
"@babel/polyfill" "^7.11.5"
"@babel/preset-env" "^7.12.0"
"@babel/runtime" "^7.12.0"
amd-name-resolver "^1.2.1"
babel-plugin-debug-macros "^0.3.3"
babel-plugin-ember-data-packages-polyfill "^0.1.2"
babel-plugin-ember-modules-api-polyfill "^3.2.1"
babel-plugin-module-resolver "^3.1.1"
broccoli-babel-transpiler "^7.8.0"
broccoli-debug "^0.6.4"
broccoli-funnel "^2.0.1"
broccoli-source "^1.1.0"
clone "^2.1.2"
ember-cli-babel-plugin-helpers "^1.1.1"
ember-cli-version-checker "^4.1.0"
ensure-posix-path "^1.0.2"
fixturify-project "^1.10.0"
rimraf "^3.0.1"
semver "^5.5.0"
ember-cli-code-coverage@^1.0.0-beta.4: ember-cli-code-coverage@^1.0.0-beta.4:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/ember-cli-code-coverage/-/ember-cli-code-coverage-1.0.2.tgz#615fc7af8bc7d9388a28371c2825c224a936d73f" resolved "https://registry.yarnpkg.com/ember-cli-code-coverage/-/ember-cli-code-coverage-1.0.2.tgz#615fc7af8bc7d9388a28371c2825c224a936d73f"
@ -7903,7 +7951,7 @@ ember-cli-htmlbars@^3.0.0, ember-cli-htmlbars@^3.0.1:
json-stable-stringify "^1.0.1" json-stable-stringify "^1.0.1"
strip-bom "^3.0.0" strip-bom "^3.0.0"
ember-cli-htmlbars@^4.0.5, ember-cli-htmlbars@^4.2.3, ember-cli-htmlbars@^4.3.1: ember-cli-htmlbars@^4.0.5, ember-cli-htmlbars@^4.2.1, ember-cli-htmlbars@^4.2.3, ember-cli-htmlbars@^4.3.1:
version "4.4.0" version "4.4.0"
resolved "https://registry.yarnpkg.com/ember-cli-htmlbars/-/ember-cli-htmlbars-4.4.0.tgz#7ca17d5ca8f7550984346d9e6e93da0c3323f8d9" resolved "https://registry.yarnpkg.com/ember-cli-htmlbars/-/ember-cli-htmlbars-4.4.0.tgz#7ca17d5ca8f7550984346d9e6e93da0c3323f8d9"
integrity sha512-ohgctqk7dXIZR4TgN0xRoUYltWhghFJgqmtuswQTpZ7p74RxI9PKx+E8WV/95mGcPzraesvMNBg5utQNvcqgNg== integrity sha512-ohgctqk7dXIZR4TgN0xRoUYltWhghFJgqmtuswQTpZ7p74RxI9PKx+E8WV/95mGcPzraesvMNBg5utQNvcqgNg==