Access Control Model
Access Control Model
Core Rule
DigitVA uses a hybrid RBAC and ABAC model.
- role defines capability
- scope defines boundary
- access is granted only when both match
In other words:
- RBAC answers: what can this user do?
- ABAC answers: where can this user do it?
The Three-Tier Mental Model & PII Boundary (Owner Decision, 2026-10-26, digitva-ewa5)
Role allocation and personal-data (PII) visibility follow one fundamental boundary: whether the person operates inside the health system or sits outside/above it.
┌──────────────────────────────────────────────────────────────────────────┐
│ TIER 1: PROJECT LEVEL (Sitting above all Sites, Districts & Institutes) │
│ • Project PI (project_pi) │
│ • Project Scientist / Viewer (collaborator, coder, reviewer at project) │
│ • Project Data Manager (data_manager at project scope) │
│ ──► OUTSIDE THE HEALTH SYSTEM ──► NO PII (Blinded / De-identified) │
└────────────────────────────────────┬─────────────────────────────────────┘
│
┌───────────────────────────┴───────────────────────────┐
▼ ▼
┌─────────────────────────────────────────┐ ┌────────────────────────────────────────────┐
│ TIER 2: MENTORING INSTITUTION │ │ TIER 3: HEALTH SYSTEM │
│ (Attached to District(s) from outside) │ │ (State/Province → District → Block/Taluka │
│ • Site PI (site_pi) │ │ → CHC → PHC/UPHC → Sub-centre → Village) │
│ • Institute Scientists │ │ • In-charge (State/Province, District CS, │
│ (coder, reviewer, collaborator) │ │ Block/CHC SMO, PHC/UPHC MO) │
│ │ │ • Unit Data Manager (State/Province, DPM, │
│ ──► OUTSIDE THE HEALTH SYSTEM │ │ DEPI, BPM) │
│ ──► NO PII (Blinded) │ │ • Interview Supervisor │
│ │ │ • Interviewer (CHO, SN, ANM, MPW) │
│ *Exception: If an external person needs │ │ • Death Reporter (ASHA, ANM, MPW) │
│ PII, they are placed in Tier 3 as a │ │ • In-System Doctors (MO/SMO coding/review) │
│ District or State/Province DM.* │ │ ──► INSIDE THE HEALTH SYSTEM │
└─────────────────────────────────────────┘ │ ──► ALL PII (within-tree & cross-tree) │
└────────────────────────────────────────────┘
Governing Rules of the Three-Tier Model
Tier 3 — Inside the Health System = All PII:
- Every role held inside the health-system hierarchy (
org_unitscope by non-mentoring-institute staff) is PII-enabled across the units its grants reach:- In-charge (
CSat District,SMOat CHC,MOat PHC — never calledSite PIfor in-system unit leaders) - Unit Data Manager (
DPM,DEPIat District,BPMat CHC) - Interview Supervisor (supervises field interviews, resolves duplicate/cancel flags)
- Interviewer (
CHO,SN,ANM,MPW) - Death Reporter (
ASHA,ANM,MPW— own registered deaths) - In-System Doctors (
coder,reviewer) (MO,SMOat PHC/CHC/District)
- In-charge (
- Within-tree vs. cross-tree does not change PII visibility: Because an in-system doctor (
MO,SMO) is part of the health system, they see PII on all cases their health-system grants cover, whether coding for their home PHC subtree (within-tree) or assigned to code sibling PHCs (cross-tree). Likewise, in a self-coding project (self_coding_enabled = true), clinicians interviewing and coding their own cases are in-system and see PII.
- Every role held inside the health-system hierarchy (
Tier 1 & Tier 2 — Outside the Health System = No PII (Blinded):
- Tier 1 (Project Level):
project_pi, project-leveldata_manager, and project-level scientists/viewers (collaborator,coder,revieweratprojectscope) sit above multiple sites, districts, and mentoring institutions. They oversee project-wide progress, workflow triage, and aggregate statistics, and do not see PII. - Tier 2 (Mentoring Institution / External Site Level): Mentoring institutions (medical colleges or technical partner institutions attached to districts) sit outside the health system:
site_pi(Site PI) is the lead / Principal Investigator role for a Mentoring Institution (or external study site), not an in-system health-unit leader. A Mentoring Institutionsite_pigrant at a District unit does not expand viaResolvedGrants.overseesinto Tier 3data_managerorinterview_supervisorpowers.- Mentoring institution scientists hold
site_pi,coder,reviewer, orcollaborator. - All Tier 2 roles are Non-PII (blinded): they see age, sex, dates, symptoms, narrative, and aggregate counts, but never deceased names, family names, addresses, GPS coordinates, or registration numbers.
- Tier 1 (Project Level):
Exceptional PII Access for an External Person:
- In the exceptional case where someone from outside the health system (for example, a Mentoring Institution coordinator) genuinely requires PII access, they are made part of the health system (Tier 3) and granted a
data_managerrole at the District (org_unit) level. - Separate workaround roles such as
"Coder with PII"orcollaborator_pii("Viewer with PII") are not needed in the role allocation workflow.
- In the exceptional case where someone from outside the health system (for example, a Mentoring Institution coordinator) genuinely requires PII access, they are made part of the health system (Tier 3) and granted a
Roles
DigitVA uses these roles:
adminproject_pi(Tier 1 — Project level, Non-PII)site_pi(Tier 2 — Mentoring Institution / external site lead, Non-PII)in_charge(Tier 3 — Health-system unit leader: District CS, CHC SMO, PHC MO, PII; transition note: currently stored assite_piatorg_unitscope prior to enum split)data_manager(Tier 1 atprojectscope = Non-PII; Tier 3 atorg_unitscope = PII)collaborator(Tier 1 / Tier 2 read-only viewer, Non-PII)collaborator_pii(legacy viewer-split role; superseded in setup workflows by granting Tier 3data_managerat District/unit level when PII access is required)coder(Tier 2 / external = Non-PII; Tier 3 in-system clinician / self-coding = PII)coding_tester(Tier 2 / external = Non-PII; Tier 3 in-system = PII)reviewer(Tier 2 / external = Non-PII; Tier 3 in-system clinician = PII)interviewer(Tier 3 — Health-system field/facility capture, PII)death_reporter(Tier 3 — Health-system death registration, PII on own registrations)interview_supervisor(Tier 3 — Health-system field supervision, PII)
Roles are additive and do not inherit from each other.
Role Meaning
admin
Global administration.
May manage:
- users
- projects
- sites
- project-site mappings
- integrations
- all application records
project_pi
Project-wide oversight within assigned projects (Tier 1 — Outside the health system, Non-PII; owner decision 2026-10-26, digitva-ewa5).
May, in every project:
- view data across all assigned sites in an assigned project (without personal data / Non-PII)
- view reporting for an assigned project
- perform oversight actions allowed by workflow policy
- set up the project and grant roles in it (see Organization Model Policy, "Spreadsheet setup")
In an organizational (unit-tree) project the project_pi additionally:
- sees every screen of the project, with personal data redacted (Non-PII): the data-management dashboard, grid, KPIs and submission view, the coder and reviewer views, the area view, intake supervision and the site PI report
- may act as
data_managerandinterview_supervisoracross the whole project: triage and not codeable, routing and pinning a submission to a unit, sync, screening, upstream-change resolution, the unrouted queue and intake supervision actions
Implemented for the data-management screens and actions (dashboard, grid, KPIs, submission view, triage, sync, routing and pinning, the unrouted queue) in digitva-0wc stage 3; intake supervision and the site PI report (every site and the top-level units of the project) in stage 5.
Coding and reviewing still require a coder or reviewer grant. In a site
(non-tree) project the project_pi keeps only the powers in the first list.
site_pi
Lead / Principal Investigator role for a Mentoring Institution (attached to
districts in an organizational project) or an external study site in a classical
project (Tier 2 — Outside the health system, Non-PII; owner decision
2026-10-26, digitva-ewa5).
Never used for in-system health-unit leaders (who hold In-charge instead).
May:
- view data and progress for assigned mentoring-institute districts or assigned project-sites (without personal data / Non-PII)
- view reporting for assigned scope
- perform oversight actions allowed by workflow policy
A user may hold site_pi grants for many project-site pairs or mentored
district subtrees.
In-charge (organizational projects)
Decision 2026-10-02, updated 2026-10-26 (digitva-ewa5). An organizational
(unit-tree) project has one In-charge role (Tier 3 — Inside the health
system, PII), held at health-system unit levels: the District in-charge
(CMO or Civil Surgeon) on a district unit, the Block in-charge (Senior
Medical Officer, CHC) on a block unit, and the PHC in-charge (Medical
Officer, PHC) on a PHC unit. In-system unit leaders are always called
In-charge (never Site PI). Within their own unit subtree every in-charge:
- sees every screen, with personal data (PII): the data-management dashboard, grid, KPIs and submission view, the coder and reviewer views, the area view, intake supervision and the unit report
- oversees field work, as
interview_supervisordoes today: views intake cases, resolves flags, cancels or reopens a case and marks duplicates (see Web Intake Policy, "Supervisors") - has every power of a
data_managerin that subtree: triage and Not Codeable, screening, sync, route and pin, the unrouted queue, upstream change resolution, and the grants a data manager may give - creates
data_managergrants on their own unit and on units beneath it, and the grants a data manager gives anywhere in their area (see "Who creates which grants")
Coding and reviewing still require a coder or reviewer grant. Mentoring
institute members may never hold the In-charge role (they use site_pi for
their Mentoring Institution lead); cadre validation applies as for any unit
grant.
Transition note: Prior to splitting in_charge into its own VaAccessRoles
enum value, In-charge was stored in va_user_access_grants as site_pi at
org_unit scope (decision 2026-10-02, migration e2b7c4d9a1f3). Separating
in_charge (Tier 3 health-system unit leader, PII) from site_pi (Tier 2
Mentoring Institution lead, Non-PII) resolves that overload. In an intake
supervision audit row an In-charge grant ranks with interview_supervisor at
equal depth.
Implemented in digitva-0wc stage 5: the screens, data-manager actions,
intake supervision and the unit report for the in-charge's own units.
Single-submission sync only: whole-form sync stays with project and site
data managers. The grant-creation powers above are implemented in
digitva-0wc stage 6. A person who holds only an interview_supervisor
grant has only its powers.
data_manager
Data-quality and operational triage role within assigned scope.
- Tier 1 (
projectorproject_sitescope — Outside the health system): sits above multiple sites/districts and mentoring institutions; does not see PII (Non-PII) (owner decision 2026-10-26,digitva-ewa5). - Tier 3 (
org_unitscope — Inside the health system): District (DPM,DEPI) or Block (BPM) data manager placed inside the health-system tree; sees PII within their unit subtree. When an external person from outside the health system exceptionally needs PII access, they are made part of the system and granteddata_managerat the District (org_unit) level.
May:
- browse all submissions within assigned scope
- open submissions in read-only mode
- document that a submission is not codeable from a data-management perspective
- view reporting and workflow context needed to diagnose submission quality issues
- create grants within their own scope, as set out in "Who creates which grants": today's rule in site projects; in district projects the subtree rule
May not:
- start coding
- submit COD assessments
- submit reviewer QA
- take coder-only or reviewer-only workflow actions
collaborator
Read-only role within assigned scope.
May:
- view data
- view reporting
- open a single submission read-only, within scope, with personal data
redacted (
authz.require(VIEW), digitva-0wc). The read-only renderings (vadata,vaarea) load the category sections plus an allowlist of other partials (workflow history, the viewer's own note); the coding forms, which carry the DORIS prefill and prior certificates, are refused, and the DORIS prefill is skipped for any redacting viewer
May not:
- code
- review
- perform oversight write actions
- administer configuration
- see personal data. Payload fields flagged
is_piiinmas_field_display_configare redacted, and so is staff identity — who collected, coded or reviewed a death. Usecollaborator_piifor that.
Rollout note: this narrows an existing role
Before the viewer split, a collaborator saw whatever a screen rendered,
personal data included. Making the plain role no-PII removes visibility
from every grant that already exists.
That is the safe direction and it is deliberate — the alternative, defaulting
existing grants to PII, would keep an unreviewed set of people seeing personal
data because of how the roles happened to be numbered. But it is not silent:
existing collaborator holders who genuinely need personal data must be
re-granted as collaborator_pii by an admin or project PI, as a decision about
each person, and the migration must not do it for them.
collaborator_pii
Read-only role within assigned scope, including personal data. Shown in
the admin panel as "Viewer (with PII)"; plain collaborator is shown as
"Viewer".
Identical to collaborator in everything it may do, including opening a
single submission read-only within scope (authz.require(VIEW),
digitva-0wc). The only difference is that personal data is not redacted from
what it sees. Mentors, who may hold collaborator_pii, open submissions the
same way.
May additionally see:
- the personal-data fields of a submission payload — those flagged
is_piiinmas_field_display_config(names, the national identification number, the ABHA identifiers) - staff identity: who collected, coded and reviewed each death
(
va_submissions.va_data_collector, the payload'sSubmitterName, and the coder and reviewer names on workflow and audit views)
On the coding screens a coder who also holds it sees the deceased's name, and in a self-coding project so does the submitter of a case (Coding Workflow State Machine Policy, "Names on the coding screens").
Grant it for the operational reason it exists: a supervisor who has to know
who did what for which death. It is not a convenience upgrade of
collaborator and should be granted deliberately, to named people.
Why this is a role and not a flag
Considered and rejected: a sees_pii boolean on the grant. Roles were chosen
so that PII visibility is legible wherever a grant is listed, audited or
reviewed, rather than hidden in a column somebody has to know to look at.
The cost is real and should be understood: every place that tests
role == collaborator must also test collaborator_pii, and a place that
forgets fails closed for collaborator_pii (a supervisor sees too little)
but a place that tests only "is read-only" and forgets to redact fails open
(a plain viewer sees PII). Redaction is therefore written as a single helper
that takes the viewer's role, never as a per-screen condition.
One PII set, or two
collaborator_pii currently carries both subject personal data (the
deceased, the respondent) and staff identity (the interviewer, the coder). The
two are governed differently — subject data sits under ethics approval, staff
names are ordinary personnel data — and a supervisor who needs only the audit
trail must today be given the deceased's name as well.
If that trade proves wrong in practice, the split is a third role
(collaborator_staff), which is exactly the cost of using roles as the
mechanism. Recorded here so the choice is revisited deliberately rather than
rediscovered.
The PII set must be confirmed per form type
Redaction keys on mas_field_display_config.is_pii, and an unflagged field is
indistinguishable from a field nobody has classified. That silence is not a
safe default: apply_pii_field_registry applies a WHO-keyed field list to
every form type, so a non-WHO questionnaire (PHMRC, Ballabgarh) receives
three redaction-only rows for field ids it does not contain while its own name
and identifier fields stay unflagged.
So a form type's PII set counts as confirmed only when at least one field
the form type owns carries is_pii = true — a row that is mapped
(subcategory_code), synced from ODK (odk_label), or otherwise not created
by the registry (is_custom = false). The rule is derived from the rows on
every read (FieldMappingService.get_pii_set_status); nothing is stored, so
there is no state to migrate or to fall out of date.
Unconfirmed fails closed:
- the submission detail render withholds the entire payload from a viewer
subject to redaction, not just the flagged fields, and logs
pii set unconfirmed | <form_type_code> | payload withheld _filter_export_payloadreturns{}for every submission on that form, so the submissions CSV export carries no payload values. Column shape is unchanged — the headers still come from the confirmed forms in the same export and the withheld rows write those columns empty, per "empty the column, never drop it" below.
One export is exempt. The SmartVA input export passes
withhold_unconfirmed=False and keeps stripping only the flagged is_pii
fields, exactly as it did before the set became confirmable. Withholding there
would leave SmartVA unable to process any new questionnaire at all, and the
export is a data_manager/admin processing feed rather than a viewer surface —
it is already reachable only by roles that may see full submission data. It
logs one warning per form instead:
pii set unconfirmed | <form_type_code> | smartva input export not withheld.
The exemption is a parameter at that one call site so it stays greppable; the
submissions CSV export keeps the default, which is consistent because it was
already PII-filtered for every role.
An admin confirms the set by flagging one owned field Is PII in the
admin field-mapping panel. Until then that form type's card in the panel
carries a standing warning, GET /admin/api/form-types reports
pii_set_confirmed: false, and flask form-types stats says so.
Activation and creation of a form type are deliberately not blocked on this: a form type has no fields at registration time, so the working order is register -> ODK sync -> flag.
Redaction rules for viewer-reachable surfaces
A surface a viewer reaches redacts through should_redact_pii, never through
a per-screen role test. Which routes a viewer reaches today, and which
surfaces redact what, is recorded in
Workflow And Permissions,
"Viewer route reach and export redaction".
- Empty the column, never drop it. Downstream consumers of the submissions export depend on its column order and offsets, so a redacted export has the same shape as an unredacted one.
- Coder daily statistics return no rows rather than pseudonymous ones to
a viewer subject to redaction. Every row of that panel is staff identity.
Blanking the name would leave
coder_idas a stable per-person key across days and across exports — the same disclosure by another route.
Blocker: narrative_text blocks wiring the snapshot export to viewers
Do not grant collaborator the coded-COD snapshot export until
narrative_text is resolved. Its staff-identity columns are redacted;
narrative_text is not, and it is the column that carries the deceased's
name.
narrative_text is the free-text death narrative. It routinely contains the
names of the deceased, the respondent and the attending clinician, in prose,
where no field-level flag reaches them. It is subject personal data governed
by is_pii on the payload field, not staff identity, so it must not be filed
under COD_SNAPSHOT_STAFF_IDENTITY_HEADERS. The submissions export carries
the same narrative, so redacting it on one export alone would be an
inconsistent half-change.
The failure this blocker exists to prevent: the staff columns look handled, so the export reads as safe to widen, and a plain viewer gets the deceased's name in free text on the first row.
Resolving it is the is_pii decision, applied consistently across both
exports.
coder
Coding role within assigned scope.
May:
- start coding
- resume owned coding
- submit coding outcomes
- view coding records when allowed by workflow policy
Coder eligibility guard:
- coder-visible forms must resolve to an active project-site mapping in
va_project_sites - if a site is moved between projects and the old project-site row is deactivated, old forms from that deactivated pair are excluded from coder access and random allocation
reviewer
Secondary coding role within assigned scope.
May:
- start reviewer coding when allowed by workflow policy
- resume owned reviewer coding
- submit reviewer COD outcomes
- view reviewer records when allowed by workflow policy
Current workflow-policy note:
- reviewer participation is optional and sample-based
- reviewer coding opens only after the coder's 24-hour recode window closes
- reviewer is a coding authority, not just an accept/reject QA overlay
coding_tester
Coding role for exercising the coding workflow within assigned scope.
May:
- code submissions in scope to check that coding works, including while coding gates are closed
Rules:
- its coding does not count toward results or real workflow counts (see Auth Decorator and RBAC Gating Policy)
- tester output is stored marked as tester output and never becomes the
case's result: when a tester finishes coding a real submission, the
submission returns to the coding pool (
ready_for_coding) for a real coder; tester output is left out of every coder, DM and burndown count and never makes a case reviewer-eligible (owner decision 2026-10-03,digitva-ggc3) - it waives coding gates — site gates and per-unit gates — but never scope: a tester codes only submissions its grants cover, and a unit tester waives gates only within its own subtree (see Organization Model Policy, "Per-unit coding gates")
- the coder eligibility guard is scope, not a gate: a tester neither views nor codes forms of a deactivated (project, site) pair (security review 10 #5); demo-training practice is unaffected
May not:
- review
- grant roles
interviewer
Data-collection role within assigned scope.
May:
- register deaths and interview respondents through web intake or the mobile app, for the projects, sites and units its grants reach
- see and follow up its own cases
May not:
- code or review
See Web Intake Policy.
death_reporter
Reporting role for field workers who register deaths (ANM and MPW at a
sub-centre, ASHA at a village; owner decisions 2026-10-01 and 2026-10-06,
digitva-t6q), held at org_unit scope only. The role itself never
interviews. Since 2026-10-12 (owner) ANM and MPW may also hold
interviewer, which their grid row now permits; ASHA may not.
May:
- register a death, as an interviewer does, in its unit's subtree
- see the list of the deaths it registered, and correct the details of one
of them until an interview of it is completed (the
PATCH /intake/deathsrule)
May not:
- start or resume an interview, log contact attempts or visits, or use any worklist action; see another user's cases; code or review
A death_reporter grant needs a cadre with the can_report_deaths flag at
the unit's level. See Web Intake Policy, "Death reporters".
interview_supervisor
Supervision role for web intake, held at org_unit scope only.
May:
- view the web intake cases in its unit subtree, confirm or reject duplicate and cancel flags, and reopen terminal cases; a supervisor never assigns
May not:
- interview, code or review without the matching grant
See Web Intake Policy, "Supervisors".
Implied roles
A few roles carry the powers of another without a second grant: the
project_pi of a tree project acts as data_manager and
interview_supervisor, and an In-charge has every power of a data_manager
and of interview_supervisor in their subtree (see above).
Self-coding projects (decision 2026-10-05, digitva-xuxk). In a project
with self_coding_enabled set, coder implies interviewer: a coder grant
also lets its holder interview, at the same scope. The interviewer grant is
derived when grants are read; no row is written, and revoking the coder
grant or turning the setting off removes it. The setting can be on only while
the project's web_intake_mode is not off. Mentoring institute members are
excluded: their coder grant never implies interviewing, since they may not
hold interviewer.
This changes who can give interviewing in a site project: a data manager
there may not create interviewer grants (see "Who creates which grants"),
but may create coder grants, so in a self-coding project a data manager who
can assign coder thereby gives interviewing. See Web Intake
Policy, "Self-coding projects".
Scope Model
Authorization scope must be explicit.
Supported scope types:
globalprojectproject_siteorg_unit
Rules:
globalmeans system-wide accessprojectmeans access across all sites within that projectproject_sitemeans access only to one site within one projectorg_unitmeans access to one node of a project's organization tree and its whole subtree; the node's project is the grant's project. Used by health-system projects — see Organization Model Policy
Broad access must be granted explicitly.
One owner-decided exception to "a grant confers nothing above its unit": a grant holder may see the people in the units their grants reach plus their reporting line above (People & roles page), with plain collaborators getting initials. It is a read-only staff directory, not death access; see People and Roles Page.
The system must not infer broader access from missing values or partial keys.
Role To Scope Rules
adminusesglobalproject_piusesprojectsite_piusesproject_site, ororg_unitin an organizational project, where it is the In-charge (see "In-charge"; databaserole_scopeCHECK since migratione2b7c4d9a1f3)data_managerusesproject,project_siteororg_unitcollaboratorusesproject,project_siteororg_unitcollaborator_piiusesproject,project_siteororg_unitcoderusesproject,project_siteororg_unitcoding_testerusesproject,project_siteororg_unitreviewerusesproject,project_siteororg_unitinterviewerusesproject,project_siteororg_unit(see Web Intake Policy)death_reporterusesorg_unitonly (databaserole_scopeCHECK); it registers deaths and lists its own (see Web Intake Policy, "Death reporters")interview_supervisorusesorg_unitonly (databaserole_scopeCHECK); it supervises web intake cases in the unit's subtree, as dodata_managergrants in their own scope (see Web Intake Policy, "Supervisors"). The In-charge issite_piatorg_unit, not this role (see "In-charge")
A unit grant of data_manager or coding_tester covers the grant's whole
unit subtree on every surface (worklists, KPI and analytics, sync, the
coder-gate waiver), exactly as a grant on each site in that
subtree would, with no hidden pages. Grants flow down the tree and never up.
Coverage is decided per submission by its routed unit
(va_submissions.org_unit_id): a submission with no unit belongs to no
subtree and is reached only through a project or site grant, with one
exception. The unrouted queue of an organizational project is open to every
data_manager of the project, at any scope, unit included: each sees the
project's unrouted submissions and may route one to a unit inside their own
subtree, never outside it. A project_site data manager, whose pair has
no units below it, may route an unrouted case to any unit of the project
(owner decision 2026-10-03, security review 10 #10). The owner accepted the trade-off that a district
data manager sees unrouted cases that may belong to another district
(decision 2026-10-02; implemented in digitva-0wc stage 3). A unit grant
resolves to the forms under its subtree (a form with a submission routed
there, or whose ODK mapping falls back to a unit there), never to its whole
project. Implemented in stage 2 of digitva-djd. The
/api/v1/analytics/dm-kpi/* panels count the same subtree per submission
(digitva-m5r): a row counts when its form's (project, site) pair is in the
manager's project or site scope (never the bare site id, digitva-lh1h) or
its submission's unit is in the subtree, tested once, so a mixed manager sees
the union with nothing counted twice. The site-keyed daily aggregates (daily
grid, burndown, backlog trend) count every project at a site, so they serve
only direct sites whose every project with forms there is in the manager's
scope; the rest of the scope is added live from the raw tables, limited to
submissions outside those sites. (Superseded (owner decision 2026-10-27, digitva-0hul): the daily aggregates are
retired as a read source and every KPI is computed live; see
Data Manager KPI Framework.) Coder counts that are numbers, not
names (the coder-availability denominator of utilization, coders per language)
count the coders who can code at least one case in the manager's subtree (owner decision 2026-10-27, digitva-0hul):
project grants, grants on in-scope (project, site) pairs, and unit grants that
are ancestors or descendants of the manager's unit. They exclude ancestors above
coding_scope_level_id when the project is view_only, since those coders
code nothing (see Organization Model, "Coding scope"). This supersedes the 2026-10-03 digitva-4b3e decision for the
availability denominator only. The coder roster, which names coders, keeps the
stricter rule and lists only coders whose grant sits inside the manager's own
scope: a project grant's coders of that project at any
scope, a site manager's coders on its own (project, site) pairs, a unit
manager's coders on units in its subtree (owner decision 2026-10-03,
digitva-4b3e). Whole-form
operations need a project or site grant: a form spans several
units, and ODK-side counts cannot be narrowed to one, so a unit-only data
manager refreshes single submissions but neither syncs nor previews a whole
form, and sees other managers' sync runs only on forms a project or site
grant covers. Every other per-submission surface (attachments, workflow
history, the coder and reviewer view pages) checks the submission's own unit,
not only form access.
On a tree project a coder or reviewer grant at project or project_site
scope is a grant at the top of the tree: it views every submission of its
project or (project, site) pair, routed or unrouted, and codes or reviews them
unless the project sets a coding scope level with above_scope_coding_mode =
view_only (see Organization Model Policy, "Coding
scope"; decision 2026-10-02, digitva-7xq).
Mentoring institute members. A person who belongs to a mentoring institute
(see Organization Model Policy, "Mentoring
institutes") sits outside the health system (Tier 2 — Non-PII) and may hold
org_unit grants of site_pi (Mentoring Institution PI), coder, reviewer,
coding_tester, or collaborator, inside the subtree of a district their
institute is attached to (owner decision 2026-10-26, digitva-ewa5). None of
these mentoring-institute roles expose PII. They may not be given project- or
global-scope grants, nor in_charge, interviewer, death_reporter or
interview_supervisor. In the exceptional case that someone from outside the
health system requires PII access, they are made part of the health system
(Tier 3) and granted data_manager at the District (org_unit) level.
A unit-scoped grant may also carry a cadre_id. It is descriptive, and
nothing at runtime consults it, but it is validated on write: the cadre must
be defined at the unit's level, a coder grant requires a cadre that may
code at that level, an interview_supervisor grant a cadre that may
supervise interviews there, and a death_reporter grant a cadre that may
report deaths there (can_report_deaths). Cadre rules live in
Organization Model Policy.
Who creates which grants
admin creates every grant. The project_pi sets up the project and grants
roles in it (see Organization Model Policy,
"Spreadsheet setup"). Data managers follow the rule for the project's kind.
Site projects
Today's rule, unchanged. A data_manager creates coder, coding_tester
and data_manager grants at their own scope: a project-scope data manager
at project or project_site scope in that project, a site-scope data
manager at their own (project, site) pair only. They never create
admin, project_pi, site_pi, reviewer, collaborator,
collaborator_pii or interviewer grants. See
Data-Manager User and Grant Management.
District (organizational) projects
Decision 2026-10-02; replaces the data-manager role list recorded earlier the
same day. Implemented in digitva-0wc stage 6: every grant write (the
data-manager users page, the admin panel, the project users import) asks
authz.can_grant, and the grant lists (the data-manager users page and the
admin panel's grant list for a project PI) are
authz.grant_list_filter. A non-admin is refused before the unit and cadre
are validated, so a unit outside their scope, or one that does not exist,
gets the same 403 (digitva-xd1q).
A grant's subtree is what it covers: a project grant covers the whole
project, a project_site grant its (project, site) pair, an org_unit grant
its unit and every unit beneath it. "Strictly below" means inside the subtree
but not at the grant's own level: below a project grant are its sites and
every unit of its tree; below an org_unit grant are its descendant units;
below a project_site grant there is nothing.
project_picreatesdata_managergrants at any level of their project.- An In-charge creates
data_managergrants at their own level and on units beneath it, within their own area (the District in-charge creates the District Programme Manager), and the seven roles below anywhere in their area, as a data manager does. - A
data_managercreates:data_managergrants only strictly below their own grant, inside their own subtree;interviewer,death_reporter,coder,reviewer,coding_tester,collaboratorandcollaborator_piigrants at any level inside their own subtree, their own level included.
- The same rule applies to every data manager of a district project, whatever the grant's scope. A project-scope data manager's subtree is the whole project.
- No data manager creates In-charge,
interview_supervisor,site_pi,project_pioradmingrants, and none grants above or outside their own subtree. - Cadre validation and the mentoring-institute guard apply to every grant written this way.
The same powers cover reactivating and revoking those grants, and a data
manager sees, in the grant list, every grant they may manage. Nobody revokes
their own data_manager grant from the data-manager page.
Users page (owner, 2026-10-22, digitva-75wn)
The web app's Users page (GET /api/v1/users, .../users/<id>/activity,
POST .../users/<id>/enrolment-code) is read through one predicate,
authz.user_list_filter(actor), over va_user_access_grants. Unlike
grant_list_filter (the grants an actor may manage) it covers every
role, the actor's own grants included:
admin: every account, with or without a grant.project_pi: holders of an active grant at any scope in their project.data_managerand the In-charge: holders of an active grant inside their own subtree (a project-scope data manager: the whole project; a project_site data manager: grants at their own pair; a unit data manager or In-charge: unit grants on their unit and every unit beneath it).- Anyone else: 403.
A user is listed when at least one active grant on an active project matches; only the matching grants are shown. Contacts (email, phone) are shown on this page: see Organization Model Policy, "Users page: contacts shown". Activity counts are per project and limited to the viewer's projects (all projects for an admin); a unit data manager sees a listed user's counts for the whole project, not only their own subtree (counts carry no case detail). Issuing an enrolment code from this page: Sign-in by Mobile Number, section 6.
The list's filters (account-created date, email verified, password set,
passkey, mobile app, role, project) only narrow the viewer's scope, never
widen it. GET .../users/<id>/status (digitva-9npt) shows one listed user's
account facts (verification, password, terms, passkeys, enrolment code
history, devices, sign-in QRs, training) to the same viewers and under the
same 404 rule as activity; it never carries a password, hash, token or code.
GET .../users/<id>/placement (owner 2026-10-24, digitva-x79t) returns the
listed user's place in each project's organization, in the shape of "My
organization", to the same viewers and under the same 404 rule as status. It
is limited to the projects the viewer holds a managing grant in: a data manager
never sees the target's grants in other projects. An admin sees every project the user holds. The reply is the target's own
placement only: every people list is empty and no other worker is named, for
every viewer including an admin.
Users page admin tools: upload and grant grid (owner 2026-10-22, digitva-cgmc)
Two Users page tools are admin only (anyone else: 403; the list above is unchanged for a PI or data manager). The account upload is described in account-onboarding-and-passwords.md ("Admin account upload").
The grant grid lists accounts (every account, grantless ones included, as
the admin list does) for one chosen project, filtered by created date (the
admin's local calendar days), name or email, role in that project and
status, newest first, at most 100 a page. Per account it shows the six
grid roles at one unit of that project: death_reporter (report),
interviewer (interview), interview_supervisor (supervise), coder
(code), reviewer (review), data_manager (manager). The unit is an
organization unit in a tree project, a project-site in a site project, or
the whole project, the same scopes the admin access-grant panel accepts.
Grants the account holds in that project at other units, or of other
roles, are shown read only.
A save names one project and one unit and applies only the difference
there: grid-role grants added (created or reactivated) and removed (soft
revoked to deactive, never deleted), plus the account's status, name and
phone. Another admin's status, name and phone are not changed here (403
admin_account; the admin Users panel manages admin accounts), an admin
cannot deactivate themself, and a name may not start with =, +, - or
@ (as in uploads). Grants at other units are untouched, so choosing another unit edits
that unit, it does not move grants. Every added grant passes the admin
access-grant panel's validation (_resolve_scope_from_payload: role at
scope, unit and cadre rules of validate_org_unit_grant, the mentoring
institute guard), so the grid cannot write a grant the panel would refuse:
death_reporter and interview_supervisor need an organization unit, and
coder, interview_supervisor and death_reporter at a unit need a cadre
permitted at that unit's level (the save names one; the grid offers the
project's cadres). coder and reviewer also need the account to have
coding languages (their case lists filter on them); an account without any
is refused with a message to set them on the admin Users panel. One save is one transaction: any refusal leaves
nothing written. Each grant change is audited (log_grant_action) and the
grantee's authorization cache invalidated, as the importer's
finalize_apply does. An admin may not deactivate their own account here.
Closed Projects
A project whose va_project_master.project_status is not active resolves
no grant of any scope — project, project_site or org_unit — for any
non-admin role. A closed project is closed for everyone who reached it
through a grant.
- Grants are not revoked. Nothing deletes the row or changes its
grant_status. The grant is dormant, not gone: reopening the project restores exactly the access that existed before, and the grant's audit history is untouched. Closing a project is reversible and must stay so. - Admin bypass is unchanged.
adminis aglobalgrant and is never grant-resolved against a project, so an admin still reaches a closed project. - Redaction uses it too.
should_redact_piicounts a PII-granting grant only while its project is active, otherwise a dormant grant on a closed project would keep personal data visible on an open project's screens. It is a decision about personal data and fails open when it disagrees with the resolvers. - Every resolver uses the shared predicate.
app/services/org_grant_service.py::active_project_conditionis the only place this rule is written. Any code that turns grants into access ANDs it into its own query — as a correlatedEXISTS, never a per-grant lookup. A new resolver that enumerates grants without it silently reopens the gap, which is exactly how the two mechanisms that predated this rule came to disagree.
See also Organization Model Policy, "Unit-scoped grants".
Authorization Rule
A request is allowed only if:
- the user is authenticated and active
- the user has the required role for the action
- the target record falls inside one of the user's explicit grants
- workflow-specific constraints also pass
Workflow-specific constraints may include:
- allowed language
- workflow state
- allocation ownership
- terminal status checks
These constraints narrow access further, but they do not replace role and scope checks.
Each check runs where the action is performed (the service entry point), so
every route to it, HTML or JSON, enforces the same rule. Starting a recode,
a review, or a reviewer Step 1 / final checks form access and the
submission's coding scope for that role; a recode also requires the caller
to be the coder of the authoritative final, within its window. A write made
in a role's name (coder Step 1, coder not-codeable, reviewer NQA) requires
the caller to hold that role's active allocation for the submission; the
validator of a caller-chosen action is not enough.
Examples
Example 1
- user role:
coder - user grant:
project_site(UNSW01, NC01) - submission scope:
project_site(UNSW01, NC01)
Result:
- allowed to code, if workflow rules also allow it
Example 2
- user role:
coder - user grant:
project_site(UNSW01, NC01) - submission scope:
project_site(UNSW01, TR01)
Result:
- denied, because scope does not match
Example 3
- user role:
coder - user grant:
project(UNSW01) - submission scope:
project_site(UNSW01, TR01)
Result:
- allowed, because the explicit grant covers the whole project
Example 4
- user role:
site_pi - user grants:
project_site(UNSW01, NC01)project_site(ICMR01, NC01)
Result:
- the user may see site
NC01data in both assigned projects - the user may not see other sites in those projects unless separately granted
Example 5
- user role:
data_manager - user grant:
project(ICMR01)
Result:
- the user may browse and view submissions across all sites in
ICMR01 - the user may mark a submission Not Codeable as a data-management outcome
the user may not start coding or review
Example 6
user role:
project_pi- user grant:
project(UNSW01)
Result:
- the user may see data across all sites in
UNSW01
What Is Not A Scope
These must not be the long-term authorization boundary:
- synthetic
va_form_id - encoded business identifiers
- ODK project id
- ODK form id
Those values may help resolve context, but they are not the access boundary.
Grant Storage Baseline
Permission data should be stored as explicit user-role-scope assignments.
Recommended shape:
user_idrolescope_typeproject_idproject_site_idorg_unit_idcadre_id
Rules (database scope_shape CHECK; exactly one target column is set):
scope_type = globalis valid only for global roles such asadmin, and leaves every target column emptyscope_type = projectrequiresproject_idscope_type = project_siterequiresproject_site_id(theva_project_sitesrow, which names both project and site) and leavesproject_idNULL; the project is read from the mappingscope_type = org_unitrequiresorg_unit_idand must leaveproject_idandproject_site_idempty; the project is read from the unitcadre_idis valid only whenscope_type = org_unit- a NULL target must not imply project-wide access unless
scope_type = project - exactly one active grant per user × role × scope target
This is preferred over loosely structured JSON.
Migration Rule (historical)
Historical: the form-centric permissions this rule migrated from no longer exist, and every grant is now an explicit role and scope. Kept as the record of how legacy access was converted.
The legacy permissions were inconsistent:
- coder and reviewer permissions were form-centric
- Site PI behavior mixed form and site assumptions
- data-manager behavior had to be introduced as explicit scope-based access rather than via coder or site-PI shortcuts
Migration policy:
- resolve each legacy permission to its real project and site
- map legacy Site PI access into explicit
site_piorproject_pigrants - map read-only access into
collaboratorgrants where applicable - store future access using explicit role and explicit scope type
- do not carry forward ambiguous permissions
Implementation Baseline
See docs/policy/auth-decorator-rbac.md for the
decorator specification, HTTP status contract, blueprint migration map, and
audit findings addressed.
Implementation should separate:
- role check
- scope check
- workflow state check
They should not remain blended together behind form-id-based helpers.
The scope check is one package, app/services/authz/ (can/require,
scope_filter, can_grant/grant_list_filter, effective_roles,
reachable_unit_ids), the single source for every role and scope decision
in this document. Workflow checks (allocation, state, language, the recode
window) stay with the workflow services. The legacy permission JSONB column is kept but never read (digitva-d3y5: production holds only coder and sitepi keys, which were already ignored).
Every access goes through authz (owner 2026-10-03)
- No unchecked endpoint. Every request to the app (web pages, partials,
JSON APIs, the browser and native app APIs, file and media delivery) is
decided by
app/services/authz/. Role gates (role_required) readauthz.effective_roles; object and list access usecan/require/scope_filter. The only exceptions are a reviewed, named public list: sign-in, sign-out, password reset and verification links, the app's own static assets (CSS, JavaScript, fonts, icons, images shipped with the code), health checks and the public landing/help pages. Each entry states why. A second reviewed list covers signed-in own-account endpoints that touch only the signed-in person's own account or session, or public reference data: their profile, terms acceptance, password, passkeys, reauthentication, timezone, ending their own device session, ICD-10 search and questionnaire strings, and data-free page shells whose data comes from authz-decided APIs (owner decision 2026-10-04). Signing in is their check; they never return another person's or any submission's data. Both lists live inapp/services/authz/consulted.py, each entry with its reason. VA interview attachments are never static and never public: photos, documents, narration audio and any other file belonging to a submission are served only through the attachment and media routes, which decide with authz (READ_ATTACHMENTS) on every request; they must not be placed under, or reachable from, the static path or any public URL. - Fail closed. A non-public endpoint that completes without consulting authz is a defect. Tests probe every registered route with a user holding no grants and with each role, and fail if any non-public endpoint skips authz. In production such a request is refused (403) and logged, once the probe shows no gaps.
- Redis cache for grant lookups. A user's resolved grants are cached in
Redis so every request decides from one fast lookup. The cache is an
accelerator, never the authority:
- keyed by user and a per-user version; every grant write, user status
change, admin flag change and factor/session reset bumps that user's
version (
authz.invalidate); - a global version is bumped by any change that can alter many users' reach: a project, site, project-site or unit activated, deactivated or closed, a project's coding-scope settings, the demo-training project switch, the organization tree;
- a short TTL (5 minutes) is the backstop for anything missed;
- holds grant ids, roles, scopes and project settings only: no personal data, no submission data;
- if Redis is unavailable the grants are read from the database for that request (slower, never more permissive); a cached entry that fails to parse is discarded.
- Decisions about one submission (
can) still read that submission's routing from the database; only the grants are cached. - The data manager KPI cache keys on the scope digest (grant ids) and on this global version, so a project-site deactivation (which a unit grant's figures depend on, though the digest does not) shows in the next request, not after the 5-minute cache life. Redis unavailable: the version part is empty and the KPI cache falls back to its TTL.
- keyed by user and a per-user version; every grant write, user status
change, admin flag change and factor/session reset bumps that user's
version (
MCP access for KPI reads (owner 2026-10-27, digitva-r5s0)
AI agents may read KPI aggregates over a minimal MCP endpoint
(POST /api/v1/mcp; see Data Manager KPI Framework).
Access is the caller's own, never a shared key.
- Token: a per-user personal token in
va_api_tokens.- Stored hashed; the plain value is shown once, at creation.
- Purpose
mcp_kpi_read; it expires after at most 90 days. - Revocable by the user and by an admin.
- Created only after re-authentication.
- Where it works: only the
/api/v1/mcproute accepts it, through its ownbefore_requestbranch. Device tokens are refused there, and MCP tokens are refused on every other route.NATIVE_DEVICE_ROLESis not widened. - Scope: resolved on every call from the user's own grants, so a revoked or narrowed grant takes effect on the next call. A request can only narrow that scope; anything outside it is a 403. A unit grant confers nothing above its unit.
- Who may use it: data managers and admins. Other roles are refused.
- What comes back: aggregates only.
- A count under 5 is shown as
<5. - A rate on a denominator under 20 is left out (null).
- No names and no per-person rows; KPIs marked
totals_onlyornonein the registry return totals or nothing. - Cell-level suppression only for now; complementary suppression is needed before MCP output is published further.
- A count under 5 is shown as
- Rate limits: per token user, 60 calls a minute overall, 20 a minute for
get_kpi_values, and a daily cap. - Audit: every call is written to
va_ai_query_audit_logwith modemcp_tool(the mode check is widened in the same migration as the token table). The audit holds the tool, the KPI id, the scope and the outcome, not the returned values.
API And CSRF Baseline
Authorization services should be reusable across server-rendered, HTMX, and React clients.
Rules:
- state-changing browser requests must be served by API-capable routes or thin route handlers that call shared authorization services
- HTMX and React clients must not have separate authorization logic
- browser-originated state-changing requests must enforce CSRF protection, even when the route returns JSON
- the required CSRF header name is
X-CSRFToken - GET routes may stay read-only and must not be used as a shortcut for mutation