diff --git a/lib/UsersModule.js b/lib/UsersModule.js index ef658f0..2a08742 100644 --- a/lib/UsersModule.js +++ b/lib/UsersModule.js @@ -1,6 +1,7 @@ import AbstractApiModule from 'adapt-authoring-api' +import { hasGroupAccess } from './utils.js' /** - * Module which handles user management + * Module which handles user management and user groups * @memberof users * @extends {AbstractApiModule} */ @@ -10,6 +11,11 @@ class UsersModule extends AbstractApiModule { await super.setValues() /** @ignore */ this.schemaName = 'user' /** @ignore */ this.collectionName = 'users' + /** + * Modules registered for user group support + * @type {Array} + */ + this.groupModules = [] } /** @@ -29,6 +35,26 @@ class UsersModule extends AbstractApiModule { this.preInsertHook.tap(this.forceLowerCaseEmail) this.preUpdateHook.tap((ogDoc, updateData) => this.forceLowerCaseEmail(updateData)) } + + await this.registerGroupModule(this) + } + + /** + * Registers a module for use with groups. Extends the module's schema + * with the userGroups field. + * @param {AbstractApiModule} mod + */ + async registerGroupModule (mod) { + if (!mod.schemaName) { + return this.log('warn', 'cannot register module, module doesn\'t define a schemaName') + } + const jsonschema = await this.app.waitForModule('jsonschema') + jsonschema.extendSchema(mod.schemaName, 'usergroups') + mod.accessCheckHook.tap((req, resource) => { + return hasGroupAccess(resource.userGroups, req.auth?.user?.userGroups) + }) + this.log('debug', `registered ${mod.name} for use with groups`) + this.groupModules.push(mod) } forceLowerCaseEmail (data) { @@ -38,7 +64,7 @@ class UsersModule extends AbstractApiModule { /** @override */ async processRequestMiddleware (req, res, next) { super.processRequestMiddleware(req, res, () => { - req.apiData.schemaName = req.auth.userSchemaName + req.apiData.schemaName = req.routeConfig.schemaName || req.auth.userSchemaName next() }) } @@ -89,6 +115,32 @@ class UsersModule extends AbstractApiModule { query.email = this.getConfig('forceLowerCaseEmail') ? query.email?.toLowerCase() : undefined return super.find(query, options, mongoOptions) } + + /** @override */ + async delete (query, options = {}, mongoOptions = {}) { + const doc = await super.delete(query, options, mongoOptions) + if (options.collectionName === 'usergroups') { + await this.removeGroupRefs(doc._id) + } + return doc + } + + /** + * Removes references to a deleted group from all registered modules + * @param {String} groupId The _id of the deleted group + */ + async removeGroupRefs (groupId) { + await Promise.all(this.groupModules.map(async m => { + const docs = await m.find({ userGroups: groupId }) + return Promise.all(docs.map(async d => { + try { + await m.update({ _id: d._id }, { $pull: { userGroups: groupId } }, { rawUpdate: true }) + } catch (e) { + this.log('warn', `Failed to remove group reference, ${e}`) + } + })) + })) + } } export default UsersModule diff --git a/lib/utils.js b/lib/utils.js new file mode 100644 index 0000000..5261ed1 --- /dev/null +++ b/lib/utils.js @@ -0,0 +1 @@ +export { hasGroupAccess } from './utils/hasGroupAccess.js' diff --git a/lib/utils/hasGroupAccess.js b/lib/utils/hasGroupAccess.js new file mode 100644 index 0000000..27d0e2d --- /dev/null +++ b/lib/utils/hasGroupAccess.js @@ -0,0 +1,14 @@ +/** + * Checks whether a user shares at least one group with a document. + * Returns true if the document has no group restrictions. + * @param {Array} docGroups The userGroups array from the document + * @param {Array} userGroups The userGroups array from the requesting user + * @return {Boolean} + * @memberof users + */ +export function hasGroupAccess (docGroups, userGroups) { + if (!docGroups?.length) return true + if (!userGroups?.length) return false + const userSet = new Set(userGroups.map(g => g.toString())) + return docGroups.some(g => userSet.has(g.toString())) +} diff --git a/package.json b/package.json index 440af0c..95fffcc 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ }, "peerDependencies": { "adapt-authoring-core": "^2.0.0", + "adapt-authoring-jsonschema": "^1.2.0", "adapt-authoring-mongodb": "^3.0.0", "adapt-authoring-server": "^2.1.0" }, diff --git a/routes.json b/routes.json index 996b988..e19b160 100644 --- a/routes.json +++ b/routes.json @@ -34,6 +34,36 @@ "modifying": false, "handlers": { "post": "queryHandler" }, "permissions": { "post": ["read:${scope}"] } + }, + { + "route": "/groups", + "collectionName": "usergroups", + "schemaName": "usergroup", + "handlers": { "get": "queryHandler", "post": "requestHandler" }, + "permissions": { "get": ["read:usergroups"], "post": ["write:usergroups"] } + }, + { + "route": "/groups/schema", + "collectionName": "usergroups", + "schemaName": "usergroup", + "handlers": { "get": "serveSchema" }, + "permissions": { "get": ["read:schema"] } + }, + { + "route": "/groups/:_id", + "collectionName": "usergroups", + "schemaName": "usergroup", + "handlers": { "get": "requestHandler", "put": "requestHandler", "patch": "requestHandler", "delete": "requestHandler" }, + "permissions": { "get": ["read:usergroups"], "put": ["write:usergroups"], "patch": ["write:usergroups"], "delete": ["write:usergroups"] } + }, + { + "route": "/groups/query", + "collectionName": "usergroups", + "schemaName": "usergroup", + "validate": false, + "modifying": false, + "handlers": { "post": "queryHandler" }, + "permissions": { "post": ["read:usergroups"] } } ] } diff --git a/schema/usergroup.schema.json b/schema/usergroup.schema.json new file mode 100644 index 0000000..161e62f --- /dev/null +++ b/schema/usergroup.schema.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$anchor": "usergroup", + "description": "A group of system users", + "type": "object", + "properties": { + "displayName": { + "description": "Display name of the user group", + "type": "string" + } + }, + "required": ["displayName"] +} diff --git a/schema/usergroups.schema.json b/schema/usergroups.schema.json new file mode 100644 index 0000000..cd7753d --- /dev/null +++ b/schema/usergroups.schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$anchor": "usergroups", + "description": "Properties related to user group sharing", + "type": "object", + "$patch": { + "source": { "$ref": "user" }, + "with": { + "properties": { + "userGroups": { + "description": "User groups the target object should be accessible to", + "type": "array", + "items": { + "type": "string", + "isObjectId": true + } + } + } + } + } +} diff --git a/tests/usergroups.spec.js b/tests/usergroups.spec.js new file mode 100644 index 0000000..8305287 --- /dev/null +++ b/tests/usergroups.spec.js @@ -0,0 +1,280 @@ +import { describe, it, mock, beforeEach } from 'node:test' +import assert from 'node:assert/strict' + +describe('UsersModule usergroups', () => { + describe('#processRequestMiddleware() schema routing', () => { + function processRequestMiddleware (req, superCallback) { + // Simulates the override: use per-route schemaName if set, otherwise userSchemaName + req.apiData = { schemaName: 'user' } // super sets this + superCallback() + } + + function override (req) { + req.apiData.schemaName = req.routeConfig.schemaName || req.auth.userSchemaName + } + + it('should use per-route schemaName for usergroup routes', () => { + const req = { + routeConfig: { schemaName: 'usergroup' }, + auth: { userSchemaName: 'superuser' }, + apiData: {} + } + processRequestMiddleware(req, () => override(req)) + assert.equal(req.apiData.schemaName, 'usergroup') + }) + + it('should fall back to auth userSchemaName for user routes', () => { + const req = { + routeConfig: {}, + auth: { userSchemaName: 'superuser' }, + apiData: {} + } + processRequestMiddleware(req, () => override(req)) + assert.equal(req.apiData.schemaName, 'superuser') + }) + }) + + describe('#registerGroupModule()', () => { + let instance, logCalls + + async function registerGroupModule (mod) { + if (!mod.schemaName) { + return this.log('warn', 'cannot register module, module doesn\'t define a schemaName') + } + const jsonschema = await this.app.waitForModule('jsonschema') + jsonschema.extendSchema(mod.schemaName, 'usergroups') + this.log('debug', `registered ${mod.name} for use with groups`) + this.groupModules.push(mod) + } + + beforeEach(() => { + logCalls = [] + instance = { + groupModules: [], + app: { + waitForModule: mock.fn(async () => ({ + extendSchema: mock.fn() + })) + }, + log: function (...args) { logCalls.push(args) } + } + }) + + it('should log a warning if mod has no schemaName', async () => { + await registerGroupModule.call(instance, {}) + assert.equal(logCalls.length, 1) + assert.equal(logCalls[0][0], 'warn') + assert.ok(logCalls[0][1].includes('schemaName')) + assert.equal(instance.groupModules.length, 0) + }) + + it('should log a warning if mod.schemaName is empty string', async () => { + await registerGroupModule.call(instance, { schemaName: '' }) + assert.equal(logCalls.length, 1) + assert.equal(logCalls[0][0], 'warn') + assert.equal(instance.groupModules.length, 0) + }) + + it('should register a module with a valid schemaName', async () => { + const extendSchemaCalls = [] + instance.app.waitForModule = mock.fn(async () => ({ + extendSchema: function (schemaName, extensionName) { + extendSchemaCalls.push({ schemaName, extensionName }) + } + })) + + const mod = { schemaName: 'user', name: 'users' } + await registerGroupModule.call(instance, mod) + + assert.equal(extendSchemaCalls.length, 1) + assert.equal(extendSchemaCalls[0].schemaName, 'user') + assert.equal(extendSchemaCalls[0].extensionName, 'usergroups') + assert.equal(instance.groupModules.length, 1) + assert.equal(instance.groupModules[0], mod) + }) + + it('should log debug message with module name after registering', async () => { + instance.app.waitForModule = mock.fn(async () => ({ extendSchema: mock.fn() })) + + const mod = { schemaName: 'user', name: 'users' } + await registerGroupModule.call(instance, mod) + + assert.equal(logCalls.length, 1) + assert.equal(logCalls[0][0], 'debug') + assert.ok(logCalls[0][1].includes('users')) + assert.ok(logCalls[0][1].includes('groups')) + }) + + it('should allow registering multiple modules', async () => { + instance.app.waitForModule = mock.fn(async () => ({ extendSchema: mock.fn() })) + + const mod1 = { schemaName: 'user', name: 'users' } + const mod2 = { schemaName: 'course', name: 'courses' } + + await registerGroupModule.call(instance, mod1) + await registerGroupModule.call(instance, mod2) + + assert.equal(instance.groupModules.length, 2) + assert.equal(instance.groupModules[0], mod1) + assert.equal(instance.groupModules[1], mod2) + }) + }) + + describe('#removeGroupRefs()', () => { + let instance, logCalls + + async function removeGroupRefs (groupId) { + await Promise.all(this.groupModules.map(async m => { + const docs = await m.find({ userGroups: groupId }) + return Promise.all(docs.map(async d => { + try { + await m.update({ _id: d._id }, { $pull: { userGroups: groupId } }, { rawUpdate: true }) + } catch (e) { + this.log('warn', `Failed to remove group reference, ${e}`) + } + })) + })) + } + + beforeEach(() => { + logCalls = [] + instance = { + groupModules: [], + log: function (...args) { logCalls.push(args) } + } + }) + + it('should remove usergroup reference from all registered module documents', async () => { + const deletedId = 'group123' + const updateCalls = [] + const mockModule = { + find: mock.fn(async () => [ + { _id: 'doc1', userGroups: [deletedId, 'other'] }, + { _id: 'doc2', userGroups: [deletedId] } + ]), + update: mock.fn(async (query, data, opts) => { + updateCalls.push({ query, data, opts }) + }) + } + instance.groupModules = [mockModule] + + await removeGroupRefs.call(instance, deletedId) + + assert.equal(mockModule.find.mock.callCount(), 1) + assert.deepEqual(mockModule.find.mock.calls[0].arguments[0], { userGroups: deletedId }) + assert.equal(mockModule.update.mock.callCount(), 2) + + assert.deepEqual(updateCalls[0].query, { _id: 'doc1' }) + assert.deepEqual(updateCalls[0].data, { $pull: { userGroups: deletedId } }) + assert.deepEqual(updateCalls[0].opts, { rawUpdate: true }) + + assert.deepEqual(updateCalls[1].query, { _id: 'doc2' }) + assert.deepEqual(updateCalls[1].data, { $pull: { userGroups: deletedId } }) + assert.deepEqual(updateCalls[1].opts, { rawUpdate: true }) + }) + + it('should handle empty documents list gracefully', async () => { + const mockModule = { + find: mock.fn(async () => []), + update: mock.fn() + } + instance.groupModules = [mockModule] + + await removeGroupRefs.call(instance, 'group456') + + assert.equal(mockModule.find.mock.callCount(), 1) + assert.equal(mockModule.update.mock.callCount(), 0) + }) + + it('should handle no registered modules', async () => { + instance.groupModules = [] + await removeGroupRefs.call(instance, 'group789') + }) + + it('should log warning and continue when update fails', async () => { + const updateError = new Error('DB write failed') + const mockModule = { + find: mock.fn(async () => [{ _id: 'doc1' }, { _id: 'doc2' }]), + update: mock.fn(async (query) => { + if (query._id === 'doc1') throw updateError + }) + } + instance.groupModules = [mockModule] + + await removeGroupRefs.call(instance, 'group-err') + + assert.equal(logCalls.length, 1) + assert.equal(logCalls[0][0], 'warn') + assert.ok(logCalls[0][1].includes('Failed to remove group reference')) + assert.ok(logCalls[0][1].includes('DB write failed')) + assert.equal(mockModule.update.mock.callCount(), 2) + }) + + it('should process multiple modules independently', async () => { + const updateCalls = [] + const mockModule1 = { + find: mock.fn(async () => [{ _id: 'mod1doc1' }]), + update: mock.fn(async (query, data, opts) => { + updateCalls.push({ module: 'mod1', query, data, opts }) + }) + } + const mockModule2 = { + find: mock.fn(async () => [{ _id: 'mod2doc1' }, { _id: 'mod2doc2' }]), + update: mock.fn(async (query, data, opts) => { + updateCalls.push({ module: 'mod2', query, data, opts }) + }) + } + instance.groupModules = [mockModule1, mockModule2] + + await removeGroupRefs.call(instance, 'groupMulti') + + assert.equal(mockModule1.find.mock.callCount(), 1) + assert.equal(mockModule2.find.mock.callCount(), 1) + assert.equal(updateCalls.length, 3) + + const mod1Updates = updateCalls.filter(c => c.module === 'mod1') + const mod2Updates = updateCalls.filter(c => c.module === 'mod2') + assert.equal(mod1Updates.length, 1) + assert.equal(mod2Updates.length, 2) + }) + }) + + describe('#delete() cascade', () => { + it('should call removeGroupRefs when deleting from usergroups collection', async () => { + const deletedId = 'groupReturn' + const removeCalls = [] + const superDeleteResult = { _id: deletedId } + + async function deleteMethod (query, options = {}, mongoOptions = {}) { + // simulate setDefaultOptions + super.delete + if (!options.collectionName) options.collectionName = 'users' + const doc = superDeleteResult + if (options.collectionName === 'usergroups') { + removeCalls.push(doc._id) + } + return doc + } + + const result = await deleteMethod({}, { collectionName: 'usergroups' }) + assert.equal(result._id, deletedId) + assert.equal(removeCalls.length, 1) + assert.equal(removeCalls[0], deletedId) + }) + + it('should not call removeGroupRefs when deleting from users collection', async () => { + const removeCalls = [] + + async function deleteMethod (query, options = {}, mongoOptions = {}) { + if (!options.collectionName) options.collectionName = 'users' + const doc = { _id: 'user123' } + if (options.collectionName === 'usergroups') { + removeCalls.push(doc._id) + } + return doc + } + + await deleteMethod({}, { collectionName: 'users' }) + assert.equal(removeCalls.length, 0) + }) + }) +}) diff --git a/tests/utils-hasGroupAccess.spec.js b/tests/utils-hasGroupAccess.spec.js new file mode 100644 index 0000000..1cef5f8 --- /dev/null +++ b/tests/utils-hasGroupAccess.spec.js @@ -0,0 +1,55 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { hasGroupAccess } from '../lib/utils/hasGroupAccess.js' + +describe('hasGroupAccess()', () => { + it('should return true when document has no groups', () => { + assert.equal(hasGroupAccess([], ['group1']), true) + }) + + it('should return true when document groups is undefined', () => { + assert.equal(hasGroupAccess(undefined, ['group1']), true) + }) + + it('should return true when document groups is null', () => { + assert.equal(hasGroupAccess(null, ['group1']), true) + }) + + it('should return false when user has no groups but document does', () => { + assert.equal(hasGroupAccess(['group1'], []), false) + }) + + it('should return false when user groups is undefined', () => { + assert.equal(hasGroupAccess(['group1'], undefined), false) + }) + + it('should return false when user groups is null', () => { + assert.equal(hasGroupAccess(['group1'], null), false) + }) + + it('should return true when user shares a group with document', () => { + assert.equal(hasGroupAccess(['group1', 'group2'], ['group2', 'group3']), true) + }) + + it('should return false when user shares no groups with document', () => { + assert.equal(hasGroupAccess(['group1', 'group2'], ['group3', 'group4']), false) + }) + + it('should handle ObjectId-like objects by comparing via toString()', () => { + const docId = { toString: () => '507f1f77bcf86cd799439011' } + const userId = { toString: () => '507f1f77bcf86cd799439011' } + assert.equal(hasGroupAccess([docId], [userId]), true) + }) + + it('should handle mixed string and object IDs', () => { + const objectId = { toString: () => 'abc123' } + assert.equal(hasGroupAccess([objectId], ['abc123']), true) + assert.equal(hasGroupAccess(['abc123'], [objectId]), true) + }) + + it('should return false for different ObjectId-like objects', () => { + const docId = { toString: () => '507f1f77bcf86cd799439011' } + const userId = { toString: () => '507f1f77bcf86cd799439022' } + assert.equal(hasGroupAccess([docId], [userId]), false) + }) +})