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
createProjectandcreateDocumentdefault toread: [...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.jsfor 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
emptyflag 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
pageSizeis refused rather than truncated, because a short page answers a different question than the one asked. - Ordering depends on the indexing policy:
/nameis indexed soORDER BYworks, and leaving a sort field out of the policy makesORDER BYfail 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 grantedsysadminon every write route are gone;ADMIN_API_KEYis the admin credential, andDOCLING_API_KEYis outbound only. GET /api/configdoes not serve search keys. All search is proxied throughGET /api/search.- Keycloak settings are pinned per environment in Bicep, so a dev-realm token is not accepted as admin elsewhere.
.envis excluded from deployment packages.CORS_ORIGINfalls back to the known DEMI frontends rather than reflecting any origin. Rate limiting is enforced at the edge (eao-nginxlimit_req), not in the API process.- Raw upstream payloads do not leave over HTTP.
publicViewinsrc/repositories/projects.jsnarrows the response atres.json, keeping DEMI's ownsources.wildfireaggregate and dropping the rawtrackandeagleblocks. It is deliberately not done in the data layer, becauseupdateProjectreads, spreads and upserts, so a stripped read would erasesourceson 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 clausesrc/helpers/access-odata.js— the AI Search twin and theemptyfail-closed flagsrc/helpers/auth.js,src/middleware/auth.js— authentication and the privilege gatesrc/controllers/search.js— the parent-document chunk gatesrc/controllers/nosql/project.js— the publish cascade onto documents and the indextest/helpers/access-sql.test.js,test/helpers/access-odata.test.js,test/helpers/access-coverage.test.js,test/middleware/passiveAuth.test.jseagle-api/api/helpers/utils.jsrunDataQuery— the pattern this follows