Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,38 @@ server API, a configuration key, or a packaging convention occurs only in
a major release - where it additionally gets an entry in the
[migration guide](docs/migration/onfhir-3.x-to-repofyr-4.0.md).

## 4.1.0 - Unreleased

### Added

- **Patient `$purge` operation** (`POST [base]/Patient/[id]/$purge`), for
GDPR-style erasure. It implements
`http://hl7.org/fhir/OperationDefinition/Patient-purge`, defined in the FHIR
6.0.0-ballot3 build, for **R4 and R5**; STU3 is not supported. Unlike a FHIR
delete, which moves the content to history, `$purge` permanently removes every
current and historical version of the Patient and of the resources in its
Patient compartment, and publishes no resource events. `AuditEvent` and
`Group` are excluded, the first to keep the audit trail and the second because
a Group is shared by its members; the Patient itself is matched by id only,
not through `Patient.link`. The call answers `200` with an OperationOutcome
listing the removed versions per resource type, and `404` for an unknown or
already purged patient. It is synchronous; the asynchronous pattern is not
supported.

The operation is opt-in: neither the R4 nor the R5 base definitions contain
it, so a deployment enables it by putting the `patient-purge.json`
OperationDefinition shipped in `repofyr-operations` into its
`fhir.initialization.operations-path` folder and declaring the operation on
the Patient resource of its CapabilityStatement. The CapabilityStatement must
also declare the Patient compartment
(`http://hl7.org/fhir/CompartmentDefinition/patient` under
`rest.compartment`), otherwise the call answers `405`. The default R5
CapabilityStatement does not declare it. Every search parameter through which
a purged resource type joins the compartment (for example `Condition.asserter`)
must be declared too: before deleting anything the call checks this, and
answers `501` naming the missing parameters rather than purging only part of
the patient's data.

## 4.0.1 - 2026-08-18

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ single `repofyr-server-*` module and receive the rest transitively.
| Module | Purpose |
|---|---|
| [`repofyr-core`](repofyr-core/README.md) | Core server runtime: FHIR API endpoints, request and response handling, MongoDB persistence, authorization, and audit. |
| [`repofyr-operations`](repofyr-operations/README.md) | The FHIR operations provided out of the box: `$validate`, `$everything`, `$document`, `$expand`, `$meta`, `$lastn`, and bulk `$import`. |
| [`repofyr-operations`](repofyr-operations/README.md) | The FHIR operations provided out of the box: `$validate`, `$everything`, `$document`, `$expand`, `$meta`, `$lastn`, Patient `$purge`, and bulk `$import`. |
| [`repofyr-event`](repofyr-event/README.md) | Server event contracts and JSON marshalling; the event bus SPI resource changes are published through. |
| [`repofyr-kafka`](repofyr-kafka/README.md) | Publishes server events and FHIR Subscription notifications to Kafka topics. |
| [`repofyr-server-r4`](repofyr-server-r4/README.md) | Runnable server for FHIR R4. |
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@

<!-- This is a special placeholder to manage the version from a single property throughout the parent and child modules. No other property name can be used. -->
<!-- https://maven.apache.org/maven-ci-friendly.html -->
<revision>4.0.2-SNAPSHOT</revision>
<revision>4.1.0-SNAPSHOT</revision>
<!-- Kept distinct now so the server reactor can consume independently
versioned library artifacts after the physical split. -->
<onfhir.libs.version>4.0.0</onfhir.libs.version>
Expand Down
38 changes: 28 additions & 10 deletions repofyr-core/src/main/scala/io/repofyr/db/DocumentManager.scala
Original file line number Diff line number Diff line change
Expand Up @@ -1376,18 +1376,36 @@ object DocumentManager {
}*/

/**
* Remove the resources from the persistence (actual delete)
* IMPORTANT !!! Never use this for FHIR, this is only used for test setups to clean data for patient accounts without deleting patient
* @param rtype FHIR Resource type
* @param query Mongo Query
* Remove the resources from the persistence (actual delete). Unlike a FHIR delete, nothing is moved to history.
* IMPORTANT !!! Not for the FHIR delete interaction; only for erasure such as Patient $purge (GDPR) and test cleanups
* @param rtype FHIR Resource type
* @param query Mongo Query
* @param history If true remove from the history collection, otherwise from the current collection
* @return Number of removed documents
*/
def removeDocuments(rtype:String, query:Bson, history:Boolean = false):Future[Long] = {
//Last gate before deleteMany; a query that matches every document would wipe out the collection
if(matchesAllDocuments(query.toBsonDocument(classOf[BsonDocument], MongoClientSettings.getDefaultCodecRegistry)))
Future.failed(new InternalServerException(s"Refusing to remove documents from $rtype with a query matching the whole collection!"))
else
MongoDB
.getCollection(rtype, history)
.deleteMany(query)
.toFuture()
.map(dr => dr.getDeletedCount)
}

/**
* Check if a query trivially matches every document i.e. {}, {$and:[]}, {$or:[{}, ...]} or a nesting of them
* @param query Rendered Mongo query
* @return
*/
def removeDocuments(rtype:String, query:Bson):Future[Long] = {
MongoDB
.getCollection(rtype)
.deleteMany(query)
.toFuture()
.map(dr => dr.getDeletedCount)
def matchesAllDocuments(query:BsonDocument):Boolean = {
query.entrySet().asScala.forall(e => (e.getKey, e.getValue) match {
case ("$and", arr:BsonArray) => arr.getValues.asScala.forall(v => v.isDocument && matchesAllDocuments(v.asDocument()))
case ("$or", arr:BsonArray) => arr.getValues.asScala.exists(v => v.isDocument && matchesAllDocuments(v.asDocument()))
case _ => false
})
}


Expand Down
23 changes: 23 additions & 0 deletions repofyr-core/src/main/scala/io/repofyr/db/ResourceManager.scala
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import org.mongodb.scala.bson.conversions.Bson
import io.repofyr.db.OnFhirBsonTransformer._
import io.repofyr.event.{IFhirEventBus, ResourceCreated, ResourceDeleted, ResourceUpdated}
import io.onfhir.exception.UnsupportedParameterException
import io.repofyr.exception.InternalServerException
import io.onfhir.util.DateTimeUtil
import org.json4s.JsonAST.JValue
import org.mongodb.scala.bson.{BsonBoolean, BsonString}
Expand Down Expand Up @@ -1829,6 +1830,28 @@ class ResourceManager(fhirConfig: FhirServerConfig, fhirEventBus: IFhirEventBus
)
}

/**
* Permanently remove all current and historical versions of the resources matching the given parameters.
* No history is kept and no event is published, as the purpose is erasure (e.g. Patient $purge).
* Only parameters resolving to a plain Mongo query (normal, compartment, _id) are supported.
*
* @param rtype Resource type
* @param queryParams Parsed FHIR query parameters selecting the resources
* @return Number of removed documents (current and history versions)
*/
def purgeResources(rtype: String, queryParams: List[Parameter]): Future[Long] = {
constructQueryNew(rtype, queryParams) match {
case (Some(query), Nil) =>
for {
current <- DocumentManager.removeDocuments(rtype, query)
history <- DocumentManager.removeDocuments(rtype, query, history = true)
} yield current + history
//Refuse rather than widen: a missing query would match the whole collection
case _ =>
Future.failed(new InternalServerException(s"Purge on $rtype requires parameters that resolve to a single Mongo query!"))
}
}

/**
* Replace a current resource with the given content (Used by onFHIR administrative parts) without adding old version to the history
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ object DefaultOperationHandlers {
"http://hl7.org/fhir/OperationDefinition/Composition-document" -> "io.repofyr.operation.DocumentOperationHandler",
"http://hl7.org/fhir/OperationDefinition/Observation-lastn" -> "io.repofyr.operation.LastNObservationOperationHandler",
"http://onfhir.io/fhir/OperationDefinition/import" -> "io.repofyr.operation.BulkOperationHandler",
"http://hl7.org/fhir/OperationDefinition/Patient-everything" -> "io.repofyr.operation.PatientEverythingOperationHandler"
"http://hl7.org/fhir/OperationDefinition/Patient-everything" -> "io.repofyr.operation.PatientEverythingOperationHandler",
"http://hl7.org/fhir/OperationDefinition/Patient-purge" -> "io.repofyr.operation.PatientPurgeOperationHandler"
)
}
18 changes: 18 additions & 0 deletions repofyr-operations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ All handlers live in `io.repofyr.operation` and take a single
| `DocumentOperationHandler` | `$document` | Builds a document Bundle from a Composition and the resources it references (`_include=*`); the `persist` parameter decides whether the Bundle is stored |
| `LastNObservationOperationHandler` | `$lastn` | Returns the last N Observations per code for a subject, as a searchset Bundle |
| `PatientEverythingOperationHandler` | `$everything` | Returns the resources in a Patient compartment, restricted to the compartment relations the server supports and excluding `AuditEvent`, `Group`, and `Provenance` |
| `PatientPurgeOperationHandler` | `$purge` | Permanently removes all current and historical versions of a Patient and of the resources in its Patient compartment, excluding `AuditEvent` and `Group`, and returns an OperationOutcome |
| `BulkOperationHandler` | `$import` | Validates the import request, starts an asynchronous `BulkImportJobHandler` actor, and replies `202 Accepted` |

Two handlers impose constraints beyond the base specification, and both fail
Expand All @@ -42,6 +43,23 @@ the job through `io.repofyr.async.BulkImportJobHandler` in `repofyr-core`.
There is no status or cancel operation; the job reports its progress to the
server log at debug level.

`$purge` comes from the FHIR 6.0.0-ballot3 build
(`http://hl7.org/fhir/OperationDefinition/Patient-purge`) and is provided for
R4 and R5. Neither base definition set contains it, so the module carries its
OperationDefinition as the `patient-purge.json` resource. A deployment enables
it by copying that file into its `operations-path` folder and declaring the
operation on the Patient resource of its CapabilityStatement, which must also
declare the Patient compartment under `rest.compartment` (the default R5
CapabilityStatement does not; without it the call answers `405`). The
compartment's search parameters must be declared for every resource type
purged; the call checks this before deleting anything and answers `501` naming
the missing ones. It is a hard
delete meant for erasure requests: unlike a FHIR delete nothing is kept in
history, no resource event is published, and it cannot be undone. The Patient
is matched by id only, so other Patient records reached through
`Patient.link` stay, and the purge is synchronous. If a run fails partway,
the Patient is removed last, so the same call can simply be repeated.

## How a handler reaches the server

The dispatch table lives in `repofyr-core`, not here.
Expand Down
31 changes: 31 additions & 0 deletions repofyr-operations/src/main/resources/patient-purge.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"resourceType": "OperationDefinition",
"id": "Patient-purge",
"url": "http://hl7.org/fhir/OperationDefinition/Patient-purge",
"version": "6.0.0-ballot3",
"name": "Purge",
"title": "Purge Patient Record",
"status": "draft",
"kind": "operation",
"date": "2026-10-27",
"publisher": "HL7 (FHIR Project)",
"description": "This operation is used to request the removal of all current and historical versions for all resources in a patient compartment. Defined in FHIR 6.0.0-ballot3 and provided by Repofyr for R4 and R5. Repofyr permanently removes the resources of the Patient compartment (except AuditEvent and Group) and the Patient itself, including their history.",
"affectsState": true,
"code": "purge",
"resource": [
"Patient"
],
"system": false,
"type": false,
"instance": true,
"parameter": [
{
"name": "return",
"use": "out",
"min": 0,
"max": "1",
"documentation": "Status and/or results of the purge request",
"type": "OperationOutcome"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package io.repofyr.operation

import io.repofyr.api.model.AkkaHttpModelAdapter._

import akka.http.scaladsl.model.StatusCodes
import io.onfhir.api.{FHIR_PARAMETER_CATEGORIES, FHIR_PARAMETER_TYPES, FHIR_SEARCH_SPECIAL_PARAMETERS}
import io.onfhir.api.model.{FHIROperationRequest, FHIROperationResponse, FHIRResponse, OutcomeIssue, Parameter}
import io.repofyr.api.service.FHIROperationHandlerService
import io.repofyr.config.IFhirConfigurationManager
import io.repofyr.exception.{MethodNotAllowedException, NotImplementedException}
import org.slf4j.{Logger, LoggerFactory}

import scala.concurrent.Future

/**
* Handles the Patient $purge operation (http://hl7.org/fhir/OperationDefinition/Patient-purge, defined in FHIR 6.0.0-ballot3)
* Permanently removes all current and historical versions of the resources in the Patient compartment and the Patient
* itself, e.g. for GDPR erasure requests. Nothing is moved to history and no resource events are published.
*
* @param fhirConfigurationManager FHIR configuration manager
*/
class PatientPurgeOperationHandler(fhirConfigurationManager:IFhirConfigurationManager) extends FHIROperationHandlerService(fhirConfigurationManager) {
private val logger: Logger = LoggerFactory.getLogger("PatientPurgeOperationHandler")

/**
* Patient compartment relations (resource type -> search parameters linking it to the patient), if configured
*/
private val patientCompartment: Option[Map[String, Set[String]]] =
fhirConfigurationManager.fhirConfig.compartmentRelations.get("Patient")

/**
* Resource types in the Patient compartment to purge through the compartment (only the ones supported by the server)
* The Patient itself is not among them; it is purged by its id, as compartment membership via 'link' would also
* remove other Patient records that merely point at this one
*/
private val resourceTypesToPurge: Seq[String] =
patientCompartment
.map(_.keySet
.diff(PatientPurgeOperationHandler.EXCLUDED_RESOURCES + "Patient")
.intersect(fhirConfigurationManager.fhirConfig.resourceConfigurations.keySet)
.toSeq.sorted)
.getOrElse(Nil)

/**
* Handles the purge operation
* @param operationName Operation name as defined after '$' symbol i.e. purge
* @param operationRequest Operation Request (no input parameters)
* @param resourceType Patient
* @param resourceId Patient id
* @return The response containing the OperationOutcome
*/
override def executeOperation(operationName: String, operationRequest: FHIROperationRequest, resourceType: Option[String], resourceId: Option[String]): Future[FHIROperationResponse] = {
//If Patient compartment is not defined, we cannot find the patient's data
if(patientCompartment.isEmpty)
throw new MethodNotAllowedException(Seq(
OutcomeIssue(
FHIRResponse.SEVERITY_CODES.ERROR,
FHIRResponse.OUTCOME_CODES.NOT_SUPPORTED,
None,
Some(s"The operation 'purge' is not supported as Patient compartment is not configured for this server!"),
Nil
)
))
//Check every compartment parameter can be queried before deleting anything, so a misconfiguration
//fails the whole purge up front instead of halfway, and never leaves data reachable only through an unsupported parameter
val unsupported =
PatientPurgeOperationHandler.findUnsupportedCompartmentParams(
patientCompartment.get,
resourceTypesToPurge,
rtype => fhirConfigurationManager.fhirConfig.getSupportedParameters(rtype).keySet
)
if(unsupported.nonEmpty)
throw new NotImplementedException(Seq(
OutcomeIssue(
FHIRResponse.SEVERITY_CODES.ERROR,
FHIRResponse.OUTCOME_CODES.NOT_SUPPORTED,
None,
Some(s"The operation 'purge' cannot reach all data in the Patient compartment, as the following search parameters of the compartment are not supported by this server: ${unsupported.mkString(", ")}. Nothing is purged; declare them in the CapabilityStatement!"),
Nil
)
))

val pid = resourceId.get
logger.info(s"Purging all data of Patient/$pid ...")
//Purge the compartment sequentially and the Patient last, so a failed run still finds the Patient and can be retried
purgeCompartment(pid, resourceTypesToPurge)
.flatMap(compartmentCounts =>
fhirConfigurationManager.resourceManager
.purgeResources("Patient", List(Parameter(FHIR_PARAMETER_CATEGORIES.SPECIAL, FHIR_PARAMETER_TYPES.TOKEN, FHIR_SEARCH_SPECIAL_PARAMETERS.ID, Seq("" -> pid))))
.map(patientCount => compartmentCounts :+ ("Patient" -> patientCount))
)
.map(counts => {
val removed = counts.filter(_._2 > 0).map(c => s"${c._1}=${c._2}").mkString(", ")
logger.info(s"Purged Patient/$pid, removed versions: $removed")
// 'success' severity only exists from FHIR 6, so R4/R5 report the outcome as information
val outcome = FHIRResponse.createOperationOutcome(Seq(
OutcomeIssue(
FHIRResponse.SEVERITY_CODES.INFORMATION,
FHIRResponse.OUTCOME_CODES.INFORMATIONAL,
None,
Some(s"All current and historical versions of Patient/$pid and the resources in its compartment (except ${PatientPurgeOperationHandler.EXCLUDED_RESOURCES.toSeq.sorted.mkString(", ")}) are purged. Removed versions per resource type: $removed"),
Nil
)
))
val response = new FHIROperationResponse(StatusCodes.OK)
response.setComplexOrResourceParam("return", outcome)
response
})
}

/**
* Purge the resources of the given types in the patient's compartment one type after another
* @param pid Patient id
* @param rtypes Remaining resource types
* @return Number of removed versions for each resource type
*/
private def purgeCompartment(pid:String, rtypes:Seq[String]):Future[Seq[(String, Long)]] = {
rtypes.foldLeft(Future.successful(Seq.empty[(String, Long)]))((acc, rtype) =>
acc.flatMap(counts =>
fhirConfigurationManager.resourceManager
.purgeResources(rtype, List(fhirConfigurationManager.fhirSearchParameterValueParser.constructCompartmentSearchParameter("Patient", pid, rtype)))
.map(c => counts :+ (rtype -> c))
)
)
}
}

object PatientPurgeOperationHandler {
/**
* Resource types in the Patient compartment that are not purged;
* - AuditEvent keeps the audit trail (including the record of this purge)
* - Group is shared among patients (member), purging it would remove a whole cohort
*/
final val EXCLUDED_RESOURCES = Set("AuditEvent", "Group")

/**
* Find the compartment search parameters that the server cannot query for the given resource types
* @param compartmentRelations Resource type -> search parameters linking it to the Patient compartment
* @param rtypes Resource types to purge
* @param supportedParams Search parameters the server supports for a resource type
* @return Unsupported parameters as ResourceType.param, sorted
*/
def findUnsupportedCompartmentParams(compartmentRelations:Map[String, Set[String]], rtypes:Seq[String], supportedParams:String => Set[String]):Seq[String] = {
rtypes.flatMap(rtype =>
compartmentRelations.getOrElse(rtype, Set.empty[String])
.filterNot(_ == "_id") //Resolved directly by the query builder, not through a search parameter
.diff(supportedParams(rtype))
.map(p => s"$rtype.$p")
).sorted
}
}
5 changes: 5 additions & 0 deletions repofyr-server-r4/src/test/resources/application.conf
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ fhir {
auditing {
repository = "none"
}
# Patient $purge is not in the R4 base definitions, so its OperationDefinition comes from the
# one repofyr-operations ships. Relative to the module directory Maven runs the suite from.
initialization {
operations-path = "../repofyr-operations/src/main/resources"
}
}

mongodb {
Expand Down
Loading
Loading