Skip to content

New: Add generic _access mechanism to AbstractApiModule #98

Description

@taylortom

Status: expand step in review — PR #108 (open, unmerged), tagged Breaking (major bump). The breaking element is the checkAccess semantics flip — see Breaking change below. The dependent chain is blocked on #108 landing; resolved design decisions and the linked issue map are in the comments below.

Context

Sharing is currently hardcoded in the adaptframework module for courses only (_isShared, _shareWithUsers, createdBy, via applyContentAccessFilter + checkContentAccess). We want a single extensible _access object that any module can opt into and extend with its own keys.

This has already started to drift: group sharing was bolted straight onto the hardcoded adaptframework path (a userGroups clause) and, separately, onto a restrictively-scoped asset filter in the usergroups module — so group access is currently implemented twice with opposite semantics (content = additive OR, assets = restrictive AND). Unifying onto _access removes that duplication, and it gets more expensive to unwind the longer it is deferred.

Ownership of _access keys

The API module owns the base _access schema and the public check. Other modules extend _access with their own keys and hook observers:

  • api_access.public
  • authoredcreatedBy ownership check
  • users_access.users (per-user sharing)
  • usergroups_access.groups (group sharing)

Note: _access.groups is owned by the standalone adapt-authoring-usergroups module, not users. Group functionality was split into its own module; putting _access.groups in users would recreate the dependency-direction problem that motivated the split.

Design

Modules opt in by calling this.enableAccessControl() in their init(). This:

  1. Extends the module's schema with _access.public via the access schema extension
  2. Registers the base public grant on both access hooks:
    • accessCheckHook — per-item grant when _access.public === true (single-document reads)
    • accessQueryHook — OR-merges { '_access.public': true } into the query (list / paginated reads)

Each module that adds _access keys taps both hooks with its own observer. Access is additive — any one observer granting is sufficient. This requires checkAccess to OR-combine observer results (some(Boolean)); the current code AND-combines them (every(Boolean)), so this issue flips that. See Breaking change below.

Both hooks are required: the project gates at the query level (accessQueryHook) for pagination accuracy, with the per-item accessCheckHook as the safety net for non-list reads. An accessCheckHook-only design would reintroduce the short-page / wrong-count problem that query-level filtering exists to avoid.

Implementation

New files

  • schema/access.schema.json — extension schema adding _access.public (matches the authored.schema.json $merge pattern)
  • lib/utils/isPublicAccess.js — pure predicate: resource._access?.public === true
  • lib/utils/addAccessClause.js — OR-merges an access clause into a mongo query (mirrors adaptframework's applyContentAccessFilter $or/$and handling), so query-level observers compose safely
  • tests/utils-isPublicAccess.spec.js, tests/utils-addAccessClause.spec.js — table-driven tests

Modified files

  • lib/utils.js — re-export isPublicAccess, addAccessClause
  • index.js — export both for consuming modules
  • lib/AbstractApiModule.js — add enableAccessControl(); flip checkAccess from .every(Boolean) to .some(Boolean) and rewrite the accessCheckHook JSDoc for the additive-grant contract
  • docs/writing-an-api.md — document the access hooks and the _access mechanism

enableAccessControl() method

async enableAccessControl () {
  if (!this.schemaName) {
    return this.log('warn', 'cannot enable access control, no schemaName defined')
  }
  const jsonschema = await this.app.waitForModule('jsonschema')
  jsonschema.extendSchema(this.schemaName, 'access')
  // per-item grant (single-document reads)
  this.accessCheckHook.tap((req, resource) => isPublicAccess(resource))
  // query-level grant (list / paginated reads) — keeps pagination accurate
  this.accessQueryHook.tap(req => addAccessClause(req.apiData.query, { '_access.public': true }))
  this.log('debug', 'access control enabled')
}

access.schema.json

{
  "$anchor": "access",
  "$merge": {
    "with": {
      "properties": {
        "_access": {
          "type": "object",
          "default": {},
          "properties": {
            "public": { "type": "boolean", "default": false }
          }
        }
      }
    }
  }
}

Breaking change: checkAccess semantics

The additive-grant model requires checkAccess to OR-combine accessCheckHook observer results, but the current code AND-combines them:

- if (!this.accessCheckHook.hasObservers || (await this.accessCheckHook.invoke(req, r)).every(Boolean)) {
+ if (!this.accessCheckHook.hasObservers || (await this.accessCheckHook.invoke(req, r)).some(Boolean)) {

This is a breaking change to the published accessCheckHook contract:

  • Observers are now additive grants — any one returning true grants access (was: all must approve).
  • Returning false now abstains rather than denies. A restriction must veto by throw (a throw still denies the item regardless of other grants).

In-repo observers are unaffected (roles vetoes by throw; adaptframework is a single observer doing its own internal OR). External consumers relying on return-false-to-deny must switch to throwing. Released under the Breaking tag.

Dependent work in other modules

Once this lands, the following modules need updates (tracked in separate issues):

  • authored — tap both hooks with the createdBy ownership grant
  • users — extend _access with the users key, tap both hooks
  • usergroups — extend _access with the groups key, tap both hooks (replaces both the adaptframework userGroups clause and the bespoke asset filter)
  • adaptframework — remove checkContentAccess and applyContentAccessFilter; rely on the generic mechanism instead
  • content — call enableAccessControl() to opt in
  • assets — call enableAccessControl() to opt in
  • contentplugin — call enableAccessControl() to opt in
  • UI (React + legacy) — patch both UIs to read/write _access.* instead of the legacy fields. The React UI uses _isShared/_shareWithUsers at CreateCourseWizard.jsx and Projects.jsx; the legacy adapt-security/adapt-authoring-ui needs an equivalent patch PR. These ship in the same release as the enforcement switch — see migration step 3.

Migration (expand → contract)

Existing data is migrated to _access.* via adapt-authoring-migrations DataMigration scripts (.where({ collection, ...filter }).mutate(fn)), rather than a permanent legacy/new dual-read. Sequence it expand→contract, not as a flag-day — mutate rewrites docs one at a time and enforcement reads whatever field is live, so dropping legacy fields before the new reads deploy (or vice versa) leaves a window where shared content is inaccessible:

  1. Expand — land the access schema extension (this issue / PR Breaking: Add generic _access mechanism (fixes #98) #108).
  2. Backfill — migration writes _access.* from the legacy fields while keeping the legacy fields in place.
  3. Switch — deploy enforcement (enableAccessControl() observers) reading _access.* together with the UI patches (React + legacy) in one release, so no UI is writing a field that nothing reads.
  4. Contract — a later, separate migration drops the legacy fields once the new reads are verified and all UIs write _access.*.

Clean cutover, not a dual-write bridge

The expand→contract data migration only handles existing docs. The open risk is ongoing writes: once enforcement reads _access.*, a UI still writing the legacy fields would have its sharing toggles silently no-op. Rather than maintain a temporary dual-write (mirroring legacy-field writes into _access.* and back), we do a clean cutover — patch the UIs to write _access.* and switch enforcement in the same release (step 3). This is viable because the deployment is single-client-per-instance (see usergroups#13), so the backend and the legacy-UI deploy are coordinated. Keep the legacy fields readable until that release lands (don't contract early), which closes the access-gap window without juggling two field systems.

Data mapping

  • content: _isShared: true_access.public: true; _shareWithUsers_access.users; course userGroups_access.groups
  • assets: existing group field → _access.groups
  • createdBy is not migrated — it stays an authored ownership check, not folded into _access

Caveats

  • Per-module ownership → per-module migrations. The _isShared/_shareWithUsers mapping belongs with adaptframework/content; the userGroups_access.groups mapping belongs with usergroups. The access schema (api) must land first.
  • The assets change is a semantic flip, not a rename. Assets currently gate restrictively (AND: public OR in-group); _access.groups is an additive grant (OR). Copying the field across silently changes asset visibility behaviour, so this migration needs a conscious decision about intended semantics.
  • Idempotency. Scope each .where(...) to docs that still carry the legacy field (or lack _access) so re-runs are no-ops. mutate writes raw via replaceOne (no schema validation), so the migration itself must produce a schema-valid _access shape.

Scope

This issue unifies the access grant model — it makes access consistent, but not exhaustively enforced. Out of scope (separate follow-ups):

  • Enforcement holes: preview / publish / export and server-side content.find() calls bypass both hooks today (gated only by the coarse build scopes). These must be closed before access can be treated as a security boundary.
  • Sharing vs security semantics: _access observers are additive grants (sharing), which matches current requirements (see usergroups#13). If a key ever needs to restrict rather than widen access, that switch then lives in a single observer rather than scattered across modules.

Relationship to usergroups#13 and prioritisation

usergroups#13 concluded that groups are a sharing mechanism (not security) for the current single-client-per-instance deployment, and that group access should be built on this _access foundation as _access.groups. This work should therefore be prioritised ahead of further usergroups access enhancements (non-access usergroups work — UI, member management, naming — can proceed in parallel).

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions