ADR 004 Read ACL Authorization Model - digitalspace/eagle-demi GitHub Wiki

ADR-004: Read ACL Authorization Model

Status

Accepted 2026-07-29. In force.

The decision survived the move to Cosmos DB for NoSQL; the mechanics changed. Visibility is a SQL fragment built by readClause(roles) in src/helpers/access-sql.js, and the Azure AI Search twin is filterFor(access, partitionField) in src/helpers/access-odata.js. Architecture holds the current shape, including how project scope rides the partition key rather than sitting in read[].

Context

An audit found that no visibility gating was executing. The symptom was benign-looking: the project list was identical in the public and EPIC Staff views.

The cause was structural. src/db/cosmos.js presented a Cosmos-SQL-shaped interface over the MongoDB driver. BaseRepository.find() concatenated a WHERE clause into a SQL string, and queryContainer "translated" it by substring matching. It recognised one predicate, sources.track, plus a short whitelist of named parameters, and silently discarded everything else, leaving filter = {}.

Every gating clause in the codebase was therefore dead code: the isPublished checks in the search, project, document and record controllers all reached the database as nothing. Anonymous callers received whole collections. Nothing errored and nothing was logged — silent fail-open.

Two problems compounded it. getUserAccessContext hardcoded the Cosmos predicate to c.isPublished = true for every JWT caller, so an admin browsing without keywords could never see unpublished records while the Typesense filter widened with roles — the two backends disagreed about what a caller could see. And countContainer ignored the filter, so pagination totals leaked the size of collections the caller could not read.

Decision

Adopt the read[] ACL model already proven in eagle-api, and route every read through a single visibility helper.

1. read[] is authoritative, isPublished is a mirror

Records carry a read[] array of role names. A record is visible when read[] intersects the caller's roles. This matches eagle-api's schemas, and it maps onto an Azure AI Search OData filter with no translation shim.

isPublished is a convenience mirror, as in eagle-api: publishing sets isPublished: true and pushes 'public' into read[]. Write paths derive one from the other rather than accepting both, so the two cannot disagree — src/controllers/nosql/project.js recomputes read[] from the requested isPublished on create, update and push.

2. One helper resolves access, one clause enforces it

src/helpers/access-sql.js owns the model:

rolesFor(req)          // ['public'] plus verified realm_access.roles — never from headers
resolveAccess(req)     // tier, roles, project scope, whether a credential was presented
readClause(roles)      // the SQL visibility fragment plus bound parameters
canRead(doc, roles)    // for point reads that bypass the query predicate
systemAccess()         // internal jobs, built from the privileged tier rather than a bypass flag

readClause resolves in two arms: read[] intersects the caller's roles, or the row carries no read[] and isPublished === true. An explicit isPublished: false is never matched. The unsetIsPublic option drops the isPublished half of the second arm so that a row carrying neither field is visible; exactly one container uses it, boundaries, whose seeded reference geography predates having an ACL. A restricted boundary carries an explicit read[] and is governed by the first arm, so the option does not widen the gate.

Privileged roles short-circuit to true — an unfiltered read from the same code path, not a bypass branch that can drift. The set is SECURE_ROLES: sysadmin, staff, demi-admin, demi-service-read. demi-service-read comes from ADR-007-Service-to-Service-Credentials and reads everything the ACL allows without being able to write; writes gate on WRITE_ROLES, the older three-role set.

Roles and project scope are orthogonal. resolveAccess reads project scope from project:<id> role names before it checks privilege, so a credential minted as roles: ['staff'], projectScope: ['207'] is privileged within those projects rather than across the corpus.

3. The query layer fails closed

There is no filter-object interface and no translator. query(name, spec, opts) throws unless the spec is {query: string, parameters: array}, values arrive as bound parameters and never by interpolation, and each repository method owns its SQL. An untranslatable filter cannot become an unfiltered read. Count queries take the same predicate as the read, so totals cannot leak hidden records.

4. Fail closed everywhere else

  • createProject and createDocument default to read: [...SECURE_ROLES] — private unless explicitly published.
  • A document, engagement period, or Update can never out-rank its parent project. Publishing or unpublishing a project cascades the derived read[] onto its documents, periods, and Updates (src/helpers/update-acl.js for Updates) and writes the same ACLs into the documents index. An Update with no project, or parented to a notification rather than a project, keeps its own read instead. See Eagle-Mirror-Reference#updates-publish-gate-and-announce.
  • Role extraction defaults to the privileged set, not ['public'], for a record with no marker.
  • In OData, "matches nothing" is an empty flag and the caller issues no request at all.

5. Chunk visibility is derived at query time from the parent document

A chunk's own read[] is an ingest-time snapshot and can outlive its parent's visibility, so it is not the gate. src/controllers/search.js resolves each chunk hit against the documents container and withholds any chunk whose parent document the caller cannot read; the point-read path re-applies the ACL at the database through getById. Project names are resolved live, and a project the caller cannot read renders as Associated Project.

This is why DEMI does not need eagle-search's re-stamping machinery — resync.js, stamp.js, awaitParents and narrows solve a problem that only exists when chunk ACLs are the gate. Do not port them.

Consequences

Positive:

  • Visibility is enforced in one place. Skipping it takes a deliberate decision not to call the helper.
  • Public and admin views genuinely differ.
  • read[] maps onto the search filter directly, so the two backends cannot disagree.
  • Silent fail-open is structurally impossible; the failure mode is a 500.

Accepted trade-offs:

  • Callers that relied on unlimited reads get capped page sizes. An oversized pageSize is refused rather than truncated, because a short page answers a different question than the one asked.
  • Ordering depends on the indexing policy: /name is indexed so ORDER BY works, and leaving a sort field out of the policy makes ORDER BY fail outright.

Related fixes from the same audit

These bear on the same trust boundary:

  • API keys come from configuration only and are compared with crypto.timingSafeEqual. The hardcoded literals that granted sysadmin on every write route are gone; ADMIN_API_KEY is the admin credential, and DOCLING_API_KEY is outbound only.
  • GET /api/config does not serve search keys. All search is proxied through GET /api/search.
  • Keycloak settings are pinned per environment in Bicep, so a dev-realm token is not accepted as admin elsewhere.
  • .env is excluded from deployment packages.
  • CORS_ORIGIN falls back to the known DEMI frontends rather than reflecting any origin. Rate limiting is enforced at the edge (eao-nginx limit_req), not in the API process.
  • Raw upstream payloads do not leave over HTTP. publicView in src/repositories/projects.js narrows the response at res.json, keeping DEMI's own sources.wildfire aggregate and dropping the raw track and eagle blocks. It is deliberately not done in the data layer, because updateProject reads, spreads and upserts, so a stripped read would erase sources on the next edit. This ACL gates which rows a caller gets, not which fields, so a field-level leak on an entitled row is invisible to it.

Authentication and authorization are separate

authenticate() performs authentication only. The privilege check lives in src/middleware/auth.js, which imports isPrivileged rather than re-listing role names.

The split matters because authenticate() has two callers. On admin routes a privilege check is correct; on src/middleware/passiveAuth.js it is not — a 403 raised there was caught, logged as "continuing as anonymous", and req.user was never set, which made a valid Keycloak user indistinguishable from a logged-out visitor. Scoped and role-typed callers therefore saw public rows only, and the scoped tier was fully implemented and completely unreachable. test/middleware/passiveAuth.test.js covers it: a verified non-privileged token populates req.user, reaches the ACL as the scoped tier, and produces both the read/any(...) clause and the projectId clause with no project: string leaking into the role list.

Token verification itself was sound and unchanged: JWKS via jwks-rsa, RS256 pinned, issuer checked.

References

  • src/helpers/access-sql.js — roles, tiers, project scope, and the SQL visibility clause
  • src/helpers/access-odata.js — the AI Search twin and the empty fail-closed flag
  • src/helpers/auth.js, src/middleware/auth.js — authentication and the privilege gate
  • src/controllers/search.js — the parent-document chunk gate
  • src/controllers/nosql/project.js — the publish cascade onto documents and the index
  • test/helpers/access-sql.test.js, test/helpers/access-odata.test.js, test/helpers/access-coverage.test.js, test/middleware/passiveAuth.test.js
  • eagle-api/api/helpers/utils.js runDataQuery — the pattern this follows