diff --git a/addons/api/addon/models/fragment-principal.js b/addons/api/addon/models/fragment-principal.js
new file mode 100644
index 000000000..4c6bcb9c0
--- /dev/null
+++ b/addons/api/addon/models/fragment-principal.js
@@ -0,0 +1,20 @@
+import Fragment from 'ember-data-model-fragments/fragment';
+import { attr } from '@ember-data/model';
+
+export default class FragmentPrincipal extends Fragment {
+
+ // =attributes
+
+ @attr('string', {
+ description: 'ID of the related scope'
+ }) scope_id;
+
+ @attr('string', {
+ description: 'ID of the related principal (user or group)'
+ }) principal_id;
+
+ @attr('string', {
+ description: 'Type of the related principal (user or group)'
+ }) type;
+
+}
diff --git a/addons/api/addon/models/role.js b/addons/api/addon/models/role.js
index ccd4d1048..f4f25df61 100644
--- a/addons/api/addon/models/role.js
+++ b/addons/api/addon/models/role.js
@@ -5,6 +5,15 @@ export default class RoleModel extends GeneratedRoleModel {
// =attributes
+ /**
+ * Principals are users and groups assigned to a role. They are represented
+ * as references to User and Group instances. Since Ember Data relationships
+ * are wanting, we do not model these as a polymorphic relationship as might
+ * see obvious. Instead, the application layer is expected to load referenced
+ * users and groups as needed.
+ */
+ @fragmentArray('fragment-principal', {readOnly: true}) principals;
+
/**
* Grants is read-only _most_ under normal circumstances. But grants can
* be persisted via a dedicated call to `saveGrants()`.
diff --git a/addons/api/addon/serializers/fragment-principal.js b/addons/api/addon/serializers/fragment-principal.js
new file mode 100644
index 000000000..8d8896594
--- /dev/null
+++ b/addons/api/addon/serializers/fragment-principal.js
@@ -0,0 +1,18 @@
+import JSONSerializer from '@ember-data/serializer/json';
+
+export default class FragmentPrincipalSerializer extends JSONSerializer {
+
+ // =methods
+
+ /**
+ * Renames `id` to `principal_id` because fragments may not have IDs.
+ * Otherwise normalizes as normal.
+ * @param {ModelClass} typeClass
+ * @param {object} resourceHash
+ * @return {object}
+ */
+ normalize(typeClass, { id: principal_id, ...obj }) {
+ return super.normalize(typeClass, { principal_id, ...obj });
+ }
+
+}
diff --git a/addons/api/app/models/fragment-principal.js b/addons/api/app/models/fragment-principal.js
new file mode 100644
index 000000000..52ab0dd03
--- /dev/null
+++ b/addons/api/app/models/fragment-principal.js
@@ -0,0 +1 @@
+export { default } from 'api/models/fragment-principal';
diff --git a/addons/api/app/serializers/fragment-principal.js b/addons/api/app/serializers/fragment-principal.js
new file mode 100644
index 000000000..ef051478b
--- /dev/null
+++ b/addons/api/app/serializers/fragment-principal.js
@@ -0,0 +1 @@
+export { default } from 'api/serializers/fragment-principal';
diff --git a/addons/api/tests/unit/models/fragment-principal-test.js b/addons/api/tests/unit/models/fragment-principal-test.js
new file mode 100644
index 000000000..055f52da7
--- /dev/null
+++ b/addons/api/tests/unit/models/fragment-principal-test.js
@@ -0,0 +1,13 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'ember-qunit';
+
+module('Unit | Model | fragment principal', function(hooks) {
+ setupTest(hooks);
+
+ // Replace this with your real tests.
+ test('it exists', function(assert) {
+ let store = this.owner.lookup('service:store');
+ let model = store.createRecord('fragment-principal', {});
+ assert.ok(model);
+ });
+});
diff --git a/addons/api/tests/unit/serializers/fragment-principal-test.js b/addons/api/tests/unit/serializers/fragment-principal-test.js
new file mode 100644
index 000000000..bda2d46c5
--- /dev/null
+++ b/addons/api/tests/unit/serializers/fragment-principal-test.js
@@ -0,0 +1,30 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'ember-qunit';
+import FragmentPrincipal from 'api/models/fragment-principal';
+
+module('Unit | Serializer | fragment principal', function(hooks) {
+ setupTest(hooks);
+
+ test('it normalizes `id` into `principal_id`', function(assert) {
+ assert.expect(1);
+ const store = this.owner.lookup('service:store');
+ const serializer = store.serializerFor('fragment-principal');
+ const normalized = serializer.normalize(FragmentPrincipal, {
+ scope_id: '1',
+ principal_id: '2',
+ type: 'user'
+ });
+ assert.deepEqual(normalized, {
+ data: {
+ attributes: {
+ scope_id: '1',
+ principal_id: '2',
+ type: 'user'
+ },
+ id: null,
+ relationships: {},
+ type: 'fragment-principal'
+ }
+ });
+ });
+});
diff --git a/ui/core/app/router.js b/ui/core/app/router.js
index bba93d1ad..b26d1d098 100644
--- a/ui/core/app/router.js
+++ b/ui/core/app/router.js
@@ -23,6 +23,7 @@ Router.map(function () {
this.route('roles', function () {
this.route('role', { path: ':role_id' }, function () {
this.route('grants');
+ this.route('principals');
});
this.route('new');
});
diff --git a/ui/core/app/routes/scopes/scope/roles/role/principals.js b/ui/core/app/routes/scopes/scope/roles/role/principals.js
new file mode 100644
index 000000000..db40834d2
--- /dev/null
+++ b/ui/core/app/routes/scopes/scope/roles/role/principals.js
@@ -0,0 +1,37 @@
+import Route from '@ember/routing/route';
+import { all } from 'rsvp';
+
+export default class ScopesScopeRolesRolePrincipalsRoute extends Route {
+
+ // =methods
+
+ /**
+ * Returns users and groups associated with this role.
+ * @param {object} params
+ * @return {Promise{RoleModel}}
+ */
+ model() {
+ const adapterOptions = { scopeID: this.modelFor('scopes.scope').id };
+ const role = this.modelFor('scopes.scope.roles.role');
+ const users = role.principals
+ .filterBy('type', 'user')
+ .map(({ principal_id }) =>
+ this.store.findRecord('user', principal_id, { adapterOptions })
+ .then(model => ({
+ type: 'user',
+ model
+ }))
+ );
+ const groups = role.principals
+ .filterBy('type', 'group')
+ .map(({ principal_id }) =>
+ this.store.findRecord('group', principal_id, { adapterOptions })
+ .then(model => ({
+ type: 'group',
+ model
+ }))
+ );
+ return all(users.concat(groups));
+ }
+
+}
diff --git a/ui/core/app/templates/scopes/scope/roles/role.hbs b/ui/core/app/templates/scopes/scope/roles/role.hbs
index 552717330..0cd189b0a 100644
--- a/ui/core/app/templates/scopes/scope/roles/role.hbs
+++ b/ui/core/app/templates/scopes/scope/roles/role.hbs
@@ -15,6 +15,9 @@
{{t 'titles.settings'}}
+
+ {{t 'titles.principals'}}
+
{{t 'titles.grants'}}
diff --git a/ui/core/app/templates/scopes/scope/roles/role/principals.hbs b/ui/core/app/templates/scopes/scope/roles/role/principals.hbs
new file mode 100644
index 000000000..28c387e47
--- /dev/null
+++ b/ui/core/app/templates/scopes/scope/roles/role/principals.hbs
@@ -0,0 +1,22 @@
+{{title (t 'titles.principals')}}
+
+
+
+
+ {{t 'form.label.name'}}
+ {{t 'form.label.type'}}
+
+
+
+ {{#each @model as |principal|}}
+
+
+ {{principal.model.displayName}}
+
+
+ {{t (concat 'resources.' principal.type)}}
+
+
+ {{/each}}
+
+
diff --git a/ui/core/mirage/factories/role.js b/ui/core/mirage/factories/role.js
index 739b273e6..0dab3b584 100644
--- a/ui/core/mirage/factories/role.js
+++ b/ui/core/mirage/factories/role.js
@@ -1,4 +1,5 @@
import factory from '../generated/factories/role';
+import { trait } from 'ember-cli-mirage';
export default factory.extend({
@@ -8,4 +9,29 @@ export default factory.extend({
'id=*;type=host-catalog;actions=create,read'
],
+ /**
+ * Create roles with associated "principals": related users and groups.
+ */
+ withPrincipals: trait({
+ afterCreate(role, server) {
+ const users = server.createList('user', 2);
+ const groups = server.createList('group', 2);
+ const userPrincipalFragments = users.map(user => ({
+ scope_id: role.scope.id,
+ id: user.id,
+ type: 'user'
+ }));
+ const groupPrincipalFragments = groups.map(group => ({
+ scope_id: role.scope.id,
+ id: group.id,
+ type: 'group'
+ }));
+ const principals = [
+ ...userPrincipalFragments,
+ ...groupPrincipalFragments
+ ];
+ role.update({ principals });
+ }
+ })
+
});
diff --git a/ui/core/mirage/scenarios/default.js b/ui/core/mirage/scenarios/default.js
index 01b55037b..6f8e29775 100644
--- a/ui/core/mirage/scenarios/default.js
+++ b/ui/core/mirage/scenarios/default.js
@@ -18,11 +18,10 @@ export default function(server) {
// User
server.createList('user', 5, { scope });
-
- // Role
- server.createList('role', 5, { scope });
- // Groups
+ // Group
server.createList('group', 5, { scope });
+ // Role
+ server.createList('role', 5, { scope }, 'withPrincipals');
// Other resources
diff --git a/ui/core/tests/acceptance/roles-test.js b/ui/core/tests/acceptance/roles-test.js
index 7db363fbe..2aa364f1b 100644
--- a/ui/core/tests/acceptance/roles-test.js
+++ b/ui/core/tests/acceptance/roles-test.js
@@ -218,6 +218,10 @@ module('Acceptance | roles', function (hooks) {
);
});
+ test('principals may be viewed in principals tab', async function (assert) {
+ assert.expect(0);
+ });
+
test('grants can be updated and saved or canceled', async function (assert) {
assert.expect(0);
});
diff --git a/ui/core/tests/unit/routes/scopes/scope/roles/role/principals-test.js b/ui/core/tests/unit/routes/scopes/scope/roles/role/principals-test.js
new file mode 100644
index 000000000..ce601db65
--- /dev/null
+++ b/ui/core/tests/unit/routes/scopes/scope/roles/role/principals-test.js
@@ -0,0 +1,11 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'ember-qunit';
+
+module('Unit | Route | scopes/scope/roles/role/principals', function(hooks) {
+ setupTest(hooks);
+
+ test('it exists', function(assert) {
+ let route = this.owner.lookup('route:scopes/scope/roles/role/principals');
+ assert.ok(route);
+ });
+});
diff --git a/ui/core/translations/en-us.yaml b/ui/core/translations/en-us.yaml
index 5252c7e6f..a1a339daf 100644
--- a/ui/core/translations/en-us.yaml
+++ b/ui/core/translations/en-us.yaml
@@ -47,6 +47,7 @@ titles:
groups-welcome: Welcome to Groups
grants: Permissions
new-grant: New Grant
+ principals: Principals
descriptions:
host-catalogs: Overview of collections of hosts and host sets.
empty-set: There are no items to display yet. You may be able to add items or try back later.
@@ -69,6 +70,7 @@ form:
identification: Username
password: Password
grant: Grant
+ principal: Principal
errors:
titles:
unauthenticated-error: You are not signed in