diff --git a/addons/api/addon/models/role.js b/addons/api/addon/models/role.js index f4f25df61..dd7e8bb2b 100644 --- a/addons/api/addon/models/role.js +++ b/addons/api/addon/models/role.js @@ -36,4 +36,64 @@ export default class RoleModel extends GeneratedRoleModel { }); } + /** + * Adds principals via the `add-principals` method. + * See serializer and adapter for more information. + * @param {[string]} principalIDs + * @param {object} options + * @param {object} options.adapterOptions + * @return {Promise} + */ + addPrincipals(principalIDs, options={ adapterOptions: {} }) { + const defaultAdapterOptions = { + method: 'add-principals', + principalIDs + }; + // There is no "deep merge" in ES. + // All of this nonsense is here to ensure we get + // a decent merge of `adapterOptions`. + return this.save({ + ...options, + adapterOptions: { + ...defaultAdapterOptions, + ...options.adapterOptions + } + }); + } + + /** + * Delete principals via the `remove-principals` method. + * See serializer and adapter for more information. + * @param {[string]} principalIDs + * @param {object} options + * @param {object} options.adapterOptions + * @return {Promise} + */ + removePrincipals(principalIDs, options={ adapterOptions: {} }) { + const defaultAdapterOptions = { + method: 'remove-principals', + principalIDs + }; + // There is no "deep merge" in ES. + // All of this nonsense is here to ensure we get + // a decent merge of `adapterOptions`. + return this.save({ + ...options, + adapterOptions: { + ...defaultAdapterOptions, + ...options.adapterOptions + } + }); + } + + /** + * Delete a single principal via the `remove-principals` method. + * @param {number} principalIDs + * @param {object} options + * @return {Promise} + */ + removePrincipal(principalID, options) { + return this.removePrincipals([principalID], options); + } + } diff --git a/addons/api/addon/serializers/role.js b/addons/api/addon/serializers/role.js index 9cf12da58..89d10353d 100644 --- a/addons/api/addon/serializers/role.js +++ b/addons/api/addon/serializers/role.js @@ -9,6 +9,8 @@ export default class RoleSerializer extends ApplicationSerializer { * If `adapterOptions.serializeGrants` is true, the serialization should * include **only grants** and the version. Normally, grants are not * serialized. + * If `adapterOptions.principalIDs` is set (to an array of user and + * group IDs), then the payload is serialized via `serializewithPrincipals`. * @override * @param {Snapshot} snapshot * @return {object} @@ -17,6 +19,9 @@ export default class RoleSerializer extends ApplicationSerializer { const serializeGrants = get(snapshot, 'adapterOptions.serializeGrants'); let serialized = super.serialize(...arguments); if (serializeGrants) serialized = this.serializeWithGrants(snapshot); + const principalIDs = snapshot?.adapterOptions?.principalIDs; + if (principalIDs) serialized = + this.serializewithPrincipals(snapshot, principalIDs); return serialized; } @@ -32,6 +37,20 @@ export default class RoleSerializer extends ApplicationSerializer { }; } + /** + * Returns a payload containing only the principal_ids array using IDs + * passed into the function (rather than existing principals on the model). + * @param {Snapshot} snapshot + * @param {[string]} principalIDs + * @return {object} + */ + serializewithPrincipals(snapshot, principalIDs) { + return { + version: snapshot.attr('version'), + principal_ids: principalIDs + }; + } + /** * Converts string arrays to object arrays for use with `fragment-string`. * E.g. `"foo"` -> `{value: "foo"}` diff --git a/addons/api/tests/unit/models/role-test.js b/addons/api/tests/unit/models/role-test.js index fe1b8224b..50a0fdb2d 100644 --- a/addons/api/tests/unit/models/role-test.js +++ b/addons/api/tests/unit/models/role-test.js @@ -41,4 +41,103 @@ module('Unit | Model | role', function (hooks) { const model = store.peekRecord('role', '123abc'); await model.saveGrants(); }); + + test('it has an `addPrincipals` method that targets a specific POST API endpoint and serialization', async function (assert) { + assert.expect(1); + this.server.post('/v1/scopes/o_1/roles/123abc:add-principals', (schema, request) => { + const body = JSON.parse(request.requestBody); + assert.deepEqual(body, { + principal_ids: ['123_abc', 'foobar'], + version: 1 + }); + }); + const store = this.owner.lookup('service:store'); + store.push({ + data: { + id: '123abc', + type: 'role', + attributes: { + name: 'Role', + description: 'Description', + principals: [ + { id: '1', type: 'user' }, + { id: '3', type: 'group' } + ], + version: 1, + scope: { + scope_id: 'o_1', + type: 'scope' + } + } + } + }); + const model = store.peekRecord('role', '123abc'); + await model.addPrincipals(['123_abc', 'foobar']); + }); + + test('it has a `removePrincipals` method that targets a specific POST API endpoint and serialization', async function (assert) { + assert.expect(1); + this.server.post('/v1/scopes/o_1/roles/123abc:remove-principals', (schema, request) => { + const body = JSON.parse(request.requestBody); + assert.deepEqual(body, { + principal_ids: ['1', '3'], + version: 1 + }); + }); + const store = this.owner.lookup('service:store'); + store.push({ + data: { + id: '123abc', + type: 'role', + attributes: { + name: 'Role', + description: 'Description', + principals: [ + { id: '1', type: 'user' }, + { id: '3', type: 'group' } + ], + version: 1, + scope: { + scope_id: 'o_1', + type: 'scope' + } + } + } + }); + const model = store.peekRecord('role', '123abc'); + await model.removePrincipals(['1', '3']); + }); + + test('it has a `removePrincipal` method that deletes a single principal using `removePrincipals` method', async function (assert) { + assert.expect(1); + this.server.post('/v1/scopes/o_1/roles/123abc:remove-principals', (schema, request) => { + const body = JSON.parse(request.requestBody); + assert.deepEqual(body, { + principal_ids: ['3'], + version: 1 + }); + }); + const store = this.owner.lookup('service:store'); + store.push({ + data: { + id: '123abc', + type: 'role', + attributes: { + name: 'Role', + description: 'Description', + principals: [ + { id: '1', type: 'user' }, + { id: '3', type: 'group' } + ], + version: 1, + scope: { + scope_id: 'o_1', + type: 'scope' + } + } + } + }); + const model = store.peekRecord('role', '123abc'); + await model.removePrincipal('3'); + }); }); diff --git a/ui/core/mirage/config.js b/ui/core/mirage/config.js index e54366fe3..2142ae804 100644 --- a/ui/core/mirage/config.js +++ b/ui/core/mirage/config.js @@ -64,12 +64,44 @@ export default function() { this.get('/scopes/:scope_id/roles/:id'); this.patch('/scopes/:scope_id/roles/:id'); this.del('/scopes/:scope_id/roles/:id'); - this.post('/scopes/:scope_id/roles/:id', function ({ roles }, request) { + this.post('/scopes/:scope_id/roles/:idMethod', function ({ roles, users, groups }, { params: { idMethod } }) { const attrs = this.normalizedRequestAttrs(); - const id = request.params.id.split(':')[0]; + const id = idMethod.split(':')[0]; + const method = idMethod.split(':')[1]; const role = roles.find(id); - attrs.id = id; - return role.update(attrs); + const updatedAttrs = { + version: attrs.version, + userIds: role.userIds, + groupIds: role.groupIds + }; + + // Principals is a combined list of users and groups, but in Mirage we've + // internally modelled them as separate lists. Therefore we must check + // the type by looking up the user or group first, then determine which + // list to add the principal to. + if (method === 'add-principals') { + attrs.principalIds.forEach(id => { + const isUser = users.find(id); + const isGroup = groups.find(id); + if (isUser && !updatedAttrs.userIds.includes(id)) { + updatedAttrs.userIds.push(id); + } + if (isGroup && !updatedAttrs.groupIds.includes(id)) { + updatedAttrs.groupIds.push(id); + } + }); + } + // If deleting principals, filter them out of both users and groups, + // and in this case don't care about the type + if (method === 'remove-principals') { + updatedAttrs.userIds = updatedAttrs.userIds.filter(id => { + return !attrs.principalIds.includes(id); + }); + updatedAttrs.groupIds = updatedAttrs.groupIds.filter(id => { + return !attrs.principalIds.includes(id); + }); + } + return role.update(updatedAttrs); }); // IAM: Groups diff --git a/ui/core/mirage/factories/group.js b/ui/core/mirage/factories/group.js index e5f474099..41bd91a03 100644 --- a/ui/core/mirage/factories/group.js +++ b/ui/core/mirage/factories/group.js @@ -1,3 +1,5 @@ import factory from '../generated/factories/group'; -export default factory.extend(); +export default factory.extend({ + id: (i) => `group-${i}` +}); diff --git a/ui/core/mirage/factories/role.js b/ui/core/mirage/factories/role.js index 0dab3b584..adebbbc12 100644 --- a/ui/core/mirage/factories/role.js +++ b/ui/core/mirage/factories/role.js @@ -10,27 +10,13 @@ export default factory.extend({ ], /** - * Create roles with associated "principals": related users and groups. + * Adds principals to the role. */ 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 }); + role.update({ users, groups }); } }) diff --git a/ui/core/mirage/factories/user.js b/ui/core/mirage/factories/user.js index 77ed8e69d..42bccd33d 100644 --- a/ui/core/mirage/factories/user.js +++ b/ui/core/mirage/factories/user.js @@ -1,3 +1,5 @@ import factory from '../generated/factories/user'; -export default factory.extend(); +export default factory.extend({ + id: (i) => `user-${i}` +}); diff --git a/ui/core/mirage/models/role.js b/ui/core/mirage/models/role.js new file mode 100644 index 000000000..6c2517f3b --- /dev/null +++ b/ui/core/mirage/models/role.js @@ -0,0 +1,8 @@ +import { Model, hasMany } from 'ember-cli-mirage'; + +export default Model.extend({ + + users: hasMany(), + groups: hasMany() + +}); diff --git a/ui/core/mirage/serializers/role.js b/ui/core/mirage/serializers/role.js index c0db8a130..56f3d0195 100644 --- a/ui/core/mirage/serializers/role.js +++ b/ui/core/mirage/serializers/role.js @@ -1,5 +1,24 @@ import ApplicationSerializer from './application'; export default ApplicationSerializer.extend({ - modelName: 'role' + modelName: 'role', + + _hashForModel(model) { + const json = ApplicationSerializer.prototype._hashForModel.apply(this, arguments); + + const serializedUsers = model.users.models.map(user => ({ + scope_id: model.scope.id, + id: user.id, + type: 'user' + })); + + const serializedGroups = model.groups.models.map(group => ({ + scope_id: model.scope.id, + id: group.id, + type: 'group' + })); + + json.principals = [ ...serializedUsers, ...serializedGroups ]; + return json; + } });