mirror of https://github.com/status-im/consul.git
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:
parent
e27f6a367f
commit
8263879e6f
|
@ -0,0 +1,3 @@
|
|||
```release-note:improvement
|
||||
ui: restrict the viewing/editing of certain UI elements based on the users ACL token
|
||||
```
|
|
@ -128,6 +128,7 @@ token/secret.
|
|||
| `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_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 |
|
||||
|
||||
See `./mock-api` for more details.
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import BaseAbility from './base';
|
||||
|
||||
export default class NodeAbility extends BaseAbility {
|
||||
resource = 'node';
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
import BaseAbility from './base';
|
||||
|
||||
export default class PermissionAbility extends BaseAbility {
|
||||
get canRead() {
|
||||
return this.permissions.permissions.length > 0;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import BaseAbility from './base';
|
||||
|
||||
export default class ServiceInstanceAbility extends BaseAbility {
|
||||
resource = 'service';
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import BaseAbility from './base';
|
||||
|
||||
export default class ServiceAbility extends BaseAbility {
|
||||
resource = 'service';
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import BaseAbility from './base';
|
||||
|
||||
export default class SessionAbility extends BaseAbility {
|
||||
resource = 'session';
|
||||
}
|
|
@ -9,7 +9,7 @@ export default class ApplicationAdapter extends Adapter {
|
|||
@service('env') env;
|
||||
|
||||
formatNspace(nspace) {
|
||||
if (this.env.env('CONSUL_NSPACES_ENABLED')) {
|
||||
if (this.env.var('CONSUL_NSPACES_ENABLED')) {
|
||||
return nspace !== '' ? { [NSPACE_QUERY_PARAM]: nspace } : undefined;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -57,33 +57,4 @@ export default class NspaceAdapter extends Adapter {
|
|||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
|
@ -5,6 +5,6 @@
|
|||
</h1>
|
||||
</BlockSlot>
|
||||
<BlockSlot @name="content">
|
||||
<ErrorState @error={{@error}} />
|
||||
<ErrorState @error={{@error}} @allowLogin={{eq @error.status "403"}} />
|
||||
</BlockSlot>
|
||||
</AppView>
|
||||
|
|
|
@ -32,7 +32,7 @@ as |api|>
|
|||
|
||||
<BlockSlot @name="form">
|
||||
{{#let api.data as |item|}}
|
||||
{{#if item.IsEditable}}
|
||||
{{#if (can 'write intention' item=item)}}
|
||||
|
||||
{{#if this.warn}}
|
||||
{{#let (changeset-get item 'Action') as |newAction|}}
|
||||
|
|
|
@ -59,7 +59,7 @@ as |item index|>
|
|||
More
|
||||
</BlockSlot>
|
||||
<BlockSlot @name="menu" as |confirm send keypressClick change|>
|
||||
{{#if item.IsEditable}}
|
||||
{{#if (can "write intention" item=item)}}
|
||||
<li role="none">
|
||||
<a role="menuitem" tabindex="-1" href={{href-to (or routeName 'dc.intentions.edit') item.ID}}>Edit</a>
|
||||
</li>
|
||||
|
|
|
@ -11,8 +11,11 @@
|
|||
as |api|
|
||||
>
|
||||
<BlockSlot @name="content">
|
||||
{{#let (cannot 'write kv' item=api.data) as |disabld|}}
|
||||
<form onsubmit={{action api.submit}}>
|
||||
<fieldset disabled={{api.disabled}}>
|
||||
<fieldset
|
||||
{{disabled (or disabld api.disabled)}}
|
||||
>
|
||||
{{#if api.isCreate}}
|
||||
<label class="type-text{{if api.data.error.Key ' has-error'}}">
|
||||
<span>Key or folder</span>
|
||||
|
@ -24,27 +27,47 @@
|
|||
<div>
|
||||
<div class="type-toggle">
|
||||
<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>
|
||||
</label>
|
||||
</div>
|
||||
<label for="" class="type-text{{if api.data.error.Value ' has-error'}}">
|
||||
<span>Value</span>
|
||||
{{#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}}
|
||||
<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}}
|
||||
</label>
|
||||
</div>
|
||||
{{/if}}
|
||||
</fieldset>
|
||||
{{#if api.isCreate}}
|
||||
{{#if (not disabld)}}
|
||||
<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>
|
||||
{{else}}
|
||||
{{#if (not disabld)}}
|
||||
<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>
|
||||
{{#if (not disabld)}}
|
||||
<ConfirmationDialog @message="Are you sure you want to delete this key?">
|
||||
<BlockSlot @name="action" as |confirm|>
|
||||
<button data-test-delete type="button" class="type-delete" {{action confirm api.delete}} disabled={{api.disabled}}>Delete</button>
|
||||
|
@ -54,6 +77,8 @@
|
|||
</BlockSlot>
|
||||
</ConfirmationDialog>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</form>
|
||||
{{/let}}
|
||||
</BlockSlot>
|
||||
</DataForm>
|
||||
|
|
|
@ -17,6 +17,7 @@ as |item index|>
|
|||
More
|
||||
</BlockSlot>
|
||||
<BlockSlot @name="menu" as |confirm send keypressClick|>
|
||||
{{#if (can 'write kv' item=item)}}
|
||||
<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>
|
||||
</li>
|
||||
|
@ -55,6 +56,11 @@ as |item index|>
|
|||
</InformedAction>
|
||||
</div>
|
||||
</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>
|
||||
</PopoverMenu>
|
||||
</BlockSlot>
|
||||
|
|
|
@ -41,6 +41,7 @@
|
|||
</dd>
|
||||
{{/if}}
|
||||
</dl>
|
||||
{{#if (can 'delete session' item=api.data)}}
|
||||
<ConfirmationDialog @message="Are you sure you want to invalidate this session?">
|
||||
<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>
|
||||
|
@ -53,6 +54,7 @@
|
|||
<button type="button" class="type-cancel" {{action cancel}}>Cancel</button>
|
||||
</BlockSlot>
|
||||
</ConfirmationDialog>
|
||||
{{/if}}
|
||||
</div>
|
||||
</BlockSlot>
|
||||
</DataForm>
|
|
@ -50,6 +50,7 @@
|
|||
</dd>
|
||||
</dl>
|
||||
</BlockSlot>
|
||||
{{#if (can "delete sessions")}}
|
||||
<BlockSlot @name="actions">
|
||||
<ConfirmationDialog @message="Are you sure you want to invalidate this session?">
|
||||
<BlockSlot @name="action" as |confirm|>
|
||||
|
@ -70,5 +71,6 @@
|
|||
</BlockSlot>
|
||||
</ConfirmationDialog>
|
||||
</BlockSlot>
|
||||
{{/if}}
|
||||
</ListCollection>
|
||||
{{/if}}
|
|
@ -62,10 +62,17 @@
|
|||
</Notification>
|
||||
{{/yield-slot}}
|
||||
</State>
|
||||
|
||||
<YieldSlot @name="loaded">
|
||||
{{yield api}}
|
||||
</YieldSlot>
|
||||
{{#if (eq error.status "403")}}
|
||||
{{#yield-slot name="error"}}
|
||||
{{yield api}}
|
||||
{{else}}
|
||||
<ErrorState @error={{error}} />
|
||||
{{/yield-slot}}
|
||||
{{else}}
|
||||
<YieldSlot @name="loaded">
|
||||
{{yield api}}
|
||||
</YieldSlot>
|
||||
{{/if}}
|
||||
|
||||
</State>
|
||||
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
{{#if @dc}}
|
||||
<ul>
|
||||
{{#let (or this.nspaces @nspaces) as |nspaces|}}
|
||||
{{#if (and (env 'CONSUL_NSPACES_ENABLED') (gt nspaces.length 0))}}
|
||||
{{#if (can "choose nspaces" nspaces=nspaces)}}
|
||||
<li
|
||||
class="nspaces"
|
||||
data-test-nspace-menu
|
||||
|
@ -52,7 +52,7 @@
|
|||
</BlockSlot>
|
||||
</MenuItem>
|
||||
{{/each}}
|
||||
{{#if this.canManageNspaces}}
|
||||
{{#if (can 'manage nspaces')}}
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
data-test-main-nav-nspaces
|
||||
|
@ -104,18 +104,27 @@
|
|||
</PopoverMenu>
|
||||
|
||||
</li>
|
||||
{{#if (can "read services")}}
|
||||
<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>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (can "read nodes")}}
|
||||
<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>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (can "read kv")}}
|
||||
<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>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (can "read intentions")}}
|
||||
<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>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (can "read acls")}}
|
||||
<li role="separator">Access Controls</li>
|
||||
<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>
|
||||
|
@ -129,6 +138,7 @@
|
|||
<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>
|
||||
</li>
|
||||
{{/if}}
|
||||
</ul>
|
||||
{{/if}}
|
||||
|
||||
|
@ -175,7 +185,7 @@
|
|||
<li data-test-main-nav-settings class={{if (is-href 'settings') 'is-active'}}>
|
||||
<a href={{href-to 'settings'}}>Settings</a>
|
||||
</li>
|
||||
{{#if (env 'CONSUL_ACLS_ENABLED')}}
|
||||
{{#if (can 'authenticate')}}
|
||||
<li data-test-main-nav-auth>
|
||||
<AuthDialog
|
||||
@dc={{@dc.Name}}
|
||||
|
|
|
@ -2,16 +2,6 @@ import Component from '@glimmer/component';
|
|||
import { action } from '@ember/object';
|
||||
|
||||
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
|
||||
open() {
|
||||
this.authForm.focus();
|
||||
|
|
|
@ -96,6 +96,7 @@
|
|||
returns=(set this 'popoverController')
|
||||
}}
|
||||
{{on 'click' (fn (optional this.popoverController.show))}}
|
||||
{{disabled (cannot 'update intention' item=item.Intention)}}
|
||||
type="button"
|
||||
style={{{concat 'top:' @position.y 'px;left:' @position.x 'px;'}}}
|
||||
aria-label={{if (eq @type 'deny') 'Add intention' 'View intention'}}
|
||||
|
|
|
@ -5,11 +5,14 @@
|
|||
background-color: $white;
|
||||
padding: 1px 1px;
|
||||
&:hover {
|
||||
cursor:pointer;
|
||||
cursor: pointer;
|
||||
}
|
||||
&:active, &:focus {
|
||||
outline: none;
|
||||
}
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
&.deny .informed-action header::before {
|
||||
display: none;
|
||||
|
|
|
@ -80,6 +80,9 @@ export function initialize(container) {
|
|||
.filter(function(item) {
|
||||
return item.startsWith('dc');
|
||||
})
|
||||
.filter(function(item) {
|
||||
return item.endsWith('path');
|
||||
})
|
||||
.map(function(item) {
|
||||
return item.replace('._options.path', '').replace(dotRe, '/');
|
||||
})
|
||||
|
|
|
@ -27,6 +27,7 @@ export default class Intention extends Model {
|
|||
@attr('number') CreateIndex;
|
||||
@attr('number') ModifyIndex;
|
||||
@attr() Meta; // {}
|
||||
@attr({ defaultValue: () => [] }) Resources; // []
|
||||
@fragmentArray('intention-permission') Permissions;
|
||||
|
||||
@computed('Meta')
|
||||
|
|
|
@ -20,6 +20,7 @@ export default class Kv extends Model {
|
|||
@attr('number') CreateIndex;
|
||||
@attr('number') ModifyIndex;
|
||||
@attr('string') Session;
|
||||
@attr({ defaultValue: () => [] }) Resources; // []
|
||||
|
||||
@computed('isFolder')
|
||||
get Kind() {
|
||||
|
|
|
@ -19,6 +19,7 @@ export default class Node extends Model {
|
|||
@attr() meta; // {}
|
||||
@attr() Meta; // {}
|
||||
@attr() TaggedAddresses; // {lan, wan}
|
||||
@attr({ defaultValue: () => [] }) Resources; // []
|
||||
// Services are reshaped to a different shape to what you sometimes get from
|
||||
// the response, see models/node.js
|
||||
@hasMany('service-instance') Services; // TODO: Rename to ServiceInstances
|
||||
|
|
|
@ -10,6 +10,7 @@ export default class Nspace extends Model {
|
|||
|
||||
@attr('number') SyncTime;
|
||||
@attr('string', { defaultValue: () => '' }) Description;
|
||||
@attr({ defaultValue: () => [] }) Resources; // []
|
||||
// TODO: Is there some sort of date we can use here
|
||||
@attr('string') DeletedAt;
|
||||
@attr({
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -37,6 +37,7 @@ export default class ServiceInstance extends Model {
|
|||
@fragmentArray('health-check') Checks;
|
||||
@attr('number') SyncTime;
|
||||
@attr() meta;
|
||||
@attr({ defaultValue: () => [] }) Resources; // []
|
||||
|
||||
// The name is the Name of the Service (the grouping of instances)
|
||||
@alias('Service.Service') Name;
|
||||
|
|
|
@ -35,6 +35,7 @@ export default class Service extends Model {
|
|||
@attr('number') InstanceCount;
|
||||
@attr('boolean') ConnectedWithGateway;
|
||||
@attr('boolean') ConnectedWithProxy;
|
||||
@attr({ defaultValue: () => [] }) Resources; // []
|
||||
@attr('number') SyncTime;
|
||||
@attr('number') CreateIndex;
|
||||
@attr('number') ModifyIndex;
|
||||
|
|
|
@ -19,4 +19,5 @@ export default class Session extends Model {
|
|||
@attr('number') ModifyIndex;
|
||||
|
||||
@attr({ defaultValue: () => [] }) Checks;
|
||||
@attr({ defaultValue: () => [] }) Resources; // []
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
});
|
|
@ -91,10 +91,16 @@ export const routes = {
|
|||
intentions: {
|
||||
_options: { path: '/intentions' },
|
||||
edit: {
|
||||
_options: { path: '/:intention_id' },
|
||||
_options: {
|
||||
path: '/:intention_id',
|
||||
abilities: ['read intentions'],
|
||||
},
|
||||
},
|
||||
create: {
|
||||
_options: { path: '/create' },
|
||||
_options: {
|
||||
path: '/create',
|
||||
abilities: ['create intentions'],
|
||||
},
|
||||
},
|
||||
},
|
||||
// Key/Value
|
||||
|
@ -107,10 +113,16 @@ export const routes = {
|
|||
_options: { path: '/*key/edit' },
|
||||
},
|
||||
create: {
|
||||
_options: { path: '/*key/create' },
|
||||
_options: {
|
||||
path: '/*key/create',
|
||||
abilities: ['create kvs'],
|
||||
},
|
||||
},
|
||||
'root-create': {
|
||||
_options: { path: '/create' },
|
||||
_options: {
|
||||
path: '/create',
|
||||
abilities: ['create kvs'],
|
||||
},
|
||||
},
|
||||
},
|
||||
// ACLs
|
||||
|
|
|
@ -36,7 +36,7 @@ export default Route.extend(WithBlockingActions, {
|
|||
error: function(e, transition) {
|
||||
// TODO: Normalize all this better
|
||||
let error = {
|
||||
status: e.code || '',
|
||||
status: e.code || e.statusCode || '',
|
||||
message: e.message || e.detail || 'Error',
|
||||
};
|
||||
if (e.errors && e.errors[0]) {
|
||||
|
|
|
@ -24,6 +24,7 @@ const findActiveNspace = function(nspaces, nspace) {
|
|||
};
|
||||
export default class DcRoute extends Route {
|
||||
@service('repository/dc') repo;
|
||||
@service('repository/permission') permissionsRepo;
|
||||
@service('repository/nspace/disabled') nspacesRepo;
|
||||
@service('settings') settingsRepo;
|
||||
|
||||
|
@ -41,11 +42,8 @@ export default class DcRoute extends Route {
|
|||
nspace =
|
||||
app.nspaces.length > 1 ? findActiveNspace(app.nspaces, nspace) : app.nspaces.firstObject;
|
||||
|
||||
let permissions;
|
||||
if (get(token, 'SecretID')) {
|
||||
// When disabled nspaces is [], so nspace is undefined
|
||||
permissions = await this.nspacesRepo.authorize(params.dc, get(nspace || {}, 'Name'));
|
||||
}
|
||||
// When disabled nspaces is [], so nspace is undefined
|
||||
const permissions = await this.permissionsRepo.findAll(params.dc, get(nspace || {}, 'Name'));
|
||||
return {
|
||||
dc,
|
||||
nspace,
|
||||
|
@ -81,7 +79,7 @@ export default class DcRoute extends Route {
|
|||
const controller = this.controllerFor('application');
|
||||
Promise.all([
|
||||
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]) => {
|
||||
if (typeof controller !== 'undefined') {
|
||||
controller.setProperties({
|
||||
|
|
|
@ -6,11 +6,9 @@ import { get } from '@ember/object';
|
|||
import ascend from 'consul-ui/utils/ascend';
|
||||
|
||||
export default class EditRoute extends Route {
|
||||
@service('repository/kv')
|
||||
repo;
|
||||
|
||||
@service('repository/session')
|
||||
sessionRepo;
|
||||
@service('repository/kv') repo;
|
||||
@service('repository/session') sessionRepo;
|
||||
@service('repository/permission') permissions;
|
||||
|
||||
model(params) {
|
||||
const create =
|
||||
|
@ -39,7 +37,7 @@ export default class EditRoute extends Route {
|
|||
// TODO: Consider loading this after initial page load
|
||||
if (typeof model.item !== 'undefined') {
|
||||
const session = get(model.item, 'Session');
|
||||
if (session) {
|
||||
if (session && this.permissions.can('read sessions')) {
|
||||
return hash({
|
||||
...model,
|
||||
...{
|
||||
|
|
|
@ -36,15 +36,7 @@ export default class IndexRoute extends Route {
|
|||
return hash({
|
||||
...model,
|
||||
...{
|
||||
items: this.repo.findAllBySlug(get(model.parent, 'Key'), dc, nspace).catch(e => {
|
||||
const status = get(e, 'errors.firstObject.status');
|
||||
switch (status) {
|
||||
case '403':
|
||||
return this.transitionTo('dc.acls.tokens');
|
||||
default:
|
||||
return this.transitionTo('dc.kv.index');
|
||||
}
|
||||
}),
|
||||
items: this.repo.findAllBySlug(get(model.parent, 'Key'), dc, nspace),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
@ -55,7 +47,8 @@ export default class IndexRoute extends Route {
|
|||
if (e.errors && e.errors[0] && e.errors[0].status == '404') {
|
||||
return this.transitionTo('dc.kv.index');
|
||||
}
|
||||
throw e;
|
||||
// let the route above handle the error
|
||||
return true;
|
||||
}
|
||||
|
||||
setupController(controller, model) {
|
||||
|
|
|
@ -1,12 +1,33 @@
|
|||
import Route from '@ember/routing/route';
|
||||
import { get, setProperties } from '@ember/object';
|
||||
import { inject as service } from '@ember/service';
|
||||
import HTTPError from 'consul-ui/utils/http/error';
|
||||
|
||||
// paramsFor
|
||||
import { routes } from 'consul-ui/router';
|
||||
import wildcard from 'consul-ui/utils/routing/wildcard';
|
||||
|
||||
const isWildcard = wildcard(routes);
|
||||
|
||||
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
|
||||
* 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'
|
||||
*/
|
||||
serializeQueryParam(value, key, type) {
|
||||
if(typeof value !== 'undefined') {
|
||||
if (typeof value !== 'undefined') {
|
||||
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,
|
||||
// therefore remove the queryParam from the URL
|
||||
if(value === '') {
|
||||
if (value === '') {
|
||||
value = undefined;
|
||||
}
|
||||
} else {
|
||||
const possible = empty[0];
|
||||
let actual = value;
|
||||
if(Array.isArray(actual)) {
|
||||
if (Array.isArray(actual)) {
|
||||
actual = actual.split(',');
|
||||
}
|
||||
const diff = possible.filter(item => !actual.includes(item))
|
||||
if(diff.length === 0) {
|
||||
const diff = possible.filter(item => !actual.includes(item));
|
||||
if (diff.length === 0) {
|
||||
value = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
@ -49,6 +71,7 @@ export default class BaseRoute extends Route {
|
|||
});
|
||||
super.setupController(...arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds urldecoding to any wildcard route `params` passed into ember `model`
|
||||
* hooks, plus of course anywhere else where `paramsFor` is used. This means
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
import Serializer from './application';
|
||||
|
||||
export default class PermissionSerializer extends Serializer {}
|
|
@ -1,10 +1,15 @@
|
|||
import Service, { inject as service } from '@ember/service';
|
||||
import { assert } from '@ember/debug';
|
||||
import { typeOf } from '@ember/utils';
|
||||
import { get } from '@ember/object';
|
||||
import { get, set } from '@ember/object';
|
||||
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 {
|
||||
@service('store') store;
|
||||
@service('repository/permission') permissions;
|
||||
|
||||
getModelName() {
|
||||
assert('RepositoryService.getModelName should be overridden', false);
|
||||
}
|
||||
|
@ -17,9 +22,59 @@ export default class RepositoryService extends Service {
|
|||
assert('RepositoryService.getSlugKey should be overridden', false);
|
||||
}
|
||||
|
||||
//
|
||||
@service('store')
|
||||
store;
|
||||
/**
|
||||
* Creates a set of permissions base don a slug, loads in the access
|
||||
* 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 = {}) {
|
||||
// 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);
|
||||
}
|
||||
|
||||
findBySlug(slug, dc, nspace, configuration = {}) {
|
||||
async findBySlug(slug, dc, nspace, configuration = {}) {
|
||||
const query = {
|
||||
dc: dc,
|
||||
ns: nspace,
|
||||
|
@ -69,7 +124,13 @@ export default class RepositoryService extends Service {
|
|||
query.index = configuration.cursor;
|
||||
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) {
|
||||
|
|
|
@ -19,7 +19,7 @@ export default class DiscoveryChainService extends RepositoryService {
|
|||
}
|
||||
return super.findBySlug(...arguments).catch(e => {
|
||||
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) {
|
||||
case '500':
|
||||
if (datacenter !== null && body.endsWith(ERROR_MESH_DISABLED)) {
|
||||
|
|
|
@ -32,6 +32,17 @@ export default class IntentionRepository extends RepositoryService {
|
|||
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) {
|
||||
const res = await super.persist(...arguments);
|
||||
// if Action is set it means we are an l4 type intention
|
||||
|
|
|
@ -2,6 +2,7 @@ import RepositoryService from 'consul-ui/services/repository';
|
|||
import isFolder from 'consul-ui/utils/isFolder';
|
||||
import { get } from '@ember/object';
|
||||
import { PRIMARY_KEY } from 'consul-ui/models/kv';
|
||||
import { ACCESS_LIST } from 'consul-ui/abilities/base';
|
||||
|
||||
const modelName = 'kv';
|
||||
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
|
||||
findBySlug(key, dc, nspace, configuration = {}) {
|
||||
if (isFolder(key)) {
|
||||
async findBySlug(slug, dc, nspace, configuration = {}) {
|
||||
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,
|
||||
// needs to eventually use ember-datas generateId thing
|
||||
// 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);
|
||||
if (!item) {
|
||||
item = this.create({
|
||||
Key: key,
|
||||
Key: slug,
|
||||
Datacenter: dc,
|
||||
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
|
||||
|
@ -47,37 +47,39 @@ export default class KvService extends RepositoryService {
|
|||
if (key === '/') {
|
||||
key = '';
|
||||
}
|
||||
const query = {
|
||||
id: key,
|
||||
dc: dc,
|
||||
ns: nspace,
|
||||
separator: '/',
|
||||
};
|
||||
if (typeof configuration.cursor !== 'undefined') {
|
||||
query.index = configuration.cursor;
|
||||
}
|
||||
return this.store
|
||||
.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();
|
||||
}
|
||||
return this.authorizeBySlug(
|
||||
async () => {
|
||||
const query = {
|
||||
id: key,
|
||||
dc: dc,
|
||||
ns: nspace,
|
||||
separator: '/',
|
||||
};
|
||||
if (typeof configuration.cursor !== 'undefined') {
|
||||
query.index = configuration.cursor;
|
||||
}
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import RepositoryService from 'consul-ui/services/repository';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { set } from '@ember/object';
|
||||
import { ACCESS_READ } from 'consul-ui/abilities/base';
|
||||
|
||||
const modelName = 'service-instance';
|
||||
export default class ServiceInstanceService extends RepositoryService {
|
||||
|
@ -19,7 +20,13 @@ export default class ServiceInstanceService extends RepositoryService {
|
|||
query.index = configuration.cursor;
|
||||
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 = {}) {
|
||||
|
@ -34,7 +41,13 @@ export default class ServiceInstanceService extends RepositoryService {
|
|||
query.index = configuration.cursor;
|
||||
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 = {}) {
|
||||
|
|
|
@ -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
|
||||
// for anything other than nspaces/OIDC for good DX
|
||||
// TODO: This one is only for permissions and OIDC, should fail nicely if you call it
|
||||
// for anything other than permissions/OIDC for good DX
|
||||
authorize(modelName, query = {}) {
|
||||
const adapter = this.adapterFor(modelName);
|
||||
const serializer = this.serializerFor(modelName);
|
||||
|
|
|
@ -6,6 +6,11 @@
|
|||
border: $decor-border-100;
|
||||
outline: none;
|
||||
}
|
||||
textarea:disabled + .CodeMirror,
|
||||
%form-element-text-input:disabled,
|
||||
%form-element-text-input:read-only {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
%form h2 {
|
||||
@extend %h200;
|
||||
}
|
||||
|
|
|
@ -21,7 +21,6 @@ as |source|>
|
|||
{{#if (not-eq router.currentRouteName 'application')}}
|
||||
<HashicorpConsul
|
||||
id="wrapper"
|
||||
@permissions={{permissions}}
|
||||
@dcs={{dcs}}
|
||||
@dc={{or dc dcs.firstObject}}
|
||||
@nspaces={{nspaces}}
|
||||
|
|
|
@ -40,7 +40,9 @@ as |sort filters items|}}
|
|||
<label for="toolbar-toggle"></label>
|
||||
</BlockSlot>
|
||||
<BlockSlot @name="actions">
|
||||
{{#if (can 'create intentions')}}
|
||||
<a data-test-create href="{{href-to 'dc.intentions.create'}}" class="type-create">Create</a>
|
||||
{{/if}}
|
||||
</BlockSlot>
|
||||
<BlockSlot @name="toolbar">
|
||||
|
||||
|
|
|
@ -24,9 +24,11 @@
|
|||
</h1>
|
||||
</BlockSlot>
|
||||
<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
|
||||
@type="warning"
|
||||
data-test-session-warning
|
||||
as |notice|>
|
||||
<notice.Body>
|
||||
<p>
|
||||
|
@ -42,6 +44,7 @@
|
|||
@onsubmit={{if (eq parent.Key '/') (transition-to 'dc.kv.index') (transition-to 'dc.kv.folder' parent.Key)}}
|
||||
@parent={{parent}}
|
||||
/>
|
||||
{{! session is slightly different to item.Session as we only have session if you have session:read perms}}
|
||||
{{#if session}}
|
||||
<Consul::LockSession::Form
|
||||
@item={{session}}
|
||||
|
|
|
@ -50,11 +50,13 @@ as |sort filters items|}}
|
|||
{{/if}}
|
||||
</BlockSlot>
|
||||
<BlockSlot @name="actions">
|
||||
{{#if (can 'create kvs')}}
|
||||
{{#if (not-eq parent.Key '/') }}
|
||||
<a data-test-create href="{{href-to 'dc.kv.create' parent.Key}}" class="type-create">Create</a>
|
||||
{{else}}
|
||||
<a data-test-create href="{{href-to 'dc.kv.root-create'}}" class="type-create">Create</a>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</BlockSlot>
|
||||
<BlockSlot @name="content">
|
||||
<DataWriter
|
||||
|
|
|
@ -18,6 +18,13 @@
|
|||
This node no longer exists in the catalog.
|
||||
</p>
|
||||
</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}}
|
||||
<Notification @sticky={{true}}>
|
||||
<p data-notification role="alert" class="warning notification-update">
|
||||
|
|
|
@ -1,14 +1,30 @@
|
|||
<EventSource @src={{sessions}} />
|
||||
<div class="tab-section">
|
||||
{{#if (gt sessions.length 0)}}
|
||||
<Consul::LockSession::List @items={{sessions}} @onInvalidate={{action send 'invalidateSession'}}/>
|
||||
<Consul::LockSession::List
|
||||
@items={{sessions}}
|
||||
@onInvalidate={{action send 'invalidateSession'}}
|
||||
/>
|
||||
{{else}}
|
||||
<EmptyState>
|
||||
<EmptyState @allowLogin={{true}}>
|
||||
<BlockSlot @name="header">
|
||||
<h2>
|
||||
Welcome to Lock Sessions
|
||||
</h2>
|
||||
</BlockSlot>
|
||||
<BlockSlot @name="body">
|
||||
<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>
|
||||
</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>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
|
|
@ -21,6 +21,13 @@
|
|||
This service has been deregistered and no longer exists in the catalog.
|
||||
</p>
|
||||
</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}}
|
||||
<Notification @sticky={{true}}>
|
||||
<p data-notification role="alert" class="warning notification-update">
|
||||
|
|
|
@ -22,6 +22,13 @@
|
|||
This service has been deregistered and no longer exists in the catalog.
|
||||
</p>
|
||||
</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}}
|
||||
<Notification @sticky={{true}}>
|
||||
<p data-notification role="alert" class="warning notification-update">
|
||||
|
|
|
@ -38,9 +38,11 @@ as |api|>
|
|||
|
||||
as |sort filters items|}}
|
||||
<div class="tab-section">
|
||||
{{#if (can 'create intentions')}}
|
||||
<Portal @target="app-view-actions">
|
||||
<a data-test-create href={{href-to 'dc.services.show.intentions.create'}} class="type-create">Create</a>
|
||||
</Portal>
|
||||
{{/if}}
|
||||
{{#if (gt items.length 0) }}
|
||||
<Consul::Intention::SearchBar
|
||||
@search={{search}}
|
||||
|
|
|
@ -1,34 +1,14 @@
|
|||
[
|
||||
{
|
||||
"Resource": "acl",
|
||||
"Access": "read",
|
||||
"Allow": false
|
||||
},
|
||||
{
|
||||
"Resource": "acl",
|
||||
"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
|
||||
}
|
||||
${
|
||||
http.body.map(item => {
|
||||
return JSON.stringify(
|
||||
Object.assign(
|
||||
item,
|
||||
{
|
||||
Allow: !!JSON.parse(env(`CONSUL_RESOURCE_${item.Resource.toUpperCase()}_${item.Access.toUpperCase()}`, 'true'))
|
||||
}
|
||||
)
|
||||
);
|
||||
})
|
||||
}
|
||||
]
|
||||
|
|
|
@ -88,6 +88,7 @@
|
|||
"d3-shape": "^2.0.0",
|
||||
"dayjs": "^1.9.3",
|
||||
"ember-auto-import": "^1.5.3",
|
||||
"ember-can": "^3.0.0",
|
||||
"ember-changeset-conditional-validations": "^0.6.0",
|
||||
"ember-changeset-validations": "^3.9.0",
|
||||
"ember-cli": "~3.20.2",
|
||||
|
|
|
@ -10,6 +10,20 @@ Feature: dc / intentions / index
|
|||
Then the url should be /dc-1/intentions
|
||||
And the title should be "Intentions - Consul"
|
||||
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
|
||||
Given 1 datacenter model with the value "dc-1"
|
||||
Given 3 intention models
|
||||
|
|
|
@ -25,6 +25,49 @@ Feature: dc / kvs / edit: KV Viewing
|
|||
kv: another-key
|
||||
---
|
||||
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
|
||||
Scenario: I have KV called [Page]
|
||||
Given 1 datacenter model with the value "datacenter"
|
||||
|
|
|
@ -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
|
||||
|
|
@ -119,3 +119,44 @@ Feature: dc / services / show: Show Service
|
|||
---
|
||||
# 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"
|
||||
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"
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -155,7 +155,9 @@ export default {
|
|||
radiogroup
|
||||
)
|
||||
),
|
||||
service: create(service(visitable, clickable, attribute, collection, text, consulIntentionList, tabgroup)),
|
||||
service: create(
|
||||
service(visitable, clickable, attribute, collection, text, consulIntentionList, tabgroup)
|
||||
),
|
||||
instance: create(
|
||||
instance(
|
||||
visitable,
|
||||
|
@ -182,7 +184,7 @@ export default {
|
|||
)
|
||||
),
|
||||
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)),
|
||||
acl: create(acl(visitable, submitable, deletable, cancelable, clickable)),
|
||||
policies: create(policies(visitable, creatable, consulPolicyList, popoverSelect)),
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
export default function(visitable, attribute, submitable, deletable, cancelable) {
|
||||
export default function(visitable, attribute, present, submitable, deletable, cancelable) {
|
||||
return {
|
||||
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
|
||||
|
@ -12,6 +12,7 @@ export default function(visitable, attribute, submitable, deletable, cancelable)
|
|||
...cancelable(),
|
||||
...deletable(),
|
||||
session: {
|
||||
warning: present('[data-test-session-warning]'),
|
||||
ID: attribute('data-test-session', '[data-test-session]'),
|
||||
...deletable({}, '[data-test-session]'),
|
||||
},
|
||||
|
|
|
@ -97,7 +97,7 @@ export default function({
|
|||
const clipboard = function() {
|
||||
return window.localStorage.getItem('clipboard');
|
||||
};
|
||||
models(library, create);
|
||||
models(library, create, setCookie);
|
||||
http(library, respondWith, setCookie);
|
||||
visit(library, pages, utils.setCurrentPage, reset);
|
||||
click(library, utils.find, helpers.click);
|
||||
|
|
|
@ -7,6 +7,9 @@ export default function(scenario, respondWith, set) {
|
|||
});
|
||||
})
|
||||
.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);
|
||||
})
|
||||
.given('a network latency of $number', function(number) {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
export default function(scenario, create, win = window, doc = document) {
|
||||
export default function(scenario, create, set, win = window, doc = document) {
|
||||
scenario
|
||||
.given(['an external edit results in $number $model model[s]?'], function(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) {
|
||||
// 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)}`;
|
||||
})
|
||||
.given(['the local datacenter is "$value"'], function(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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
50
ui/yarn.lock
50
ui/yarn.lock
|
@ -4303,6 +4303,13 @@ babel-plugin-ember-modules-api-polyfill@^3.2.0:
|
|||
dependencies:
|
||||
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:
|
||||
version "10.0.33"
|
||||
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-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:
|
||||
version "0.6.0"
|
||||
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"
|
||||
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:
|
||||
version "1.0.2"
|
||||
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"
|
||||
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"
|
||||
resolved "https://registry.yarnpkg.com/ember-cli-htmlbars/-/ember-cli-htmlbars-4.4.0.tgz#7ca17d5ca8f7550984346d9e6e93da0c3323f8d9"
|
||||
integrity sha512-ohgctqk7dXIZR4TgN0xRoUYltWhghFJgqmtuswQTpZ7p74RxI9PKx+E8WV/95mGcPzraesvMNBg5utQNvcqgNg==
|
||||
|
|
Loading…
Reference in New Issue