diff --git a/SUMMARY.md b/SUMMARY.md index 8a4d9ccb1..41d160b00 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -188,6 +188,7 @@ * [$meta](api/rest-api/other/meta.md) * [$meta-add](api/rest-api/other/meta-add.md) * [$meta-delete](api/rest-api/other/meta-delete.md) + * [SMART Health Cards](api/rest-api/other/smart-health-cards.md) * [Bulk API](api/bulk-api/README.md) * [Configure Access Policies for Bulk API](api/bulk-api/configure-access-policies-for-bulk-api.md) * [$dump](api/bulk-api/dump.md) diff --git a/docs/access-control/authorization/scoped-api/organization-based-hierarchical-access-control.md b/docs/access-control/authorization/scoped-api/organization-based-hierarchical-access-control.md index ed75bd506..357d1f2a4 100644 --- a/docs/access-control/authorization/scoped-api/organization-based-hierarchical-access-control.md +++ b/docs/access-control/authorization/scoped-api/organization-based-hierarchical-access-control.md @@ -291,6 +291,16 @@ GET /Organization//fhir/Composition/$document See also [$document endpoint](../../../api/rest-api/other/document.md) +### $health-cards-issue + +``` +POST /Organization//fhir/Patient//$health-cards-issue +``` + +Issues a SMART Health Card scoped to the organization: the target `Patient` and every internal read run inside the organization's compartment. Available starting from version 2607. + +See also [SMART Health Cards](../../../api/rest-api/other/smart-health-cards.md) + ### History Resource full history diff --git a/docs/api/rest-api/other/smart-health-cards.md b/docs/api/rest-api/other/smart-health-cards.md new file mode 100644 index 000000000..a3be30bf4 --- /dev/null +++ b/docs/api/rest-api/other/smart-health-cards.md @@ -0,0 +1,317 @@ +--- +description: >- + Issue signed SMART Health Cards from a patient's clinical data with the + $health-cards-issue operation. +--- + +# SMART Health Cards + +Since the 2607 version, Aidbox can issue [SMART Health Cards](https://smarthealth.cards/): verifiable health credentials packaged as a signed, compact JSON Web Signature (JWS) that a patient presents as a QR code or file. The [`$health-cards-issue`](https://spec.smarthealth.cards/#via-fhir-health-cards-issue-operation) operation issues a signed card from the patient's clinical resources. + +Aidbox publishes the verification public key at a JWKS endpoint, so any SMART Health Cards verifier can validate the cards. + +## Configuration + +The operation signs cards with an issuer **EC P-256 private key**, set as a PEM in `module.health-cards-links.issuer-private-key`; the public key and JWKS are derived from it. + +Generate a key with OpenSSL and set it as the issuer key: + +```bash +openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out issuer.pem +``` + +``` +BOX_MODULE_HEALTH_CARDS_LINKS_ISSUER_PRIVATE_KEY= +``` + +{% hint style="warning" %} +Keep the private key secret and back it up. Rotating it changes the `kid`, so cards signed with the previous key stop verifying against the published JWKS. +{% endhint %} + +## Issuing a card + +```http +POST /fhir/Patient//$health-cards-issue +``` + +The body is a `Parameters` resource. `credentialType` is required; the other parameters are optional. + +| Parameter | Type | Description | +| ---------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | +| `credentialType` | uri / string | Resource types the card should carry. Required, repeatable. See [credentialType](smart-health-cards.md#credentialtype). | +| `credentialValueSet` | uri / string | Restrict included resources to members of a ValueSet. Repeatable. See [credentialValueSet](smart-health-cards.md#credentialvalueset). | +| `includeIdentityClaim` | string / boolean | Which `Patient` identity fields to include. See [includeIdentityClaim](smart-health-cards.md#includeidentityclaim). | +| `_since` | dateTime / instant | Only include resources modified at or after this instant (`_lastUpdated ge`). | + +### Example request + +```http +POST /fhir/Patient/pt-1/$health-cards-issue +``` + +```json +{ + "resourceType": "Parameters", + "parameter": [ + { + "name": "credentialType", + "valueUri": "Immunization" + } + ] +} +``` + +### Example response + +```json +{ + "resourceType": "Parameters", + "parameter": [ + { + "name": "verifiableCredential", + "valueString": "eyJ6aXAiOiJERUYiLCJhbGciOiJFUzI1NiIsImtpZCI6Ii4uLiJ9.." + }, + { + "name": "resourceLink", + "part": [ + { "name": "vcIndex", "valueInteger": 0 }, + { "name": "bundledResource", "valueUri": "resource:0" }, + { + "name": "hostedResource", + "valueUri": "https:///fhir/Patient/pt-1" + } + ] + }, + { + "name": "resourceLink", + "part": [ + { "name": "vcIndex", "valueInteger": 0 }, + { "name": "bundledResource", "valueUri": "resource:1" }, + { + "name": "hostedResource", + "valueUri": "https:///fhir/Immunization/imm-1" + } + ] + } + ] +} +``` + +`verifiableCredential.valueString` is the signed SMART Health Card (a compact JWS). Each `resourceLink` maps a bundled resource (`resource:N`) to its source Aidbox resource (`hostedResource`). + +## Input parameters + +### credentialType + +The operation accepts any FHIR resource type in the Patient compartment, such as `Immunization`, `Observation`, or `Condition`. It searches the patient's compartment for that type and adds the matching resources to the card. + +`credentialType` is required (at least one). Aidbox combines multiple values with logical AND: the card carries every requested type the patient has. Aidbox rejects a missing or unsupported `credentialType` with `400`. + +### credentialValueSet + +Keeps only resources whose clinical code is a member of the given ValueSet. For example, supply a ValueSet of COVID-19 vaccine codes to include only COVID-19 immunizations. Aidbox validates the resource's coded fields (`vaccineCode`, `code`, `medicationCodeableConcept`, and `type`) against the ValueSet with [`$validate-code`](validate.md), so the ValueSet must be loaded into Aidbox's terminology. An unresolvable ValueSet fails the request with `400`. + +For example, a COVID-19-only immunization card: + +```json +{ + "resourceType": "Parameters", + "parameter": [ + { "name": "credentialType", "valueUri": "Immunization" }, + { + "name": "credentialValueSet", + "valueUri": "https://terminology.smarthealth.cards/ValueSet/immunization-covid-cvx" + } + ] +} +``` + +Passing several `credentialValueSet` parameters combines them as a card-level logical AND: a resource is kept if it matches at least one, and the card is issued only when every ValueSet matched some resource (otherwise `404`). So passing a COVID-19 vaccine ValueSet and an mpox vaccine ValueSet requests a card that carries a vaccine from each. + +Any ValueSet that Aidbox's terminology can resolve works. The standard SMART Health Cards value sets come from the [SMART Health Cards terminology IG](https://terminology.smarthealth.cards/artifacts.html); load the ones you need into Aidbox as `ValueSet` resources. Their codes come from external code systems (CVX, SNOMED CT, LOINC), so also run the [terminology engine](../../../terminology-module/aidbox-terminology-module/hybrid.md) in `hybrid` mode against a terminology server that provides those code systems, otherwise the codes will not validate. + +The common value sets, under `https://terminology.smarthealth.cards/ValueSet/`: + +| ValueSet | Contents | +| --- | --- | +| `immunization-covid-all` | COVID-19 vaccines (CVX, SNOMED CT, ICD-11) | +| `immunization-covid-cvx` | COVID-19 vaccines (CVX) | +| `immunization-orthopoxvirus-all` | mpox / orthopoxvirus vaccines | +| `immunization-all-cvx` | All CVX vaccine codes | +| `lab-qualitative-test-covid` | Qualitative COVID-19 lab tests (LOINC) | +| `lab-qualitative-result` | Qualitative infectious-disease lab results | + +### includeIdentityClaim + +Controls which `Patient` identity fields the card carries: + +| Value | Effect | +| ----------------------------------------------------------- | ------------------------------------------------------------------------------- | +| omitted or `true` | Default claims: `Patient.name` (family and given only) and `Patient.birthDate`. | +| `Patient.name`, `Patient.gender` (one `valueString` each) | Only the named fields. | +| `false` | Omit the `Patient` from the card entirely. | + +### \_since + +The operation includes only resources with `_lastUpdated` at or after the given instant, for example `2024-01-01T00:00:00Z`. + +## Verifying cards (JWKS) + +Aidbox publishes the issuer's public key as a JWK Set at a public endpoint: + +```http +GET /health-cards/.well-known/jwks.json +``` + +```json +{ + "keys": [ + { + "kty": "EC", + "crv": "P-256", + "x": "vbo4liL4JMgrGcYgAhVWreZPFMisG65EhM0p3Vdgzi8", + "y": "U3_QRvRc12uGpFiwBlX43vXbqYoVwGQ2obGZgF2gCYc", + "kid": "jDfy60_p2Gyvcl_sXayw0FcSECzZtrC7WUKrVaigVYI", + "use": "sig", + "alg": "ES256" + } + ] +} +``` + +A verifier picks the right key by the `kid` in the card's JWS header. The card's issuer (`iss`) is `/health-cards`, and its key set lives at `/.well-known/jwks.json`. Returns `404` until the issuer key is configured. + +## Access control + +A caller needs both permission to **invoke the operation** and permission for the **internal reads** it issues. The operation re-dispatches those reads through the regular pipeline, so your [Access Policies](../../../access-control/authorization/access-policies.md) gate them; a read the caller may not perform stops the card (for example `403`). The `credentialValueSet` terminology check (`$validate-code`) runs in-process and needs no policy. + +For a single call the operation issues these authorized requests: + +- `POST /fhir/Patient/{id}/$health-cards-issue`, the operation itself (`fhir-patient-health-cards-issue`). +- `GET /fhir/Patient/{id}?_elements=name,birthDate` reads the identity claims as `FhirRead` (skipped when `includeIdentityClaim` is `false`; `_elements` lists the requested claim fields). +- `GET /fhir/{type}?{compartment-param}=Patient/{id}` searches each requested `credentialType` as `FhirSearch`, using the type's Patient-compartment parameter (`patient` for `Immunization`, `subject` for `Observation` and `Condition`, and so on) and following `next` links to page through every match. Adds `&_lastUpdated=ge{_since}` when `_since` is set. + +Each policy links to an operation. First, allow invoking the operation itself: + +```json +{ + "resourceType": "AccessPolicy", + "id": "health-cards-issuer-op", + "engine": "matcho", + "link": [{ "reference": "Operation/fhir-patient-health-cards-issue" }], + "matcho": { "client": { "id": "my-client-id" } } +} +``` + +Next, allow the type searches. Link to `FhirSearch` and, with `$one-of`, match the compartment parameter each requested type uses so the policy only permits patient-scoped searches: + +```json +{ + "resourceType": "AccessPolicy", + "id": "health-cards-issuer-search", + "engine": "matcho", + "link": [{ "reference": "Operation/FhirSearch" }], + "matcho": { + "client": { "id": "my-client-id" }, + "params": { + "$one-of": [ + { "resource/type": "Immunization", "patient": "present?" }, + { "resource/type": { "$enum": ["Observation", "Condition"] }, "subject": "present?" } + ] + } + } +} +``` + +List the resource types you request under the compartment parameter each one uses (`patient` for `Immunization`, `subject` for `Observation` and `Condition`). Finally, allow the `Patient` read: + +```json +{ + "resourceType": "AccessPolicy", + "id": "health-cards-issuer-read-patient", + "engine": "matcho", + "link": [{ "reference": "Operation/FhirRead" }], + "matcho": { + "client": { "id": "my-client-id" }, + "params": { "resource/type": "Patient" } + } +} +``` + +### Organization scope (OrgBAC) + +Under [organization-based access control](../../../access-control/authorization/scoped-api/organization-based-hierarchical-access-control.md), the operation is also served at: + +```http +POST /Organization//fhir/Patient//$health-cards-issue +``` + +It issues a card only from the organization's data: the target `Patient` and every internal read run inside the organization's compartment, so a `Patient` from another organization returns `403`. `hostedResource` URLs use the same `/Organization//fhir` base. + +Access Policies still apply (the compartment restricts data but does not grant access). Under OrgBAC the operation and its reads run as the org-scoped operations `orgbac-fhir-health-cards-issue`, `orgbac-fhir-read`, and `orgbac-fhir-search` (kebab-case route ids, not the camel-case `FhirRead` / `FhirSearch`), so link the org policies to those. The three policies mirror the ones above with the ids swapped: + +```json +{ + "resourceType": "AccessPolicy", + "id": "health-cards-org-issuer-op", + "engine": "matcho", + "link": [{ "reference": "Operation/orgbac-fhir-health-cards-issue" }], + "matcho": { "client": { "id": "my-client-id" } } +} +``` + +```json +{ + "resourceType": "AccessPolicy", + "id": "health-cards-org-issuer-search", + "engine": "matcho", + "link": [{ "reference": "Operation/orgbac-fhir-search" }], + "matcho": { + "client": { "id": "my-client-id" }, + "params": { + "$one-of": [ + { "resource/type": "Immunization", "patient": "present?" }, + { "resource/type": { "$enum": ["Observation", "Condition"] }, "subject": "present?" } + ] + } + } +} +``` + +```json +{ + "resourceType": "AccessPolicy", + "id": "health-cards-org-issuer-read", + "engine": "matcho", + "link": [{ "reference": "Operation/orgbac-fhir-read" }], + "matcho": { + "client": { "id": "my-client-id" }, + "params": { "resource/type": "Patient" } + } +} +``` + +The response is the same `Parameters`, with each `hostedResource` under the organization: + +```json +{ + "name": "resourceLink", + "part": [ + { "name": "vcIndex", "valueInteger": 0 }, + { "name": "bundledResource", "valueUri": "resource:1" }, + { "name": "hostedResource", "valueUri": "https:///Organization//fhir/Immunization/imm-1" } + ] +} +``` + +## Errors + +The operation returns every error as an `OperationOutcome`. + +| Status | When | +| ------ | --------------------------------------------------------------- | +| `422` | The issuer private key is not configured. | +| `400` | `credentialType` is missing or unsupported. | +| `400` | A requested `credentialValueSet` could not be resolved. | +| `404` | No resources match the requested credential type or value sets. | +| `403` | An Access Policy denies reading the patient's data. |