ui: Auth Methods - Create Binding Rules tab (#9914)

* Create BindingRule adapter and tests

* Create BindingRule serializer and test

* Create BindingRule model and repository

* Add binding-rules mock data

* Create binding-rules router and call endpoint

* Create Binding rules tab

* Create and use BindingView component

* Create empty state for BindingView

* Remove binding rule requestForQueryRecord endpoint and tests

* Update binding rules selector to be monospaced

* Add bind type tooltip

* Create and Tabular-dl styling component

* Update hr tag global styling

* Rename BindingView to BindingList and refactor

* Add translations for bind types tooltip info

* Remove unused endpoint

* Refactor based on review notes
This commit is contained in:
Kenia 2021-03-26 11:47:47 -04:00 committed by GitHub
parent 0398833f54
commit 5ce88774b8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 399 additions and 44 deletions

View File

@ -0,0 +1,14 @@
import Adapter from './application';
export default class BindingRuleAdapter extends Adapter {
requestForQuery(request, { dc, ns, authmethod, index, id }) {
return request`
GET /v1/acl/binding-rules?${{ dc, authmethod }}
${{
...this.formatNspace(ns),
index,
}}
`;
}
}

View File

@ -0,0 +1,28 @@
<div class="consul-auth-method-binding-list">
<h2>{{@item.BindName}}</h2>
<dl>
<dt class="type">{{t "models.binding-rule.BindType"}}</dt>
<dd>
{{@item.BindType}}
<span>
{{#if (eq @item.BindType 'service')}}
<Tooltip>
{{t "components.consul.auth-method.binding-list.bind-type.service"}}
</Tooltip>
{{else if (eq @item.BindType 'node')}}
<Tooltip>
{{t "components.consul.auth-method.binding-list.bind-type.node"}}
</Tooltip>
{{else if (eq @item.BindType 'role')}}
<Tooltip>
{{t "components.consul.auth-method.binding-list.bind-type.role"}}
</Tooltip>
{{/if}}
</span>
</dd>
<dt>{{t "models.binding-rule.Selector"}}</dt>
<dd><code>{{@item.Selector}}</code></dd>
<dt>{{t "models.binding-rule.Description"}}</dt>
<dd>{{@item.Description}}</dd>
</dl>
</div>

View File

@ -1,14 +1,14 @@
.consul-consul-auth-method-view-list ul {
// List
.consul-auth-method-list ul {
.locality::before {
@extend %with-public-default-mask, %as-pseudo;
margin-right: 4px;
}
}
// View
.consul-auth-method-view {
margin-bottom: 32px;
> hr {
background-color: var(--gray-200);
}
section {
@extend %p1;
width: 100%;
@ -32,43 +32,26 @@
}
dl,
section dl {
display: flex;
flex-wrap: wrap;
> dt:last-of-type,
> dd:last-of-type {
border-bottom: 1px solid var(--gray-300) !important;
}
dt, dd {
padding: 12px 0;
margin: 0;
border-top: 1px solid var(--gray-300) !important;
color: $black !important;
}
dt {
width: 20%;
font-weight: $typo-weight-bold;
}
dd {
margin-left: auto;
width: 80%;
display: flex;
}
dd > ul li {
display: flex;
}
dd > ul li:not(:last-of-type) {
padding-bottom: 12px;
}
dd .copy-button button {
padding: 0 !important;
margin: 0 4px 0 0 !important;
}
dd .copy-button button::before {
background-color: $black;
}
dt.check + dd {
padding-top: 16px;
}
@extend %tabular-dl;
}
}
// Binding List
.consul-auth-method-binding-list {
p {
margin-bottom: 4px !important;
}
h2 {
@extend %h200;
padding-bottom: 12px;
}
dl {
@extend %tabular-dl;
}
code {
background-color: var(--gray-050);
padding: 0 12px;
}
}

View File

@ -24,7 +24,7 @@
%tooltip-content {
@extend %p3;
padding: 12px;
max-width: 192px;
max-width: 224px;
position: relative;
z-index: 1;
}

View File

@ -0,0 +1,19 @@
import Model, { attr } from '@ember-data/model';
export const PRIMARY_KEY = 'uid';
export const SLUG_KEY = 'ID';
export default class BindingRule extends Model {
@attr('string') uid;
@attr('string') ID;
@attr('string') Datacenter;
@attr('string') Namespace;
@attr('string', { defaultValue: () => '' }) Description;
@attr('string') AuthMethod;
@attr('string', { defaultValue: () => '' }) Selector;
@attr('string') BindType;
@attr('string') BindName;
@attr('number') CreateIndex;
@attr('number') ModifyIndex;
}

View File

@ -196,6 +196,9 @@ export const routes = {
'auth-method': {
_options: { path: '/auth-method' },
},
'binding-rules': {
_options: { path: '/binding-rules' },
},
},
},
},

View File

@ -4,16 +4,25 @@ import { hash } from 'rsvp';
export default class ShowRoute extends SingleRoute {
@service('repository/auth-method') repo;
@service('repository/binding-rule') bindingRuleRepo;
model(params) {
const dc = this.modelFor('dc').dc;
const nspace = this.modelFor('nspace').nspace.substr(1);
return super.model(...arguments).then(model => {
return hash({
...model,
...{
item: this.repo.findBySlug({
id: params.id,
dc: this.modelFor('dc').dc.Name,
ns: this.modelFor('nspace').nspace.substr(1),
dc: dc.Name,
ns: nspace,
}),
bindingRules: this.bindingRuleRepo.findAllByDatacenter({
ns: nspace,
dc: dc.Name,
authmethod: params.id,
}),
},
});

View File

@ -0,0 +1,16 @@
import Route from 'consul-ui/routing/route';
export default class BindingRulesRoute extends Route {
model() {
const parent = this.routeName
.split('.')
.slice(0, -1)
.join('.');
return this.modelFor(parent);
}
setupController(controller, model) {
super.setupController(...arguments);
controller.setProperties(model);
}
}

View File

@ -0,0 +1,7 @@
import Serializer from './application';
import { PRIMARY_KEY, SLUG_KEY } from 'consul-ui/models/binding-rule';
export default class BindingRuleSerializer extends Serializer {
primaryKey = PRIMARY_KEY;
slugKey = SLUG_KEY;
}

View File

@ -0,0 +1,32 @@
import RepositoryService from 'consul-ui/services/repository';
import statusFactory from 'consul-ui/utils/acls-status';
import isValidServerErrorFactory from 'consul-ui/utils/http/acl/is-valid-server-error';
import { PRIMARY_KEY, SLUG_KEY } from 'consul-ui/models/binding-rule';
import dataSource from 'consul-ui/decorators/data-source';
const isValidServerError = isValidServerErrorFactory();
const status = statusFactory(isValidServerError, Promise);
const MODEL_NAME = 'binding-rule';
export default class BindingRuleService extends RepositoryService {
getModelName() {
return MODEL_NAME;
}
getPrimaryKey() {
return PRIMARY_KEY;
}
getSlugKey() {
return SLUG_KEY;
}
@dataSource('/:ns/:dc/binding-rules')
async findAllByDatacenter() {
return super.findAllByDatacenter(...arguments);
}
status(obj) {
return status(obj);
}
}

View File

@ -104,4 +104,5 @@ html {
hr {
height: 1px;
margin: 1.5rem 0;
background-color: var(--gray-200);
}

View File

@ -33,6 +33,7 @@
@import './components/more-popover-menu';
@import './components/definition-table';
@import './components/radio-card';
@import './components/tabular-dl';
/**/

View File

@ -0,0 +1,2 @@
@import './layout';
@import './skin';

View File

@ -0,0 +1,38 @@
%tabular-dl {
display: flex;
flex-wrap: wrap;
> dt:last-of-type,
> dd:last-of-type {
border-bottom: 1px solid !important;
}
dt,
dd {
padding: 12px 0;
margin: 0;
border-top: 1px solid !important;
}
dt {
width: 20%;
}
dd {
margin-left: auto;
width: 80%;
display: flex;
}
dd > ul li {
display: flex;
}
dd > ul li:not(:last-of-type) {
padding-bottom: 12px;
}
dd .copy-button button {
padding: 0 !important;
margin: 0 4px 0 0 !important;
}
dt.check + dd {
padding-top: 16px;
}
dt.type + dd span::before {
margin-left: 4px;
}
}

View File

@ -0,0 +1,21 @@
%tabular-dl {
> dt:last-of-type,
> dd:last-of-type {
border-color: var(--gray-300) !important;
}
dt,
dd {
border-color: var(--gray-300) !important;
color: $black !important;
}
dt {
font-weight: $typo-weight-bold;
}
dd .copy-button button::before {
background-color: $black;
}
dt.type + dd span::before {
@extend %with-info-circle-outline-mask, %as-pseudo;
background-color: var(--gray-500);
}
}

View File

@ -27,6 +27,7 @@
compact
(array
(hash label="General info" href=(href-to "dc.acls.auth-methods.show.auth-method") selected=(is-href "dc.acls.auth-methods.show.auth-method"))
(hash label="Binding rules" href=(href-to "dc.acls.auth-methods.show.binding-rules") selected=(is-href "dc.acls.auth-methods.show.binding-rules"))
)
}}/>
</BlockSlot>

View File

@ -0,0 +1,28 @@
<div class="tab-section">
{{#if (gt bindingRules.length 0)}}
<p>Binding rules allow an operator to express a systematic way of automatically linking roles and service identities to newly created tokens without operator intervention.
</p>
<p>Successful authentication with an auth method returns a set of trusted identity attributes corresponding to the authenticated identity. Those attributes are matched against all configured binding rules for that auth method to determine what privileges to grant the Consul ACL token it will ultimately create.
</p>
<hr />
{{#each bindingRules as |item|}}
<Consul::AuthMethod::BindingList @item={{item}} />
<hr />
{{/each}}
{{else}}
<EmptyState>
<BlockSlot @name="header">
<h2>No binding rules</h2>
</BlockSlot>
<BlockSlot @name="body">
<p>Binding rules allow an operator to express a systematic way of automatically linking roles and service identities to newly created tokens without operator intervention.</p>
</BlockSlot>
<BlockSlot @name="actions">
<li class="docs-link">
<a href="{{env 'CONSUL_DOCS_API_URL'}}/acl/binding-rules" rel="noopener noreferrer" target="_blank">Read the documentation</a>
</li>
</BlockSlot>
</EmptyState>
{{/if}}
</div>

View File

@ -0,0 +1,30 @@
[
${
range(
env(
'CONSUL_BINDING_RULE_COUNT',
Math.floor(
(
Math.random() * env('CONSUL_BINDING_RULE_MAX', 10)
) + parseInt(env('CONSUL_BINDING_RULE_MIN', 1))
)
)
).map(
function(item, i) {
return `
{
"ID": "${fake.random.uuid()}",
"Description": "${fake.lorem.sentence()}",
"AuthMethod": "${fake.hacker.noun()}-${i}",
"Selector": "serviceaccount.namespace==${fake.hacker.noun()} and serviceaccount.name!=${fake.hacker.noun()}",
"BindType": "${fake.helpers.randomize(['service', 'node', 'role'])}",
"BindName": "${fake.hacker.noun()}-${i}",
"Namespace": "${location.search.ns}",
"CreateIndex": ${fake.random.number()},
"ModifyIndex": 10
}
`
}
)
}
]

View File

@ -0,0 +1,38 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import getNspaceRunner from 'consul-ui/tests/helpers/get-nspace-runner';
const nspaceRunner = getNspaceRunner('binding-rule');
module('Integration | Adapter | binding-rule', function(hooks) {
setupTest(hooks);
const dc = 'dc-1';
test('requestForQuery returns the correct url/method', function(assert) {
const adapter = this.owner.lookup('adapter:binding-rule');
const client = this.owner.lookup('service:client/http');
const expected = `GET /v1/acl/binding-rules?dc=${dc}`;
const actual = adapter.requestForQuery(client.requestParams.bind(client), {
dc: dc,
});
assert.equal(`${actual.method} ${actual.url}`, expected);
});
test('requestForQuery returns the correct body', function(assert) {
return nspaceRunner(
(adapter, serializer, client) => {
return adapter.requestForQuery(client.body, {
dc: dc,
ns: 'team-1',
index: 1,
});
},
{
index: 1,
ns: 'team-1',
},
{
index: 1,
},
this,
assert
);
});
});

View File

@ -0,0 +1,39 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { get } from 'consul-ui/tests/helpers/api';
module('Integration | Serializer | binding-rule', function(hooks) {
setupTest(hooks);
const dc = 'dc-1';
const undefinedNspace = 'default';
[undefinedNspace, 'team-1', undefined].forEach(nspace => {
test(`respondForQuery returns the correct data for list endpoint when nspace is ${nspace}`, function(assert) {
const serializer = this.owner.lookup('serializer:binding-rule');
const request = {
url: `/v1/acl/binding-rules?dc=${dc}${
typeof nspace !== 'undefined' ? `&ns=${nspace}` : ``
}`,
};
return get(request.url).then(function(payload) {
const expected = payload.map(item =>
Object.assign({}, item, {
Datacenter: dc,
Namespace: item.Namespace || undefinedNspace,
uid: `["${item.Namespace || undefinedNspace}","${dc}","${item.ID}"]`,
})
);
const actual = serializer.respondForQuery(
function(cb) {
const headers = {};
const body = payload;
return cb(headers, body);
},
{
dc: dc,
ns: nspace,
}
);
assert.deepEqual(actual, expected);
});
});
});
});

View File

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

View File

@ -0,0 +1,23 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Serializer | binding-rule', 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('binding-rule');
assert.ok(serializer);
});
test('it serializes records', function(assert) {
let store = this.owner.lookup('service:store');
let record = store.createRecord('binding-rule', {});
let serializedRecord = record.serialize();
assert.ok(serializedRecord);
});
});

View File

@ -143,6 +143,11 @@ components:
options:
local: Creates local tokens
global: Creates global tokens
binding-list:
bind-type:
service: The bind name value is used as an ACLServiceIdentity.ServiceName field in the token that is created.
node: The bind name value is used as an ACLNodeIdentity.NodeName field in the token that is created.
role: The bind name value is used as an RoleLink.Name field in the token that is created.
kv:
search-bar:
kind:
@ -209,6 +214,11 @@ models:
VerboseOIDCLogging: Verbose OIDC logging
ClaimMappings: Claim Mappings
ListClaimMappings: List Claim Mappings
binding-rule:
BindType: Type
Description: Description
Selector: Selector