Multitenancy

openFHIR Engine supports multitenancy, allowing multiple organizations or tenants to use the same openFHIR instance while maintaining complete data isolation and tenant-specific configurations.

Tenant Isolation

All FhirConnect state and mapping access is completely separated per tenant. This includes:

  • Operational Templates (OPTs): Each tenant maintains their own set of operational templates

  • FhirConnect Context Mappers: Context configurations are isolated per tenant

  • FhirConnect Model Mappers: Mapping definitions are tenant-specific

  • ConceptMaps: Terminology mappings are maintained separately for each tenant

  • Mapping Insights: All mapping execution history and insights are tenant-scoped

When a tenant accesses any openFHIR functionality, they only see and can modify their own data. There is no cross-tenant data visibility or interference.

Tenant-Specific Configuration

Each tenant can be configured with specific settings that control how openFHIR processes their data. These configurations are stored as JSON properties within each tenant entity.

Currently available tenant configuration options:

contained_to_separate_bundle_entry

Type: boolean Default: true

Controls how referenced resources are handled in FHIR Bundle generation:

  • true (default): Referenced resources (like PractitionerRole referenced by Consent) are created as separate entries in the FHIR Bundle. This results in more Bundle entries but follows standard FHIR referencing patterns.

  • false: Referenced resources are embedded as contained resources within the parent resource. This results in fewer Bundle entries but uses FHIR’s contained resource mechanism.

Example:

When contained_to_separate_bundle_entry is false:

{
  "resourceType": "Bundle",
  "entry": [
    {
      "fullUrl": "urn:uuid:5b1f1b0e-0e3a-4a3e-9a0e-1f3c9d5a7b21",
      "resource": {
        "resourceType": "Consent",
        "contained": [
          {
            "resourceType": "PractitionerRole",
            "id": "contained-practitioner"
          }
        ],
        "provision": {
          "actor": [
            {
              "reference": {
                "reference": "#contained-practitioner"
              }
            }
          ]
        }
      }
    }
  ]
}

Either way every entry is given a fullUrl and a resource id that is legal on a Bundle entry: a resource created behind a resolve() is instantiated with an internal #<uuid> id, and the leading # is stripped when it ends up as an entry of its own. Resources that stay nested keep their local # reference, as #contained-practitioner above.

When contained_to_separate_bundle_entry is true:

{
  "resourceType": "Bundle",
  "entry": [
    {
      "fullUrl": "urn:uuid:5b1f1b0e-0e3a-4a3e-9a0e-1f3c9d5a7b21",
      "resource": {
        "resourceType": "Consent",
        "provision": {
          "actor": [
            {
              "reference": {
                "reference": "PractitionerRole/separate-practitioner"
              }
            }
          ]
        }
      }
    },
    {
      "fullUrl": "urn:uuid:9c2d7e4a-6b18-4c55-8f0d-2a7e4b6c9d10",
      "resource": {
        "resourceType": "PractitionerRole",
        "id": "separate-practitioner"
      }
    }
  ]
}

cdr_opt_source

Type: object Default: absent

Overrides, for this tenant only, the openEHR CDR that operational templates are fetched from. See Integrations for what the integration does and for the full list of settings.

The object takes the same settings as the global openfhir.cdr configuration block. Keys may be written in kebab-case (as in the YAML configuration) or camelCase:

{
  "properties": {
    "cdr_opt_source": {
      "enabled": true,
      "base-url": "http://tenant-cdr:8080/ehrbase/rest/openehr/v1",
      "auth": "BASIC",
      "username": "tenant-user",
      "password": "tenant-password"
    }
  }
}

Resolution order for each request is: the tenant’s cdr_opt_source property, then the global openfhir.cdr configuration, then disabled.

Note

An absent property means “inherit the global default” — it is not the same as being switched off. A tenant that should keep using the engine’s own template store while the global default has the integration enabled must set the property explicitly with "enabled": false. Conversely, a tenant can enable the integration with its own CDR while the global default leaves it off, which is the usual way to roll the feature out one tenant at a time.

Tenant Management API

Tenants can be managed through RESTful API endpoints:

POST /tenant

Create a new tenant with specific configuration.

Example request:

POST /tenant HTTP/1.1
Content-Type: application/json
Authorization: Bearer <token>

{
  "id": "organization-123",
  "properties": {
    "contained_to_separate_bundle_entry": false
  }
}
PUT /tenant/(id)

Update an existing tenant’s configuration.

Example request:

PUT /tenant/organization-123 HTTP/1.1
Content-Type: application/json
Authorization: Bearer <token>

{
  "properties": {
    "contained_to_separate_bundle_entry": true
  }
}
GET /tenant/(id)

Retrieve a specific tenant’s configuration.

Example request:

GET /tenant/organization-123 HTTP/1.1
Authorization: Bearer <token>
GET /tenant

Retrieve all tenants (administrative operation).

Example request:

GET /tenant HTTP/1.1
Authorization: Bearer <token>
DELETE /tenant/(id)

Delete a tenant and all associated data.

Example request:

DELETE /tenant/organization-123 HTTP/1.1
Authorization: Bearer <token>

Security and Authorization

Tenant operations require specific scopes:

  • tenant.c: Create tenants

  • tenant.r: Read tenant information

  • tenant.u: Update tenant configuration

  • tenant.d: Delete tenants

All other openFHIR operations automatically scope to the authenticated user’s tenant context, ensuring complete data isolation between organizations.

Messages consumed through queue support carry no authenticated user. They are translated under the tenant configured on their subscription (openfhir.messaging.subscriptions[].tenant), with that tenant’s mappers, templates and properties; a message cannot choose or override it.