ADR 010 Attribute Level Access Control - digitalspace/eagle-demi GitHub Wiki
Accepted.
DEMI gates records two ways: row visibility (read[], readClause/canRead, OData filterFor)
and project scope (project:<id> roles). Neither controls which fields of a visible record a
caller sees; a staff member and an anonymous visitor who can both read a project get the same
document.
The design comes from "DEMI Role-Based Access Control & Field-Level Redaction" (Mark Lise, Digitalspace), audited against the codebase, then revised against the EAO's sharing model.
Add a third plane, field visibility, alongside the two that exist. Row visibility and project scope are unchanged in mechanism; the row plane now carries a sharing ladder.
Row plane: a four-value ladder. read[] holds ladder tokens, role types only, never ids, not
cumulative: 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, matched as an OR arm against the 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 |
Levels 2-4 all carry staff, so a staff caller reads every row at level 2+ with one token; level 1
rows are reached only through the team arm. A key's projectScope is a separate RESTRICTION set by
the key's issuer: it ANDs against every read regardless of level, never merged with the team grant.
Team membership (project:<id> roles) is minted from Track's staff_work_roles feed (planned,
Phase 3).
A record's level is derived from read[], never stored separately: the widest token present, or 1
when none. Default on admission is level 1 — every write site creating a record starts it at
team only. Widening is an explicit, audited act: PUT /api/{projects,documents}/:id/level
with { level, confirm, reason } changes a record's level; nothing widens a record automatically.
Level 4 requires confirm: true and a reason, and refuses without either. A document cannot
out-rank its project. Pulling a record back from level 4 is sysadmin-only, audited, incident
response — not this endpoint — and follows a takedown runbook.
Level 0: a sealed compartment, outside the ladder. Not a fifth rung. A level-0 record carries
read: ['compliance']; only the compliance role matches that token. No encryption: the row plane
is the seal, so every read predicate excludes compliance-only rows even for privileged callers and
systemAccess(). The seal depends on the ADMIN_API_KEY break-glass never resolving compliance
on exports/backups staying locked down, and (planned, Phase 5) on compliance keys being mintable
only by a compliance holder. sysadmin has no route in. A record leaves level 0
only through an explicit release to level 1 (team), which requires a case number and a decision
and notifies the C&E lead.
Amendment, 2026-09-26 (eagle-demi #454): a level-0 row without
sealedAtwas sealed by an Eagle push, not by DEMI, and heals on its own next Eagle push rather than through release. See Attribute-Level-Access#level-0-sealed-compartment and Eagle-Mirror-Reference#mirrored-kinds.
Selected Credentials (planned, Phase 3). A named party — a BCeID Business login (proponents,
consultants) or a registry API key — gets a time-bound, revocable grant to specific records at
levels 1-3, without changing the record's level or anyone else's access. end is required and the
grant auto-revokes when the project closes. Level 4 needs no credential.
Field plane. Every caller resolves to one integer level (1-4); a role not on the ladder
resolves to 4. Every field of every entity is classified in code (defaultVis, maxVis ceiling),
never read from Cosmos. A sparse vis dial can override a field's baseline, clamped to [0, maxVis]. Field dials are independent of the record's level: the row plane decides who sees the
record, the field plane refines by the caller's own level.
Redaction runs at the response boundary, not inside getById (Cosmos upsert replaces the whole
item, so a redacting getById would erase hidden fields on the next write). List reads use a
catalog select so hidden values never leave Cosmos. Unknown role, missing field classification,
or an out-of-range dial fail closed toward restriction.
- Every entity response site must call the redactor; a coverage test enforces this per call site.
- The field catalog must track what the merge/seed pipelines emit, not a source document's field list; a completeness test diffs the two.
- Day-one field defaults reproduce today's public output, not the source design's defaults.
-
sysadminis superuser on levels 1-4, no route into level 0; onlycompliancedoes. - Ladder, level 0, Selected Credentials are Phase 3 design, not live; field plane is live.
- Open EAO questions live in the design doc.
- docs/rbac-architecture.md — full model, audit
-
Attribute-Level-Access — level table, catalog format,
/api/me - ADR-004: Read ACL Authorization Model — row visibility
- ADR-007: Service-to-Service Credentials