Repository navigation
Attribute Level Access
Field-level (attribute) visibility control for DEMI API responses. Distinct from row visibility
(read[], see ADR-004) and project scope: this controls
which fields of a record a caller who can already see the row gets back.
Design: ADR-010. Full source:
docs/rbac-architecture.md.
Row visibility and field visibility share one ladder, 1 (narrowest) to 4 (anonymous public). Every
caller resolves to one integer level, computed in src/vis/level.js from roles and claims: the
narrowest level among matching roles wins. A role not on the ladder, or no roles, resolves to
level 4.
read[] holds ladder tokens, not cumulative downwards: a level-2 row's read[] carries no team
token, so a team-only caller cannot read an All-EAO row even in its own project.
| Level | Name | read[] |
Matched by |
|---|---|---|---|
| 1 | Team only | ['team'] |
team AND row's project in caller's project:<id> roles |
| 2 | All EAO | ['staff'] |
staff realm role |
| 3 | All IDIR | ['staff','idir'] |
staff or IDIR login |
| 4 | Public | ['staff','idir','public'] |
anyone |
A caller's project:<id> realm roles are a per-row GRANT, matched as the team OR arm above; an API
key's projectScope is a separate RESTRICTION by the key's issuer that ANDs against every read
regardless of level, never merged with the team grant. Team membership is minted from Track: a
project's team is the union of staff across its works (staff_work_roles joined to works), fed
into the project:<id> realm roles (planned, Phase 3).
sysadmin is superuser across levels 1-4, matching every row regardless of level, and has no route
into level 0 — level 0 is not on the ladder. systemAccess(), used only by internal jobs, is the
sole caller that resolves to level 0; no human role does.
Outside the ladder. A level-0 record carries read: ['compliance'] and stays in its ordinary
Cosmos container — no encryption, no separate store. Every read predicate (readClause,
canRead, filterFor) excludes compliance-only rows for every caller on the ordinary routes,
privileged callers and systemAccess() included; this is the query-time filter, not the write
path. Two callers read stored rows directly. The Eagle-origin seal heal is described below.
listShortCodeOwners (src/repositories/projects.js) is an ids-only ownership check for short
codes. It sees sealed holders, so PUT and DELETE /links/:code cannot repoint or delete a sealed
project's code. When the caller cannot read the holder, the link controller answers 409 without a
project id (PR #459, 2026-09-29). Only the compartment routes read sealed records:
POST /api/sealed, GET /api/sealed, GET /api/sealed/:id, GET /api/sealed/:id/download
and POST /api/sealed/:id/release, gated on the compliance role through their own guard chain
(not authMiddleware, which refuses compliance), each call audited (sealed.create,
sealed.list, sealed.read, sealed.download, sealed.release). Reads are audited here and
nowhere else.
Three conditions keep the seal real: the ADMIN_API_KEY break-glass never resolves
compliance; exports and backups stay locked down (no script under src/scripts may name the
role, enforced by a test); and a compliance API key is mintable only by a caller that already
holds compliance (POST /admin/api-keys answers 400 otherwise).
Release is the only exit for a DEMI seal: the body carries caseNumber and decision, the record
lands at level 1 (team), and the audit row is the record of the release. A second, narrower exit
exists for a level-0 row sealed by an Eagle push, not by DEMI: it heals on its own next Eagle push,
taking that push's read minus compliance; see Eagle-Mirror-Reference#mirrored-kinds. Pulling
a record back from level 4 is separate: sysadmin-only, audited, incident response via the
takedown runbook.
Every field of every entity is declared in catalog/<entity>.js as { defaultVis, maxVis }:
| Property | Meaning |
|---|---|
defaultVis |
Level a field is visible at when no per-record dial overrides it. |
maxVis |
Ceiling a dial may ever raise the field to. |
A field with no catalog entry is dropped from every response. Catalog keys may be dotted one
level (e.g. sources.wildfire); the redactor descends only for keys declared that way.
A record may carry a sparse vis map keyed by field name (for example { eacExpires: 3 }). The
effective visibility for a field is the dial value clamped to [0, maxVis] if present and valid,
otherwise defaultVis. Field dials are independent of the record's level: the row plane decides
who sees the record, the field plane refines by caller level. A field is included in the response
when caller level <= effective visibility.
A catalog entry may name a predicate with when (src/vis/predicates.js). A true predicate
widens the field to its maxVis; a dial always beats the predicate. The only predicate is
cacPublished: cacEmail is defaultVis: 2, maxVis: 4 and reaches the public while
projectCACPublished is true. That flag is written by the Eagle push; PUT /api/projects/:id
strips it. A when naming no registered predicate throws at load.
Search hits are redacted with the same catalog. The projects index carries vis as a JSON
string (ToString(c.vis) in the data source query); the search controller parses it, translates
stored field names to index column names (src/vis/catalog/index-projects-renames.js), and
drops highlighted entries for fields the caller does not see. Adding a project field to the
index means PUT the index, PUT the data source, deploy, then reset and run the indexer.
catalog/index.js is the only place an entity's catalog is registered. An entity absent from it
has no policy and can't be redacted.
| Entity key | Catalog file | Field names match | Applied at |
|---|---|---|---|
projects |
catalog/projects.js |
stored Cosmos projects doc |
nosql/project.js (CRUD), search.js (project rows/parents) |
documents |
catalog/documents.js |
stored Cosmos documents doc |
nosql/document.js (CRUD), search.js (parent documents) |
index-projects |
catalog/index-projects.js |
AI Search projects index names (renamed by source, e.g. proponentName AS proponent) |
search.js (project hits) |
index-documents |
catalog/index-documents.js |
AI Search documents index names |
search.js (document hits) |
users |
catalog/users.js |
stored Cosmos users doc |
nosql/user.js |
groups |
catalog/groups.js |
stored Cosmos groups doc |
nosql/group.js |
inspections |
catalog/inspections.js |
stored inspections container row, kind: 'Inspection'
|
nosql/inspection.js |
inspectionElements |
catalog/inspections.js |
same container, kind: 'InspectionElement'
|
nosql/inspection.js |
inspectionItems |
catalog/inspections.js |
same container, kind: 'InspectionItem'
|
nosql/inspection.js |
The two index-* catalogs exist because AI Search renames columns on the way into the index, so a
hit doesn't share field names with the stored Cosmos row it came from.
Redaction runs through src/vis/redact.js: redactForAccess(entity, doc, access) for one record,
redactAllForAccess(entity, docs, access) for a list. Both call catalogFor(entity) first, so an
unknown entity throws rather than returning an unfiltered record. On writes,
refusedWriteKeys(entity, changes, access, existing) rejects body keys the caller can't currently
see, plus vis itself at every level.
Level 4 is public, level 2 is staff only. Row-plane fields (read[] and the like) are not listed.
Eagle's password and salt are never stored, so no catalog lists them.
| Entity | Level 4 (public) | Level 2 (staff only) |
|---|---|---|
users |
firstName, middleName, lastName, displayName, salutation, title, department, org, orgName
|
email, phoneNumber, cellPhoneNumber, faxNumber, address1, address2, city, province, country, postalCode, notes
|
groups |
projectId, name
|
members, links
|
inspections |
kind, projectId, inspection, dateAdded, dateUpdated, updatedAt, inspectionId, name, label, case, startDate, endDate, customProjectName, elements
|
addedBy, updatedBy, email
|
inspectionElements |
kind, projectId, inspection, dateAdded, dateUpdated, updatedAt, elementId, title, requirement, description, timestamp, items
|
addedBy, updatedBy
|
inspectionItems |
kind, projectId, inspection, dateAdded, dateUpdated, updatedAt, element, itemId, type, uri, geo, caption, timestamp
|
addedBy, updatedBy, internalURL, internalExt, internalSize, internalMime
|
A user row that Eagle published still hides its contact fields from the public. The row itself is level 2 unless Eagle's read says otherwise.
An Eagle record whose read[] has no ladder token, such as ['sysadmin'], gains the staff token
when it is stored and so lands at level 2. Eagle's staff role skips every read check, and this
keeps that behaviour. Under a parent, the parent's level still caps the row. The rule and its
exceptions are in
docs/rbac-architecture.md
(Eagle-mirror exception).
Authenticated. Describes a hypothetical caller (roles, identityProvider, teams, projectScope, one credential) and returns what the engine decides for it: the /api/me fields, each ladder level's readability with the arm that grants it (role, team, credential) and its read[] tokens, and both field catalogs with defaultVis, maxVis, predicate and visibility. Reads no data and grants nothing. The demo frontend's Access Model screen renders this answer instead of holding its own copy of the rules.
Unauthenticated, like /api/config. Returns the caller's resolved access:
{
"roles": ["staff"],
"level": 2,
"tier": "privileged",
"privileged": false,
"staffUi": true
}| Field | Type | Meaning |
|---|---|---|
roles |
array of string | Roles from the caller's token, or empty for anonymous. |
level |
integer, 1-4 | Field-visibility level, from src/vis/level.js. |
tier |
string, one of privileged, scoped, public
|
Row-visibility tier from access-sql.js, unrelated to level. |
privileged |
boolean | True for a SECURE_ROLES role (isPrivileged); staff is not privileged. |
staffUi |
boolean | True when the caller passes authMiddleware (staff, admin, service roles; not compliance). The frontend gates staff screens on it. Frontend gates staff screens on this field. |
Frontends use level/roles to decide whether to render a UI section, but the real gate is always
field presence in the response, never a client-side role check.
A credential grants a named external party (First Nation, proponent, local government) sight of
specific records at levels 1-3, without changing the record's level or anyone else's access.
Party: a person on BCeID Business (proponents, consultants), a realm group, or a registry API
key; IDIR guest only for an external acting as staff. BCeID is not idir, so a credential
holder never reaches level 3 by logging in. Stored in container credentials (partition
/party.id), loaded per request by src/middleware/credentials.js (60-second per-party cache)
and evaluated as one extra OR arm in readClause, canRead and filterFor. The level match is
exact: a levels: [2] grant reads level-2 records only, not level 3, although ladder tokens
nest. Grant and revoke are POST /api/credentials and POST /api/credentials/revoke
(id | batchId | party | projectId), sysadmin only, audited credential.grant /
credential.revoke. Shape:
| Field | Meaning |
|---|---|
id |
Credential id |
party.type |
user, group, or apikey
|
party.id |
Granted party's ID |
scope.type |
document or project
|
scope.ids |
Record ids the grant covers |
levels |
Subset of [1, 2, 3] the party may read within scope |
start, end
|
Grant window; end required, 90 days default, enforced on grant not login |
grantedBy, grantedAt, revokedAt
|
Audit fields |
batchId, note
|
Grouping, free text for bulk grant/revoke |
A grant refuses a missing or past end, levels 0 or 4, and more than 200 scope ids. The nightly
Track sync revokes or narrows grants over closed projects (revokeForProject). Expiry is visible,
not mailed: the holder sees end on GET /api/me, the grantor on GET /api/credentials.
Credentials do not touch the field plane: a credential holder's own resolved level still governs which fields they see.
- 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