diff --git a/.changelog/9687.txt b/.changelog/9687.txt new file mode 100644 index 000000000..da9097bb6 --- /dev/null +++ b/.changelog/9687.txt @@ -0,0 +1,3 @@ +```release-note:improvement +ui: restrict the viewing/editing of certain UI elements based on the users ACL token +``` diff --git a/ui/packages/consul-ui/README.md b/ui/packages/consul-ui/README.md index 31c851ccf..09ad1bdcd 100644 --- a/ui/packages/consul-ui/README.md +++ b/ui/packages/consul-ui/README.md @@ -128,6 +128,7 @@ token/secret. | `CONSUL_EXPOSED_COUNT` | (random) | Configure the number of exposed paths that the API returns. | | `CONSUL_CHECK_COUNT` | (random) | Configure the number of health checks that the API returns. | | `CONSUL_OIDC_PROVIDER_COUNT` | (random) | Configure the number of OIDC providers that the API returns. | +| `CONSUL_RESOURCE__` | true | Configure permissions e.g `CONSUL_RESOURCE_INTENTION_WRITE=false`. | | `DEBUG_ROUTES_ENDPOINT` | undefined | When using the window.Routes() debug utility ([see utility functions](#browser-debug-utility-functions)), use a URL to pass the route DSL to. %s in the URL will be replaced with the route DSL - http://url.com?routes=%s | See `./mock-api` for more details. diff --git a/ui/packages/consul-ui/app/abilities/acl.js b/ui/packages/consul-ui/app/abilities/acl.js new file mode 100644 index 000000000..f62383f41 --- /dev/null +++ b/ui/packages/consul-ui/app/abilities/acl.js @@ -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; + } +} diff --git a/ui/packages/consul-ui/app/abilities/authenticate.js b/ui/packages/consul-ui/app/abilities/authenticate.js new file mode 100644 index 000000000..99d3ae040 --- /dev/null +++ b/ui/packages/consul-ui/app/abilities/authenticate.js @@ -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'); + } +} diff --git a/ui/packages/consul-ui/app/abilities/base.js b/ui/packages/consul-ui/app/abilities/base.js new file mode 100644 index 000000000..58254c6f1 --- /dev/null +++ b/ui/packages/consul-ui/app/abilities/base.js @@ -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; + } +} diff --git a/ui/packages/consul-ui/app/abilities/intention.js b/ui/packages/consul-ui/app/abilities/intention.js new file mode 100644 index 000000000..35551e670 --- /dev/null +++ b/ui/packages/consul-ui/app/abilities/intention.js @@ -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); + } +} diff --git a/ui/packages/consul-ui/app/abilities/kv.js b/ui/packages/consul-ui/app/abilities/kv.js new file mode 100644 index 000000000..a13736f2d --- /dev/null +++ b/ui/packages/consul-ui/app/abilities/kv.js @@ -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; + } +} diff --git a/ui/packages/consul-ui/app/abilities/node.js b/ui/packages/consul-ui/app/abilities/node.js new file mode 100644 index 000000000..768ccb5ee --- /dev/null +++ b/ui/packages/consul-ui/app/abilities/node.js @@ -0,0 +1,5 @@ +import BaseAbility from './base'; + +export default class NodeAbility extends BaseAbility { + resource = 'node'; +} diff --git a/ui/packages/consul-ui/app/abilities/nspace.js b/ui/packages/consul-ui/app/abilities/nspace.js new file mode 100644 index 000000000..f13f36e6d --- /dev/null +++ b/ui/packages/consul-ui/app/abilities/nspace.js @@ -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; + } +} diff --git a/ui/packages/consul-ui/app/abilities/permission.js b/ui/packages/consul-ui/app/abilities/permission.js new file mode 100644 index 000000000..bf2e02dc6 --- /dev/null +++ b/ui/packages/consul-ui/app/abilities/permission.js @@ -0,0 +1,7 @@ +import BaseAbility from './base'; + +export default class PermissionAbility extends BaseAbility { + get canRead() { + return this.permissions.permissions.length > 0; + } +} diff --git a/ui/packages/consul-ui/app/abilities/service-instance.js b/ui/packages/consul-ui/app/abilities/service-instance.js new file mode 100644 index 000000000..628a92c75 --- /dev/null +++ b/ui/packages/consul-ui/app/abilities/service-instance.js @@ -0,0 +1,5 @@ +import BaseAbility from './base'; + +export default class ServiceInstanceAbility extends BaseAbility { + resource = 'service'; +} diff --git a/ui/packages/consul-ui/app/abilities/service.js b/ui/packages/consul-ui/app/abilities/service.js new file mode 100644 index 000000000..4c78ab0b3 --- /dev/null +++ b/ui/packages/consul-ui/app/abilities/service.js @@ -0,0 +1,5 @@ +import BaseAbility from './base'; + +export default class ServiceAbility extends BaseAbility { + resource = 'service'; +} diff --git a/ui/packages/consul-ui/app/abilities/session.js b/ui/packages/consul-ui/app/abilities/session.js new file mode 100644 index 000000000..b37b3c5cc --- /dev/null +++ b/ui/packages/consul-ui/app/abilities/session.js @@ -0,0 +1,5 @@ +import BaseAbility from './base'; + +export default class SessionAbility extends BaseAbility { + resource = 'session'; +} diff --git a/ui/packages/consul-ui/app/adapters/application.js b/ui/packages/consul-ui/app/adapters/application.js index f49fc422d..a79d64d72 100644 --- a/ui/packages/consul-ui/app/adapters/application.js +++ b/ui/packages/consul-ui/app/adapters/application.js @@ -9,7 +9,7 @@ export default class ApplicationAdapter extends Adapter { @service('env') env; formatNspace(nspace) { - if (this.env.env('CONSUL_NSPACES_ENABLED')) { + if (this.env.var('CONSUL_NSPACES_ENABLED')) { return nspace !== '' ? { [NSPACE_QUERY_PARAM]: nspace } : undefined; } } diff --git a/ui/packages/consul-ui/app/adapters/nspace.js b/ui/packages/consul-ui/app/adapters/nspace.js index 7a468f9ef..043887f7b 100644 --- a/ui/packages/consul-ui/app/adapters/nspace.js +++ b/ui/packages/consul-ui/app/adapters/nspace.js @@ -57,33 +57,4 @@ export default class NspaceAdapter extends Adapter { DELETE /v1/namespace/${data[SLUG_KEY]} `; } - - requestForAuthorize(request, { dc, ns, index }) { - return request` - POST /v1/internal/acl/authorize?${{ dc, ns, index }} - - ${[ - { - Resource: 'operator', - Access: 'write', - }, - ]} - `; - } - - authorize(store, type, id, snapshot) { - return this.rpc( - function(adapter, request, serialized, unserialized) { - return adapter.requestForAuthorize(request, serialized, unserialized); - }, - function(serializer, respond, serialized, unserialized) { - // Completely skip the serializer here - return respond(function(headers, body) { - return body; - }); - }, - snapshot, - type.modelName - ); - } } diff --git a/ui/packages/consul-ui/app/adapters/permission.js b/ui/packages/consul-ui/app/adapters/permission.js new file mode 100644 index 000000000..cdcee918d --- /dev/null +++ b/ui/packages/consul-ui/app/adapters/permission.js @@ -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 + ); + } +} diff --git a/ui/packages/consul-ui/app/components/app-error/index.hbs b/ui/packages/consul-ui/app/components/app-error/index.hbs index 0b96fa2d4..fabaec5ea 100644 --- a/ui/packages/consul-ui/app/components/app-error/index.hbs +++ b/ui/packages/consul-ui/app/components/app-error/index.hbs @@ -5,6 +5,6 @@ - + diff --git a/ui/packages/consul-ui/app/components/consul/intention/form/index.hbs b/ui/packages/consul-ui/app/components/consul/intention/form/index.hbs index 788faccc5..4860bf509 100644 --- a/ui/packages/consul-ui/app/components/consul/intention/form/index.hbs +++ b/ui/packages/consul-ui/app/components/consul/intention/form/index.hbs @@ -32,7 +32,7 @@ as |api|> {{#let api.data as |item|}} - {{#if item.IsEditable}} +{{#if (can 'write intention' item=item)}} {{#if this.warn}} {{#let (changeset-get item 'Action') as |newAction|}} diff --git a/ui/packages/consul-ui/app/components/consul/intention/list/table/index.hbs b/ui/packages/consul-ui/app/components/consul/intention/list/table/index.hbs index faf35be4a..d54b8b0cd 100644 --- a/ui/packages/consul-ui/app/components/consul/intention/list/table/index.hbs +++ b/ui/packages/consul-ui/app/components/consul/intention/list/table/index.hbs @@ -59,7 +59,7 @@ as |item index|> More - {{#if item.IsEditable}} + {{#if (can "write intention" item=item)}}
  • Edit
  • diff --git a/ui/packages/consul-ui/app/components/consul/kv/form/index.hbs b/ui/packages/consul-ui/app/components/consul/kv/form/index.hbs index a0a067081..da045157d 100644 --- a/ui/packages/consul-ui/app/components/consul/kv/form/index.hbs +++ b/ui/packages/consul-ui/app/components/consul/kv/form/index.hbs @@ -11,8 +11,11 @@ as |api| > +{{#let (cannot 'write kv' item=api.data) as |disabld|}}
    -
    +
    {{#if api.isCreate}}
    {{#if api.isCreate}} +{{#if (not disabld)}} +{{/if}} {{else}} +{{#if (not disabld)}} +{{/if}} +{{#if (not disabld)}} @@ -54,6 +77,8 @@ {{/if}} +{{/if}} +{{/let}} diff --git a/ui/packages/consul-ui/app/components/consul/kv/list/index.hbs b/ui/packages/consul-ui/app/components/consul/kv/list/index.hbs index da80b8948..2c78ac191 100644 --- a/ui/packages/consul-ui/app/components/consul/kv/list/index.hbs +++ b/ui/packages/consul-ui/app/components/consul/kv/list/index.hbs @@ -17,6 +17,7 @@ as |item index|> More + {{#if (can 'write kv' item=item)}}
  • {{if item.isFolder 'View' 'Edit'}}
  • @@ -55,6 +56,11 @@ as |item index|> + {{else}} +
  • + View +
  • + {{/if}}
    diff --git a/ui/packages/consul-ui/app/components/consul/lock-session/form/index.hbs b/ui/packages/consul-ui/app/components/consul/lock-session/form/index.hbs index 3eb0b8a16..5872006d4 100644 --- a/ui/packages/consul-ui/app/components/consul/lock-session/form/index.hbs +++ b/ui/packages/consul-ui/app/components/consul/lock-session/form/index.hbs @@ -41,6 +41,7 @@ {{/if}} +{{#if (can 'delete session' item=api.data)}} @@ -53,6 +54,7 @@ +{{/if}} \ No newline at end of file diff --git a/ui/packages/consul-ui/app/components/consul/lock-session/list/index.hbs b/ui/packages/consul-ui/app/components/consul/lock-session/list/index.hbs index 82d6650f1..6f3a16de2 100644 --- a/ui/packages/consul-ui/app/components/consul/lock-session/list/index.hbs +++ b/ui/packages/consul-ui/app/components/consul/lock-session/list/index.hbs @@ -50,6 +50,7 @@ +{{#if (can "delete sessions")}} @@ -70,5 +71,6 @@ +{{/if}} {{/if}} \ No newline at end of file diff --git a/ui/packages/consul-ui/app/components/data-loader/index.hbs b/ui/packages/consul-ui/app/components/data-loader/index.hbs index 00db04484..2f6492f6a 100644 --- a/ui/packages/consul-ui/app/components/data-loader/index.hbs +++ b/ui/packages/consul-ui/app/components/data-loader/index.hbs @@ -62,10 +62,17 @@ {{/yield-slot}} - - - {{yield api}} - + {{#if (eq error.status "403")}} + {{#yield-slot name="error"}} + {{yield api}} + {{else}} + + {{/yield-slot}} + {{else}} + + {{yield api}} + + {{/if}} diff --git a/ui/packages/consul-ui/app/components/hashicorp-consul/index.hbs b/ui/packages/consul-ui/app/components/hashicorp-consul/index.hbs index bac2214fe..0c5c4e4f8 100644 --- a/ui/packages/consul-ui/app/components/hashicorp-consul/index.hbs +++ b/ui/packages/consul-ui/app/components/hashicorp-consul/index.hbs @@ -18,7 +18,7 @@ {{#if @dc}}
      {{#let (or this.nspaces @nspaces) as |nspaces|}} - {{#if (and (env 'CONSUL_NSPACES_ENABLED') (gt nspaces.length 0))}} + {{#if (can "choose nspaces" nspaces=nspaces)}}
    • {{/each}} - {{#if this.canManageNspaces}} + {{#if (can 'manage nspaces')}}
    • +{{#if (can "read services")}}
    • Services
    • +{{/if}} +{{#if (can "read nodes")}}
    • Nodes
    • +{{/if}} +{{#if (can "read kv")}}
    • Key/Value
    • +{{/if}} +{{#if (can "read intentions")}}
    • Intentions
    • +{{/if}} +{{#if (can "read acls")}}
    • Access Controls
    • Tokens @@ -129,6 +138,7 @@
    • Auth Methods
    • +{{/if}}
    {{/if}} @@ -175,7 +185,7 @@
  • Settings
  • - {{#if (env 'CONSUL_ACLS_ENABLED')}} + {{#if (can 'authenticate')}}
  • [] }) Resources; // [] @fragmentArray('intention-permission') Permissions; @computed('Meta') diff --git a/ui/packages/consul-ui/app/models/kv.js b/ui/packages/consul-ui/app/models/kv.js index cb25f37f7..f61e5e2e8 100644 --- a/ui/packages/consul-ui/app/models/kv.js +++ b/ui/packages/consul-ui/app/models/kv.js @@ -20,6 +20,7 @@ export default class Kv extends Model { @attr('number') CreateIndex; @attr('number') ModifyIndex; @attr('string') Session; + @attr({ defaultValue: () => [] }) Resources; // [] @computed('isFolder') get Kind() { diff --git a/ui/packages/consul-ui/app/models/node.js b/ui/packages/consul-ui/app/models/node.js index 58801c24f..cc8d3e846 100644 --- a/ui/packages/consul-ui/app/models/node.js +++ b/ui/packages/consul-ui/app/models/node.js @@ -19,6 +19,7 @@ export default class Node extends Model { @attr() meta; // {} @attr() Meta; // {} @attr() TaggedAddresses; // {lan, wan} + @attr({ defaultValue: () => [] }) Resources; // [] // Services are reshaped to a different shape to what you sometimes get from // the response, see models/node.js @hasMany('service-instance') Services; // TODO: Rename to ServiceInstances diff --git a/ui/packages/consul-ui/app/models/nspace.js b/ui/packages/consul-ui/app/models/nspace.js index d35312491..e6f92cac2 100644 --- a/ui/packages/consul-ui/app/models/nspace.js +++ b/ui/packages/consul-ui/app/models/nspace.js @@ -10,6 +10,7 @@ export default class Nspace extends Model { @attr('number') SyncTime; @attr('string', { defaultValue: () => '' }) Description; + @attr({ defaultValue: () => [] }) Resources; // [] // TODO: Is there some sort of date we can use here @attr('string') DeletedAt; @attr({ diff --git a/ui/packages/consul-ui/app/models/permission.js b/ui/packages/consul-ui/app/models/permission.js new file mode 100644 index 000000000..4deb0a50f --- /dev/null +++ b/ui/packages/consul-ui/app/models/permission.js @@ -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; +} diff --git a/ui/packages/consul-ui/app/models/service-instance.js b/ui/packages/consul-ui/app/models/service-instance.js index 902511895..85c88d971 100644 --- a/ui/packages/consul-ui/app/models/service-instance.js +++ b/ui/packages/consul-ui/app/models/service-instance.js @@ -37,6 +37,7 @@ export default class ServiceInstance extends Model { @fragmentArray('health-check') Checks; @attr('number') SyncTime; @attr() meta; + @attr({ defaultValue: () => [] }) Resources; // [] // The name is the Name of the Service (the grouping of instances) @alias('Service.Service') Name; diff --git a/ui/packages/consul-ui/app/models/service.js b/ui/packages/consul-ui/app/models/service.js index 18f645358..72e07c8ed 100644 --- a/ui/packages/consul-ui/app/models/service.js +++ b/ui/packages/consul-ui/app/models/service.js @@ -35,6 +35,7 @@ export default class Service extends Model { @attr('number') InstanceCount; @attr('boolean') ConnectedWithGateway; @attr('boolean') ConnectedWithProxy; + @attr({ defaultValue: () => [] }) Resources; // [] @attr('number') SyncTime; @attr('number') CreateIndex; @attr('number') ModifyIndex; diff --git a/ui/packages/consul-ui/app/models/session.js b/ui/packages/consul-ui/app/models/session.js index e08765c89..0645d55bf 100644 --- a/ui/packages/consul-ui/app/models/session.js +++ b/ui/packages/consul-ui/app/models/session.js @@ -19,4 +19,5 @@ export default class Session extends Model { @attr('number') ModifyIndex; @attr({ defaultValue: () => [] }) Checks; + @attr({ defaultValue: () => [] }) Resources; // [] } diff --git a/ui/packages/consul-ui/app/modifiers/disabled.js b/ui/packages/consul-ui/app/modifiers/disabled.js new file mode 100644 index 000000000..3e5827646 --- /dev/null +++ b/ui/packages/consul-ui/app/modifiers/disabled.js @@ -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; + } + } +}); diff --git a/ui/packages/consul-ui/app/router.js b/ui/packages/consul-ui/app/router.js index f931be963..ebe655f04 100644 --- a/ui/packages/consul-ui/app/router.js +++ b/ui/packages/consul-ui/app/router.js @@ -91,10 +91,16 @@ export const routes = { intentions: { _options: { path: '/intentions' }, edit: { - _options: { path: '/:intention_id' }, + _options: { + path: '/:intention_id', + abilities: ['read intentions'], + }, }, create: { - _options: { path: '/create' }, + _options: { + path: '/create', + abilities: ['create intentions'], + }, }, }, // Key/Value @@ -107,10 +113,16 @@ export const routes = { _options: { path: '/*key/edit' }, }, create: { - _options: { path: '/*key/create' }, + _options: { + path: '/*key/create', + abilities: ['create kvs'], + }, }, 'root-create': { - _options: { path: '/create' }, + _options: { + path: '/create', + abilities: ['create kvs'], + }, }, }, // ACLs diff --git a/ui/packages/consul-ui/app/routes/application.js b/ui/packages/consul-ui/app/routes/application.js index 82bf5bdd4..640e84d2a 100644 --- a/ui/packages/consul-ui/app/routes/application.js +++ b/ui/packages/consul-ui/app/routes/application.js @@ -36,7 +36,7 @@ export default Route.extend(WithBlockingActions, { error: function(e, transition) { // TODO: Normalize all this better let error = { - status: e.code || '', + status: e.code || e.statusCode || '', message: e.message || e.detail || 'Error', }; if (e.errors && e.errors[0]) { diff --git a/ui/packages/consul-ui/app/routes/dc.js b/ui/packages/consul-ui/app/routes/dc.js index d503d6868..19404acff 100644 --- a/ui/packages/consul-ui/app/routes/dc.js +++ b/ui/packages/consul-ui/app/routes/dc.js @@ -24,6 +24,7 @@ const findActiveNspace = function(nspaces, nspace) { }; export default class DcRoute extends Route { @service('repository/dc') repo; + @service('repository/permission') permissionsRepo; @service('repository/nspace/disabled') nspacesRepo; @service('settings') settingsRepo; @@ -41,11 +42,8 @@ export default class DcRoute extends Route { nspace = app.nspaces.length > 1 ? findActiveNspace(app.nspaces, nspace) : app.nspaces.firstObject; - let permissions; - if (get(token, 'SecretID')) { - // When disabled nspaces is [], so nspace is undefined - permissions = await this.nspacesRepo.authorize(params.dc, get(nspace || {}, 'Name')); - } + // When disabled nspaces is [], so nspace is undefined + const permissions = await this.permissionsRepo.findAll(params.dc, get(nspace || {}, 'Name')); return { dc, nspace, @@ -81,7 +79,7 @@ export default class DcRoute extends Route { const controller = this.controllerFor('application'); Promise.all([ this.nspacesRepo.findAll(), - this.nspacesRepo.authorize(get(controller, 'dc.Name'), get(controller, 'nspace.Name')), + this.permissionsRepo.findAll(get(controller, 'dc.Name'), get(controller, 'nspace.Name')), ]).then(([nspaces, permissions]) => { if (typeof controller !== 'undefined') { controller.setProperties({ diff --git a/ui/packages/consul-ui/app/routes/dc/kv/edit.js b/ui/packages/consul-ui/app/routes/dc/kv/edit.js index a839cc798..127332cf9 100644 --- a/ui/packages/consul-ui/app/routes/dc/kv/edit.js +++ b/ui/packages/consul-ui/app/routes/dc/kv/edit.js @@ -6,11 +6,9 @@ import { get } from '@ember/object'; import ascend from 'consul-ui/utils/ascend'; export default class EditRoute extends Route { - @service('repository/kv') - repo; - - @service('repository/session') - sessionRepo; + @service('repository/kv') repo; + @service('repository/session') sessionRepo; + @service('repository/permission') permissions; model(params) { const create = @@ -39,7 +37,7 @@ export default class EditRoute extends Route { // TODO: Consider loading this after initial page load if (typeof model.item !== 'undefined') { const session = get(model.item, 'Session'); - if (session) { + if (session && this.permissions.can('read sessions')) { return hash({ ...model, ...{ diff --git a/ui/packages/consul-ui/app/routes/dc/kv/index.js b/ui/packages/consul-ui/app/routes/dc/kv/index.js index db400a67f..bbbccde3d 100644 --- a/ui/packages/consul-ui/app/routes/dc/kv/index.js +++ b/ui/packages/consul-ui/app/routes/dc/kv/index.js @@ -36,15 +36,7 @@ export default class IndexRoute extends Route { return hash({ ...model, ...{ - items: this.repo.findAllBySlug(get(model.parent, 'Key'), dc, nspace).catch(e => { - const status = get(e, 'errors.firstObject.status'); - switch (status) { - case '403': - return this.transitionTo('dc.acls.tokens'); - default: - return this.transitionTo('dc.kv.index'); - } - }), + items: this.repo.findAllBySlug(get(model.parent, 'Key'), dc, nspace), }, }); }); @@ -55,7 +47,8 @@ export default class IndexRoute extends Route { if (e.errors && e.errors[0] && e.errors[0].status == '404') { return this.transitionTo('dc.kv.index'); } - throw e; + // let the route above handle the error + return true; } setupController(controller, model) { diff --git a/ui/packages/consul-ui/app/routing/route.js b/ui/packages/consul-ui/app/routing/route.js index d7311f6e7..6259af33c 100644 --- a/ui/packages/consul-ui/app/routing/route.js +++ b/ui/packages/consul-ui/app/routing/route.js @@ -1,12 +1,33 @@ import Route from '@ember/routing/route'; import { get, setProperties } from '@ember/object'; +import { inject as service } from '@ember/service'; +import HTTPError from 'consul-ui/utils/http/error'; // paramsFor import { routes } from 'consul-ui/router'; import wildcard from 'consul-ui/utils/routing/wildcard'; + const isWildcard = wildcard(routes); export default class BaseRoute extends Route { + @service('repository/permission') permissions; + + /** + * Inspects a custom `abilities` array on the router for this route. Every + * abililty needs to 'pass' for the route not to throw a 403 error. Anything + * more complex then this (say ORs) should use a single ability and perform + * the OR lgic in the test for the ability. Note, this ability check happens + * before any calls to the backend for this model/route. + */ + async beforeModel() { + const abilities = get(routes, `${this.routeName}._options.abilities`) || []; + if (abilities.length > 0) { + if (!abilities.every(ability => this.permissions.can(ability))) { + throw new HTTPError(403); + } + } + } + /** * By default any empty string query parameters should remove the query * parameter from the URL. This is the most common behavior if you don't @@ -16,28 +37,29 @@ export default class BaseRoute extends Route { * queryParameter configuration to configure what is deemed 'empty' */ serializeQueryParam(value, key, type) { - if(typeof value !== 'undefined') { + if (typeof value !== 'undefined') { const empty = get(this, `queryParams.${key}.empty`); - if(typeof empty === 'undefined') { + if (typeof empty === 'undefined') { // by default any queryParams when an empty string mean undefined, // therefore remove the queryParam from the URL - if(value === '') { + if (value === '') { value = undefined; } } else { const possible = empty[0]; let actual = value; - if(Array.isArray(actual)) { + if (Array.isArray(actual)) { actual = actual.split(','); } - const diff = possible.filter(item => !actual.includes(item)) - if(diff.length === 0) { + const diff = possible.filter(item => !actual.includes(item)); + if (diff.length === 0) { value = undefined; } } } return value; } + /** * Set the routeName for the controller so that it is available in the template * for the route/controller.. This is mainly used to give a route name to the @@ -49,6 +71,7 @@ export default class BaseRoute extends Route { }); super.setupController(...arguments); } + /** * Adds urldecoding to any wildcard route `params` passed into ember `model` * hooks, plus of course anywhere else where `paramsFor` is used. This means diff --git a/ui/packages/consul-ui/app/serializers/permission.js b/ui/packages/consul-ui/app/serializers/permission.js new file mode 100644 index 000000000..6fe922382 --- /dev/null +++ b/ui/packages/consul-ui/app/serializers/permission.js @@ -0,0 +1,3 @@ +import Serializer from './application'; + +export default class PermissionSerializer extends Serializer {} diff --git a/ui/packages/consul-ui/app/services/repository.js b/ui/packages/consul-ui/app/services/repository.js index 82cb346cc..4d7f2181c 100644 --- a/ui/packages/consul-ui/app/services/repository.js +++ b/ui/packages/consul-ui/app/services/repository.js @@ -1,10 +1,15 @@ import Service, { inject as service } from '@ember/service'; import { assert } from '@ember/debug'; import { typeOf } from '@ember/utils'; -import { get } from '@ember/object'; +import { get, set } from '@ember/object'; import { isChangeset } from 'validated-changeset'; +import HTTPError from 'consul-ui/utils/http/error'; +import { ACCESS_READ } from 'consul-ui/abilities/base'; export default class RepositoryService extends Service { + @service('store') store; + @service('repository/permission') permissions; + getModelName() { assert('RepositoryService.getModelName should be overridden', false); } @@ -17,9 +22,59 @@ export default class RepositoryService extends Service { assert('RepositoryService.getSlugKey should be overridden', false); } - // - @service('store') - store; + /** + * Creates a set of permissions base don a slug, loads in the access + * permissions for themand checks/validates + */ + async authorizeBySlug(cb, access, slug, dc, nspace) { + return this.validatePermissions( + cb, + await this.permissions.findBySlug(slug, this.getModelName(), dc, nspace), + access, + dc, + nspace + ); + } + + /** + * Loads in the access permissions and checks/validates them for a set of + * permissions + */ + async authorizeByPermissions(cb, permissions, access, dc, nspace) { + return this.validatePermissions( + cb, + await this.permissions.authorize(permissions, dc, nspace), + access, + dc, + nspace + ); + } + + /** + * Checks already loaded permissions for certain access before calling cb to + * return the thing you wanted to check the permissions on + */ + async validatePermissions(cb, permissions, access, dc, nspace) { + // inspect the permissions for this segment/slug remotely, if we have zero + // permissions fire a fake 403 so we don't even request the model/resource + if (permissions.length > 0) { + const permission = permissions.find(item => item.Access === access); + if (permission && permission.Allow === false) { + // TODO: Here we temporarily make a hybrid HTTPError/ember-data HTTP error + // we should eventually use HTTPError's everywhere + const e = new HTTPError(403); + e.errors = [{ status: '403' }]; + throw e; + } + } + const item = await cb(); + // add the `Resource` information to the record/model so we can inspect + // them in other places like templates etc + if (get(item, 'Resources')) { + set(item, 'Resources', permissions); + } + return item; + } reconcile(meta = {}) { // unload anything older than our current sync date/time @@ -59,7 +114,7 @@ export default class RepositoryService extends Service { return this.store.query(this.getModelName(), query); } - findBySlug(slug, dc, nspace, configuration = {}) { + async findBySlug(slug, dc, nspace, configuration = {}) { const query = { dc: dc, ns: nspace, @@ -69,7 +124,13 @@ export default class RepositoryService extends Service { query.index = configuration.cursor; query.uri = configuration.uri; } - return this.store.queryRecord(this.getModelName(), query); + return this.authorizeBySlug( + () => this.store.queryRecord(this.getModelName(), query), + ACCESS_READ, + slug, + dc, + nspace + ); } create(obj) { diff --git a/ui/packages/consul-ui/app/services/repository/discovery-chain.js b/ui/packages/consul-ui/app/services/repository/discovery-chain.js index 0ce555872..cb7fd8894 100644 --- a/ui/packages/consul-ui/app/services/repository/discovery-chain.js +++ b/ui/packages/consul-ui/app/services/repository/discovery-chain.js @@ -19,7 +19,7 @@ export default class DiscoveryChainService extends RepositoryService { } return super.findBySlug(...arguments).catch(e => { const code = get(e, 'errors.firstObject.status'); - const body = get(e, 'errors.firstObject.detail').trim(); + const body = (get(e, 'errors.firstObject.detail') || '').trim(); switch (code) { case '500': if (datacenter !== null && body.endsWith(ERROR_MESH_DISABLED)) { diff --git a/ui/packages/consul-ui/app/services/repository/intention.js b/ui/packages/consul-ui/app/services/repository/intention.js index 7489b3beb..d215797e1 100644 --- a/ui/packages/consul-ui/app/services/repository/intention.js +++ b/ui/packages/consul-ui/app/services/repository/intention.js @@ -32,6 +32,17 @@ export default class IntentionRepository extends RepositoryService { return this.managedByCRDs; } + // legacy intentions are strange that in order to read/write you need access + // to either/or the destination or source + async authorizeBySlug(cb, access, slug, dc, nspace) { + const [, source, , destination] = slug.split(':'); + const ability = this.permissions.abilityFor(this.getModelName()); + const permissions = ability + .generateForSegment(source) + .concat(ability.generateForSegment(destination)); + return this.authorizeByPermissions(cb, permissions, access, dc, nspace); + } + async persist(obj) { const res = await super.persist(...arguments); // if Action is set it means we are an l4 type intention diff --git a/ui/packages/consul-ui/app/services/repository/kv.js b/ui/packages/consul-ui/app/services/repository/kv.js index 5d4e3c1eb..fadc4feed 100644 --- a/ui/packages/consul-ui/app/services/repository/kv.js +++ b/ui/packages/consul-ui/app/services/repository/kv.js @@ -2,6 +2,7 @@ import RepositoryService from 'consul-ui/services/repository'; import isFolder from 'consul-ui/utils/isFolder'; import { get } from '@ember/object'; import { PRIMARY_KEY } from 'consul-ui/models/kv'; +import { ACCESS_LIST } from 'consul-ui/abilities/base'; const modelName = 'kv'; export default class KvService extends RepositoryService { @@ -14,31 +15,30 @@ export default class KvService extends RepositoryService { } // this one gives you the full object so key,values and meta - findBySlug(key, dc, nspace, configuration = {}) { - if (isFolder(key)) { + async findBySlug(slug, dc, nspace, configuration = {}) { + if (isFolder(slug)) { + // we only use findBySlug for a folder when we are looking to create a + // parent for a key for retriveing something Model shaped. Therefore we + // only use existing records or a fake record with the correct Key, + // which means we don't need to inpsect permissions as its an already + // existing KV or a fake one + // TODO: This very much shouldn't be here, // needs to eventually use ember-datas generateId thing // in the meantime at least our fingerprinter - const id = JSON.stringify([nspace, dc, key]); + const id = JSON.stringify([nspace, dc, slug]); let item = this.store.peekRecord(this.getModelName(), id); if (!item) { item = this.create({ - Key: key, + Key: slug, Datacenter: dc, Namespace: nspace, }); } - return Promise.resolve(item); + return item; + } else { + return super.findBySlug(slug, dc, nspace, configuration); } - const query = { - id: key, - dc: dc, - ns: nspace, - }; - if (typeof configuration.cursor !== 'undefined') { - query.index = configuration.cursor; - } - return this.store.queryRecord(this.getModelName(), query); } // this one only gives you keys @@ -47,37 +47,39 @@ export default class KvService extends RepositoryService { if (key === '/') { key = ''; } - const query = { - id: key, - dc: dc, - ns: nspace, - separator: '/', - }; - if (typeof configuration.cursor !== 'undefined') { - query.index = configuration.cursor; - } - return this.store - .query(this.getModelName(), query) - .then(function(items) { - return items.filter(function(item) { - return key !== get(item, 'Key'); - }); - }) - .catch(e => { - // TODO: Double check this was loose on purpose, its probably as we were unsure of - // type of ember-data error.Status at first, we could probably change this - // to `===` now - if (get(e, 'errors.firstObject.status') == '404') { - // TODO: This very much shouldn't be here, - // needs to eventually use ember-datas generateId thing - // in the meantime at least our fingerprinter - const id = JSON.stringify([dc, key]); - const record = this.store.peekRecord(this.getModelName(), id); - if (record) { - record.unloadRecord(); - } + return this.authorizeBySlug( + async () => { + const query = { + id: key, + dc: dc, + ns: nspace, + separator: '/', + }; + if (typeof configuration.cursor !== 'undefined') { + query.index = configuration.cursor; } - throw e; - }); + let items; + try { + items = await this.store.query(this.getModelName(), query); + } catch (e) { + if (get(e, 'errors.firstObject.status') === '404') { + // TODO: This very much shouldn't be here, + // needs to eventually use ember-datas generateId thing + // in the meantime at least our fingerprinter + const id = JSON.stringify([dc, key]); + const record = this.store.peekRecord(this.getModelName(), id); + if (record) { + record.unloadRecord(); + } + } + throw e; + } + return items.filter(item => key !== get(item, 'Key')); + }, + ACCESS_LIST, + key, + dc, + nspace + ); } } diff --git a/ui/packages/consul-ui/app/services/repository/permission.js b/ui/packages/consul-ui/app/services/repository/permission.js new file mode 100644 index 000000000..ff8a18ee5 --- /dev/null +++ b/ui/packages/consul-ui/app/services/repository/permission.js @@ -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; + } +} diff --git a/ui/packages/consul-ui/app/services/repository/service-instance.js b/ui/packages/consul-ui/app/services/repository/service-instance.js index 49399ed09..ec604d38e 100644 --- a/ui/packages/consul-ui/app/services/repository/service-instance.js +++ b/ui/packages/consul-ui/app/services/repository/service-instance.js @@ -1,6 +1,7 @@ import RepositoryService from 'consul-ui/services/repository'; import { inject as service } from '@ember/service'; import { set } from '@ember/object'; +import { ACCESS_READ } from 'consul-ui/abilities/base'; const modelName = 'service-instance'; export default class ServiceInstanceService extends RepositoryService { @@ -19,7 +20,13 @@ export default class ServiceInstanceService extends RepositoryService { query.index = configuration.cursor; query.uri = configuration.uri; } - return this.store.query(this.getModelName(), query); + return this.authorizeBySlug( + async () => this.store.query(this.getModelName(), query), + ACCESS_READ, + slug, + dc, + nspace + ); } async findBySlug(serviceId, node, service, dc, nspace, configuration = {}) { @@ -34,7 +41,13 @@ export default class ServiceInstanceService extends RepositoryService { query.index = configuration.cursor; query.uri = configuration.uri; } - return this.store.queryRecord(this.getModelName(), query); + return this.authorizeBySlug( + async () => this.store.queryRecord(this.getModelName(), query), + ACCESS_READ, + service, + dc, + nspace + ); } async findProxyBySlug(serviceId, node, service, dc, nspace, configuration = {}) { diff --git a/ui/packages/consul-ui/app/services/store.js b/ui/packages/consul-ui/app/services/store.js index e5a372c4d..b23f21af7 100644 --- a/ui/packages/consul-ui/app/services/store.js +++ b/ui/packages/consul-ui/app/services/store.js @@ -63,8 +63,8 @@ export default class StoreService extends Store { }); } - // TODO: This one is only for nspaces and OIDC, should fail nicely if you call it - // for anything other than nspaces/OIDC for good DX + // TODO: This one is only for permissions and OIDC, should fail nicely if you call it + // for anything other than permissions/OIDC for good DX authorize(modelName, query = {}) { const adapter = this.adapterFor(modelName); const serializer = this.serializerFor(modelName); diff --git a/ui/packages/consul-ui/app/styles/base/components/form-elements/skin.scss b/ui/packages/consul-ui/app/styles/base/components/form-elements/skin.scss index 17dcbcd06..8d552193f 100644 --- a/ui/packages/consul-ui/app/styles/base/components/form-elements/skin.scss +++ b/ui/packages/consul-ui/app/styles/base/components/form-elements/skin.scss @@ -6,6 +6,11 @@ border: $decor-border-100; outline: none; } +textarea:disabled + .CodeMirror, +%form-element-text-input:disabled, +%form-element-text-input:read-only { + cursor: not-allowed; +} %form h2 { @extend %h200; } diff --git a/ui/packages/consul-ui/app/templates/application.hbs b/ui/packages/consul-ui/app/templates/application.hbs index b6732a513..8688ab200 100644 --- a/ui/packages/consul-ui/app/templates/application.hbs +++ b/ui/packages/consul-ui/app/templates/application.hbs @@ -21,7 +21,6 @@ as |source|> {{#if (not-eq router.currentRouteName 'application')}} +{{#if (can 'create intentions')}} Create +{{/if}} diff --git a/ui/packages/consul-ui/app/templates/dc/kv/edit.hbs b/ui/packages/consul-ui/app/templates/dc/kv/edit.hbs index 03ea5661e..cb5c180af 100644 --- a/ui/packages/consul-ui/app/templates/dc/kv/edit.hbs +++ b/ui/packages/consul-ui/app/templates/dc/kv/edit.hbs @@ -24,9 +24,11 @@ - {{#if session}} + {{! if a KV has a session `Session` will always be populated despite any specific session permissions }} + {{#if item.Session}}

    @@ -42,6 +44,7 @@ @onsubmit={{if (eq parent.Key '/') (transition-to 'dc.kv.index') (transition-to 'dc.kv.folder' parent.Key)}} @parent={{parent}} /> + {{! session is slightly different to item.Session as we only have session if you have session:read perms}} {{#if session}} +{{#if (can 'create kvs')}} {{#if (not-eq parent.Key '/') }} Create {{else}} Create {{/if}} +{{/if}} + {{else if (eq loader.error.status "403")}} + +

    + {{else}}
    {{#if (gt sessions.length 0)}} - + {{else}} - + + +

    + Welcome to Lock Sessions +

    +

    - There are no Lock Sessions for this Node. For more information, view our documentation + 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.

    + +
  • + + {{/if}} diff --git a/ui/packages/consul-ui/app/templates/dc/services/instance.hbs b/ui/packages/consul-ui/app/templates/dc/services/instance.hbs index 597295fc1..417ce5209 100644 --- a/ui/packages/consul-ui/app/templates/dc/services/instance.hbs +++ b/ui/packages/consul-ui/app/templates/dc/services/instance.hbs @@ -21,6 +21,13 @@ This service has been deregistered and no longer exists in the catalog.

    + {{else if (eq loader.error.status "403")}} + + + {{else}} + {{else if (eq loader.error.status "403")}} + + + {{else}}
    +{{#if (can 'create intentions')}} Create +{{/if}} {{#if (gt items.length 0) }} { + return JSON.stringify( + Object.assign( + item, + { + Allow: !!JSON.parse(env(`CONSUL_RESOURCE_${item.Resource.toUpperCase()}_${item.Access.toUpperCase()}`, 'true')) + } + ) + ); + }) +} ] diff --git a/ui/packages/consul-ui/package.json b/ui/packages/consul-ui/package.json index 8640e944b..f29d858e7 100644 --- a/ui/packages/consul-ui/package.json +++ b/ui/packages/consul-ui/package.json @@ -88,6 +88,7 @@ "d3-shape": "^2.0.0", "dayjs": "^1.9.3", "ember-auto-import": "^1.5.3", + "ember-can": "^3.0.0", "ember-changeset-conditional-validations": "^0.6.0", "ember-changeset-validations": "^3.9.0", "ember-cli": "~3.20.2", diff --git a/ui/packages/consul-ui/tests/acceptance/dc/intentions/index.feature b/ui/packages/consul-ui/tests/acceptance/dc/intentions/index.feature index 014fde220..29db2c9b7 100644 --- a/ui/packages/consul-ui/tests/acceptance/dc/intentions/index.feature +++ b/ui/packages/consul-ui/tests/acceptance/dc/intentions/index.feature @@ -10,6 +10,20 @@ Feature: dc / intentions / index Then the url should be /dc-1/intentions And the title should be "Intentions - Consul" Then I see 3 intention models on the intentionList component + Scenario: Viewing intentions with no write access + Given 1 datacenter model with the value "dc-1" + And 3 intention models + And permissions from yaml + --- + intention: + write: false + --- + When I visit the intentions page for yaml + --- + dc: dc-1 + --- + Then the url should be /dc-1/intentions + And I don't see create Scenario: Viewing intentions in the listing live updates Given 1 datacenter model with the value "dc-1" Given 3 intention models diff --git a/ui/packages/consul-ui/tests/acceptance/dc/kvs/edit.feature b/ui/packages/consul-ui/tests/acceptance/dc/kvs/edit.feature index e17e27e89..832c8209e 100644 --- a/ui/packages/consul-ui/tests/acceptance/dc/kvs/edit.feature +++ b/ui/packages/consul-ui/tests/acceptance/dc/kvs/edit.feature @@ -25,6 +25,49 @@ Feature: dc / kvs / edit: KV Viewing kv: another-key --- Then I don't see ID on the session + Scenario: Viewing a kv with no write access + Given 1 datacenter model with the value "datacenter" + And 1 kv model from yaml + --- + Key: key + Session: session-id + --- + And permissions from yaml + --- + key: + write: false + session: + read: false + --- + When I visit the kv page for yaml + --- + dc: datacenter + kv: key + --- + Then the url should be /datacenter/kv/key/edit + And I don't see create + And I don't see ID on the session + And I see warning on the session + Scenario: Viewing a kv with no read access + Given 1 datacenter model with the value "datacenter" + And 1 kv model from yaml + --- + Key: key + --- + And permissions from yaml + --- + key: + write: false + read: false + --- + When I visit the kv page for yaml + --- + dc: datacenter + kv: key + --- + Then the url should be /datacenter/kv/key/edit + And I see status on the error like "403" + And a GET request wasn't made to "/v1/kv/key?dc=datacenter" # Make sure we can view KVs that have similar names to sections in the UI Scenario: I have KV called [Page] Given 1 datacenter model with the value "datacenter" diff --git a/ui/packages/consul-ui/tests/acceptance/dc/kvs/index.feature b/ui/packages/consul-ui/tests/acceptance/dc/kvs/index.feature new file mode 100644 index 000000000..dc4cb041a --- /dev/null +++ b/ui/packages/consul-ui/tests/acceptance/dc/kvs/index.feature @@ -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 + diff --git a/ui/packages/consul-ui/tests/acceptance/dc/services/show.feature b/ui/packages/consul-ui/tests/acceptance/dc/services/show.feature index 599475ed7..ed007ca7d 100644 --- a/ui/packages/consul-ui/tests/acceptance/dc/services/show.feature +++ b/ui/packages/consul-ui/tests/acceptance/dc/services/show.feature @@ -119,3 +119,44 @@ Feature: dc / services / show: Show Service --- # The Metrics dashboard should use the Service.Name not the ID And I see href on the metricsAnchor like "https://something.com?service-0&dc1" + Scenario: With no access to service + Given 1 datacenter model with the value "dc1" + And permissions from yaml + --- + service: + read: false + --- + When I visit the service page for yaml + --- + dc: dc1 + service: service-0 + --- + Then I see status on the error like "403" + Scenario: When access is removed from a service + Given 1 datacenter model with the value "dc1" + And 1 node models + And 1 service model from yaml + And a network latency of 100 + And permissions from yaml + --- + service: + read: true + --- + When I visit the service page for yaml + --- + dc: dc1 + service: service-0 + --- + And I click instances on the tabs + And I see 1 instance model + Given permissions from yaml + --- + service: + read: false + --- + # authorization requests are not blocking so we just wait until the next + # service blocking query responds + Then pause until I see the text "no longer have access" in "[data-notification]" + And "[data-notification]" has the "error" class + And I see status on the error like "403" + diff --git a/ui/packages/consul-ui/tests/acceptance/navigation-links.feature b/ui/packages/consul-ui/tests/acceptance/navigation-links.feature new file mode 100644 index 000000000..e001d9451 --- /dev/null +++ b/ui/packages/consul-ui/tests/acceptance/navigation-links.feature @@ -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 + diff --git a/ui/packages/consul-ui/tests/acceptance/steps/dc/kvs/index-steps.js b/ui/packages/consul-ui/tests/acceptance/steps/dc/kvs/index-steps.js new file mode 100644 index 000000000..ba1093295 --- /dev/null +++ b/ui/packages/consul-ui/tests/acceptance/steps/dc/kvs/index-steps.js @@ -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); + }); +} diff --git a/ui/packages/consul-ui/tests/acceptance/steps/navigation-links-steps.js b/ui/packages/consul-ui/tests/acceptance/steps/navigation-links-steps.js new file mode 100644 index 000000000..c90033499 --- /dev/null +++ b/ui/packages/consul-ui/tests/acceptance/steps/navigation-links-steps.js @@ -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); + }); +} diff --git a/ui/packages/consul-ui/tests/integration/adapters/permission-test.js b/ui/packages/consul-ui/tests/integration/adapters/permission-test.js new file mode 100644 index 000000000..c65e3dc20 --- /dev/null +++ b/ui/packages/consul-ui/tests/integration/adapters/permission-test.js @@ -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); + }); + }); +}); diff --git a/ui/packages/consul-ui/tests/pages.js b/ui/packages/consul-ui/tests/pages.js index 26d7f00ce..d6235204f 100644 --- a/ui/packages/consul-ui/tests/pages.js +++ b/ui/packages/consul-ui/tests/pages.js @@ -155,7 +155,9 @@ export default { radiogroup ) ), - service: create(service(visitable, clickable, attribute, collection, text, consulIntentionList, tabgroup)), + service: create( + service(visitable, clickable, attribute, collection, text, consulIntentionList, tabgroup) + ), instance: create( instance( visitable, @@ -182,7 +184,7 @@ export default { ) ), kvs: create(kvs(visitable, creatable, consulKvList)), - kv: create(kv(visitable, attribute, submitable, deletable, cancelable, clickable)), + kv: create(kv(visitable, attribute, isPresent, submitable, deletable, cancelable, clickable)), acls: create(acls(visitable, deletable, creatable, clickable, attribute, collection)), acl: create(acl(visitable, submitable, deletable, cancelable, clickable)), policies: create(policies(visitable, creatable, consulPolicyList, popoverSelect)), diff --git a/ui/packages/consul-ui/tests/pages/dc/kv/edit.js b/ui/packages/consul-ui/tests/pages/dc/kv/edit.js index 96af4d72d..bb577470d 100644 --- a/ui/packages/consul-ui/tests/pages/dc/kv/edit.js +++ b/ui/packages/consul-ui/tests/pages/dc/kv/edit.js @@ -1,4 +1,4 @@ -export default function(visitable, attribute, submitable, deletable, cancelable) { +export default function(visitable, attribute, present, submitable, deletable, cancelable) { return { visit: visitable(['/:dc/kv/:kv/edit', '/:dc/kv/create'], function(str) { // this will encode the parts of the key path but means you can no longer @@ -12,6 +12,7 @@ export default function(visitable, attribute, submitable, deletable, cancelable) ...cancelable(), ...deletable(), session: { + warning: present('[data-test-session-warning]'), ID: attribute('data-test-session', '[data-test-session]'), ...deletable({}, '[data-test-session]'), }, diff --git a/ui/packages/consul-ui/tests/steps.js b/ui/packages/consul-ui/tests/steps.js index 08866ecbc..9ba679438 100644 --- a/ui/packages/consul-ui/tests/steps.js +++ b/ui/packages/consul-ui/tests/steps.js @@ -97,7 +97,7 @@ export default function({ const clipboard = function() { return window.localStorage.getItem('clipboard'); }; - models(library, create); + models(library, create, setCookie); http(library, respondWith, setCookie); visit(library, pages, utils.setCurrentPage, reset); click(library, utils.find, helpers.click); diff --git a/ui/packages/consul-ui/tests/steps/doubles/http.js b/ui/packages/consul-ui/tests/steps/doubles/http.js index fca448679..798fd1670 100644 --- a/ui/packages/consul-ui/tests/steps/doubles/http.js +++ b/ui/packages/consul-ui/tests/steps/doubles/http.js @@ -7,6 +7,9 @@ export default function(scenario, respondWith, set) { }); }) .given(['the url "$endpoint" responds with from yaml\n$yaml'], function(url, data) { + if (typeof data.body !== 'string') { + data.body = JSON.stringify(data.body); + } respondWith(url, data); }) .given('a network latency of $number', function(number) { diff --git a/ui/packages/consul-ui/tests/steps/doubles/model.js b/ui/packages/consul-ui/tests/steps/doubles/model.js index f589ff3ae..0fc33b6b5 100644 --- a/ui/packages/consul-ui/tests/steps/doubles/model.js +++ b/ui/packages/consul-ui/tests/steps/doubles/model.js @@ -1,4 +1,4 @@ -export default function(scenario, create, win = window, doc = document) { +export default function(scenario, create, set, win = window, doc = document) { scenario .given(['an external edit results in $number $model model[s]?'], function(number, model) { return create(number, model); @@ -21,9 +21,20 @@ export default function(scenario, create, win = window, doc = document) { }); }) .given(['ui_config from yaml\n$yaml'], function(data) { + // this one doesn't interact with the api therefore you don't need to use + // setCookie/set. Ideally setCookie should probably use doc.cookie also so + // there is no difference between these doc.cookie = `CONSUL_UI_CONFIG=${JSON.stringify(data)}`; }) .given(['the local datacenter is "$value"'], function(value) { doc.cookie = `CONSUL_DATACENTER_LOCAL=${value}`; + }) + .given(['permissions from yaml\n$yaml'], function(data) { + Object.entries(data).forEach(([key, value]) => { + const resource = `CONSUL_RESOURCE_${key.toUpperCase()}`; + Object.entries(value).forEach(([key, value]) => { + set(`${resource}_${key.toUpperCase()}`, value); + }); + }); }); } diff --git a/ui/packages/consul-ui/tests/unit/adapters/permission-test.js b/ui/packages/consul-ui/tests/unit/adapters/permission-test.js new file mode 100644 index 000000000..6f09aa8bc --- /dev/null +++ b/ui/packages/consul-ui/tests/unit/adapters/permission-test.js @@ -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); + }); +}); diff --git a/ui/packages/consul-ui/tests/unit/models/permission-test.js b/ui/packages/consul-ui/tests/unit/models/permission-test.js new file mode 100644 index 000000000..3f9a2db2b --- /dev/null +++ b/ui/packages/consul-ui/tests/unit/models/permission-test.js @@ -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); + }); +}); diff --git a/ui/packages/consul-ui/tests/unit/serializers/permission-test.js b/ui/packages/consul-ui/tests/unit/serializers/permission-test.js new file mode 100644 index 000000000..a2e8d0714 --- /dev/null +++ b/ui/packages/consul-ui/tests/unit/serializers/permission-test.js @@ -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); + }); +}); diff --git a/ui/packages/consul-ui/tests/unit/services/repository/permission-test.js b/ui/packages/consul-ui/tests/unit/services/repository/permission-test.js new file mode 100644 index 000000000..1dd4c3abf --- /dev/null +++ b/ui/packages/consul-ui/tests/unit/services/repository/permission-test.js @@ -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); + }); +}); diff --git a/ui/yarn.lock b/ui/yarn.lock index 5c030049e..e102d55ac 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -4303,6 +4303,13 @@ babel-plugin-ember-modules-api-polyfill@^3.2.0: dependencies: ember-rfc176-data "^0.3.16" +babel-plugin-ember-modules-api-polyfill@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/babel-plugin-ember-modules-api-polyfill/-/babel-plugin-ember-modules-api-polyfill-3.2.1.tgz#715252ffde309da36fb32cd6a9bad5c6b61edd33" + integrity sha512-7k4gM0VLAMjoWVxLBDqavH/Dn4mBfzqTuQmtGmZgsdQ4SYVEJ7dewUVeqWBVn5v3QspW4VSoeXh4rHPPlp/rPw== + dependencies: + ember-rfc176-data "^0.3.16" + babel-plugin-emotion@^10.0.20, babel-plugin-emotion@^10.0.27: version "10.0.33" resolved "https://registry.yarnpkg.com/babel-plugin-emotion/-/babel-plugin-emotion-10.0.33.tgz#ce1155dcd1783bbb9286051efee53f4e2be63e03" @@ -7720,6 +7727,15 @@ ember-basic-dropdown@^3.0.10: ember-maybe-in-element "^2.0.1" ember-truth-helpers "2.1.0" +ember-can@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/ember-can/-/ember-can-3.0.0.tgz#86988e2ec35ece0ac7b28f7c436228485911528a" + integrity sha512-URgfnVIe2O6bkgC1OAKGuKwbNklwONpT4MbQM17g7v0IKoHW+k6mL1iGQzBIPdjTBtlwRJNAl6nddYE8T5UVig== + dependencies: + ember-cli-babel "^7.13.2" + ember-cli-htmlbars "^4.2.1" + ember-inflector "^3.0.1" + ember-changeset-conditional-validations@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/ember-changeset-conditional-validations/-/ember-changeset-conditional-validations-0.6.0.tgz#78369ad3af0aea338e00a9bdf1b622fb512d9a00" @@ -7824,6 +7840,38 @@ ember-cli-babel@^6.0.0, ember-cli-babel@^6.0.0-beta.4, ember-cli-babel@^6.11.0, ember-cli-version-checker "^2.1.2" semver "^5.5.0" +ember-cli-babel@^7.13.2: + version "7.23.1" + resolved "https://registry.yarnpkg.com/ember-cli-babel/-/ember-cli-babel-7.23.1.tgz#d1517228ede08a5d4b045c78a7429728e956b30b" + integrity sha512-qYggmt3hRs6QJ6cRkww3ahMpyP8IEV2KFrIRO/Z6hu9MkE/8Y28Xd5NjQl6fPV3oLoG0vwuHzhNe3Jr7Wec8zw== + dependencies: + "@babel/core" "^7.12.0" + "@babel/helper-compilation-targets" "^7.12.0" + "@babel/plugin-proposal-class-properties" "^7.10.4" + "@babel/plugin-proposal-decorators" "^7.10.5" + "@babel/plugin-transform-modules-amd" "^7.10.5" + "@babel/plugin-transform-runtime" "^7.12.0" + "@babel/plugin-transform-typescript" "^7.12.0" + "@babel/polyfill" "^7.11.5" + "@babel/preset-env" "^7.12.0" + "@babel/runtime" "^7.12.0" + amd-name-resolver "^1.2.1" + babel-plugin-debug-macros "^0.3.3" + babel-plugin-ember-data-packages-polyfill "^0.1.2" + babel-plugin-ember-modules-api-polyfill "^3.2.1" + babel-plugin-module-resolver "^3.1.1" + broccoli-babel-transpiler "^7.8.0" + broccoli-debug "^0.6.4" + broccoli-funnel "^2.0.1" + broccoli-source "^1.1.0" + clone "^2.1.2" + ember-cli-babel-plugin-helpers "^1.1.1" + ember-cli-version-checker "^4.1.0" + ensure-posix-path "^1.0.2" + fixturify-project "^1.10.0" + rimraf "^3.0.1" + semver "^5.5.0" + ember-cli-code-coverage@^1.0.0-beta.4: version "1.0.2" resolved "https://registry.yarnpkg.com/ember-cli-code-coverage/-/ember-cli-code-coverage-1.0.2.tgz#615fc7af8bc7d9388a28371c2825c224a936d73f" @@ -7903,7 +7951,7 @@ ember-cli-htmlbars@^3.0.0, ember-cli-htmlbars@^3.0.1: json-stable-stringify "^1.0.1" strip-bom "^3.0.0" -ember-cli-htmlbars@^4.0.5, ember-cli-htmlbars@^4.2.3, ember-cli-htmlbars@^4.3.1: +ember-cli-htmlbars@^4.0.5, ember-cli-htmlbars@^4.2.1, ember-cli-htmlbars@^4.2.3, ember-cli-htmlbars@^4.3.1: version "4.4.0" resolved "https://registry.yarnpkg.com/ember-cli-htmlbars/-/ember-cli-htmlbars-4.4.0.tgz#7ca17d5ca8f7550984346d9e6e93da0c3323f8d9" integrity sha512-ohgctqk7dXIZR4TgN0xRoUYltWhghFJgqmtuswQTpZ7p74RxI9PKx+E8WV/95mGcPzraesvMNBg5utQNvcqgNg==