diff --git a/ui/packages/consul-ui/app/adapters/binding-rule.js b/ui/packages/consul-ui/app/adapters/binding-rule.js new file mode 100644 index 000000000..b487f2d6f --- /dev/null +++ b/ui/packages/consul-ui/app/adapters/binding-rule.js @@ -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, + }} + `; + } +} diff --git a/ui/packages/consul-ui/app/components/consul/auth-method/binding-list/index.hbs b/ui/packages/consul-ui/app/components/consul/auth-method/binding-list/index.hbs new file mode 100644 index 000000000..d0a5189fa --- /dev/null +++ b/ui/packages/consul-ui/app/components/consul/auth-method/binding-list/index.hbs @@ -0,0 +1,28 @@ +
+

{{@item.BindName}}

+
+
{{t "models.binding-rule.BindType"}}
+
+ {{@item.BindType}} + + {{#if (eq @item.BindType 'service')}} + + {{t "components.consul.auth-method.binding-list.bind-type.service"}} + + {{else if (eq @item.BindType 'node')}} + + {{t "components.consul.auth-method.binding-list.bind-type.node"}} + + {{else if (eq @item.BindType 'role')}} + + {{t "components.consul.auth-method.binding-list.bind-type.role"}} + + {{/if}} + +
+
{{t "models.binding-rule.Selector"}}
+
{{@item.Selector}}
+
{{t "models.binding-rule.Description"}}
+
{{@item.Description}}
+
+
\ No newline at end of file diff --git a/ui/packages/consul-ui/app/components/consul/auth-method/index.scss b/ui/packages/consul-ui/app/components/consul/auth-method/index.scss index 7dcac12a1..de2f4eb28 100644 --- a/ui/packages/consul-ui/app/components/consul/auth-method/index.scss +++ b/ui/packages/consul-ui/app/components/consul/auth-method/index.scss @@ -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; + } +} + + diff --git a/ui/packages/consul-ui/app/components/tooltip/index.scss b/ui/packages/consul-ui/app/components/tooltip/index.scss index d4271ce71..952723e77 100644 --- a/ui/packages/consul-ui/app/components/tooltip/index.scss +++ b/ui/packages/consul-ui/app/components/tooltip/index.scss @@ -24,7 +24,7 @@ %tooltip-content { @extend %p3; padding: 12px; - max-width: 192px; + max-width: 224px; position: relative; z-index: 1; } diff --git a/ui/packages/consul-ui/app/models/binding-rule.js b/ui/packages/consul-ui/app/models/binding-rule.js new file mode 100644 index 000000000..90d7fa3d9 --- /dev/null +++ b/ui/packages/consul-ui/app/models/binding-rule.js @@ -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; +} diff --git a/ui/packages/consul-ui/app/router.js b/ui/packages/consul-ui/app/router.js index e7534ccea..14cb6656f 100644 --- a/ui/packages/consul-ui/app/router.js +++ b/ui/packages/consul-ui/app/router.js @@ -196,6 +196,9 @@ export const routes = { 'auth-method': { _options: { path: '/auth-method' }, }, + 'binding-rules': { + _options: { path: '/binding-rules' }, + }, }, }, }, diff --git a/ui/packages/consul-ui/app/routes/dc/acls/auth-methods/show.js b/ui/packages/consul-ui/app/routes/dc/acls/auth-methods/show.js index 368ccc2ed..6f543e1ae 100644 --- a/ui/packages/consul-ui/app/routes/dc/acls/auth-methods/show.js +++ b/ui/packages/consul-ui/app/routes/dc/acls/auth-methods/show.js @@ -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, }), }, }); diff --git a/ui/packages/consul-ui/app/routes/dc/acls/auth-methods/show/binding-rules.js b/ui/packages/consul-ui/app/routes/dc/acls/auth-methods/show/binding-rules.js new file mode 100644 index 000000000..14668be38 --- /dev/null +++ b/ui/packages/consul-ui/app/routes/dc/acls/auth-methods/show/binding-rules.js @@ -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); + } +} diff --git a/ui/packages/consul-ui/app/serializers/binding-rule.js b/ui/packages/consul-ui/app/serializers/binding-rule.js new file mode 100644 index 000000000..fce850715 --- /dev/null +++ b/ui/packages/consul-ui/app/serializers/binding-rule.js @@ -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; +} diff --git a/ui/packages/consul-ui/app/services/repository/binding-rule.js b/ui/packages/consul-ui/app/services/repository/binding-rule.js new file mode 100644 index 000000000..f2b4c58a3 --- /dev/null +++ b/ui/packages/consul-ui/app/services/repository/binding-rule.js @@ -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); + } +} diff --git a/ui/packages/consul-ui/app/styles/base/reset/system.scss b/ui/packages/consul-ui/app/styles/base/reset/system.scss index 26c58a311..b44318a6f 100644 --- a/ui/packages/consul-ui/app/styles/base/reset/system.scss +++ b/ui/packages/consul-ui/app/styles/base/reset/system.scss @@ -104,4 +104,5 @@ html { hr { height: 1px; margin: 1.5rem 0; + background-color: var(--gray-200); } diff --git a/ui/packages/consul-ui/app/styles/components.scss b/ui/packages/consul-ui/app/styles/components.scss index 673cbe77f..ada90c902 100644 --- a/ui/packages/consul-ui/app/styles/components.scss +++ b/ui/packages/consul-ui/app/styles/components.scss @@ -33,6 +33,7 @@ @import './components/more-popover-menu'; @import './components/definition-table'; @import './components/radio-card'; +@import './components/tabular-dl'; /**/ diff --git a/ui/packages/consul-ui/app/styles/components/tabular-dl/index.scss b/ui/packages/consul-ui/app/styles/components/tabular-dl/index.scss new file mode 100644 index 000000000..62cfad5c0 --- /dev/null +++ b/ui/packages/consul-ui/app/styles/components/tabular-dl/index.scss @@ -0,0 +1,2 @@ +@import './layout'; +@import './skin'; diff --git a/ui/packages/consul-ui/app/styles/components/tabular-dl/layout.scss b/ui/packages/consul-ui/app/styles/components/tabular-dl/layout.scss new file mode 100644 index 000000000..edeb482a4 --- /dev/null +++ b/ui/packages/consul-ui/app/styles/components/tabular-dl/layout.scss @@ -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; + } +} diff --git a/ui/packages/consul-ui/app/styles/components/tabular-dl/skin.scss b/ui/packages/consul-ui/app/styles/components/tabular-dl/skin.scss new file mode 100644 index 000000000..71de9caf5 --- /dev/null +++ b/ui/packages/consul-ui/app/styles/components/tabular-dl/skin.scss @@ -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); + } +} diff --git a/ui/packages/consul-ui/app/templates/dc/acls/auth-methods/show.hbs b/ui/packages/consul-ui/app/templates/dc/acls/auth-methods/show.hbs index 0b5889978..52e86479c 100644 --- a/ui/packages/consul-ui/app/templates/dc/acls/auth-methods/show.hbs +++ b/ui/packages/consul-ui/app/templates/dc/acls/auth-methods/show.hbs @@ -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")) ) }}/> diff --git a/ui/packages/consul-ui/app/templates/dc/acls/auth-methods/show/binding-rules.hbs b/ui/packages/consul-ui/app/templates/dc/acls/auth-methods/show/binding-rules.hbs new file mode 100644 index 000000000..84cc12520 --- /dev/null +++ b/ui/packages/consul-ui/app/templates/dc/acls/auth-methods/show/binding-rules.hbs @@ -0,0 +1,28 @@ + +
+{{#if (gt bindingRules.length 0)}} +

Binding rules allow an operator to express a systematic way of automatically linking roles and service identities to newly created tokens without operator intervention. +

+

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. +

+
+ {{#each bindingRules as |item|}} + +
+ {{/each}} +{{else}} + + +

No binding rules

+
+ +

Binding rules allow an operator to express a systematic way of automatically linking roles and service identities to newly created tokens without operator intervention.

+
+ + + +
+{{/if}} +
diff --git a/ui/packages/consul-ui/mock-api/v1/acl/binding-rules b/ui/packages/consul-ui/mock-api/v1/acl/binding-rules new file mode 100644 index 000000000..c563a280f --- /dev/null +++ b/ui/packages/consul-ui/mock-api/v1/acl/binding-rules @@ -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 + } + ` + } + ) + } +] diff --git a/ui/packages/consul-ui/tests/integration/adapters/binding-rule-test.js b/ui/packages/consul-ui/tests/integration/adapters/binding-rule-test.js new file mode 100644 index 000000000..08f64d207 --- /dev/null +++ b/ui/packages/consul-ui/tests/integration/adapters/binding-rule-test.js @@ -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 + ); + }); +}); diff --git a/ui/packages/consul-ui/tests/integration/serializers/binding-rule-test.js b/ui/packages/consul-ui/tests/integration/serializers/binding-rule-test.js new file mode 100644 index 000000000..f4730ee13 --- /dev/null +++ b/ui/packages/consul-ui/tests/integration/serializers/binding-rule-test.js @@ -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); + }); + }); + }); +}); diff --git a/ui/packages/consul-ui/tests/unit/adapters/binding-rule-test.js b/ui/packages/consul-ui/tests/unit/adapters/binding-rule-test.js new file mode 100644 index 000000000..a726f13ae --- /dev/null +++ b/ui/packages/consul-ui/tests/unit/adapters/binding-rule-test.js @@ -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); + }); +}); diff --git a/ui/packages/consul-ui/tests/unit/serializers/binding-rule-test.js b/ui/packages/consul-ui/tests/unit/serializers/binding-rule-test.js new file mode 100644 index 000000000..a4e0f0ac8 --- /dev/null +++ b/ui/packages/consul-ui/tests/unit/serializers/binding-rule-test.js @@ -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); + }); +}); diff --git a/ui/packages/consul-ui/translations/en-us.yaml b/ui/packages/consul-ui/translations/en-us.yaml index 3262c5a31..85a44a0b1 100644 --- a/ui/packages/consul-ui/translations/en-us.yaml +++ b/ui/packages/consul-ui/translations/en-us.yaml @@ -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 +