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
804b24ae6b
commit
dc183b1786
|
@ -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_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.
|
||||||
|
|
|
@ -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;
|
@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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
</h1>
|
||||||
</BlockSlot>
|
</BlockSlot>
|
||||||
<BlockSlot @name="content">
|
<BlockSlot @name="content">
|
||||||
<ErrorState @error={{@error}} />
|
<ErrorState @error={{@error}} @allowLogin={{eq @error.status "403"}} />
|
||||||
</BlockSlot>
|
</BlockSlot>
|
||||||
</AppView>
|
</AppView>
|
||||||
|
|
|
@ -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|}}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
|
@ -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}}
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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}}
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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'}}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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, '/');
|
||||||
})
|
})
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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;
|
@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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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; // []
|
||||||
}
|
}
|
||||||
|
|
|
@ -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: {
|
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
|
||||||
|
|
|
@ -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]) {
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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,
|
||||||
...{
|
...{
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 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) {
|
||||||
|
|
|
@ -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)) {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 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 = {}) {
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}}
|
||||||
|
|
|
@ -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">
|
||||||
|
|
||||||
|
|
|
@ -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}}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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}}
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
# 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"
|
||||||
|
|
||||||
|
|
|
@ -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
|
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)),
|
||||||
|
|
|
@ -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]'),
|
||||||
},
|
},
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -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:
|
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==
|
||||||
|
|
Loading…
Reference in New Issue