OHS Player backend extensions for OHS Player clients (KMP and Web). Provides custom endpoints and access checker plugins loaded into the FHIR Gateway at runtime.
JDK 21 is required to build this project. The build tooling (Spotless / google-java-format, Error Prone) depends on internal JDK APIs that are only available in JDK 21+. The compiled output still targets Java 11 bytecode, so the JAR can be loaded into any JDK 11+ runtime.
Verify your version before building:
java -version # must be 21+mvn clean packageOutput: target/ohs-player-backend-extensions-1.0-SNAPSHOT.jar
To run Error Prone and NullAway checks locally (the same checks that run in CI):
mvn clean verify -Perror-proneViolations will fail the build with compiler errors indicating the rule and location.
Load the plugin into the FHIR Gateway via -Dloader.path:
java -Dloader.path="PATH_TO_PLUGIN/ohs-player-backend-extensions-1.0-SNAPSHOT.jar" \
-jar fhir-gateway.jar --server.port=8081See the FHIR Gateway documentation for full deployment details.
The plugin does not bundle FHIR Gateway classes — they are declared provided in pom.xml and supplied by the host at runtime.
| Variable | Source | Description |
|---|---|---|
PROXY_TO |
env var | FHIR server base URL |
IAM_PROVIDER |
env var | IAM provider to use. Defaults to keycloak if unset. |
TOKEN_ISSUER |
env var | (Keycloak) Issuer URL, e.g. http://keycloak:8080/realms/my-realm. Server URL and realm are parsed from this. |
IAM_PROVIDER_CLIENT_ID |
env var or iam.provider.client-id in application.properties |
(Keycloak) Admin client ID |
IAM_PROVIDER_CLIENT_SECRET |
env var or iam.provider.client-secret in application.properties |
(Keycloak) Admin client secret |
| Property | Default | Description |
|---|---|---|
fhir.client.connect-timeout-ms |
5000 |
Maximum time in milliseconds to establish a connection to the upstream FHIR server. |
fhir.client.read-timeout-ms |
30000 |
Maximum time in milliseconds to wait for one upstream FHIR response after the request is sent. |
location-hierarchy.max-part-of-batch-size |
100 |
Number of parent Location ids to search for in one Location.partOf request. |
location-hierarchy.upstream-page-size |
200 |
Number of child Locations to ask the FHIR server for per page. |
location-hierarchy.max-depth |
25 |
Deepest child level to return below the requested root. 0 returns only the root. |
location-hierarchy.max-nodes |
10000 |
Maximum number of Location nodes to return in one response, including the root. |
location-hierarchy.cache-ttl-seconds |
86400 |
How long to keep a successful hierarchy response in the local Caffeine cache. |
location-hierarchy.cache-max-total-nodes |
100000 |
Approximate maximum number of Location nodes held across all cached hierarchy responses in one JVM. |
The IAM_PROVIDER variable selects the identity provider used for user management. Supported values:
| Value | Provider |
|---|---|
keycloak |
Keycloak (default) |
An unknown value causes the application to fail at startup with a clear error message.
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/users |
Create user in Keycloak and a matching FHIR Practitioner |
GET |
/api/users |
List all Practitioners. Supports any FHIR query parameter, e.g. ?name=John&_sort=family&_count=10 |
GET |
/api/users/{id} |
Get a single Practitioner by FHIR ID |
PUT |
/api/users/{id} |
Update user in Keycloak and Practitioner |
PUT |
/api/users/{id}/password |
Set a user's password in Keycloak |
DELETE |
/api/users/{id} |
Delete user from Keycloak and FHIR |
Request body (POST / PUT /api/users and /api/users/{id}):
{
"username": "charity",
"firstName": "Charity",
"lastName": "Otala",
"email": "charity@ohs.dev",
"enabled": true,
"dob": "1990-05-15",
"gender": "female",
"national_id": "NID-12345678",
"phone": "+254700000000",
"groupIds": ["group-uuid-1", "group-uuid-2"]
}username and email are required. enabled defaults to false if omitted.
dob, gender, national_id, and phone are optional. When provided they are written to the FHIR Practitioner resource:
| Field | FHIR mapping |
|---|---|
dob |
Practitioner.birthDate (format: YYYY-MM-DD) |
gender |
Practitioner.gender — accepted values: male, female, other, unknown |
national_id |
Practitioner.identifier with system http://ohs.dev/identifiers/national-id |
phone |
Practitioner.telecom with system=phone, use=mobile |
The email field is always synced to Practitioner.telecom with system=email, use=work.
groupIds is optional. When present, group membership is set to exactly the listed IDs: missing memberships are added and extra ones removed. An empty array ([]) removes the user from all groups. Omitting the field leaves memberships unchanged. Group assignment failures are logged as warnings and do not fail the overall request.
Request body (PUT /api/users/{id}/password):
{
"password": "secret123",
"temporary": false
}password is required. temporary is optional and defaults to false. When true, Keycloak marks the credential as temporary and the user must change it on next login. Returns 204 No Content on success.
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/groups |
List all groups (id, name, path) |
POST |
/api/groups |
Create a group |
GET |
/api/groups/{id} |
Get a group by ID (includes role assignments) |
PUT |
/api/groups/{id} |
Update a group (replaces name and role assignments) |
DELETE |
/api/groups/{id} |
Delete a group |
POST |
/api/groups/{groupId}/members/{userId} |
Add a user to a group |
DELETE |
/api/groups/{groupId}/members/{userId} |
Remove a user from a group |
Request body (POST / PUT /api/groups and /api/groups/{id}):
{
"name": "clinicians",
"realmRoles": ["GET_PATIENT"],
"clientRoles": {
"my-client": ["web.manage-locations", "web.manage-orgs"]
}
}name is required. realmRoles and clientRoles are optional. On PUT, role assignments are fully replaced by whatever is supplied.
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/roles |
List all realm roles and client roles available for assignment |
Response:
{
"realmRoles": [
{ "id": "...", "name": "offline_access", "description": "..." }
],
"clients": [
{
"clientId": "my-client",
"clientName": "My Client",
"roles": [
{ "id": "...", "name": "view-records", "description": "..." }
]
}
]
}| Method | Endpoint | Description |
|---|---|---|
GET |
/api/location-hierarchy/{rootId} |
Return the Location.partOf tree starting from the requested FHIR Location id. |
rootId must be a valid FHIR id: 1–64 characters using only letters, numbers, -, or .. The values . and .. are rejected.
Location node id values are FHIR logical ids, while relationship references such as partOf.reference use canonical FHIR references like Location/{id}.
Response:
{
"root": {
"id": "root-location-id",
"name": "Root Location",
"status": "active",
"description": "Top-level location",
"partOf": null,
"physicalType": {
"coding": [
{
"system": "http://terminology.hl7.org/CodeSystem/location-physical-type",
"code": "jdn",
"display": "Jurisdiction"
}
]
},
"type": [],
"hasMoreChildren": false,
"children": [
{
"id": "child-location-id",
"name": "Child Location",
"status": "active",
"description": "Child location",
"partOf": {
"reference": "Location/root-location-id",
"display": "Root Location"
},
"physicalType": {
"coding": [
{
"system": "http://terminology.hl7.org/CodeSystem/location-physical-type",
"code": "bu",
"display": "Building"
}
]
},
"type": [
{
"coding": [
{
"system": "http://ohs.dev/codes/administrative-level",
"code": "1",
"display": "Level 1"
}
]
}
],
"hasMoreChildren": false,
"children": []
}
]
},
"meta": {
"nodeCount": 2,
"depth": 1,
"truncated": false,
"builtAt": "2026-06-26T12:00:00Z"
}
}The service builds the tree one level at a time. It starts with the root, fetches its children, then fetches the next level of children, and continues until it reaches max-depth, max-nodes, or the end of the tree. Results are ordered consistently so the same stored hierarchy returns the same response order.
Each node includes selected fields from the backing FHIR Location: status, description, physicalType, and type. Child nodes include partOf.reference and partOf.display; the root node has partOf=null.
max-depth controls how many child levels can be returned. max-nodes controls how many Locations can be returned in one response, including the root. If the response limit is reached, the service stops at a clean parent boundary instead of returning only some children for the same parent. Nodes that may still have children are marked with hasMoreChildren=true. When hasMoreChildren=false, the returned child list for that node is complete.
If meta.truncated=true, the returned hierarchy is incomplete because traversal stopped before the service checked every descendant. To return a fuller tree, update the OHS Player backend deployment configuration, for example location-hierarchy.max-depth or location-hierarchy.max-nodes, then refresh/rebuild the cached hierarchy.
Successful responses are cached in-process with Caffeine. The default TTL is one day, so the endpoint accepts up to one day of Location hierarchy staleness. The cache is local to each backend JVM; each replica can build the same root independently after startup or expiry. Shared Redis caching and distributed miss coordination are planned for v2.
| Status | Meaning |
|---|---|
200 |
Hierarchy returned successfully. |
400 |
Missing or malformed {rootId} path segment. |
401 |
Missing or invalid authentication. |
403 |
Authenticated caller lacks location-hierarchy.view. |
404 |
Requested root Location was not found. |
500 |
Unexpected backend failure. |
502 |
Upstream FHIR server failure while building the hierarchy. |
All imports resolve existing resources by identifier (and organizations/locations by name) with Cache-Control: no-cache, so the FHIR server re-runs each lookup instead of replaying a cached result set. HAPI JPA otherwise reuses search results for reuse_cached_search_results_millis (60 seconds by default), which would make a resource created by one import invisible to an import that runs within that window — for example a user-assignments CSV submitted less than a minute after the user import that created the practitioners.
| Method | Endpoint | Content-Type | Description |
|---|---|---|---|
POST |
/api/bulk-import/users |
multipart/form-data |
Import users from a CSV file with SSE progress |
Upload a CSV file in the file field. The server streams Server-Sent Events back as each row is processed.
curl -N --form file=@users.csv http://localhost:8081/api/bulk-import/usersCSV columns (header row required; column order does not matter):
| Column | Required | Description |
|---|---|---|
id |
No | FHIR Practitioner ID. If present, the row is an update. |
username |
Yes | IAM username |
first_name |
No | |
last_name |
No | |
email |
Yes | |
group |
No | Group name (resolved to ID at import time). |
password |
No | Defaults to {username}123 if omitted. |
is_password_temp |
No | true or 1 to mark password as temporary. |
dob |
No | Date of birth (YYYY-MM-DD), maps to Practitioner.birthDate. |
gender |
No | male, female, other, or unknown. |
national_id |
No | Maps to Practitioner.identifier with system http://ohs.dev/identifiers/national-id. |
phone |
No | Maps to Practitioner.telecom (phone/mobile). |
source_id |
No | External reference ID. Maps to Practitioner.identifier with system http://ohs.dev/identifiers/source-id. Also used as an alternate lookup key for updates when id is absent. |
Create vs update resolution: if id is present it takes precedence; otherwise source_id is used to look up an existing Practitioner (update if found, create if not); if neither is present a new user is created.
SSE event format:
data: {"processed": 5, "total": 100}
data: {"error": "Group not found: unknown-group", "row": 6}
A progress event is emitted after each successful row. On failure the error event is emitted and the stream closes — subsequent rows are not processed.
Note: This endpoint is intended for initial bulk imports and is not meant for updates in production. It does not perform upsert logic beyond the simple id and source_id resolution described above. To avoid losing data, use the User Management API for ongoing user maintenance.
| Method | Endpoint | Content-Type | Description |
|---|---|---|---|
POST |
/api/bulk-import/organizations |
multipart/form-data |
Import organizations from a CSV file with SSE progress |
Upload a CSV file in the file field. The server streams Server-Sent Events back as each row is processed.
curl -N --form file=@organizations.csv http://localhost:8081/api/bulk-import/organizationsCSV columns (header row required; column order does not matter):
| Column | Required | Description |
|---|---|---|
id |
No | FHIR Organization ID. If present, the row is an update. |
name |
Yes | Organization name |
source_id |
No | External reference ID. Maps to Organization.identifier with system http://ohs.dev/identifiers/source-id. Also used as an alternate lookup key for updates when id is absent. |
is_team |
No | true or 1 to add Organization.type with code team (http://terminology.hl7.org/CodeSystem/organization-type). Defaults to false. |
parent_id |
No | FHIR ID of the parent Organization. Sets Organization.partOf. |
parent_name |
No | Name of the parent Organization. Resolved by FHIR name search. Used when parent_id is absent. |
source_parent_id |
No | Source ID of the parent Organization. Resolved by identifier lookup. Takes precedence over parent_name. |
phone |
No | Maps to Organization.telecom (phone/work). |
email |
No | Maps to Organization.telecom (email/work). |
physical_address |
No | Maps to Organization.address with use=work, type=physical. |
postal_address |
No | Maps to Organization.address with use=work, type=postal. |
Create vs update resolution: rows are submitted to the FHIR server as a BATCH bundle. If id is present the entry is a direct PUT Organization/{id}. If source_id is present the entry is a conditional PUT Organization?identifier=… (creates if absent, updates if found — idempotent). If neither is present the entry is a POST Organization (not idempotent; re-running creates a duplicate).
Parent resolution precedence: parent_id > source_parent_id > parent_name. If any parent column is provided but no matching Organization is found, the row is skipped with an error event and processing continues. Rows without parent columns are imported without a partOf reference.
Ordering note: Parent organizations must appear in earlier rows than their children, or be pre-existing in the FHIR server. When a child's parent appears earlier in the same batch, the batch is flushed early (committing the parent), then the child is retried in a new batch — no FHIR indexing lag and no manual ordering of the CSV required beyond parents preceding their children.
Idempotent re-runs: when rows fail at the FHIR level the error is emitted per row and processing continues (remaining rows and batches are not skipped). Fix the failing rows and re-POST the full CSV — rows with id or source_id are safe to re-run without creating duplicates.
SSE event format:
data: {"processed":50,"total":100}
data: {"error":"Parent organization not found with name: Unknown","row":53}
data: {"processed":95,"total":100}
data: {"done":true,"processed":95,"failed":5,"total":100}
One progress event is emitted per completed batch (not per row). Error events are emitted per failed row. A terminal done event is always emitted when processing finishes — use done to detect stream end and failed to decide whether to re-run. Processing continues after errors so all failures are visible in a single pass.
Configuration:
| Environment variable | Default | Description |
|---|---|---|
BULK_IMPORT_BATCH_SIZE |
50 |
Number of rows per FHIR BATCH bundle. Change to a higher number for production to reduce FHIR HTTP round-trips significantly (e.g. for 100, 50K rows → ~500 bundle calls instead of ~1000). |
| Method | Endpoint | Content-Type | Description |
|---|---|---|---|
POST |
/api/bulk-import/locations |
multipart/form-data |
Import locations from a CSV file with SSE progress |
Upload a CSV file in the file field. The server streams Server-Sent Events back as each row is processed.
curl -N --form file=@locations.csv http://localhost:8081/api/bulk-import/locationsCSV columns (header row required; column order does not matter):
| Column | Required | Description |
|---|---|---|
id |
No | FHIR Location ID. If present, the row is an update. |
name |
Yes | Location name |
physical_type |
No | Physical type display name (e.g. building, ward, room). See table below. |
level |
No | Administrative level code (e.g. country, county). Maps to Location.type with system http://ohs.dev/codes/administrative-level. |
longitude |
No | Decimal longitude. Set together with latitude to populate Location.position. |
latitude |
No | Decimal latitude. Set together with longitude to populate Location.position. |
source_id |
No | External reference ID. Maps to Location.identifier with system http://ohs.dev/identifiers/source-id. Also used as an alternate lookup key for updates when id is absent. |
parent_id |
No | FHIR ID of the parent Location. Sets Location.partOf. |
source_parent_id |
No | Source ID of the parent Location. Resolved by identifier lookup. |
org_id |
No | FHIR ID of the managing Organization. Sets Location.managingOrganization. |
source_org_id |
No | Source ID of the managing Organization. Resolved by identifier lookup. |
physical_type values: accepts any display name from the FHIR R4 location-physical-type valueset (e.g. building, ward, room). Non-empty values not in the valueset are mapped to code other with the entered text (capitalized) as the display. Blank values are ignored.
Materialized path aliases: each imported Location gets two alias entries — a /-separated path of ancestor names ending with the current location's name (e.g. Country/County/Clinic A), and the equivalent path of FHIR IDs (e.g. fhir-id-country/fhir-id-county/fhir-id-clinic).
Parent resolution precedence: parent_id > source_parent_id. If a parent column is provided but no matching Location is found, the row is skipped with an error event and processing continues.
Managing organization resolution: org_id > source_org_id. If provided but not found, the row is skipped with an error event and processing continues.
Create vs update resolution: same as org import — id → direct PUT Location/{id}; source_id → conditional PUT Location?identifier=… (idempotent); neither → POST Location (not idempotent on re-run).
Ordering note: Parent locations must appear in earlier rows than their children, or be pre-existing in the FHIR server. When a child's parent appears earlier in the same batch, the batch is flushed early, then the child is retried in a new batch.
SSE event format: identical to the org import format above.
Configuration: uses the same BULK_IMPORT_BATCH_SIZE environment variable as the org import.
| Method | Endpoint | Content-Type | Description |
|---|---|---|---|
POST |
/api/bulk-import/user-assignments |
multipart/form-data |
Create PractitionerRole resources from a CSV file with SSE progress |
Upload a CSV file in the file field. The server streams Server-Sent Events back as each row is processed.
curl -N --form file=@assignments.csv http://localhost:8081/api/bulk-import/user-assignmentsCSV columns (header row required; column order does not matter):
| Column | Required | Description |
|---|---|---|
practitioner_id |
One of these two | Direct FHIR ID of the Practitioner |
practitioner_source_id |
One of these two | Source ID of the Practitioner — resolved via Practitioner.identifier with system http://ohs.dev/identifiers/source-id |
org_id |
No | Direct FHIR ID of the Organization. Sets PractitionerRole.organization. |
org_source_id |
No | Source ID of the Organization — resolved via identifier lookup. Used when org_id is absent. |
location_id |
No | Semicolon-separated list of FHIR Location IDs (e.g. loc-1;loc-2). Each ID is added to PractitionerRole.location. |
location_source_id |
No | Semicolon-separated list of Location source IDs — each resolved via identifier lookup. Used when location_id is absent. |
Each row creates one PractitionerRole resource linking the practitioner to an optional organization and zero or more locations. Multiple locations are specified as a semicolon-separated list in location_id or location_source_id (e.g. LOC-A;LOC-B;LOC-C). At least one of practitioner_id or practitioner_source_id must be present — rows missing both are skipped with an error event.
FHIR resource produced:
{
"resourceType": "PractitionerRole",
"active": true,
"practitioner": { "reference": "Practitioner/{id}" },
"organization": { "reference": "Organization/{id}" },
"location": [{ "reference": "Location/{id}" }]
}organization and location are omitted when the corresponding CSV columns are blank.
SSE event format: identical to the org and location import format above.
Configuration: uses the same BULK_IMPORT_BATCH_SIZE environment variable as the org and location imports.
Re-run warning: this endpoint is create-only. Each row always issues a
POST PractitionerRole, so re-running the same CSV will create duplicatePractitionerRoleresources. There is noidorsource_idcolumn for the role itself. Deduplicate or filter the CSV before re-submitting if you need to avoid duplicates.
Benchmarked locally with the performance-tests/ harness against a single-node FHIR Gateway + Keycloak: 10,000-row CSVs, 4 runs per entity, default BULK_IMPORT_BATCH_SIZE (50).
| Entity | Avg wall time | Avg rows/sec | Range (rows/sec) |
|---|---|---|---|
| Users | ~12.1 min | ~13.9 | 12.4 – 15.3 |
| Organizations | ~40 s | ~253 | 230 – 274 |
| Locations | ~2.6 min | ~65 | 58 – 71 |
| User assignments | ~2.9 min | ~58 | 54 – 62 |
Users import is disproportionately slower because, unlike the other three endpoints, it does not use FHIR BATCH bundles — each row issues its own synchronous IAM createUser call plus a Practitioner write. Throughput is bound by round-trips to the IAM provider, so raising BULK_IMPORT_BATCH_SIZE has no effect on user import speed.
Raising BULK_IMPORT_BATCH_SIZE above the default is expected to improve organizations/locations/user-assignments throughput further by reducing FHIR round-trips; these figures were measured at the default of 50.
Reproduce with python runners/run_all.py from performance-tests/ — see performance-tests/README.md for setup. Raw per-run output is appended to performance-tests/reports/summary.csv.
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/practitioner-details |
Fetch full practitioner context for the currently authenticated user (IAM id taken from the JWT) |
GET |
/api/practitioner-details?iam-id=<id> |
Fetch full practitioner context by Keycloak ID |
GET |
/api/practitioner-details?practitioner-id=<id> |
Fetch full practitioner context by FHIR Practitioner ID |
Optional query parameters: organisation-id, location-id — filter results to PractitionerRoles that reference the given organisation or location. These apply to all three forms above.
Response:
{
"practitioner": {},
"practitionerRoles": [
{
"practitionerRole": {},
"organization": {},
"locations": [],
"careTeams": []
}
]
}Each field is a FHIR R4 resource serialised as JSON. organization is null when the PractitionerRole has no affiliated organisation. careTeams contains only CareTeam resources whose participant.member references that specific PractitionerRole.
When iam-id is supplied the endpoint first resolves the FHIR Practitioner ID (Step 1), then fetches the full context in a single FHIR call using _include and _revinclude (Step 2). When practitioner-id is supplied Step 1 is skipped. When neither is supplied, the IAM id is extracted from the caller's JWT by the configured IAM provider (for Keycloak, the sub claim) and used for Step 1, i.e. the request resolves to the caller's own practitioner record.
Returns 404 when no matching practitioner or roles are found.
All /api/* endpoints require a valid JWT Bearer token.
Authorization: Bearer <access_token>
On startup the plugin performs OIDC discovery at {TOKEN_ISSUER}/.well-known/openid-configuration to obtain the jwks_uri, then validates incoming tokens offline using the provider's public keys. Tokens are checked for valid signature, correct issuer, and expiry.
Authorization uses a per-resource, three-level hierarchy. Higher levels satisfy lower-level checks: manage ⊇ edit ⊇ view.
| Role | Grants access to |
|---|---|
users.view |
GET /api/users/* |
users.edit |
users.view + POST /api/users, PUT /api/users/{id}, PUT /api/users/{id}/password |
users.manage |
users.edit + DELETE /api/users/{id} |
groups.view |
GET /api/groups/* |
groups.edit |
groups.view + POST /api/groups, PUT /api/groups/{id}, POST /api/groups/{gid}/members/{uid} |
groups.manage |
groups.edit + DELETE /api/groups/{id}, DELETE /api/groups/{gid}/members/{uid} |
bulk-import.manage |
POST /api/bulk-import/* |
roles.view |
GET /api/roles |
practitioner-details.view |
GET /api/practitioner-details?iam-id=<id>, GET /api/practitioner-details?practitioner-id=<id> (looking up another user's record) |
location-hierarchy.view |
GET /api/location-hierarchy/{rootId} |
Roles are read from the JWT claim path returned by the configured IAM provider. For Keycloak this is realm_access.roles. A token missing the required role receives 403 Forbidden; a missing or invalid token receives 401 Unauthorized.
This is separate from the /api/* role model above: it gates requests the Gateway forwards to the upstream FHIR store (e.g. /fhir/*), not this project's own servlets.
Set ACCESS_CHECKER=ohs_player_access on the Gateway to enable OhsPlayerAccessChecker. It expects the configured IAM provider to issue one role per <HTTP_VERB>_<FHIR_RESOURCE_TYPE> combination, e.g. GET_ENCOUNTER to read Encounters or DELETE_PATIENT to delete Patients. A request is granted only if the caller's roles contain the role matching its verb and resource type; for a Bundle (batch/transaction), every entry must be individually authorized for the whole Bundle to be granted.
Roles are extracted with the same IamProviderService.extractRolesFromToken used by the /api/* token validator, so this checker works unchanged with any configured IAM provider.
To enable AuditEvents for these requests, set AUDIT_EVENT_ACTIONS_CONFIG on the Gateway to the FHIR AuditEvent action codes to log, written together with no separator (not comma-separated), e.g. CRUDE for all CRUD + search, or CUD for writes only. This is independent of the checker choice.
Note: an AuditEvent is written per REST request — and per entry within a Bundle (batch/transaction), not just once for the whole Bundle. On a busy deployment, or with a broad config like CRUDE (which also covers reads and searches), this can generate AuditEvent resources quickly and grow the upstream FHIR store's storage significantly. Prefer a narrower config (e.g. CUD for writes only) unless you specifically need read/search auditing, and plan storage/retention for the upstream FHIR store accordingly.
GET /api/practitioner-details with no query parameters resolves the caller's own practitioner record from the JWT and requires only a valid token — no practitioner-details.view role is needed for this self-lookup form.
A set up of a Keycloak OAuth2 client with client credentials grant type is required to manage users. The admin client requires a service account with user management permissions.
- In the Keycloak Admin Console, go to your realm → Clients → select your client
- Under Settings, enable Service accounts enabled
- Go to the Service accounts roles tab → Assign role
- Filter by clients → search
realm-management→ assign:
| Role | Purpose |
|---|---|
manage-users |
Create, update, delete users; add/remove group members |
view-users |
Read users |
manage-realm |
Create, update, delete groups; assign roles to groups |
view-realm |
List realm roles and clients for role discovery |
Without the relevant roles, requests will return 403 Forbidden.