Skip to content

Commit d331bbf

Browse files
committed
Breaking: Add generic _access mechanism (fixes #98)
Adds an extensible `_access` object that any API module can opt into via `enableAccessControl()`, replacing per-module hardcoded sharing. It extends the module schema with `_access.public` and registers the base `public` grant on both accessCheckHook (per-item, single-document reads) and accessQueryHook (query-level, keeps pagination accurate). Ships the `isPublicAccess` predicate and `addAccessClause` query helper (both re-exported) and the `access` schema extension. Other modules extend `_access` with their own keys and tap both hooks with additive grants. accessCheckHook observers are now additive access grants, OR-combined across observers (previously AND-combined). Returning false now abstains rather than denies; a restriction must veto by throwing. Existing in-repo observers are unaffected (roles vetoes by throw, adaptframework is a single observer), but external consumers relying on return-false-to-deny must switch to throwing.
1 parent 2efd4e6 commit d331bbf

9 files changed

Lines changed: 202 additions & 17 deletions

docs/writing-an-api.md

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,9 @@ See the table below for a list of hooks provided by the `AbstractApiModule` clas
341341
| `postUpdateHook` | After updating a document | No |
342342
| `preDeleteHook` | Before deleting a document | No |
343343
| `postDeleteHook` | After deleting a document | No |
344-
| `accessCheckHook` | When checking access to a resource | No |
344+
| `accessCheckHook` | Per-item access check (single-document reads) | No |
345+
| `accessQueryHook` | Merges access-control clauses into a list query (skipped for super users) | No |
346+
| `queryHook` | Merges user-driven filter clauses into a list query (runs for all users) | Yes |
345347

346348
### Using hooks
347349

@@ -374,17 +376,31 @@ class NotesModule extends AbstractApiModule {
374376
}
375377
```
376378

377-
### Access control with accessCheckHook
379+
### Access control
378380

379-
Use `accessCheckHook` to implement custom access control:
381+
Access is an **additive grant model**: observers widen access, they don't restrict it. Both access hooks are OR-combined across all observers, so any one observer granting access is sufficient.
382+
383+
- `accessQueryHook` — the primary gate. Merge a clause into `req.apiData.query` so the database only returns documents the user can see. Filtering here (rather than after the query) keeps pagination counts and the `Link` header accurate. Skipped for super users.
384+
- `accessCheckHook` — the per-item safety net for single-document reads. Return `true` to grant, a non-truthy value to abstain, or `throw` to hard-veto the item (a veto denies regardless of other grants — this is how a restriction is expressed).
385+
386+
```javascript
387+
this.accessCheckHook.tap((req, doc) => doc.createdBy === req.auth.user._id.toString())
388+
this.accessQueryHook.tap(req => addAccessClause(req.apiData.query, { createdBy: req.auth.user._id.toString() }))
389+
```
390+
391+
#### The generic `_access` mechanism
392+
393+
Call `enableAccessControl()` in your module's `init()` to opt into a shared, extensible `_access` object. This extends the module schema with `_access.public` and registers a `public` grant on both hooks:
380394

381395
```javascript
382-
this.accessCheckHook.tap(async (req, doc) => {
383-
// Return true to allow access, false or undefined to deny
384-
return doc.createdBy === req.auth.user._id.toString()
385-
})
396+
async init () {
397+
await super.init()
398+
await this.enableAccessControl()
399+
}
386400
```
387401

402+
Other modules extend `_access` with their own keys (e.g. `_access.users`, `_access.groups`) and tap both hooks with additional grants, all OR-combined with the base `public` grant. Use `addAccessClause` (exported from `adapt-authoring-api`) for the query-level grant so observers compose safely with each other and with any user-driven `$or`.
403+
388404
## Overriding methods
389405

390406
You can override database methods to customise behaviour:

index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@
44
*/
55
export { default as AbstractApiModule } from './lib/AbstractApiModule.js'
66
export { default } from './lib/AbstractApiModule.js'
7+
export { addAccessClause, isPublicAccess } from './lib/utils.js'
78
/** @deprecated Use named import { stringifyValues } from 'adapt-authoring-core' instead */
89
export { stringifyValues } from 'adapt-authoring-core'

lib/AbstractApiModule.js

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import _ from 'lodash'
22
import { AbstractModule, DataCache, Hook, stringifyValues } from 'adapt-authoring-core'
3-
import { argsFromReq, generateApiMetadata, httpMethodToDBFunction } from './utils.js'
3+
import { addAccessClause, argsFromReq, generateApiMetadata, httpMethodToDBFunction, isPublicAccess } from './utils.js'
44
import { loadRouteConfig } from 'adapt-authoring-server'
55
/**
66
* Abstract module for creating APIs
@@ -92,14 +92,15 @@ class AbstractApiModule extends AbstractModule {
9292
*/
9393
this.postDeleteHook = new Hook()
9494
/**
95-
* Hook invoked to check access to individual data items.
96-
* Observer contract — for each item, every observer must approve:
97-
* - `return true` — approve (or abstain; AND-combined across observers)
98-
* - `return false` — deny
99-
* - `throw` — deny; for single-doc requests, propagates the thrown error
100-
* `undefined` and any non-truthy return are treated as `false`.
101-
* Single-doc requests that are denied by any observer respond `401 Unauthorised`.
102-
* List requests silently filter denied items.
95+
* Hook invoked to check access to individual data items. Observers are additive access grants —
96+
* for each item, any one observer approving is sufficient (OR-combined):
97+
* - `return true` — grant access
98+
* - `return false` — abstain (no grant; another observer may still grant)
99+
* - `throw` — hard veto; denies the item regardless of other grants, and for single-doc
100+
* requests propagates the thrown error. Restrictions must veto by throwing.
101+
* `undefined` and any non-truthy return are treated as an abstention.
102+
* Single-doc requests granted by no observer respond `401 Unauthorised`.
103+
* List requests silently filter ungranted items.
103104
*
104105
* Runs post-query, so it is best reserved as a safety net for checks that cannot be expressed
105106
* as a query. For filtering, prefer `accessQueryHook` — filtering at this stage produces short
@@ -605,6 +606,25 @@ class AbstractApiModule extends AbstractModule {
605606
Object.assign(mongoOpts, { limit: pageSize, skip: mongoOpts.skip || (page - 1) * pageSize })
606607
}
607608

609+
/**
610+
* Opts this module into the generic `_access` access-control mechanism. Extends the module schema
611+
* with `_access.public` and registers the base `public` grant on both access hooks: a per-item grant
612+
* on `accessCheckHook` (single-document reads) and a query-level grant on `accessQueryHook` (list /
613+
* paginated reads, kept query-level so pagination stays accurate). Other modules extend `_access` with
614+
* their own keys and tap both hooks with additional additive grants.
615+
* @return {Promise}
616+
*/
617+
async enableAccessControl () {
618+
if (!this.schemaName) {
619+
return this.log('warn', 'cannot enable access control, no schemaName defined')
620+
}
621+
const jsonschema = await this.app.waitForModule('jsonschema')
622+
jsonschema.extendSchema(this.schemaName, 'access')
623+
this.accessCheckHook.tap((req, resource) => isPublicAccess(resource))
624+
this.accessQueryHook.tap(req => addAccessClause(req.apiData.query, { '_access.public': true }))
625+
this.log('debug', 'access control enabled')
626+
}
627+
608628
/**
609629
* Invokes the access check hook to allow modules to determine whether the request user has sufficient access to the requested resource(s)
610630
* @param {external:ExpressRequest} req
@@ -620,7 +640,7 @@ class AbstractApiModule extends AbstractModule {
620640
let error
621641
await Promise.allSettled((isArray ? data : [data]).map(async r => {
622642
try {
623-
if (!this.accessCheckHook.hasObservers || (await this.accessCheckHook.invoke(req, r)).every(Boolean)) {
643+
if (!this.accessCheckHook.hasObservers || (await this.accessCheckHook.invoke(req, r)).some(Boolean)) {
624644
filtered.push(r)
625645
}
626646
} catch (e) {

lib/utils.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
export { addAccessClause } from './utils/addAccessClause.js'
12
export { argsFromReq } from './utils/argsFromReq.js'
23
export { generateApiMetadata } from './utils/generateApiMetadata.js'
34
export { httpMethodToAction } from './utils/httpMethodToAction.js'
45
export { httpMethodToDBFunction } from './utils/httpMethodToDBFunction.js'
6+
export { isPublicAccess } from './utils/isPublicAccess.js'

lib/utils/addAccessClause.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
const accessGroups = new WeakMap()
2+
3+
/**
4+
* OR-merges an access-control clause into a mongo query, mutating it in place. Repeated calls on the
5+
* same query accumulate clauses into a single shared `$or` group (additive grants), AND-combined with
6+
* any pre-existing query — an existing user-driven `$or` (e.g. search) is lifted into `$and` so it
7+
* isn't widened by the grants.
8+
* @param {Object} query The mongo query to mutate
9+
* @param {Object} clause The access clause to grant (e.g. `{ '_access.public': true }`)
10+
* @return {Object} The mutated query
11+
* @memberof api
12+
*/
13+
export function addAccessClause (query, clause) {
14+
let group = accessGroups.get(query)
15+
if (!group) {
16+
group = []
17+
accessGroups.set(query, group)
18+
if (query.$or) {
19+
query.$and = [...(query.$and ?? []), { $or: query.$or }, { $or: group }]
20+
delete query.$or
21+
} else if (query.$and) {
22+
query.$and.push({ $or: group })
23+
} else {
24+
query.$or = group
25+
}
26+
}
27+
group.push(clause)
28+
return query
29+
}

lib/utils/isPublicAccess.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* Predicate determining whether a resource has been granted public access
3+
* @param {Object} resource The resource to check
4+
* @return {Boolean}
5+
* @memberof api
6+
*/
7+
export function isPublicAccess (resource) {
8+
return resource?._access?.public === true
9+
}

schema/access.schema.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"$anchor": "access",
4+
"description": "Adds a generic access-control object that modules extend with their own grant keys",
5+
"$merge": {
6+
"with": {
7+
"properties": {
8+
"_access": {
9+
"type": "object",
10+
"default": {},
11+
"properties": {
12+
"public": {
13+
"description": "Whether the resource is accessible to all users",
14+
"type": "boolean",
15+
"default": false
16+
}
17+
}
18+
}
19+
}
20+
}
21+
}
22+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { describe, it } from 'node:test'
2+
import assert from 'node:assert/strict'
3+
import { addAccessClause } from '../lib/utils/addAccessClause.js'
4+
5+
describe('addAccessClause()', () => {
6+
it('should add the clause as a top-level $or on an empty query', () => {
7+
const query = {}
8+
addAccessClause(query, { '_access.public': true })
9+
assert.deepEqual(query, { $or: [{ '_access.public': true }] })
10+
})
11+
12+
it('should preserve non-$or top-level fields', () => {
13+
const query = { _type: 'course' }
14+
addAccessClause(query, { '_access.public': true })
15+
assert.deepEqual(query, { _type: 'course', $or: [{ '_access.public': true }] })
16+
})
17+
18+
it('should accumulate multiple grants into one shared $or group', () => {
19+
const query = {}
20+
addAccessClause(query, { '_access.public': true })
21+
addAccessClause(query, { createdBy: 'abc' })
22+
addAccessClause(query, { '_access.groups': { $in: ['g1'] } })
23+
assert.deepEqual(query, {
24+
$or: [
25+
{ '_access.public': true },
26+
{ createdBy: 'abc' },
27+
{ '_access.groups': { $in: ['g1'] } }
28+
]
29+
})
30+
})
31+
32+
it('should lift a pre-existing user $or into $and so grants do not widen it', () => {
33+
const query = { $or: [{ title: 'a' }, { title: 'b' }] }
34+
addAccessClause(query, { '_access.public': true })
35+
addAccessClause(query, { createdBy: 'abc' })
36+
assert.deepEqual(query, {
37+
$and: [
38+
{ $or: [{ title: 'a' }, { title: 'b' }] },
39+
{ $or: [{ '_access.public': true }, { createdBy: 'abc' }] }
40+
]
41+
})
42+
assert.ok(!('$or' in query))
43+
})
44+
45+
it('should append the grant group to a pre-existing $and', () => {
46+
const query = { $and: [{ x: 1 }] }
47+
addAccessClause(query, { '_access.public': true })
48+
assert.deepEqual(query, {
49+
$and: [{ x: 1 }, { $or: [{ '_access.public': true }] }]
50+
})
51+
})
52+
53+
it('should return the mutated query', () => {
54+
const query = {}
55+
assert.equal(addAccessClause(query, { '_access.public': true }), query)
56+
})
57+
58+
it('should track grant groups per query object', () => {
59+
const a = {}
60+
const b = {}
61+
addAccessClause(a, { '_access.public': true })
62+
addAccessClause(b, { createdBy: 'x' })
63+
assert.deepEqual(a, { $or: [{ '_access.public': true }] })
64+
assert.deepEqual(b, { $or: [{ createdBy: 'x' }] })
65+
})
66+
})

tests/utils-isPublicAccess.spec.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { describe, it } from 'node:test'
2+
import assert from 'node:assert/strict'
3+
import { isPublicAccess } from '../lib/utils/isPublicAccess.js'
4+
5+
describe('isPublicAccess()', () => {
6+
const cases = [
7+
{ name: 'true when _access.public is true', resource: { _access: { public: true } }, expected: true },
8+
{ name: 'false when _access.public is false', resource: { _access: { public: false } }, expected: false },
9+
{ name: 'false when _access.public is missing', resource: { _access: {} }, expected: false },
10+
{ name: 'false when _access is missing', resource: {}, expected: false },
11+
{ name: 'false for a truthy non-boolean public value', resource: { _access: { public: 'true' } }, expected: false },
12+
{ name: 'false when resource is undefined', resource: undefined, expected: false },
13+
{ name: 'false when resource is null', resource: null, expected: false }
14+
]
15+
cases.forEach(({ name, resource, expected }) => {
16+
it(`should return ${expected}: ${name}`, () => {
17+
assert.equal(isPublicAccess(resource), expected)
18+
})
19+
})
20+
})

0 commit comments

Comments
 (0)