ADR 010: GRC assets
Status: Accepted
Context
HIPAA asks a practice to know where its patient data (ePHI) lives: which applications, devices, cloud storage, paper records and removable media hold it, who is responsible for each, how critical each one is, whether it is encrypted, and what happened to it when it was thrown out. Today RiskFlow has no list like that. Five control templates depend on it:
| Template key | HIPAA | What the list proves |
|---|---|---|
device-inventory | 164.310(d)(2)(iii) | every device and disk has an owner and a place, and the list is checked yearly |
data-criticality | 164.308(a)(7)(ii)(E) | every system is rated |
encryption-at-rest | 164.312(a)(2)(iv) | no unencrypted device, disk or cloud store |
media-disposal | 164.310(d)(2)(i) | every retired device or disk has a wipe or destroy record |
business-associate-agreements | 164.308(b)(3) | every outside company that hosts ePHI has a signed BAA |
The asset epic (#60) builds the list and lets it drive those controls. Every issue in it (API, signals, hosting check, page, import) rests on the same decisions:
- What kinds of asset are there, and which fields does each have?
- Who owns an asset, and who hosts it?
- What happens when an asset is thrown out?
- How does the list go out of date?
- What does a team with no assets see?
- Who can read and change the list?
Decision
1. The model
Asset (teamId, type, holdsPhi, status) ← team-owned
├─ type: APPLICATION · DEVICE · CLOUD_STORAGE · PAPER · MEDIA
├─ owner → User (must be on the team)
├─ hosted by → Vendor (through the team's VendorOrgLink)
│ or → hostingProviderName + externalBaaDocument → Document
├─ criticality?, maxDowntimeHours?, encryption
└─ RETIRED → disposal (method, date, note, certificate → Document)
AssetInventoryReview (teamId, reviewedAt, activeAssetCount) ← "I checked the list"
Both tables carry teamId. Every query filters by the team taken from the
principal, and a miss across teams is a 404
(ADR 004). Deleting a team deletes its assets and
reviews.
2. Asset types
| Type | Examples | Notes |
|---|---|---|
APPLICATION | EHR, billing app, patient portal | usually hosted by someone |
DEVICE | laptop, desktop, phone, tablet, server | optional deviceKind |
CLOUD_STORAGE | Dropbox, shared drive, S3 bucket | usually hosted by someone |
PAPER | paper charts, a filing cabinet | |
MEDIA | USB drive, backup disk |
deviceKind is LAPTOP | DESKTOP | PHONE | TABLET | SERVER | OTHER and only
devices may have one (check constraint). It is for display and filtering; no
rule depends on it.
Five types, not a free-text category: each control rule picks the types it looks at (§7), and a free-text field would make that guesswork. A sixth type later is an enum value and a line in each rule table.
identifier is optional free text (serial number, asset tag or URL). It is
unique per team (@@unique([teamId, identifier]); NULLs are distinct, so any
number of assets can leave it empty). CSV import (#66) uses it to match a row
to an existing asset instead of adding a duplicate.
3. Holds ePHI
Every asset has holdsPhi (default true, because that is why people add
assets). Assets that don't hold ePHI can be listed for completeness but are
never looked at by a control rule. The default is "yes" so that forgetting
to tick a box errs on the side of a red control, not a missing one.
4. Owner
ownerId points at a User, and the API only accepts a user who is a member
of the team (404 ASSET_OWNER_NOT_FOUND). If the user is deleted the column is
set to null. If the user leaves the team the column is kept (so the page
can say who used to own it), but every rule treats the asset as having no
owner. Removing a member therefore recalculates device-inventory (#63).
5. Hosting
Vendor is a global table: vendors log in on their own and can work for
many teams (ADR 003). A team sees a vendor only
through its VendorOrgLink. An asset is hosted by one of:
- One of the team's vendors (
hostingVendorId). The API accepts a vendor only when it has aVendorOrgLinkto the team that is notREVOKED; any other vendor is 404ASSET_VENDOR_NOT_FOUND, the same answer whether the vendor doesn't exist or works only for someone else. Whether the vendor has signed comes from the BAA coverage logic ingrc-evidence/baa, not from a copy of it (#62, #64). - A provider typed by hand (
hostingProviderName), for big providers whose BAA is signed outside RiskFlow (Google, AWS, Microsoft). They will never log in to sign, so the BAA file can be attached to the asset asexternalBaaDocumentId→Document(same team, else 404ASSET_DOCUMENT_NOT_FOUND). - Nobody: in-house devices, paper.
A check constraint allows at most one of the two hosting fields
(assets_hosting_check), and only a typed provider can carry
externalBaaDocumentId. The API checks first and answers 400
ASSET_HOSTING_CONFLICT, so the constraint is only a safety net.
When the vendor leaves the team. Removing a vendor deletes its
VendorOrgLink, but the asset keeps hostingVendorId. We don't clear it: "the
EHR is hosted by a company you no longer work with" is exactly the problem the
business-associate-agreements control should show. The hosting rule (#64)
treats a vendor without a non-revoked link to the team as not covered,
whatever it signed. The foreign key is SetNull, so only deleting the global
vendor itself clears the column.
6. Criticality, downtime and encryption
- Criticality is
LOW | MEDIUM | HIGH | CRITICAL, nullable. Null means "not rated yet", so a quick add or a CSV import doesn't have to guess. Thedata-criticalityrule fails on unrated systems. maxDowntimeHoursis optional (≥ 0): how long the practice can work without it. It feeds contingency planning later and drives no rule today.- Encryption is
ENCRYPTED | NOT_ENCRYPTED | UNKNOWN, defaultUNKNOWN. It is self-reported for now. Checking it for real (Google endpoint management, an MDM) is module 10.NOT_ENCRYPTEDfailsencryption-at-rest;UNKNOWNsendsMISSING, so the control shows Needs evidence until someone answers.
7. Which assets drive which control
Only active assets that hold ePHI count, except for disposal, which looks at retired ones. The full rules live in #63 and #64; the scopes are fixed here:
| Control | Assets in scope | Fails when |
|---|---|---|
device-inventory | active DEVICE, MEDIA | no owner on the team, or no location; STALE when the yearly review is overdue (§9) |
data-criticality | active APPLICATION, CLOUD_STORAGE | not rated |
encryption-at-rest | active DEVICE, MEDIA, CLOUD_STORAGE | NOT_ENCRYPTED (UNKNOWN → MISSING) |
media-disposal | retired DEVICE, MEDIA | no disposal record |
business-associate-agreements | active, with hosting set | vendor not covered, or typed provider with no BAA file |
PAPER is in no rule today; paper charts are listed so the SRA and risks can
point at them later.
All of this is one more signal source on the existing
ControlStatusService (source: "assets"), not a new status engine. Each
signal names the assets behind it in items, the same shape evidence uses.
8. Retire, don't delete
Assets are retired, not deleted: status goes from ACTIVE to
RETIRED and retiredAt is set. The row stays for history and so the
disposal can be proven later. Disposal is:
disposalMethod:WIPED | DESTROYED | RETURNED | OTHERdisposedAt: the date it happeneddisposalNote: optional ("Shredded by SecureShred, ticket 4471")disposalDocumentId: optional certificate, aDocumentof the same team
Disposal can be recorded at retirement or any time after. Recording it on an
active asset is 400 ASSET_NOT_RETIRED. Two check constraints keep the data
honest:
assets_retired_check: an active asset has noretiredAtand no disposal fields; a retired one hasretiredAt.assets_disposal_check: method and date are set together, and a note or a certificate needs a method.
An asset retired by mistake can be made active again while it has no disposal
record (the API clears retiredAt). Once disposal is recorded, the device is
gone and the asset stays retired.
Hard delete (DELETE /assets/:id) exists only for mistakes, such as a
duplicate row. It is audited like every other write.
Documents linked from an asset (BAA file, certificate) are SetNull:
deleting the file in Documents clears the link, and the control turns red
again on the next recalculation if that file was the proof.
9. Inventory review
HIPAA expects the list to be checked, not just kept. A person with
MANAGE_ASSETS can mark the whole list reviewed, which writes an
AssetInventoryReview (who, when, how many active assets, optional note).
Reviews are never edited or deleted; the latest one is what counts.
The yearly clock starts at the last review, or, if there was never one, at
the oldest active ePHI asset in the device-inventory scope. So a brand-new
list is not stale on day one, and a list that was imported a year ago and
never looked at again is. More than 365 days → device-inventory gets a
STALE signal (Needs evidence) until someone reviews.
The clock is checked by a step in the existing daily
/internal/grc-evidence cron (#63), not by a new cron. AssetInventoryDueCheck
keeps, per team, the due date that step last recalculated, so each due date is
recalculated once after it passes (a second run the same day does nothing, a
missed day is caught on the next).
10. No assets means no signal
A team that hasn't added assets keeps its controls exactly as the other
sources leave them. A rule whose scope is empty sends nothing; it never sends
MISSING for "you have no laptops". Turning on HIPAA must not turn five
controls yellow on day one, the same choice as evidence
(ADR 009 §5).
11. Why a table of its own
Assets could have been a new ControlEvidence kind. They are not, because
other modules will point at them: risks (module 5) are about assets, and the
signed SRA (module 7) freezes the asset list. An asset is a thing the practice
owns, with a life of its own (owner, hosting, retirement), not proof attached
to one control. The asset signal source reads the table; nothing is copied
into evidence.
12. Permissions
| Action | Permission |
|---|---|
| List and read assets, the summary and reviews | ACCESS_CONTROLS |
| Add, edit, retire, dispose, delete, import, mark reviewed | MANAGE_ASSETS |
MANAGE_ASSETS = 1 << 26 is a new bit. We keep it: the person who keeps
the device list in a small practice is usually the office or IT manager, and
they should be able to do that without being able to mark controls not
applicable or change owners. MANAGE_ASSETS implies ACCESS_CONTROLS (a spec
checks every preset).
- Presets: every preset that has
MANAGE_CONTROLSgets it (MSP_SUPER_ADMIN,ORG_ADMIN). - Stored roles: a backfill migration ORs the bit into every
UserTeamwhose stored permissions includeMANAGE_CONTROLS, including custom roles, so nobody who could change controls yesterday loses the asset list today. Presets are also merged in at read time. - Uploading files. Recording a certificate or a BAA file saves it into
Documents. A custom role with
MANAGE_ASSETSbut withoutUPLOAD_DOCScan still pick a document that already exists; uploading from the asset page needsUPLOAD_DOCStoo (#62).
Assets are ORG-team data. Vendors never see them (they have no permission
bitfield; see AGENTS.md).
13. Audit
Category COMPLIANCE, resource asset (or asset_inventory_review):
| Action | When |
|---|---|
compliance.asset_created | one asset added |
compliance.asset_updated | any field changed, including making a retired asset active again |
compliance.asset_retired | status set to RETIRED (with disposal, if sent in the same call) |
compliance.asset_disposed | disposal recorded after retirement |
compliance.asset_deleted | hard delete of a mistake |
compliance.asset_inventory_reviewed | the list was marked reviewed |
compliance.assets_imported | one event per CSV import, with counts, not one per row |
Every write runs in one transaction with its audit event, then recalculates only the asset-related controls of that team, once per request (an import is one request, so one recalculation).
14. Error codes
| Code | HTTP | When |
|---|---|---|
ASSET_NOT_FOUND | 404 | no such asset in this team |
ASSET_OWNER_NOT_FOUND | 404 | the owner is not a member of this team |
ASSET_VENDOR_NOT_FOUND | 404 | the vendor has no live link to this team |
ASSET_DOCUMENT_NOT_FOUND | 404 | the BAA file or certificate is not in this team |
ASSET_HOSTING_CONFLICT | 400 | both a vendor and a typed provider, or a BAA file without a typed provider |
ASSET_NOT_RETIRED | 400 | disposal on an active asset |
ASSET_ALREADY_DISPOSED | 400 | making a retired asset active again after its disposal was recorded |
ASSET_DEVICE_KIND_INVALID | 400 | a device kind on an asset that is not a DEVICE |
ASSET_ORG_WORKSPACE_ONLY | 400 | any asset route called from an MSP workspace |
ASSET_IDENTIFIER_TAKEN | 409 | another asset of the team has the same identifier |
ASSET_IMPORT_FILE_TOO_LARGE | 413 | the CSV import file is over 1 MB (#66) |
ASSET_IMPORT_TOO_MANY_ROWS | 400 | the CSV import file has more than 1,000 rows |
ASSET_IMPORT_HEADER_INVALID | 400 | the CSV is empty or has no name and type columns |
ASSET_IMPORT_HAS_ERRORS | 400 | a real import was sent with error rows; nothing was saved |
Consequences
- The tables exist before anything uses them. Nothing visible changes until the API (#62), the signals (#63, #64) and the page (#65).
- Encryption is only as true as what people type. The page must say so, and module 10 replaces it with real checks for devices Google or an MDM knows.
- Keeping
hostingVendorIdafter a vendor is removed means a team can see an asset "hosted by" a vendor that is no longer in its vendor list. That is on purpose (§5); the page should say "no longer your vendor". - A new asset type or disposal method is a migration (enum value), plus a line in the rule table. That is the price of rules that can pick types.
MANAGE_ASSETSis one more box on the roles page. If it turns out nobody uses it apart from admins, it can be folded intoMANAGE_CONTROLSlater with a migration that drops the bit; nothing else depends on it being separate.