Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 12 commits into from
Feb 19, 2021
Merged
3 changes: 3 additions & 0 deletions .changelog/9687.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:improvement
ui: restrict the viewing/editing of certain UI elements based on the users ACL token
```
1 change: 1 addition & 0 deletions ui/packages/consul-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ token/secret.
| `CONSUL_EXPOSED_COUNT` | (random) | Configure the number of exposed paths that the API returns. |
| `CONSUL_CHECK_COUNT` | (random) | Configure the number of health checks that the API returns. |
| `CONSUL_OIDC_PROVIDER_COUNT` | (random) | Configure the number of OIDC providers that the API returns. |
| `CONSUL_RESOURCE_<singular-resource-name>_<access-type>` | true | Configure permissions e.g `CONSUL_RESOURCE_INTENTION_WRITE=false`. |
| `DEBUG_ROUTES_ENDPOINT` | undefined | When using the window.Routes() debug utility ([see utility functions](#browser-debug-utility-functions)), use a URL to pass the route DSL to. %s in the URL will be replaced with the route DSL - http://url.com?routes=%s |

See `./mock-api` for more details.
Expand Down
16 changes: 16 additions & 0 deletions ui/packages/consul-ui/app/abilities/acl.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
8 changes: 8 additions & 0 deletions ui/packages/consul-ui/app/abilities/authenticate.js
Original file line number Diff line number Diff line change
@@ -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');
}
}
83 changes: 83 additions & 0 deletions ui/packages/consul-ui/app/abilities/base.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
9 changes: 9 additions & 0 deletions ui/packages/consul-ui/app/abilities/intention.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
13 changes: 13 additions & 0 deletions ui/packages/consul-ui/app/abilities/kv.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
5 changes: 5 additions & 0 deletions ui/packages/consul-ui/app/abilities/node.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import BaseAbility from './base';

export default class NodeAbility extends BaseAbility {
resource = 'node';
}
17 changes: 17 additions & 0 deletions ui/packages/consul-ui/app/abilities/nspace.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
7 changes: 7 additions & 0 deletions ui/packages/consul-ui/app/abilities/permission.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import BaseAbility from './base';

export default class PermissionAbility extends BaseAbility {
get canRead() {
return this.permissions.permissions.length > 0;
}
}
5 changes: 5 additions & 0 deletions ui/packages/consul-ui/app/abilities/service-instance.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import BaseAbility from './base';

export default class ServiceInstanceAbility extends BaseAbility {
resource = 'service';
}
5 changes: 5 additions & 0 deletions ui/packages/consul-ui/app/abilities/service.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import BaseAbility from './base';

export default class ServiceAbility extends BaseAbility {
resource = 'service';
}
5 changes: 5 additions & 0 deletions ui/packages/consul-ui/app/abilities/session.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import BaseAbility from './base';

export default class SessionAbility extends BaseAbility {
resource = 'session';
}
2 changes: 1 addition & 1 deletion ui/packages/consul-ui/app/adapters/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down
29 changes: 0 additions & 29 deletions ui/packages/consul-ui/app/adapters/nspace.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}
}
39 changes: 39 additions & 0 deletions ui/packages/consul-ui/app/adapters/permission.js
Original file line number Diff line number Diff line change
@@ -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
);
}
}
2 changes: 1 addition & 1 deletion ui/packages/consul-ui/app/components/app-error/index.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
</h1>
</BlockSlot>
<BlockSlot @name="content">
<ErrorState @error={{@error}} />
<ErrorState @error={{@error}} @allowLogin={{eq @error.status "403"}} />
</BlockSlot>
</AppView>
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ as |api|>

<BlockSlot @name="form">
{{#let api.data as |item|}}
{{#if item.IsEditable}}
{{#if (can 'write intention' item=item)}}

{{#if this.warn}}
{{#let (changeset-get item 'Action') as |newAction|}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ as |item index|>
More
</BlockSlot>
<BlockSlot @name="menu" as |confirm send keypressClick change|>
{{#if item.IsEditable}}
{{#if (can "write intention" item=item)}}
<li role="none">
<a role="menuitem" tabindex="-1" href={{href-to (or routeName 'dc.intentions.edit') item.ID}}>Edit</a>
</li>
Expand Down
33 changes: 29 additions & 4 deletions ui/packages/consul-ui/app/components/consul/kv/form/index.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@
as |api|
>
<BlockSlot @name="content">
{{#let (cannot 'write kv' item=api.data) as |disabld|}}
<form onsubmit={{action api.submit}}>
<fieldset disabled={{api.disabled}}>
<fieldset
{{disabled (or disabld api.disabled)}}
>
{{#if api.isCreate}}
<label class="type-text{{if api.data.error.Key ' has-error'}}">
<span>Key or folder</span>
Expand All @@ -24,27 +27,47 @@
<div>
<div class="type-toggle">
<label>
<input type="checkbox" name="json" checked={{if json 'checked'}} onchange={{action api.change}} />
<input
type="checkbox"
name="json"
{{disabled false}}
checked={{if json 'checked'}}
onchange={{action api.change}}
/>
<span>Code</span>
</label>
</div>
<label for="" class="type-text{{if api.data.error.Value ' has-error'}}">
<span>Value</span>
{{#if json}}
<CodeEditor @name="value" @value={{atob api.data.Value}} @onkeyup={{action api.change "value"}} />
<CodeEditor
@name="value"
@readonly={{or disabld api.disabled}}
@value={{atob api.data.Value}}
@onkeyup={{action api.change "value"}}
/>
{{else}}
<textarea autofocus={{not api.isCreate}} name="value" oninput={{action api.change}}>{{atob api.data.Value}}</textarea>
<textarea
{{disabled (or disabld api.disabled)}}
autofocus={{not api.isCreate}}
name="value"
oninput={{action api.change}}>{{atob api.data.Value}}</textarea>
{{/if}}
</label>
</div>
{{/if}}
</fieldset>
{{#if api.isCreate}}
{{#if (not disabld)}}
<button type="submit" disabled={{or api.data.isPristine api.data.isInvalid api.disabled}}>Save</button>
{{/if}}
<button type="reset" onclick={{action oncancel api.data}} disabled={{api.disabled}}>Cancel</button>
{{else}}
{{#if (not disabld)}}
<button type="submit" disabled={{or api.data.isInvalid api.disabled}}>Save</button>
{{/if}}
<button type="reset" onclick={{action oncancel api.data}} disabled={{api.disabled}}>Cancel</button>
{{#if (not disabld)}}
<ConfirmationDialog @message="Are you sure you want to delete this key?">
<BlockSlot @name="action" as |confirm|>
<button data-test-delete type="button" class="type-delete" {{action confirm api.delete}} disabled={{api.disabled}}>Delete</button>
Expand All @@ -54,6 +77,8 @@
</BlockSlot>
</ConfirmationDialog>
{{/if}}
{{/if}}
</form>
{{/let}}
</BlockSlot>
</DataForm>
6 changes: 6 additions & 0 deletions ui/packages/consul-ui/app/components/consul/kv/list/index.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ as |item index|>
More
</BlockSlot>
<BlockSlot @name="menu" as |confirm send keypressClick|>
{{#if (can 'write kv' item=item)}}
<li role="none">
<a data-test-edit role="menuitem" tabindex="-1" href={{href-to (if item.isFolder 'dc.kv.folder' 'dc.kv.edit') item.Key}}>{{if item.isFolder 'View' 'Edit'}}</a>
</li>
Expand Down Expand Up @@ -55,6 +56,11 @@ as |item index|>
</InformedAction>
</div>
</li>
{{else}}
<li role="none">
<a data-test-edit role="menuitem" tabindex="-1" href={{href-to (if item.isFolder 'dc.kv.folder' 'dc.kv.edit') item.Key}}>View</a>
</li>
{{/if}}
</BlockSlot>
</PopoverMenu>
</BlockSlot>
Expand Down
Loading