Merge pull request #506 from hashicorp/feature-primary-auth-method

Feature primary auth method
This commit is contained in:
Randall Morey authored and GitHub committed 2021-03-30 14:14:02 -04:00
commit 73503f98dc
25 files changed
+1641 -931

No files matched your search

@@ -7,6 +7,12 @@ import { attr } from '@ember-data/model';
export default class GeneratedAuthMethodModel extends BaseModel {
// =attributes
@attr('boolean', {
description: 'Specifies if this is the primary auth method for its scope.',
readOnly: true,
})
primary;
@attr('string', {
description: 'The type of the resource, to help differentiate schemas',
})
@@ -7,6 +7,12 @@ import { attr } from '@ember-data/model';
export default class GeneratedScopeModel extends BaseModel {
// =attributes
@attr('string', {
description:
'The ID of the primary auth method for this scope. A primary auth method\nis allowed to vivify users when new accounts are created and is the source for the users account info.',
})
primary_auth_method_id;
@attr('string', {
description: 'The type of the resource',
})
+9 -1
View File
@@ -1,3 +1,11 @@
import GeneratedAuthMethodModel from '../generated/models/auth-method';
import { equal } from '@ember/object/computed';
export default class AuthMethodModel extends GeneratedAuthMethodModel {}
export default class AuthMethodModel extends GeneratedAuthMethodModel {
// =attributes
/**
* @type {boolean}
*/
@equal('primary', true) isPrimary;
}
@@ -0,0 +1,18 @@
import ApplicationSerializer from './application';
export default class AuthMethodSerializer extends ApplicationSerializer {
/**
* Sorts the array in order of "primary" such that the primary method appears
* first. The "natural" order of auth methods places primary first.
*
* @override
* @return {object}
*/
normalizeArrayResponse() {
const normalized = super.normalizeArrayResponse(...arguments);
normalized.data = normalized.data.sort((a) =>
a.attributes.primary ? -1 : 1
);
return normalized;
}
}
@@ -0,0 +1 @@
export { default } from 'api/serializers/auth-method';
+11 -1
View File
@@ -60,7 +60,17 @@ export default function () {
this.post('/auth-methods');
this.get('/auth-methods/:id');
this.patch('/auth-methods/:id');
this.del('/auth-methods/:id');
this.del('/auth-methods/:id', ({ authMethods }, { params: { id } }) => {
const authMethod = authMethods.find(id);
const scope = authMethod.scope;
authMethod.destroy();
// If the primary auth method is deleted, the associated scope's
// primary_auth_method_id field should be set to null.
if (scope.primaryAuthMethodId === id) {
scope.update({ primaryAuthMethodId: null });
}
return new Response(204);
});
// Auth Method Accounts
this.get(
+6 -2
View File
@@ -20,18 +20,22 @@ export default function (server) {
server.create('user', { id: 'authenticateduser' });
// Auth
server.createList(
const globalAuthMethods = server.createList(
'auth-method',
1,
{ scope: globalScope },
'withAccountsAndUsers'
);
server.createList(
const orgAuthMethods = server.createList(
'auth-method',
3,
{ scope: orgScope },
'withAccountsAndUsers'
);
// Assign primary auth methods per scope
// TODO make this generic
globalScope.update({ primaryAuthMethodId: globalAuthMethods[0].id });
orgScope.update({ primaryAuthMethodId: orgAuthMethods[1].id });
// Groups and Users
server.createList('group', 1, { scope: globalScope }, 'withMembers');
@@ -2,4 +2,17 @@ import ApplicationSerializer from './application';
export default ApplicationSerializer.extend({
modelName: 'auth-method',
_hashForModel(model) {
const json = ApplicationSerializer.prototype._hashForModel.apply(
this,
arguments
);
// If the scope marks this auth method as primary, designate it
// as such with a read-only boolean field (this is how the API behaves).
const isPrimary =
model.scope && model.scope.primaryAuthMethodId === model.id;
json.primary = isPrimary;
return json;
},
});
@@ -0,0 +1,66 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Serializer | auth method', 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('auth-method');
assert.ok(serializer);
});
test('it serializes records', function (assert) {
let store = this.owner.lookup('service:store');
let record = store.createRecord('auth-method', {});
let serializedRecord = record.serialize();
assert.ok(serializedRecord);
});
test('it sorts primary first in normalizeArrayResponse', function (assert) {
assert.expect(2);
const store = this.owner.lookup('service:store');
const serializer = store.serializerFor('auth-method');
const modelClass = store.createRecord('auth-method').constructor;
const payload = {
items: [{ id: 1 }, { id: 2, primary: true }, { id: 3 }],
};
const normalizedArray = serializer.normalizeArrayResponse(
store,
modelClass,
payload
);
assert.ok(payload.items[1].primary, 'Second payload item is primary');
assert.deepEqual(
normalizedArray,
{
included: [],
data: [
{
id: '2',
type: 'auth-method',
attributes: { primary: true },
relationships: {},
},
{
id: '1',
type: 'auth-method',
attributes: {},
relationships: {},
},
{
id: '3',
type: 'auth-method',
attributes: {},
relationships: {},
},
],
},
'First normalized item is primary'
);
});
});
+2 -2
View File
@@ -5,7 +5,7 @@ import { getOwner } from '@ember/application';
* confirmation via the confirmation service. If the user accepts, the
* original method is executed. If the user denies, it is not.
*/
export function confirm(confirmationTextKey) {
export function confirm(confirmationTextKey, options) {
return function (_target, _propertyKey, desc) {
const method = desc.value;
desc.value = function () {
@@ -15,7 +15,7 @@ export function confirm(confirmationTextKey) {
const text = intl.t(confirmationTextKey);
return (
confirmService
.confirm(text)
.confirm(text, options)
// If user confirmed, execute the decorated method
.then(() => method.apply(this, arguments))
// If the user denied, gracefully handle promise rejection with no-op
+14 -1
View File
@@ -16,7 +16,7 @@ account:
auth-method:
title: Auth Method
title_plural: Auth Methods
description: Auth methods govern how an org's users can login to Boundary. Boundary’s default auth method is username & passwords.
description: Auth methods allow users to authenticate within a scope.
messages:
welcome:
title: Welcome to Auth Methods
@@ -24,11 +24,24 @@ auth-method:
none:
title: No Auth Methods
description: No auth methods were found for this scope.
no-primary-auth-method:
title: "Recommendation: Select a Primary Auth Method"
description: A primary auth method auto-creates users when they authenticate for the first time. Scopes without primary auth methods may experience authentication failures.
types:
password: Password
actions:
create: Create Auth Method
delete: Delete Auth Method
make-primary: Make Primary
remove-as-primary: Remove as Primary
notifications:
make-primary-success: Auth method successfully elected to primary.
remove-as-primary-success: Auth method successfully removed as primary.
questions:
make-primary: Make primary auth method?
remove-as-primary: Remove primary auth method?
make-primary-confirm: "A primary auth method auto-creates users on their first authentication attempt if needed. Note: if you change the primary auth method, the previous primary will no longer auto-create users."
remove-as-primary-confirm: Removing the primary auth method may lead to authentication failures for this scope.
group:
title: Group
title_plural: Groups
@@ -7,3 +7,4 @@ copied: Copied
connection:
success:
description: "Local proxy address ({protocol}): {address}:{port}"
primary: Primary
@@ -19,6 +19,7 @@
&.rose-badge-outline {
--border-color: var(--ui-gray-starker-1);
--background-color: none;
}
&.rose-badge-dark {
@@ -40,6 +41,7 @@
&.rose-badge-informational-outline {
--color: var(--info-starker-1);
--border-color: var(--info-starker-1);
--background-color: none;
}
&.rose-badge-alert {
@@ -50,6 +52,7 @@
&.rose-badge-alert-outline {
--color: var(--alert-starker-1);
--border-color: var(--alert-starker-1);
--background-color: none;
}
&.rose-badge-error {
@@ -60,6 +63,7 @@
&.rose-badge-error-outline {
--color: var(--failure-starker-1);
--border-color: var(--failure-starker-1);
--background-color: none;
}
&.rose-badge-success {
@@ -70,6 +74,7 @@
&.rose-badge-success-outline {
--color: var(--success-starker-1);
--border-color: var(--success-starker-1);
--background-color: none;
}
&.rose-badge-warning {
@@ -80,5 +85,6 @@
&.rose-badge-warning-outline {
--color: var(--warning-starker-1);
--border-color: var(--warning-starker-1);
--background-color: none;
}
}
@@ -0,0 +1,13 @@
import Controller from '@ember/controller';
export default class ScopesScopeAuthMethodsIndexController extends Controller {
// =attributes
/**
* True if the current scope has a primary auth method set.
* @type {boolean}
*/
get hasPrimaryAuthMethod() {
return Boolean(this.scopeModel.primary_auth_method_id);
}
}
@@ -71,8 +71,67 @@ export default class ScopesScopeAuthMethodsRoute extends Route {
@notifyError(({ message }) => message, { catch: true })
@notifySuccess('notifications.delete-success')
async delete(authMethod) {
const scopeModel = this.modelFor('scopes.scope');
await authMethod.destroyRecord();
// Reload the scope, since this is where the primary_auth_method_id is
// stored. An auth method deletion could affect this field.
await scopeModel.reload();
await this.replaceWith('scopes.scope.auth-methods');
this.refresh();
}
/**
* Elects the specified auth method to primary for the current scope.
* @param {AuthMethodModel} authMethod
* @param {string} authMethod.id
*/
@action
@loading
@confirm('resources.auth-method.questions.make-primary-confirm', {
title: 'resources.auth-method.questions.make-primary',
})
@notifyError(({ message }) => message, { catch: true })
@notifySuccess('resources.auth-method.notifications.make-primary-success')
async makePrimary({ id }) {
const scopeModel = this.modelFor('scopes.scope');
scopeModel.primary_auth_method_id = id;
// Attempt to save the change to the scope. If this operation fails,
// we rollback the change and rethrow the error so the user can be notified.
try {
await scopeModel.save();
} catch (e) {
scopeModel.rollbackAttributes();
throw e;
}
await this.refresh();
}
/**
* Sets the `primary_auth_method_id` field to null for the current scope
* and saves it if and only if the specified auth method is in fact primary
* for the current scope.
* @param {AuthMethodModel} authMethod
*/
@action
@loading
@confirm('resources.auth-method.questions.remove-as-primary-confirm', {
title: 'resources.auth-method.questions.remove-as-primary',
})
@notifyError(({ message }) => message, { catch: true })
@notifySuccess(
'resources.auth-method.notifications.remove-as-primary-success'
)
async removeAsPrimary(authMethod) {
const scopeModel = this.modelFor('scopes.scope');
scopeModel.primary_auth_method_id = null;
// Attempt to save the change to the scope. If this operation fails,
// we rollback the change and rethrow the error so the user can be notified.
try {
await scopeModel.save();
} catch (e) {
scopeModel.rollbackAttributes();
throw e;
}
await authMethod.reload();
}
}
@@ -1,3 +1,15 @@
import Route from '@ember/routing/route';
export default class ScopesScopeAuthMethodsIndexRoute extends Route {}
export default class ScopesScopeAuthMethodsIndexRoute extends Route {
// =methods
/**
* Adds the scope to the controller context.
* @param {Controller} controller
*/
setupController(controller) {
super.setupController(...arguments);
const scopeModel = this.modelFor('scopes.scope');
controller.scopeModel = scopeModel;
}
}
+1 -1
View File
@@ -113,7 +113,7 @@
</Rose::Dialog>
{{else}}
<Rose::Dialog
@heading={{t "actions.confirm"}}
@heading={{if confirmation.options.title (t confirmation.options.title) (t "actions.confirm")}}
@dismissButtonText={{t "actions.cancel"}}
@icon="alert-triangle"
@style="warning"
@@ -1,4 +1,22 @@
<Rose::Dropdown @text="{{t 'actions.manage'}}" @dropdownRight={{true}} as |dropdown|>
{{#if (feature-flag 'primary-auth-method')}}
{{#unless @model.isPrimary}}
<dropdown.button
@disabled={{@model.canSave}}
{{on "click" (route-action "makePrimary" @model)}}
>
{{t 'resources.auth-method.actions.make-primary'}}
</dropdown.button>
{{/unless}}
{{#if @model.isPrimary}}
<dropdown.button
@disabled={{@model.canSave}}
{{on "click" (route-action "removeAsPrimary" @model)}}
>
{{t 'resources.auth-method.actions.remove-as-primary'}}
</dropdown.button>
{{/if}}
{{/if}}
<dropdown.link
@route="scopes.scope.auth-methods.auth-method.accounts.new"
@model={{@model}}
@@ -1,5 +1,14 @@
<h2>
{{t "resources.auth-method.title"}}
{{#if (feature-flag 'primary-auth-method')}}
{{#if @model.isPrimary}}
&nbsp;
<Rose::Badge @style="success-outline">
{{t 'states.primary'}}
</Rose::Badge>
&nbsp;
{{/if}}
{{/if}}
<DocLink @doc="auth-method" @iconSize="large" />
</h2>
<p>{{t "resources.auth-method.description"}}</p>
@@ -30,12 +30,27 @@
<page.body>
{{#if @model}}
{{#if (feature-flag 'primary-auth-method')}}
{{#unless this.hasPrimaryAuthMethod}}
<Rose::Notification
@style="warning"
@heading={{t "resources.auth-method.messages.no-primary-auth-method.title"}}
>
{{t "resources.auth-method.messages.no-primary-auth-method.description"}}
</Rose::Notification>
{{/unless}}
{{/if}}
<Rose::Table as |table|>
<table.header as |header|>
<header.row as |row|>
<row.headerCell>{{t 'form.name.label'}}</row.headerCell>
<row.headerCell>{{t 'form.type.label'}}</row.headerCell>
<row.headerCell>{{t 'form.id.label'}}</row.headerCell>
{{#if (feature-flag 'primary-auth-method')}}
<row.cell></row.cell>
<row.headerCell>{{t 'titles.actions'}}</row.headerCell>
{{/if}}
</header.row>
</table.header>
@@ -65,6 +80,39 @@
<code>{{authMethod.id}}</code>
</Copyable>
</row.cell>
{{#if (feature-flag 'primary-auth-method')}}
<row.cell>
{{#if authMethod.isPrimary}}
<Rose::Badge @style="success-outline">
{{t 'states.primary'}}
</Rose::Badge>
{{/if}}
</row.cell>
<row.cell>
<Rose::Dropdown
@text={{t "actions.manage"}}
@dropdownRight={{true}}
@iconOnly={{true}}
@showCaret={{false}}
@icon="more-horizontal"
as |dropdown|>
{{#unless authMethod.isPrimary}}
<dropdown.button
{{on "click" (route-action "makePrimary" authMethod)}}
>
{{t 'resources.auth-method.actions.make-primary'}}
</dropdown.button>
{{/unless}}
{{#if authMethod.isPrimary}}
<dropdown.button
{{on "click" (route-action "removeAsPrimary" authMethod)}}
>
{{t 'resources.auth-method.actions.remove-as-primary'}}
</dropdown.button>
{{/if}}
</Rose::Dropdown>
</row.cell>
{{/if}}
</body.row>
{{/each}}
</table.body>
+7
View File
@@ -83,6 +83,10 @@ module.exports = function (environment) {
'ember-cli-mirage': {
directory: '../../addons/api/mirage',
},
featureFlags: {
'primary-auth-method': false,
},
};
// Unsafe policy is necessary in development and test environments, but should
@@ -115,6 +119,9 @@ module.exports = function (environment) {
// rather than automatically include API_HOST. Changes to CSP should
// be explicit.
if (API_HOST) ENV.contentSecurityPolicy['connect-src'].push(API_HOST);
// Enable features in development
ENV.featureFlags['primary-auth-method'] = true;
}
if (environment === 'test') {
+1
View File
@@ -62,6 +62,7 @@
"ember-concurrency": "^1.3.0",
"ember-exam": "^6.0.1",
"ember-export-application-global": "^2.0.1",
"ember-feature-flags": "^6.0.0",
"ember-inline-svg": "^1.0.0",
"ember-load-initializers": "^2.1.2",
"ember-maybe-import-regenerator": "^0.1.6",
@@ -10,6 +10,7 @@ import {
// //currentSession,
// //invalidateSession,
} from 'ember-simple-auth/test-support';
import { enableFeature } from 'ember-feature-flags/test-support';
module('Acceptance | auth methods', function (hooks) {
setupApplicationTest(hooks);
@@ -270,4 +271,118 @@ module('Acceptance | auth methods', function (hooks) {
'Displays field-level errors.'
);
});
test('user can make primary an auth method', async function (assert) {
assert.expect(2);
assert.notOk(
instances.scopes.org.primaryAuthMethodId,
'Primary auth method is not yet set.'
);
enableFeature('primary-auth-method');
await visit(urls.authMethod);
await click(
'.rose-layout-page-actions .rose-dropdown-content [type="button"]:first-child'
);
const scope = this.server.schema.scopes.find(instances.scopes.org.id);
assert.equal(
scope.primaryAuthMethodId,
instances.authMethod.id,
'Primary auth method is set.'
);
});
test('user is notified of error on make primary an auth method', async function (assert) {
assert.expect(2);
this.server.patch('/scopes/:id', () => {
return new Response(
400,
{},
{
status: 400,
code: 'fail',
message: 'Sorry!',
}
);
});
assert.notOk(
instances.scopes.org.primaryAuthMethodId,
'Primary auth method is not yet set.'
);
enableFeature('primary-auth-method');
await visit(urls.authMethod);
await click(
'.rose-layout-page-actions .rose-dropdown-content [type="button"]:first-child'
);
assert.ok(find('.rose-notification'));
});
test('user can remove as primary an auth method', async function (assert) {
assert.expect(2);
instances.scopes.org.update({
primaryAuthMethodId: instances.authMethod.id,
});
assert.ok(
instances.scopes.org.primaryAuthMethodId,
'Primary auth method is set.'
);
enableFeature('primary-auth-method');
await visit(urls.authMethod);
await click(
'.rose-layout-page-actions .rose-dropdown-content [type="button"]:first-child'
);
const scope = this.server.schema.scopes.find(instances.scopes.org.id);
assert.notOk(scope.primaryAuthMethodId, 'Primary auth method is unset.');
});
test('user is notified of error on remove as primary an auth method', async function (assert) {
assert.expect(2);
this.server.patch('/scopes/:id', () => {
return new Response(
400,
{},
{
status: 400,
code: 'fail',
message: 'Sorry!',
}
);
});
instances.scopes.org.update({
primaryAuthMethodId: instances.authMethod.id,
});
assert.ok(
instances.scopes.org.primaryAuthMethodId,
'Primary auth method is set.'
);
enableFeature('primary-auth-method');
await visit(urls.authMethod);
await click(
'.rose-layout-page-actions .rose-dropdown-content [type="button"]:first-child'
);
assert.ok(find('.rose-notification'));
});
test('user can make and remove primary auth methods from index', async function (assert) {
assert.expect(3);
assert.notOk(
instances.scopes.org.primaryAuthMethodId,
'Primary auth method is not yet set.'
);
enableFeature('primary-auth-method');
await visit(urls.authMethods);
await click(
'.rose-table-body .rose-table-row:first-child .rose-dropdown-content [type="button"]:first-child'
);
let scope = this.server.schema.scopes.find(instances.scopes.org.id);
assert.equal(
scope.primaryAuthMethodId,
instances.authMethod.id,
'Primary auth method is set.'
);
await click(
'.rose-table-body .rose-table-row:first-child .rose-dropdown-content [type="button"]:first-child'
);
scope = this.server.schema.scopes.find(instances.scopes.org.id);
assert.notOk(scope.primaryAuthMethodId, 'Primary auth method is unset.');
});
});
@@ -0,0 +1,14 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Controller | scopes/scope/auth-methods/index', function (hooks) {
setupTest(hooks);
// TODO: Replace this with your real tests.
test('it exists', function (assert) {
let controller = this.owner.lookup(
'controller:scopes/scope/auth-methods/index'
);
assert.ok(controller);
});
});
+1184 -922
View File
File diff suppressed because it is too large. Load diff