Skip to content

Attribute Level Access

Daniel Truong edited this page Oct 7, 2026 · 17 revisions

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.

Level

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.

Level 0: sealed compartment

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.

Catalog

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.

Registered entities

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.

Fields of the Eagle user, group and inspection entities

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).

POST /api/access/simulate

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.

GET /api/me

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.

Selected Credentials

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.

DEMI Wiki

Home

Current system

Azure

Domain

ADRs

History

Related

Clone this wiki locally