Skip to content

Commit

Permalink
[ui] Sentinel Policies CRUD UI (#20483)
Browse files Browse the repository at this point in the history
* Gallery allows picking stuff

* Small fixes

* added sentinel templates

* Can set enforcement level on policies

* Working on the interactive sentinel dev mode

* Very rough development flow on FE

* Changed position in gutter menu

* More sentinel stuff

* PR cleanup: removed testmode, removed unneeded mixins and deps

* Heliosification

* Index-level sentinel policy deletion and page title fixes

* Makes the Canaries sentinel policy real and then comments out the unfinished ones

* rename Access Control to Administration in prep for moving Sentinel Policies and Node Pool admin there

* Sentinel policies moved within the Administration section

* Mirage fixture for sentinel policy endpoints

* Description length check and 500 prevention

* Sync review PR feedback addressed, implied butons on radio cards

* Cull un-used sentinel policies

---------

Co-authored-by: Mike Nomitch <mail@mikenomitch.com>
  • Loading branch information
philrenaud and mikenomitch authored May 22, 2024
1 parent 4415fab commit 86c858c
Show file tree
Hide file tree
Showing 89 changed files with 1,138 additions and 231 deletions.
3 changes: 3 additions & 0 deletions .changelog/20483.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:improvement
ui: Added a UI for creating, editing and deleting Sentinel Policies
```
27 changes: 27 additions & 0 deletions ui/app/abilities/sentinel-policy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/

import AbstractAbility from './abstract';
import { alias, and, or } from '@ember/object/computed';
import { computed } from '@ember/object';

export default class SentinelPolicy extends AbstractAbility {
@alias('hasFeatureAndManagement') canRead;
@alias('hasFeatureAndManagement') canList;
@alias('hasFeatureAndManagement') canWrite;
@alias('hasFeatureAndManagement') canUpdate;
@alias('hasFeatureAndManagement') canDestroy;

@or('bypassAuthorization', 'selfTokenIsManagement')
hasAuthority;

@and('sentinelIsPresent', 'hasAuthority')
hasFeatureAndManagement;

@computed('features.[]')
get sentinelIsPresent() {
return this.featureIsPresent('Sentinel Policies');
}
}
25 changes: 25 additions & 0 deletions ui/app/adapters/sentinel-policy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/

import { default as ApplicationAdapter } from './application';
import classic from 'ember-classic-decorator';

@classic
export default class SentinelPolicyAdapter extends ApplicationAdapter {
pathForType = () => 'sentinel/policies';

// namespace = namespace + '/acl';
urlForCreateRecord(_modelName, model) {
return this.urlForUpdateRecord(model.attr('name'), 'sentinel/policy');
}

urlForFindRecord(id) {
return '/v1/sentinel/policy/' + id;
}

urlForDeleteRecord(id) {
return '/v1/sentinel/policy/' + id;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@ import { tagName } from '@ember-decorators/component';
import { inject as service } from '@ember/service';

@tagName('')
export default class AccessControlSubnav extends Component {
export default class AdministrationSubnav extends Component {
@service keyboard;
}
2 changes: 1 addition & 1 deletion ui/app/components/namespace-editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export default class NamespaceEditorComponent extends Component {

if (shouldRedirectAfterSave) {
this.router.transitionTo(
'access-control.namespaces.acl-namespace',
'administration.namespaces.acl-namespace',
this.namespace.name
);
}
Expand Down
12 changes: 6 additions & 6 deletions ui/app/components/policy-editor.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,12 @@
class="policy-editor"
data-test-policy-editor
{{code-mirror
screenReaderLabel="Policy definition"
theme="hashi"
mode="ruby"
content=@policy.rules
onUpdate=this.updatePolicyRules
autofocus=false
screenReaderLabel="Policy definition"
theme="hashi"
mode="ruby"
content=@policy.rules
onUpdate=this.updatePolicyRules
autofocus=false
extraKeys=(hash Cmd-Enter=this.save)
}} />
</div>
Expand Down
2 changes: 1 addition & 1 deletion ui/app/components/policy-editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export default class PolicyEditorComponent extends Component {

if (shouldRedirectAfterSave) {
this.router.transitionTo(
'access-control.policies.policy',
'administration.policies.policy',
this.policy.id
);
}
Expand Down
2 changes: 1 addition & 1 deletion ui/app/components/role-editor.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
<B.Td data-test-policy-name>{{B.data.name}}</B.Td>
<B.Td>{{B.data.description}}</B.Td>
<B.Td>
<LinkTo @route="access-control.policies.policy" @model={{B.data.name}}>
<LinkTo @route="administration.policies.policy" @model={{B.data.name}}>
View Policy Definition
</LinkTo>
</B.Td>
Expand Down
2 changes: 1 addition & 1 deletion ui/app/components/role-editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export default class RoleEditorComponent extends Component {
});

if (shouldRedirectAfterSave) {
this.router.transitionTo('access-control.roles.role', this.role.id);
this.router.transitionTo('administration.roles.role', this.role.id);
}
} catch (err) {
let message = err.errors?.length
Expand Down
90 changes: 90 additions & 0 deletions ui/app/components/sentinel-policy-editor.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}

<form class="acl-form" autocomplete="off" {{on "submit" this.save}}>
{{#if @policy.isNew }}
<Hds::Form::TextInput::Field
@isRequired={{true}}
data-test-policy-name-input
@value={{@policy.name}}
{{on "input" this.updatePolicyName}}
{{autofocus}}
as |F|>
<F.Label>Policy Name</F.Label>
</Hds::Form::TextInput::Field>
{{/if}}

<div class="boxed-section">
<div class="boxed-section-head">
Policy Definition
</div>
<div class="boxed-section-body is-full-bleed">
<div
class="policy-editor"
data-test-policy-editor
{{code-mirror
screenReaderLabel="Policy definition"
theme="hashi"
mode="ruby"
content=@policy.policy
onUpdate=this.updatePolicy
autofocus=false
extraKeys=(hash Cmd-Enter=this.save)
}} />
</div>
</div>

<div>
<label>
<span>
Description (optional)
</span>
<Input
data-test-policy-description
@value={{@policy.description}}
class="input"
/>
</label>
</div>

<div>
<Hds::Form::Radio::Group @layout="horizontal" @name="method-demo1" {{on "change" this.updatePolicyEnforcementLevel}} as |G|>
<G.Legend>Enforcement Level</G.Legend>
<G.HelperText>See <Hds::Link::Inline @href="https://developer.hashicorp.com/nomad/tutorials/access-control/access-control-tokens#token-types">Sentinel Policy documentation</Hds::Link::Inline> for more information.</G.HelperText>
<G.Radio::Field
@id="advisory"
checked={{eq @policy.enforcementLevel "advisory"}}
data-test-token-type="client"
as |F|>
<F.Label>Advisory</F.Label>
</G.Radio::Field>
<G.Radio::Field
@id="soft-mandatory"
checked={{eq @policy.enforcementLevel "soft-mandatory"}}
data-test-token-type="soft-mandatory"
as |F|>
<F.Label>Soft Mandatory</F.Label>
</G.Radio::Field>
<G.Radio::Field
@id="hard-mandatory"
checked={{eq @policy.enforcementLevel "hard-mandatory"}}
data-test-token-type="hard-mandatory"
as |F|>
<F.Label>Hard Mandatory</F.Label>
</G.Radio::Field>
</Hds::Form::Radio::Group>
</div>

<footer>
{{#if (can "update sentinel-policy")}}
<Hds::Button
@text="Save Policy"
@type="submit"
data-test-save-policy
{{on "click" this.save}}
/>
{{/if}}
</footer>
</form>
92 changes: 92 additions & 0 deletions ui/app/components/sentinel-policy-editor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/

// @ts-check

import Component from '@glimmer/component';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { alias } from '@ember/object/computed';
import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error';

export default class SentinelPolicyEditorComponent extends Component {
@service notifications;
@service router;
@service store;

@alias('args.policy') policy;

@action updatePolicy(value) {
this.policy.set('policy', value);
}

@action updatePolicyName({ target: { value } }) {
this.policy.set('name', value);
}

@action updatePolicyEnforcementLevel({ target: { id } }) {
this.policy.set('enforcementLevel', id);
}

@action async save(e) {
if (e instanceof Event) {
e.preventDefault(); // code-mirror "command+enter" submits the form, but doesnt have a preventDefault()
}
try {
const nameRegex = '^[a-zA-Z0-9-]{1,128}$';
if (!this.policy.name?.match(nameRegex)) {
throw new Error(
`Policy name must be 1-128 characters long and can only contain letters, numbers, and dashes.`
);
}
if (this.policy.description?.length > 256) {
throw new Error(
`Policy description must be under 256 characters long.`
);
}

const shouldRedirectAfterSave = this.policy.isNew;
// Because we set the ID for adapter/serialization reasons just before save here,
// that becomes a barrier to our Unique Name validation. So we explicltly exclude
// the current policy when checking for uniqueness.
if (
this.policy.isNew &&
this.store
.peekAll('sentinel-policy')
.filter((policy) => policy !== this.policy)
.findBy('name', this.policy.name)
) {
throw new Error(
`A sentinel policy with name ${this.policy.name} already exists.`
);
}
this.policy.set('id', this.policy.name);
await this.policy.save();

this.notifications.add({
title: 'Sentinel Policy Saved',
color: 'success',
});

if (shouldRedirectAfterSave) {
this.router.transitionTo(
'administration.sentinel-policies.policy',
this.policy.name
);
}
} catch (err) {
let message = err.errors?.length
? messageFromAdapterError(err)
: err.message || 'Unknown Error';

this.notifications.add({
title: `Error creating Sentinel Policy ${this.policy.name}`,
message,
color: 'critical',
sticky: true,
});
}
}
}
10 changes: 5 additions & 5 deletions ui/app/components/token-editor.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@
<B.Td data-test-policy-name>{{B.data.name}}</B.Td>
<B.Td>{{B.data.description}}</B.Td>
<B.Td>
<LinkTo @route="access-control.policies.policy" @model={{B.data.name}}>
<LinkTo @route="administration.policies.policy" @model={{B.data.name}}>
View Policy Definition
</LinkTo>
</B.Td>
Expand All @@ -158,7 +158,7 @@
No Policies
</h3>
<p class="empty-message-body">
Get started by <LinkTo @route="access-control.policies.new">creating a new policy</LinkTo>
Get started by <LinkTo @route="administration.policies.new">creating a new policy</LinkTo>
</p>
</div>
{{/if}}
Expand Down Expand Up @@ -196,15 +196,15 @@
<div class="tag-group">
{{#each B.data.policies as |policy|}}
{{#if policy.name}}
<Hds::Tag @color="primary" @text="{{policy.name}}" @route="access-control.policies.policy" @model="{{policy.name}}" />
<Hds::Tag @color="primary" @text="{{policy.name}}" @route="administration.policies.policy" @model="{{policy.name}}" />
{{/if}}
{{else}}
Role contains no policies
{{/each}}
</div>
</B.Td>
<B.Td>
<LinkTo @route="access-control.roles.role" @model={{B.data.id}}>
<LinkTo @route="administration.roles.role" @model={{B.data.id}}>
View Role Info
</LinkTo>
</B.Td>
Expand All @@ -217,7 +217,7 @@
No Roles
</h3>
<p class="empty-message-body">
Get started by <LinkTo @route="access-control.roles.new">creating a new role</LinkTo>
Get started by <LinkTo @route="administration.roles.new">creating a new role</LinkTo>
</p>
</div>
{{/if}}
Expand Down
2 changes: 1 addition & 1 deletion ui/app/components/token-editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export default class TokenEditorComponent extends Component {

if (shouldRedirectAfterSave) {
this.router.transitionTo(
'access-control.tokens.token',
'administration.tokens.token',
this.activeToken.id
);
}
Expand Down
2 changes: 1 addition & 1 deletion ui/app/components/variable-form/related-entities.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,6 @@
job <Hds::Link::Inline @route="jobs.job" @model={{concat @job "@" @namespace}} @icon="external-link">{{@job}}</Hds::Link::Inline>
{{else}}
all nomad jobs in this namespace
{{/if}}
{{/if}}
</A.Description>
</Hds::Alert>
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export default class AccessControlNamespacesAclNamespaceController extends Contr
type: `success`,
destroyOnClick: false,
});
this.router.transitionTo('access-control.namespaces');
this.router.transitionTo('administration.namespaces');
} catch (err) {
// A failed delete resulted in errors when you then navigated away and back
// to the show page rollbackWithoutChangedAttrs fixes it, but there might
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ export default class AccessControlNamespacesIndexController extends Controller {

@action openNamespace(namespace) {
this.router.transitionTo(
'access-control.namespaces.acl-namespace',
'administration.namespaces.acl-namespace',
namespace.name
);
}

@action goToNewNamespace() {
this.router.transitionTo('access-control.namespaces.new');
this.router.transitionTo('administration.namespaces.new');
}

get columns() {
Expand Down
Loading

0 comments on commit 86c858c

Please sign in to comment.