Repository navigation
Document Intake
Reference for the pipeline that takes a new document from a browser, scans it for malware, and
releases it to storage. The decision is in ADR-015-Malware-Scanning-on-Intake. The pipeline is
defined in the eagle-demi repository.
The pipeline is built and merged (eagle-demi main 9dc8858, PRs #606 and #607) and parked, as
of 2026-10-14. deployDocumentIntake is false in azure/main.test.bicepparam and
azure/main.prod.bicepparam. No INTAKE_* app setting is set on test or prod, so
POST /intake/uploads answers 503 and the sweep does nothing. How to resume is under Resume.
The first test infra deploy (infra-fnd-9dc8858-234425) failed on two things. The resources it
created were deleted from test on 2026-10-15: the storage account demiintestvymaysch2agdq, the
identity demi-intake-test, the Defender setting, the three alert rules and the Storage Blob Data
Owner grant. Test holds no intake resources and nothing bills. The foundation deploy recreates
them all when the module is turned back on.
- Private endpoint
demi-intake-pe-test. The subnetc4b0a8-test-cond-ext-pe-subnet(10.46.52.32/28) is full: 7 live endpoints hold all 11 usable IPs, and none is orphaned. The prod subnet has 5 free IPs. TheprivateEndpointsubnet (10.46.52.144/28) in the same vnet has 3 free IPs. It is the candidate forprivateEndpointSubnetIdon test. - Role assignment conditions. Azure rejected the Storage Blob Data Contributor conditions with
InvalidRoleAssignmentCondition: Action pattern .../blobs/tags/write is not referenced in role ba92f5b4-.... A condition may only name actions its role grants. The fix is in a pending PR: the Contributor grants deny only theBlob.Write.WithTagHeaderssub-operations, and the Owner grant onincomingalso deniesblobs/tags/write.
The deploy showed that Azure accepts a container-scoped Storage Blob Data Owner grant with a condition that has no OR branch.
Not yet verified: the casing of the tag key Malware scanning scan result, the key and format of
the scan time tag, and the rest of the API identity's condition. The EICAR proof on test checks
them, and it needs the private endpoint first.
- The client calls
POST /intake/uploadswithprojectId,fileNameand optionallyrequestedRead. The call creates adocumentsrow withscanStatus: 'pending'andread: []. - The response carries
documentId,uploadUrlandexpiresAt. The link is a user-delegation SAS forincoming/<documentId>and lasts 15 minutes. The client PUTs the file to it. - Defender for Storage scans the blob on upload. It writes the blob index tags
Malware scanning scan resultandMalware scanning scan time. - The timer
intakeScanSweepindemi-api-fc-<env>runs every 5 minutes. It reads the tags of eachpendingrow's blob withdemi-intake-<env>. - The sweep applies the result (table under Scan results).
| Resource | Name | Notes |
|---|---|---|
| Storage account | demiin<env><hash> |
Defined in azure/modules/document-intake.bicep. Shared keys off. Public blob endpoint on, because the browser uploads to it. Private endpoint for the API app. Blob soft delete on. |
| Containers |
incoming, quarantine, deadletter
|
Uploads land in incoming. Rejected files are copied to quarantine. Defender skips quarantine and deadletter. |
| Identity | demi-identity-<env> |
The API identity. Limited by the access rule below. |
| Identity | demi-intake-<env> |
Second identity on the API app. Reads blob tags in the sweep and writes the quarantine copy. It can never write a tag. |
| Alerts |
demi-intake-scan-cap-75-<env>, demi-intake-scan-cap-100-<env>, demi-intake-sweep-silent-<env>
|
Notify demi-alerts-<env>. See Scan cap. |
Set on the intake account by Microsoft.Security/defenderForStorageSettings.
| Property | Value |
|---|---|
isEnabled |
true |
overrideSubscriptionLevelSettings |
true |
malwareScanning.onUpload.isEnabled |
true |
malwareScanning.onUpload.capGBPerMonth |
intakeCapGBPerMonth: 5 on test, 25 on prod |
filters.excludeBlobsWithPrefix |
quarantine, deadletter
|
filters.excludeBlobsLargerThan |
2147483648 bytes (2 GB) |
malwareScanning.blobScanResultsOptions |
blobIndexTags |
sensitiveDataDiscovery.isEnabled |
false |
A file over 2 GB is not scanned. The sweep rejects it as not-scanned. Every other storage account in DEMI has
Defender off (Azure-Environments).
The sweep matches the value of the Malware scanning scan result tag. Key names match without
regard to case. Only the exact value No threats found is clean. Other values match by prefix.
| Tag value | Row scanStatus
|
scanRejectReason |
File |
|---|---|---|---|
No threats found |
clean |
none | Streamed to the NRS object store at <projectId>/<id>.<ext>, then deleted from incoming. The row takes its requestedRead list. |
Starts with Malicious
|
rejected |
malicious |
Copied to quarantine. |
Starts with Not scanned
|
rejected |
not-scanned |
Copied to quarantine. |
Starts with Scan failed or Error
|
rejected |
scan-error |
Copied to quarantine. |
| Any other value | rejected |
unknown-result |
Copied to quarantine. |
The sweep sets scanStatus, scanResult (the raw tag value), scanRejectReason, scannedAt,
scanSizeBytes and malwareNames. scannedAt is the time of the sweep run, not the scan time.
malwareNames is always empty, because tags carry no malware names. A rejected row has
read: [] and isPublished: false. Rejected rows are hidden from every read except the status
route below. No admin list of rejected rows exists.
GET /intake/uploads/:id is for the uploader. It returns documentId, scanStatus,
scanRejectReason, scannedAt and createdAt. It answers 404 for a row without a scanStatus,
so it never reads an ordinary document, and 503 when intake is off.
The sweep writes a row only while it is pending, so a repeated run changes nothing. A clean tag
whose Malware scanning scan time is older than the blob's last write is ignored until the new
bytes are scanned. A missing or unreadable scan time also leaves the row pending.
The timer intakeScanSweep runs every 5 minutes (INTAKE_SWEEP_SCHEDULE). Each run has two passes.
- Result pass. It walks
pendingrows oldest first, in pages of 100. For each row it stats the blob inincoming, then reads the tags. A row with no result tag stayspending. The pass stops after 500 rows or 20 minutes per run. The rows left are logged as a warning withrowsLeftand wait for the next run. A failed tag read leaves the rowpending, is logged as an error and never rejects it. - Timeout pass. It rejects rows still
pendingafter 6 hours (INTAKE_PENDING_TIMEOUT_HOURS).
The documents container indexes /scanStatus/?, /createdAt/? and /scannedAt/?. The result
pass needs the first two for its query and its ordering.
A result reaches the row up to about 5 minutes after the scan ends. Scanning takes up to 3 hours, so a rejection by the timeout pass is final.
scanRejectReason |
Meaning |
|---|---|
timeout |
The file arrived and no result came. The file is copied to quarantine. |
no-upload |
The link was issued and never used. The result pass sets it once the row is older than the 15-minute link plus 5 minutes and has no blob. |
The API identity demi-identity-<env> holds Storage Blob Data Contributor on the intake account
with an ABAC condition. All three parts must hold.
- A blob read is allowed only in
incoming, and only when the blob'sMalware scanning scan resulttag equalsNo threats found. Listing is not restricted. - Writes and deletes are allowed only in
incoming. - No write may set the
Malware scanning scan resulttag.
The identity also holds Storage Blob Delegator, which signs the upload links.
demi-intake-<env> holds these assignments:
-
Storage Blob Data Contributoron the account, with a condition that denies tag writes. -
Storage Blob Data Owneron theincomingcontainer, with the same condition. Owner supplies the tag read that Contributor lacks. -
Storage Blob Delegator, without a condition.
Neither conditioned assignment can write a tag, so the sweep cannot mark a blob clean. A wrong
sweep decision cannot release an unscanned file: the API identity's read of incoming gets 403
until the clean tag exists.
Set on demi-api-fc-<env> by azure/modules/api-function-flex.bicep. When
INTAKE_STORAGE_ACCOUNT is empty, the app registers neither intake function and
POST /intake/uploads answers 503.
| Setting | Value |
|---|---|
INTAKE_STORAGE_ACCOUNT |
the intake account name |
INTAKE_INCOMING_CONTAINER |
incoming |
INTAKE_QUARANTINE_CONTAINER |
quarantine |
INTAKE_QUARANTINE_CLIENT_ID |
client id of demi-intake-<env>
|
INTAKE_SWEEP_SCHEDULE |
0 */5 * * * * |
INTAKE_PENDING_TIMEOUT_HOURS |
6 |
INTAKE_CAP_GB_PER_MONTH |
intakeCapGBPerMonth, the same cap Defender uses |
Defender stops scanning for the rest of the month when the cap is reached.
The sweep intakeScanSweep logs this line on every run, every 5 minutes. scannedBytes is the
sum of scanSizeBytes over rows whose scannedAt is in the current UTC month. capBytes is
INTAKE_CAP_GB_PER_MONTH in bytes. The cap alerts read the newest such line:
[intake] month-to-date scannedBytes=<N> capBytes=<M>
One alert fires when scannedBytes reaches 75% of capBytes (severity 2), one at 100%
(severity 1). Both run hourly and notify demi-alerts-<env>.
demi-intake-sweep-silent-<env> checks every 30 minutes and fires when no such line has appeared
for 2 hours. The sweep is then not running, so pending uploads are not decided or timed out and
the cap alerts cannot fire. It fires once on resume if the code deploy comes more than 2
hours after the infra pass.
At the cap, new uploads receive no scan result. The sweep rejects them after 6 hours, so the
pipeline fails closed. To recover, raise intakeCapGBPerMonth in the environment's bicepparam and
rerun infra (Operating-DEMI-in-Azure).
Measured from Eagle prod staff uploads, 2026-10-14: mean 1.06 GB and 130 files a month, peak 5.28 GB in 2025-05. The account costs about 9.80 USD a month plus 0.15 USD per GB scanned.
| Environment | Expected | At the cap |
|---|---|---|
| Test | about 9.80 USD a month | 10.55 USD |
| Prod | about 9.96 USD a month, peak month about 10.60 USD | 13.55 USD |
Infra goes before the code that reads it. Test first, then prod.
- Pick a private endpoint subnet with free IPs. Override
privateEndpointSubnetIdinazure/main.test.bicepparam, or get the landing-zone subnet enlarged. - Set
deployDocumentIntake = truefor test. - Run the infra pass:
scripts/deploy-infra.sh test --foundation --live. - Set
INTAKE_STORAGE_ACCOUNTthrough the normal code deploy. - Apply the documents datasource:
scripts/demi-devbox.sh apply --env test --datasources demi-documents-ds --yes. - Prove the pipeline with an EICAR test file built at run time, never committed. Upload it and a
clean file through
POST /intake/uploadsand watchGET /intake/uploads/:id. The EICAR file must be taggedMalicious, sit inquarantine, and its row must berejected. The clean file must reach the object store with its rowcleanand no blob left inincoming.
If a clean release returns 403, check the casing of the tag key first. Also check that the
Malware scanning scan time tag parses as a date.
Before prod:
- Replace the Storage Blob Data Owner grant with a custom role that holds only
blobs/tags/read. - The first prod foundation deploy after the merge reindexes the
documentscontainer online.
- ADR-015-Malware-Scanning-on-Intake — why Defender, why one account
- Azure-Environments — Defender state on the other accounts, spend
- Architecture — document storage
- 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