Feature breakdown · built with CraftHQ
File upload with virus scanning
Whole-team time at this build's pace
2 people incl. the product owner, requirements to merge
Estimate at this build's pace, not a measurement of this feature
Every upload is encrypted at rest, versioned, and admitted only if its content matches an allowed type; files live in a per-customer store and are served only as authenticated downloads. A virus-scanning step is built and tested and switched on per deployment; when enabled, every upload is scanned, a file reported infected is blocked on download and audited, and an upload is refused if the scanner cannot be reached.
How long a feature this size usually takes
Whole-team time — this build vs a typical team
25 FP · FP × 6.8 to 9.4 h (ISBSG 2023)
Estimate at this build's pace, not a measurement of this feature
Both figures are whole-team time over the whole lifecycle, so they compare like for like. Ours is this build's pace — about 0.8 hours per function point across the whole build (two people, including the product owner, with requirements and review time). The industry range is an ISBSG project-level figure applied to one feature, so it is indicative.
Workflow
How it moved through the workflow
Select a stage. The build shipped, so every stage is complete; a person approved the merge.
Request
Files come in from outside the organisation. Each one must be private to its owner, kept as versions, and admitted only if it is a safe, expected type.
Questions
One design decision was recorded before building: scan before storing, or store first and gate the download behind the scan verdict.
Approved
The product owner signs off the breakdown by moving the story into the approved state, and the build runs against that reference.
Definition of ready
Building
CraftHQ's build agents write the code and the tests together, run as a loop; a failing check halts the work until it passes.
Checks
Three checks run on every pull request: one on the product owner's machine as CraftHQ builds, one on the engineer's machine before review, and a code-quality scan in CI. Merging is gated by the CI check passing and a person's approval, enforced by branch protection.
Review
An engineer reviews the pull request and merges it.
Merged
The pull request is merged to main after a person approves it. Nothing merges without that approval.
Definition of done
Breakdown
The feature breakdown
Business first, then the engineering view. Tab through it.
Overview
The gap it closes
Files arrive from outside the organisation, so every one has to be kept private, kept versioned, and admitted only if it is a safe, expected type.
Who uses it
Customer users supply files into their own workspace; staff attach and verify files on their side.
User story
As a customer, I want to upload my files securely so that only my team sees them and only safe, expected file types reach the platform.
Examples
Example 1
A customer drags a file in. Its size and content type are checked, it is stored encrypted as a new version in the customer's own container, and it is served back only as an authenticated download.
Example 2
A file reported infected is blocked on download and the detection is recorded in the audit trail.
Dependencies & risks
Depends on
A versioned, encrypted file store and a self-hosted virus-scan service.
Risk & mitigation
The first line of defence is admission: a strict content-type allowlist with a magic-byte check, so archives, executables, HTML and SVG never reach the platform. The virus scanner then scans every upload, and a file reported infected is blocked on download.
How it works
Simplified diagrams of the parts involved, the steps of one request, and where it runs.
Architecture
- 1upload
- 2admit, then store encrypted
- 3scan the stored file
- 4verdict (response)
- 5record the outcome
Sequence
- 1upload a file
- 2admit: content type by bytes, 50 MB cap, sanitise the name
- 3store encrypted as a new version
- 4scan the stored file
- 5scan verdict
- 6record the verdict
- 7served as an authenticated download; an infected version is blocked
Infrastructure
- 1upload (authenticate + tenant check at the edge)
- 2admit, then store encrypted
- 3scan the stored file
- 4record scan state and audit
Acceptance criteria
Each scenario is written as a Gherkin spec. The Engineering tab shows the test that covers each one, or notes where it was verified by review.
@files @securityFeature: File upload with virus scanningAs a customerI want to upload my files securelySo that only safe, expected file types reach the platform and only my team sees themEvery upload is admitted only if its content matches an allowed type, then encrypted at rest and version-tracked. A virus-scanning step is built and tested and switched on per deployment; when enabled, every upload is scanned and a file reported infected is blocked on download.Background:Given a customer is signed in to their own workspaceAnd an upload slot admits a set of allowed file types with a 50 MB size capRule: A file is admitted by its content, not its name or extensionScenario: A file whose bytes do not match its claimed type is refusedGiven a customer uploads a file whose content does not match its declared typeWhen the upload path checks the first bytes against the declared typeThen the file is refused before it is storedScenario Outline: Archives, executables and active-content files are refused at the allowlistGiven a customer uploads a "<kind>"When the upload path checks the type against the allowlistThen the file is refused outright and never stored or expandedExamples:| kind || zip or rar archive || executable || HTML or SVG file |Scenario: An oversize file is refused before it is storedGiven a customer uploads a file larger than 50 MBWhen the size is checked from the declared length and again as the bytes streamThen the file is refusedScenario: A file name is sanitised and never used as the storage keyGiven a customer uploads a file with an unsafe nameWhen the file is storedThen the name is sanitised for display and the storage key is system-generatedRule: Files are encrypted at rest in a per-customer store and version-trackedScenario: An upload is encrypted and isolated per customerGiven a customer uploads an allowed fileWhen it is storedThen only ciphertext is written, in that customer's own storage containerScenario: Re-uploading keeps the earlier versionGiven a file has already been uploaded to a slotWhen a newer file is uploaded to the same slotThen the newer file is stored as a new version and the earlier version is retainedRule: The virus-scanning step is built and tested and switched on per deploymentScenario: With the scanning step enabled, a clean file is scanned and usableGiven the virus-scanning step is enabledAnd a customer uploads an allowed fileWhen the scanner reports the file is cleanThen the file is recorded clean and can be usedScenario: With the scanning step enabled, an infected file is blockedGiven the virus-scanning step is enabledWhen the scanner reports an uploaded file infectedThen every download of that version is blocked and the detection is written to the audit trailScenario: With the scanning step enabled, an unreachable scanner refuses the uploadGiven the virus-scanning step is enabled but the scanner cannot be reachedWhen a customer uploads a fileThen the upload is refused and the attempt is recorded in the audit trailRule: Downloads are served privatelyScenario: A download is served as an attachmentGiven a stored file is requestedWhen it is servedThen it is sent as an attachment over an authenticated session, never rendered inline and never from a public URLScenario: Download is scoped to the owner's organisationGiven a file belongs to one organisationWhen a user from another organisation requests itThen the download is refused@edge-casesScenario Outline: Admission and scan outcomesGiven a customer uploads a "<type>" fileWhen the upload and any enabled scan completeThen the result is "<result>"Examples:| type | result || allowed, scanner clean | scanned, recorded clean, usable || allowed, scanner infected | blocked on download || archive or executable | refused at the allowlist || content does not match type | refused before storing |
Questions
Questions raised in planning, each with the decision taken.
Should a file be scanned before it is stored, or stored first and then scanned?
Scanning before storing blocks the write on the scanner; storing first keeps uploads responsive, with the scan verdict gating the download.
How we sized it
A function point measures what the software does — its inputs, outputs, lookups and the data it stores — regardless of language or how many lines it took. Each counted item is weighted by complexity: a user input counts 3–6 points, an output or report 4–7, a lookup 3–6, data the feature owns 7–10, and data read from elsewhere 5–7.
- The document store this feature ownsData the feature owns10
- Upload a file (encrypted, versioned, sent to scan)User input6
- Download a file, scoped to who may see itLookup4
- Record and show the virus-scan verdictOutput or report5
- Total25
Components touched
Upload path
Accepts a file into an upload slot, enforces a single 50 MB cap (from the declared length and again as the bytes stream), sanitises the file name, and admits the file only if a magic-byte check of its first bytes backs the declared type. Archives, executables, HTML and SVG are not on the allowlist and are refused outright.
Encrypted file store
Keeps files envelope-encrypted at rest (AES-256-GCM, a per-object data key wrapped by a master key); only ciphertext is written. Each customer has their own container; a key that is not customer-scoped is rejected. Versions are tracked, so a re-upload keeps the earlier copy.
Virus-scan adapter
Speaks to a self-hosted virus-scan service over its network protocol.
Scan gate
Scans every upload; a file reported infected is blocked on every download of that version and the detection is audited, and an upload is refused if the scanner cannot be reached.
Download handler
Serves a file as an attachment over an authenticated session, scoped to the owning organisation, never inline and never from a public URL.
Data touched
File storewrite
Envelope-encrypted (AES-256-GCM), versioned file contents in a per-customer container; keys are held by the platform.
Scan verdictwrite
The per-version scan verdict the gate records, read before a download is served.
Audit logappend
Upload, an infected detection, an upload refused because the scanner could not be reached, and download events.
Virus scannerinternal
A self-hosted scan service the app calls.
Security decisions
- Files are envelope-encrypted at rest (AES-256-GCM, a per-object key wrapped by a master key).
- Content type is admitted by an allowlist plus a magic-byte check of the first bytes; archives, executables, HTML and SVG are refused at the allowlist, so there is no inline-active-content path.
- A single 50 MB cap is checked before and during the stream; the file name is sanitised and never used as the storage key (keys are system-generated and a path-traversal key is rejected).
- Each customer's files are in their own storage container.
- Downloads are served as attachments over an authenticated session, never inline and never from a public URL.
- Every upload is scanned by the virus scanner; a file reported infected is blocked on download and audited, and an upload is refused if the scanner cannot be reached.
Subtasks
- Upload path with per-slot allowed types, a 50 MB cap, name sanitising and a magic-byte content check.
- Envelope-encrypted, versioned file store with a per-customer container.
- Virus-scan adapter to a self-hosted scan service.
- A post-store scan gate that scans every upload, blocks a file reported infected on download, and refuses an upload if the scanner cannot be reached.
- Access-scoped download served as an attachment.
How each scenario is verified
- Admission: type, size and nameAn upload-admission test rejects an off-allowlist content type and archives, sanitises the file name, refuses a non-octet-stream transport, and stops the hashing stream at the 50 MB cap.
- Content type is checked by bytes, not nameA content-sniffing test accepts a valid head, rejects an executable head for an allowed type, rejects a cross-type mislabel, and rejects a binary head for a text type.
- The scanning step blocks an infected fileAn integration test against a scan daemon stores and serves a clean file, and records, audits and hard-blocks an infected file; an unreachable scanner refuses the upload and records the attempt.
- Encryption at restAn envelope-encryption test proves only ciphertext is written, and tamper, truncation or substitution yield no plaintext; the same plaintext twice gives different ciphertext.
- Per-customer storageA storage test round-trips ciphertext, gives two customers separate containers, and treats a non-customer-scoped key as an invariant breach.
- End-to-end documentsA documents integration test stores ciphertext and recovers plaintext with a matching hash, blocks an infected version on download, and never inherits a prior verdict on re-upload.
- Download scoped to the owner's teamVerified by review of the access-scoped, attachment-only retrieval path.
Risk tier
Core A person approves every merge. This is a Core-tier change: it handles untrusted files, so it also carries the fullest set of automated tests; a regression here affects the whole product, not one page.
Start here
Want this on your backlog?
Get a free baseline report on one repository, or scope a 3-month pilot measured against what your team’s work costs today.