|
| 1 | +# De-identification |
| 2 | + |
| 3 | +Aidbox supports per-column de-identification in ViewDefinitions via a FHIR extension. When a column has a de-identification extension, the SQL compiler wraps the column expression with a PostgreSQL function that transforms the value before it reaches the output. |
| 4 | + |
| 5 | +This works with all ViewDefinition operations: `$run`, `$sql`, and `$materialize`. |
| 6 | + |
| 7 | +## Extension format |
| 8 | + |
| 9 | +Add the de-identification extension to any column in the `select` array: |
| 10 | + |
| 11 | +```json |
| 12 | +{ |
| 13 | + "name": "birth_date", |
| 14 | + "path": "birthDate", |
| 15 | + "extension": [ |
| 16 | + { |
| 17 | + "url": "http://health-samurai.io/fhir/core/StructureDefinition/de-identification", |
| 18 | + "extension": [ |
| 19 | + {"url": "method", "valueCode": "dateshift"}, |
| 20 | + {"url": "dateShiftKey", "valueString": "my-secret-key"} |
| 21 | + ] |
| 22 | + } |
| 23 | + ] |
| 24 | +} |
| 25 | +``` |
| 26 | + |
| 27 | +The extension uses sub-extensions for the method and its parameters. The `method` sub-extension is required and specifies which de-identification method to apply. |
| 28 | + |
| 29 | +## Methods |
| 30 | + |
| 31 | +### redact |
| 32 | + |
| 33 | +Replaces the value with NULL. No parameters. |
| 34 | + |
| 35 | +```json |
| 36 | +{"url": "method", "valueCode": "redact"} |
| 37 | +``` |
| 38 | + |
| 39 | +### cryptoHash |
| 40 | + |
| 41 | +Replaces the value with its HMAC-SHA256 hash (hex-encoded). Deterministic — same input always produces the same hash. One-way, cannot be reversed. |
| 42 | + |
| 43 | +| Parameter | Type | Required | Description | |
| 44 | +|-----------|------|----------|-------------| |
| 45 | +| cryptoHashKey | string | yes | HMAC secret key | |
| 46 | + |
| 47 | +```json |
| 48 | +[ |
| 49 | + {"url": "method", "valueCode": "cryptoHash"}, |
| 50 | + {"url": "cryptoHashKey", "valueString": "my-hash-key"} |
| 51 | +] |
| 52 | +``` |
| 53 | + |
| 54 | +### dateshift |
| 55 | + |
| 56 | +Shifts date and dateTime values by a deterministic offset derived from the resource id. All dates within the same resource shift by the same number of days, preserving temporal relationships. The offset range is -50 to +50 days. |
| 57 | + |
| 58 | +Year-only values (`"2000"`) and year-month values (`"2000-06"`) cannot be shifted meaningfully and are replaced with NULL. |
| 59 | + |
| 60 | +| Parameter | Type | Required | Description | |
| 61 | +|-----------|------|----------|-------------| |
| 62 | +| dateShiftKey | string | yes | HMAC key used to compute the per-resource offset | |
| 63 | + |
| 64 | +```json |
| 65 | +[ |
| 66 | + {"url": "method", "valueCode": "dateshift"}, |
| 67 | + {"url": "dateShiftKey", "valueString": "my-shift-key"} |
| 68 | +] |
| 69 | +``` |
| 70 | + |
| 71 | +### birthDateSafeHarbor |
| 72 | + |
| 73 | +Intended **only for `Patient.birthDate`**. Behaves like `dateshift` but returns NULL when the birth date implies the patient is over 89 years old, per HIPAA Safe Harbor rule 45 CFR 164.514(b)(2)(i)(C). |
| 74 | + |
| 75 | +Applying this method to any other date column is semantically incorrect — the function computes `age(current_date, input)` and treats the input as a birth date. Use plain `dateshift` for non-birth-date fields. |
| 76 | + |
| 77 | +Because the function depends on `current_date`, it is marked `STABLE` rather than `IMMUTABLE`. The age cutoff re-evaluates on every query. |
| 78 | + |
| 79 | +| Parameter | Type | Required | Description | |
| 80 | +|-----------|------|----------|-------------| |
| 81 | +| dateShiftKey | string | yes | HMAC key used to compute the per-resource offset | |
| 82 | + |
| 83 | +```json |
| 84 | +[ |
| 85 | + {"url": "method", "valueCode": "birthDateSafeHarbor"}, |
| 86 | + {"url": "dateShiftKey", "valueString": "my-shift-key"} |
| 87 | +] |
| 88 | +``` |
| 89 | + |
| 90 | +### encrypt |
| 91 | + |
| 92 | +AES-128-CBC encrypts the value and returns a base64-encoded string. Reversible with the key. Uses a zero initialization vector for deterministic output — same plaintext always produces the same ciphertext. |
| 93 | + |
| 94 | +| Parameter | Type | Required | Description | |
| 95 | +|-----------|------|----------|-------------| |
| 96 | +| encryptKey | string | yes | Hex-encoded AES-128 key (32 hex characters = 16 bytes) | |
| 97 | + |
| 98 | +```json |
| 99 | +[ |
| 100 | + {"url": "method", "valueCode": "encrypt"}, |
| 101 | + {"url": "encryptKey", "valueString": "0123456789abcdef0123456789abcdef"} |
| 102 | +] |
| 103 | +``` |
| 104 | + |
| 105 | +### substitute |
| 106 | + |
| 107 | +Replaces the value with a fixed string. |
| 108 | + |
| 109 | +| Parameter | Type | Required | Description | |
| 110 | +|-----------|------|----------|-------------| |
| 111 | +| replaceWith | string | yes | Replacement value | |
| 112 | + |
| 113 | +```json |
| 114 | +[ |
| 115 | + {"url": "method", "valueCode": "substitute"}, |
| 116 | + {"url": "replaceWith", "valueString": "REDACTED"} |
| 117 | +] |
| 118 | +``` |
| 119 | + |
| 120 | +### perturb |
| 121 | + |
| 122 | +Adds random noise to numeric values. The result is non-deterministic — each query produces different output. |
| 123 | + |
| 124 | +| Parameter | Type | Required | Description | |
| 125 | +|-----------|------|----------|-------------| |
| 126 | +| span | decimal | no | Noise magnitude. Default: 1.0 | |
| 127 | +| rangeType | code | no | `fixed` (absolute noise) or `proportional` (relative to value). Default: `fixed` | |
| 128 | +| roundTo | integer | no | Decimal places to round to. 0 means integer. Default: 0 | |
| 129 | + |
| 130 | +With `fixed` range type, noise is in the range ±span/2. With `proportional`, noise is ±(span × value)/2. Any other `rangeType` value raises a SQL error. |
| 131 | + |
| 132 | +```json |
| 133 | +[ |
| 134 | + {"url": "method", "valueCode": "perturb"}, |
| 135 | + {"url": "span", "valueDecimal": 10}, |
| 136 | + {"url": "rangeType", "valueCode": "fixed"}, |
| 137 | + {"url": "roundTo", "valueInteger": 0} |
| 138 | +] |
| 139 | +``` |
| 140 | + |
| 141 | +### custom_function |
| 142 | + |
| 143 | +Applies a user-provided PostgreSQL function. The function must already exist in the database. Its first argument is the column value cast to text. An optional second argument can be passed via `custom_arg`. |
| 144 | + |
| 145 | +| Parameter | Type | Required | Description | |
| 146 | +|-----------|------|----------|-------------| |
| 147 | +| custom_function | string | yes | PostgreSQL function name. Must match `^[a-zA-Z][a-zA-Z0-9_.]*$` | |
| 148 | +| custom_arg | string, integer, decimal, boolean, or code | no | Optional second argument | |
| 149 | + |
| 150 | +```json |
| 151 | +[ |
| 152 | + {"url": "method", "valueCode": "custom_function"}, |
| 153 | + {"url": "custom_function", "valueString": "left"}, |
| 154 | + {"url": "custom_arg", "valueInteger": 4} |
| 155 | +] |
| 156 | +``` |
| 157 | + |
| 158 | +This example uses the built-in PostgreSQL `left` function to keep only the first 4 characters (e.g. extracting just the year from a date string). |
| 159 | + |
| 160 | +## Example ViewDefinition |
| 161 | + |
| 162 | +A complete ViewDefinition that de-identifies Patient data: |
| 163 | + |
| 164 | +```json |
| 165 | +{ |
| 166 | + "resourceType": "ViewDefinition", |
| 167 | + "id": "deident-patients", |
| 168 | + "name": "deident_patients", |
| 169 | + "status": "active", |
| 170 | + "resource": "Patient", |
| 171 | + "select": [{ |
| 172 | + "column": [{ |
| 173 | + "name": "id", |
| 174 | + "path": "id", |
| 175 | + "extension": [{ |
| 176 | + "url": "http://health-samurai.io/fhir/core/StructureDefinition/de-identification", |
| 177 | + "extension": [{ |
| 178 | + "url": "method", |
| 179 | + "valueCode": "cryptoHash" |
| 180 | + }, { |
| 181 | + "url": "cryptoHashKey", |
| 182 | + "valueString": "patient-hash-key" |
| 183 | + }] |
| 184 | + }] |
| 185 | + }, { |
| 186 | + "name": "gender", |
| 187 | + "path": "gender" |
| 188 | + }, { |
| 189 | + "name": "birth_date", |
| 190 | + "path": "birthDate", |
| 191 | + "extension": [{ |
| 192 | + "url": "http://health-samurai.io/fhir/core/StructureDefinition/de-identification", |
| 193 | + "extension": [{ |
| 194 | + "url": "method", |
| 195 | + "valueCode": "dateshift" |
| 196 | + }, { |
| 197 | + "url": "dateShiftKey", |
| 198 | + "valueString": "date-shift-key" |
| 199 | + }] |
| 200 | + }] |
| 201 | + }] |
| 202 | + }, { |
| 203 | + "forEach": "name", |
| 204 | + "select": [{ |
| 205 | + "column": [{ |
| 206 | + "name": "use", |
| 207 | + "path": "use" |
| 208 | + }, { |
| 209 | + "name": "family", |
| 210 | + "path": "family", |
| 211 | + "extension": [{ |
| 212 | + "url": "http://health-samurai.io/fhir/core/StructureDefinition/de-identification", |
| 213 | + "extension": [{ |
| 214 | + "url": "method", |
| 215 | + "valueCode": "redact" |
| 216 | + }] |
| 217 | + }] |
| 218 | + }] |
| 219 | + }] |
| 220 | + }, { |
| 221 | + "forEach": "address", |
| 222 | + "select": [{ |
| 223 | + "column": [{ |
| 224 | + "name": "state", |
| 225 | + "path": "state" |
| 226 | + }, { |
| 227 | + "name": "postal_code", |
| 228 | + "path": "postalCode", |
| 229 | + "extension": [{ |
| 230 | + "url": "http://health-samurai.io/fhir/core/StructureDefinition/de-identification", |
| 231 | + "extension": [{ |
| 232 | + "url": "method", |
| 233 | + "valueCode": "substitute" |
| 234 | + }, { |
| 235 | + "url": "replaceWith", |
| 236 | + "valueString": "000" |
| 237 | + }] |
| 238 | + }] |
| 239 | + }] |
| 240 | + }] |
| 241 | + }] |
| 242 | +} |
| 243 | +``` |
| 244 | + |
| 245 | +In this example: |
| 246 | + |
| 247 | +- `id` is replaced with a consistent hash |
| 248 | +- `gender` passes through unchanged (no extension) |
| 249 | +- `birthDate` is shifted by a deterministic offset per patient |
| 250 | +- `name.family` is redacted (NULL) |
| 251 | +- `name.use` passes through (code values are not PHI) |
| 252 | +- `address.state` passes through (safe at state level) |
| 253 | +- `address.postalCode` is replaced with "000" |
| 254 | + |
| 255 | +## Materialization restriction |
| 256 | + |
| 257 | +A ViewDefinition that contains any de-identification extension can only be materialized as a `table`. Attempting to materialize as `view` or `materialized-view` returns HTTP 422 with an OperationOutcome: |
| 258 | + |
| 259 | +> ViewDefinitions with de-identification extensions can only be materialized as 'table'. Views and materialized views expose cryptographic keys in PostgreSQL system catalogs. |
| 260 | +
|
| 261 | +This restriction exists because PostgreSQL stores the full view definition (including the compiled SQL with embedded keys) in `pg_views.definition` and `pg_matviews.definition`. Any user with `SELECT` on those catalogs would see the `cryptoHashKey`, `dateShiftKey`, or `encryptKey` values in plaintext. Tables materialize the transformed data only, leaving the keys inside the ViewDefinition resource itself (which is access-controlled). |
| 262 | + |
| 263 | +`$run` and `$sql` are unaffected — they return data or SQL strings directly without storing anything in system catalogs. |
| 264 | + |
| 265 | +## Pre-built ViewDefinitions |
| 266 | + |
| 267 | +Aidbox ships an IG package `io.health-samurai.de-identification.r4` with ready-made Safe Harbor ViewDefinitions for common FHIR R4 resource types: |
| 268 | + |
| 269 | +| Resource | Use | |
| 270 | +|----------|-----| |
| 271 | +| Patient | Uses `birthDateSafeHarbor` on `birthDate`, cryptoHash on `id`, redact on name/address identifiers | |
| 272 | +| Encounter, Condition, Observation | `dateshift` on clinical dates, cryptoHash on references | |
| 273 | +| Claim, ExplanationOfBenefit | `dateshift` on billable periods | |
| 274 | +| AllergyIntolerance, DiagnosticReport, MedicationRequest, MedicationDispense, MedicationAdministration, Immunization, Procedure, Specimen, DocumentReference | Same general approach | |
| 275 | +| Practitioner, Location | Identifier redaction | |
| 276 | + |
| 277 | +Install the package via FHIR package management and use these ViewDefinitions directly, or copy and customize them. Every cryptographic key parameter in the pre-built VDs is blank (`""`) — you must set real keys before using them for actual de-identification. |
| 278 | + |
| 279 | +## Using the UI |
| 280 | + |
| 281 | +The ViewDefinition builder in Aidbox UI includes a de-identification picker on each column. Click the shield icon next to a column's path to open the configuration popover. |
| 282 | + |
| 283 | +When a de-identification method is configured, the shield icon turns blue. Hovering shows the current method name. |
| 284 | + |
| 285 | +## Writing custom PostgreSQL functions |
| 286 | + |
| 287 | +Custom functions referenced via `custom_function` must: |
| 288 | + |
| 289 | +- Accept `text` as the first argument (the column value) |
| 290 | +- Optionally accept a second argument of any type (passed via `custom_arg`) |
| 291 | +- Already exist in the database before the ViewDefinition is executed |
| 292 | + |
| 293 | +Example: |
| 294 | + |
| 295 | +```sql |
| 296 | +CREATE OR REPLACE FUNCTION my_mask(value text) |
| 297 | +RETURNS text LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$ |
| 298 | + SELECT CASE |
| 299 | + WHEN value IS NULL THEN NULL |
| 300 | + ELSE left(value, 1) || repeat('*', greatest(length(value) - 1, 0)) |
| 301 | + END; |
| 302 | +$$; |
| 303 | +``` |
| 304 | + |
| 305 | +Then reference it in a column: |
| 306 | + |
| 307 | +```json |
| 308 | +{ |
| 309 | + "url": "http://health-samurai.io/fhir/core/StructureDefinition/de-identification", |
| 310 | + "extension": [ |
| 311 | + {"url": "method", "valueCode": "custom_function"}, |
| 312 | + {"url": "custom_function", "valueString": "my_mask"} |
| 313 | + ] |
| 314 | +} |
| 315 | +``` |
| 316 | + |
| 317 | +## Security considerations |
| 318 | + |
| 319 | +### Key management |
| 320 | + |
| 321 | +Cryptographic keys (`cryptoHashKey`, `dateShiftKey`, `encryptKey`) are stored as plaintext strings inside the ViewDefinition resource. Anyone with read access to the ViewDefinition can see the keys. |
| 322 | + |
| 323 | +Restrict access to ViewDefinition resources using [AccessPolicy](../../access-control/authorization/README.md) to ensure only authorized users can view or modify de-identification configurations. |
| 324 | + |
| 325 | +### SQL injection prevention |
| 326 | + |
| 327 | +The `custom_function` parameter is validated against `^[a-zA-Z][a-zA-Z0-9_.]*$` — only letters, digits, underscores, and dots are allowed. This validation happens both in the Aidbox UI and in the SQL compiler. String arguments passed via `custom_arg` are safely escaped by the SQL generator. |
| 328 | + |
| 329 | +### Encryption limitations |
| 330 | + |
| 331 | +The `encrypt` method uses AES-128-CBC with a zero initialization vector. This makes encryption deterministic — the same plaintext always produces the same ciphertext, which is useful for consistent de-identification but leaks frequency information. This is not suitable for general-purpose encryption. |
| 332 | + |
| 333 | +See also: |
| 334 | + |
| 335 | +- [Defining flat views with view definitions](./defining-flat-views-with-view-definitions.md) |
| 336 | +- [$run operation](./operation-run.md) |
| 337 | +- [$materialize operation](./operation-materialize.md) |
0 commit comments