Repository navigation
ADR 004 Read ACL Authorization Model
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[].
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.
Adopt the read[] ACL model already proven in eagle-api, and route every read through a single
visibility helper.
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.
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 flagreadClause 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.
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.
-
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.
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.
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.
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.
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.
-
src/helpers/access-sql.js— roles, tiers, project scope, and the SQL visibility clause -
src/helpers/access-odata.js— the AI Search twin and theemptyfail-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.jsrunDataQuery— the pattern this follows
- Environment Reality & Gotchas
- Architecture
- Seeding — filling Cosmos from Track and Eagle
- Sources and Status — every source, one state
- Search & Retrieval — overview
- Search Index Reference
- Search Query Construction
- Search Measurements
- Extraction Pipeline
- Extraction Quality — coverage, word-joining, tiling
- Identity Model — which id means what
- Attribute-Level Access — field-visibility levels, catalog
- Sync Architecture — how records get in
- Eagle Mirror Reference — mirrored kinds, refusals, drift classes
- ENGAGE Rows Reference — ENGAGE-owned comment periods, reconcile
- Public-Read Backfill — filling and repairing the mirror containers
-
Short Links —
/s/{code}redirect and/api/links -
Recent Uploads —
/api/documents/recent-uploads, eagle-public homepage panel - Reports — comment export and BCGW CSV reads
-
User Data — saved map areas, preferences,
/meroutes -
Bulk Download —
/api/bulk-downloads, zip jobs - Bulk Download: Operations — enable, poison-queue runbook
- Document Intake — upload link, malware scan, release
- Connecting an Application to DEMI
- Azure Environments
- Document Backup — archive copy of originals, run and check
- Document Backup: Revert and Drill — put documents back from the archive
- PDF Titles — public name written into stored PDFs, run, restore
- Secret Sync — Key Vault, secret-sync Function app, required names
-
Operating DEMI in Azure — deploy, verify
- Operating the DEMI Data Plane — devbox access, probes, audit and cost reads
- Edge ban detector — hourly timer, modes, kill switch, soak and promote
-
CI Workflows — every GitHub Actions workflow, trigger, identity, roles
- CI Deploy Workflows — eagle-demi staging and production deploys
- CI Admin Frontend Workflows — eagle-demi-admin checks and deploys
- Release Process — cutting, tagging, shipping and rolling back a version
- Runbook: Search Outage — index/app schema drift, diagnose, fix, roll back
- BC Gov Landing Zone
- Business Rules
- EPIC.search Business Rules — where DEMI differs from EPIC.search
- 001: Typesense for DFL Search — superseded
- 002: Async Processing (MongoDB Queue) — superseded
- 003: Rule-Based Classification
- 004: Read ACL Authorization Model
- 005: Cosmos Full-Text Search Ruled Out
- 006: AI Summarization over BM25
- 007: Service-to-Service Credentials
- 008: Audit and Usage Analytics Store
- 009: Track Feed — proposed
- 010: Attribute-Level Access Control
- 011: Public Read Surface
- 012: Public Config Surface
- 013: Stored Project Summaries — proposed
- 014: Sync-out to Eagle
- 015: Malware scanning on intake