Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
* @namespace users
*/
export { default } from './lib/UsersModule.js'
export { hasUserAccess } from './lib/utils.js'
42 changes: 41 additions & 1 deletion lib/UsersModule.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import AbstractApiModule from 'adapt-authoring-api'
import AbstractApiModule, { addAccessClause } from 'adapt-authoring-api'
import { hasUserAccess } from './utils.js'
/**
* Module which handles user management
* @memberof users
Expand Down Expand Up @@ -35,6 +36,45 @@ class UsersModule extends AbstractApiModule {
if (data.email) data.email = data.email.toLowerCase()
}

/**
* Extends a module's `_access` object with a `users` grant key so its resources can be shared
* with specific users, and taps the module's access hooks to grant a listed user access. This is
* the consumer-facing API: a module opts in by calling this alongside `enableAccessControl()`.
* @param {AbstractApiModule} mod The module to register for user-level access sharing
* @return {Promise}
*/
async registerUserModule (mod) {
if (!mod.schemaName) {
return this.log('warn', 'cannot register module for user access, no schemaName defined')
}
const jsonschema = await this.app.waitForModule('jsonschema')
jsonschema.extendSchema(mod.schemaName, 'users')
mod.accessCheckHook.tap(this.checkUserAccess)
mod.accessQueryHook.tap(this.grantUserAccess)
this.log('debug', `registered ${mod.name} for user access`)
}

/**
* Per-item user grant (an `accessCheckHook` observer): grants access when the requesting user is
* listed in the resource's `_access.users`.
* @param {external:ExpressRequest} req
* @param {Object} resource The resource being accessed
* @return {Boolean}
*/
checkUserAccess (req, resource) {
return hasUserAccess(resource?._access?.users, req.auth?.user?._id)
}

/**
* Query-level user grant (an `accessQueryHook` observer): widens the query to include resources
* shared with the requesting user. No-op when there is no authenticated user.
* @param {external:ExpressRequest} req
*/
grantUserAccess (req) {
const _id = req.auth?.user?._id
if (_id) addAccessClause(req.apiData.query, { '_access.users': _id.toString() })
}

/** @override */
async processRequestMiddleware (req, res, next) {
super.processRequestMiddleware(req, res, () => {
Expand Down
1 change: 1 addition & 0 deletions lib/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { hasUserAccess } from './utils/hasUserAccess.js'
13 changes: 13 additions & 0 deletions lib/utils/hasUserAccess.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Checks whether a resource has been explicitly shared with a specific user.
* Returns false when the resource lists no users; open access is expressed
* separately by the additive `_access.public` grant.
* @param {Array} docUsers The `_access.users` array from the resource
* @param {String} userId The id of the requesting user
* @return {Boolean}
* @memberof users
*/
export function hasUserAccess (docUsers, userId) {
if (!docUsers?.length || !userId) return false
return docUsers.some(u => u.toString() === userId.toString())
}
33 changes: 33 additions & 0 deletions schema/users.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$anchor": "users",
"description": "Adds a users grant key to the generic _access object, sharing a resource with specific users",
"$merge": {
"with": {
"properties": {
"_access": {
"type": "object",
"properties": {
"users": {
"description": "Ids of the users the resource is shared with",
"type": "array",
"default": [],
"items": {
"type": "string",
"isObjectId": true
},
"_adapt": {
"inputType": "Principal",
"options": {
"apiRoot": "users",
"labelField": "email",
"kind": "user"
}
}
}
}
}
}
}
}
}
73 changes: 73 additions & 0 deletions tests/UsersModule.spec.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, mock } from 'node:test'
import assert from 'node:assert/strict'
import UsersModule from '../lib/UsersModule.js'

/**
* UsersModule extends AbstractApiModule and requires a running app.
Expand Down Expand Up @@ -139,4 +140,76 @@ describe('UsersModule', () => {
assert.equal(next.mock.calls.length, 1)
})
})

describe('#registerUserModule()', () => {
function fakeInstance (extendSchema) {
const inst = Object.create(UsersModule.prototype)
inst.log = mock.fn()
inst.app = { waitForModule: mock.fn(async () => ({ extendSchema })) }
return inst
}

function fakeMod () {
const checkTaps = []
const queryTaps = []
return {
checkTaps,
queryTaps,
name: 'content',
schemaName: 'content',
accessCheckHook: { tap: fn => checkTaps.push(fn) },
accessQueryHook: { tap: fn => queryTaps.push(fn) }
}
}

it('should warn and not register a module with no schemaName', async () => {
const inst = fakeInstance()
await inst.registerUserModule({ accessCheckHook: {}, accessQueryHook: {} })
assert.equal(inst.log.mock.calls.length, 1)
assert.equal(inst.log.mock.calls[0].arguments[0], 'warn')
assert.equal(inst.app.waitForModule.mock.calls.length, 0)
})

it('should extend the schema and tap both access hooks', async () => {
const extendSchema = mock.fn()
const inst = fakeInstance(extendSchema)
const mod = fakeMod()
await inst.registerUserModule(mod)
assert.deepEqual(extendSchema.mock.calls[0].arguments, ['content', 'users'])
assert.equal(mod.checkTaps.length, 1)
assert.equal(mod.queryTaps.length, 1)
assert.equal(mod.checkTaps[0], UsersModule.prototype.checkUserAccess)
assert.equal(mod.queryTaps[0], UsersModule.prototype.grantUserAccess)
})
})

describe('#checkUserAccess()', () => {
const check = UsersModule.prototype.checkUserAccess
it('should grant a listed user', () => {
assert.equal(check({ auth: { user: { _id: 'u1' } } }, { _access: { users: ['u1'] } }), true)
})
it('should not grant an unlisted user', () => {
assert.equal(check({ auth: { user: { _id: 'u2' } } }, { _access: { users: ['u1'] } }), false)
})
it('should not grant when there is no auth user', () => {
assert.equal(check({ auth: {} }, { _access: { users: ['u1'] } }), false)
})
it('should not grant when the resource lists no users', () => {
assert.equal(check({ auth: { user: { _id: 'u1' } } }, {}), false)
})
})

describe('#grantUserAccess()', () => {
const grant = UsersModule.prototype.grantUserAccess
it('should widen the query with the requesting user id', () => {
const query = {}
grant({ auth: { user: { _id: 'u1' } }, apiData: { query } })
assert.deepEqual(query.$or, [{ '_access.users': 'u1' }])
})
it('should be a no-op when there is no authenticated user', () => {
const query = {}
grant({ auth: {}, apiData: { query } })
assert.deepEqual(query, {})
})
})
})
20 changes: 20 additions & 0 deletions tests/utils-hasUserAccess.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { hasUserAccess } from '../lib/utils/hasUserAccess.js'

describe('hasUserAccess()', () => {
const cases = [
{ name: 'no doc users (undefined)', docUsers: undefined, userId: 'a', expected: false },
{ name: 'no doc users (empty)', docUsers: [], userId: 'a', expected: false },
{ name: 'no requesting user', docUsers: ['a'], userId: undefined, expected: false },
{ name: 'user listed', docUsers: ['a', 'b'], userId: 'b', expected: true },
{ name: 'user not listed', docUsers: ['a', 'b'], userId: 'c', expected: false },
{ name: 'ObjectId-like values compared via toString', docUsers: [{ toString: () => 'x' }], userId: { toString: () => 'x' }, expected: true },
{ name: 'mixed string/ObjectId-like non-match', docUsers: [{ toString: () => 'x' }], userId: 'y', expected: false }
]
for (const { name, docUsers, userId, expected } of cases) {
it(`should return ${expected} when ${name}`, () => {
assert.equal(hasUserAccess(docUsers, userId), expected)
})
}
})
Loading