Netherlands - Generic Functions for data exchange Implementation Guide
0.2.0 - ci-build
Netherlands - Generic Functions for data exchange Implementation Guide, published by Stichting Nuts. This guide is not an authorized publication; it is the continuous build for version 0.2.0 built by the FHIR (HL7® FHIR® Standard) CI Build. This version is based on the current content of https://github.com/nuts-foundation/nl-generic-functions-ig/ and changes regularly. See the Directory of published versions
The Care Services expose an Administration Directory as a FHIR service, as described in:
This Administration Directory might be the internal FHIR service of the supplier. To prevent leakage of FHIR resources, the internal FHIR service requires protection. This is commonly realized through an AAA (Authentication, Authorization, and Accounting) proxy acting as a Policy Enforcement Point (PEP).
The proxy can be implemented using:
The design is specifically intended for implementation in generic proxies like HAProxy through a two-call API pattern that separates authentication from authorization logic. NUTS provides the Policy Decision Point (PDP) APIs but does not provide proxy software.
The Policy Enforcement Point (PEP) implements a two-phase authorization mechanism to protect FHIR APIs. This approach ensures that:
The proxy first performs OAuth 2.0 token introspection to validate the access token and retrieve the Verifiable Presentations that were used during authentication.
Endpoint: POST /internal/auth/v2/accesstoken/introspect_extended (NUTS API)
This call:
active, iss, client_id, exp, scope, etc.)Example extended introspection response:
{
"active": true,
"iss": "https://example.com/oauth2/authorizer",
"client_id": "https://requester.example.com",
"exp": 1735689599,
"iat": 1735603199,
"scope": "patient/*.read",
"presentation_definitions": {
"user_wallet": {
"id": "healthcare-professional-access-pd",
"input_descriptors": [
{
"id": "dezi_login_credential",
"constraints": {
"fields": [
{
"path": ["$.credentialSubject.type"],
"filter": {
"type": "string",
"const": "Practitioner"
}
}
]
}
}
]
}
},
"presentation_submissions": {
"healthcare-professional-access-pd": {
"id": "submission-123",
"definition_id": "healthcare-professional-access-pd",
"descriptor_map": [
{
"id": "dezi_login_credential",
"format": "jwt_vc",
"path": "$.verifiableCredential[0]"
}
]
}
},
"vps": [
{
"@context": ["https://www.w3.org/2018/credentials/v1"],
"type": ["VerifiablePresentation"],
"verifiableCredential": [
{
"@context": [
"https://www.w3.org/2018/credentials/v1",
"https://dezi.nl/contexts/v1"
],
"type": ["VerifiableCredential", "DeziLoginCredential"],
"issuer": "did:web:dezi.nl",
"issuanceDate": "2024-01-01T00:00:00Z",
"credentialSubject": {
"id": "did:web:practitioner.example.com",
"type": "Practitioner",
"identifier": "urn:oid:2.16.528.1.1007.3.1:123456789",
"name": "Dr. Jane Smith",
"qualification": "Medical Doctor",
"organization": {
"identifier": "ura|24173480",
"name": "Example Hospital"
}
},
"proof": { "..." }
}
],
"proof": { "..." }
}
]
}
Rationale: The extended endpoint provides the PDP with complete Verifiable Presentations, enabling:
After successful introspection, the proxy calls the Policy Decision Point (PDP) to obtain a rewritten FHIR query that applies search-narrowing based on authorization policies.
Endpoint: POST /authorization/search-narrowing
This call provides:
The PDP returns:
The authorization policies are defined in a comprehensive authorization matrix that covers all use cases and resource types. This matrix defines which actors can access which resources under what conditions.
The authorization matrix follows a similar approach to the OZO Authorization Matrix for Practitioner, defining:
The Policy Decision Point (PDP) enforces these rules by rewriting FHIR queries to automatically apply the appropriate search parameters.
Scenario: A healthcare professional authenticated via DEZI wants to read Patient resources.
Phase 1 - Token Introspection:
POST /internal/auth/v2/accesstoken/introspect_extended HTTP/1.1
Content-Type: application/x-www-form-urlencoded
token=<access_token>
Introspection Response (truncated for brevity):
{
"active": true,
"iss": "https://example.com/oauth2/authorizer",
"client_id": "https://requester.example.com",
"exp": 1735689599,
"iat": 1735603199,
"scope": "patient/*.read",
"vps": [
{
"type": ["VerifiablePresentation"],
"verifiableCredential": [
{
"type": ["VerifiableCredential", "DeziLoginCredential"],
"issuer": "did:web:dezi.nl",
"credentialSubject": {
"type": "Practitioner",
"identifier": "urn:oid:2.16.528.1.1007.3.1:123456789",
"name": "Dr. Jane Smith",
"qualification": "Medical Doctor",
"organization": {
"identifier": "ura|24173480",
"name": "Example Hospital"
}
}
}
]
}
]
}
Phase 2 - Search Narrowing:
Incoming FHIR request:
GET /Patient HTTP/1.1
PDP call:
POST /authorization/search-narrowing HTTP/1.1
Content-Type: application/json
{
"introspection_result": {
"active": true,
"vps": [
{
"verifiableCredential": [
{
"type": ["VerifiableCredential", "DeziLoginCredential"],
"credentialSubject": {
"type": "Practitioner",
"identifier": "urn:oid:2.16.528.1.1007.3.1:123456789",
"organization": {
"identifier": "ura|24173480"
}
}
}
]
}
]
},
"http_request": {
"method": "GET",
"path": "/Patient",
"query_params": {}
}
}
PDP Response (Narrowed Query):
{
"allowed": true,
"rewritten_query": "/Patient?_has:CareTeam:patient:participant:Practitioner.identifier=urn:oid:2.16.528.1.1007.3.1:123456789",
"original_query": "/Patient",
"applied_filters": [
{
"parameter": "_has:CareTeam:patient:participant:Practitioner.identifier",
"value": "urn:oid:2.16.528.1.1007.3.1:123456789",
"reason": "Practitioner can only access patients where they are a CareTeam participant"
}
]
}
The proxy then executes:
GET /Patient?_has:CareTeam:patient:participant:Practitioner.identifier=urn:oid:2.16.528.1.1007.3.1:123456789 HTTP/1.1
Scenario: An organization queries an mCSD Administration Directory that contains more than just mCSD resources.
Requirements:
mcsd-profile extensionhttp://nuts.nl/fhir/StructureDefinition/mcsd-profile with value admin indicates mCSD conformancemcsd-profile=adminPhase 1 - Token Introspection (truncated):
{
"active": true,
"iss": "https://example.com/oauth2/authorizer",
"client_id": "https://organization.example.com",
"exp": 1735689599,
"scope": "organization/*.read",
"vps": [
{
"type": ["VerifiablePresentation"],
"verifiableCredential": [
{
"type": ["VerifiableCredential", "OrganizationCredential"],
"credentialSubject": {
"type": "Organization",
"identifier": "ura|24173480",
"name": "Example Hospital"
}
}
]
}
]
}
Phase 2 - Search Narrowing:
Incoming request:
GET /Organization HTTP/1.1
PDP Response:
{
"allowed": true,
"rewritten_query": "/Organization?mcsd-profile=admin",
"applied_filters": [
{
"parameter": "mcsd-profile",
"value": "admin",
"reason": "Restrict to organizations with mCSD admin profile"
}
],
"allowed_operations": ["GET", "POST"],
"resource_constraints": {
"required_extension": "http://nuts.nl/fhir/StructureDefinition/mcsd-profile"
}
}
Note: Use a custom SearchParameter that makes extensions searchable:
mcsd-profile that searches on the extension http://nuts.nl/fhir/StructureDefinition/mcsd-profilemcsd-profile=adminScenario: Dedicated FHIR server containing only mCSD resources.
In this case, search narrowing may be minimal since the server only contains authorized resource types:
GET /Organization HTTP/1.1
May be narrowed to:
GET /Organization?mcsd-profile=admin&active=true
SearchParameter Definition Example:
To make the mcsd-profile extension searchable, define a SearchParameter:
{
"resourceType": "SearchParameter",
"id": "Organization-mcsd-profile",
"url": "http://nuts.nl/fhir/SearchParameter/Organization-mcsd-profile",
"name": "McsdProfile",
"status": "active",
"code": "mcsd-profile",
"base": ["Organization"],
"type": "token",
"description": "Search Organizations by mCSD profile extension value",
"expression": "Organization.extension('http://nuts.nl/fhir/StructureDefinition/mcsd-profile').value",
"xpath": "f:Organization/f:extension[@url='http://nuts.nl/fhir/StructureDefinition/mcsd-profile']/f:valueCode",
"xpathUsage": "normal"
}
This allows querying: GET /Organization?mcsd-profile=admin
Recommended approach:
http://nuts.nl/fhir/StructureDefinition/mcsd-profilecode or Codingadmin (Administration Directory), care-services-updates, etc.mcsd-profiletokenOrganization.extension('http://nuts.nl/fhir/StructureDefinition/mcsd-profile').valueGET /Organization?mcsd-profile=adminGET /Organization?mcsd-profile=admin&active=trueBenefits:
The two-phase authorization approach is specifically designed to be implementable in generic reverse proxies like HAProxy, NGINX, or Envoy.
frontend fhir_frontend
bind *:443 ssl crt /path/to/cert.pem
# Extract access token from Authorization header
http-request set-var(txn.token) req.hdr(Authorization),regsub(^Bearer[[:space:]]+,)
# Phase 1: Token Introspection
http-request lua.introspect_token
# Phase 2: Search Narrowing
http-request lua.narrow_search
# Forward to backend with rewritten query
use_backend fhir_backend
backend fhir_backend
server fhir1 127.0.0.1:8080
Lua Script for HAProxy:
-- Phase 1: Introspect token (extended to get VPs)
core.register_action("introspect_token", {"http-req"}, function(txn)
local token = txn.get_var(txn, "txn.token")
-- Call NUTS extended introspection endpoint to get Verifiable Presentations
local response = http_post("http://nuts:8080/internal/auth/v2/accesstoken/introspect_extended",
"token=" .. token,
{["Content-Type"] = "application/x-www-form-urlencoded"})
-- Store introspection result (includes VPs)
txn.set_var(txn, "txn.introspection", response)
end)
-- Phase 2: Get narrowed query
core.register_action("narrow_search", {"http-req"}, function(txn)
local introspection = txn.get_var(txn, "txn.introspection")
local method = txn.sf:method()
local path = txn.sf:path()
local query = txn.sf:query()
-- Call NUTS search narrowing endpoint
local request_body = json.encode({
introspection_result = json.decode(introspection),
http_request = {
method = method,
path = path,
query_params = parse_query(query)
}
})
local response = http_post("http://nuts:8080/authorization/search-narrowing",
request_body,
{["Content-Type"] = "application/json"})
local narrowed = json.decode(response)
if narrowed.allowed then
-- Rewrite the request path with narrowed query
txn.sf:req_set_uri(narrowed.rewritten_query)
else
-- Deny access
txn.set_var(txn, "txn.auth_failed", "true")
txn:done(403)
end
end)
The complete OpenAPI specification for these endpoints is available here: Care Services Proxy OpenAPI Specification
┌─────────┐ ┌───────┐ ┌──────────┐ ┌──────────┐
│ Client │ │ Proxy │ │NUTS (PDP)│ │FHIR API │
│ │ │ (PEP) │ │ │ │ │
└────┬────┘ └───┬───┘ └────┬─────┘ └────┬─────┘
│ │ │ │
│ GET /Patient │ │ │
├─────────────────>│ │ │
│ │ │ │
│ │ POST /internal/auth/v2/accesstoken/introspect_extended
│ ├─────────────────>│ │
│ │ │ │
│ │ {active: true, │ │
│ │ vps: [...]} │ │
│ │<─────────────────┤ │
│ │ │ │
│ │ POST /authorization/search-narrowing │
│ ├─────────────────>│ │
│ │ │ │
│ │ {allowed: true, │ │
│ │ rewritten_query}│ │
│ │<─────────────────┤ │
│ │ │ │
│ │ GET /Patient?_has:CareTeam:patient:participant:Practitioner.identifier=...
│ ├──────────────────────────────────────>│
│ │ │ │
│ │ │ FHIR Bundle │
│ │<──────────────────────────────────────┤
│ │ │ │
│ FHIR Bundle │ │ │
│<─────────────────┤ │ │
│ │ │ │