diff --git a/config.yaml b/config.yaml index 3270b0c1..2ca0b3a4 100644 --- a/config.yaml +++ b/config.yaml @@ -27,6 +27,7 @@ packages: - database-postgres - reprocess-mediator - fhir-ig-importer + - fhir-info-gateway - openfn - datalake @@ -49,6 +50,7 @@ profiles: - kafka-unbundler-consumer - fhir-ig-importer - reprocess-mediator + - fhir-info-gateway - datalake envFiles: - cdr-dw.env @@ -66,6 +68,8 @@ profiles: - client-registry-jempi - identity-access-manager-keycloak - openhim-mapping-mediator + - fhir-ig-importer + - fhir-info-gateway envFiles: - cdr.env diff --git a/documentation/SUMMARY.md b/documentation/SUMMARY.md index f9f161ae..cb35a4a4 100644 --- a/documentation/SUMMARY.md +++ b/documentation/SUMMARY.md @@ -52,6 +52,8 @@ - [Reverse Proxy Nginx](packages/reverse-proxy-nginx/README.md) - [Local Development](packages/reverse-proxy-nginx/local-development.md) - [Environment Variables](packages/reverse-proxy-nginx/environment-variables.md) + - [FHIR Info Gateway](packages/fhir-info-gateway/README.md) + - [Environment Variables](packages/fhir-info-gateway/environment-variables.md) - [OpenFn](packages/openfn/README.md) - [Environment Variables](packages/openfn/environment-variables.md) - [Reverse Proxy Traefik](packages/reverse-proxy-traefik/README.md) diff --git a/documentation/packages/fhir-info-gateway/README.md b/documentation/packages/fhir-info-gateway/README.md new file mode 100644 index 00000000..e90fc95b --- /dev/null +++ b/documentation/packages/fhir-info-gateway/README.md @@ -0,0 +1,138 @@ +# Table of Contents + +- [Overview](#overview) +- [System Configuration](#system-configuration) +- [Routing FHIR Requests](#routing-fhir-requests) +- [Authentication Setup](#authentication-setup) +- [Client Role Management](#client-role-management) +- [API Testing](#api-testing) +- [References](#references) + +--- + +## Overview + +This document outlines the setup and integration of the FHIR Info Gateway to enhance the handling of FHIR-based requests. The system leverages OpenHIM for routing, Keycloak for authentication, and custom configurations for managing client access and secure data exchange. This setup enables seamless orchestration of Create/Read operations for patient clinical data. + +--- + +## System Configuration + +### Prerequisite Setup + +- **Keycloak Integration**: Keycloak is configured as the primary access token provider. +- **Initialization**: Use the following command to initialize the FHIR Info Gateway package: + + ```bash + ./instant-linux package init -n fhir-info-gateway --dev + ``` + +### Default Environment Variables + +| Variable | Description | Example Value | +| ------------------ | --------------------------------------- | --------------------------- | +| `ACCESS_CHECKER` | Enables role-based scope checking | `scope` | +| `REALM_URL` | Keycloak realm URL for token generation | `http://localhost:9088` | +| `GATEWAY_ENDPOINT` | Endpoint for FHIR Info Gateway API | `http://localhost:8080/api` | + +--- + +## Routing FHIR Requests + +### Updating OpenHIM Channels + +1. Navigate to the OpenHIM console. +2. Update the MPI Channel settings: + - **Channel Name**: MPI Orchestrations + - Ensure all Create/Read requests are routed through the FHIR Info Gateway. + +#### Route Configuration Example + + + +![RouteConfiguration](images/RouteConfiguration.png "Route Configuration") + +## Authentication Setup + +### Retrieve the User UUID + +The User UUID is the Keycloak user UUID. Obtain this UUID by querying Keycloak or checking the admin console. + +![userUuid](images/userUuid.png "User UUID") + +### Create a New Client in OpenHIM + +1. Use the retrieved Keycloak User UUID as the Client ID. +2. Create a new client in OpenHIM using this UUID. + +![NewClient](images/NewClient.png "New Client in OpenHIM") + +### Generating Client Credentials + +Run the following command to generate an access token: + +```bash +curl -X POST -d 'client_id=emr' -d 'username=fhiruser' \ +-d 'password=dev_password_only' -d 'grant_type=password' \ +"http://localhost:9088/realms/platform-realm/protocol/openid-connect/token" | jq +``` + +Replace `localhost:9088` with the appropriate Keycloak server address. + +![GeneratingClientCredentials](images/GeneratingClientCredentials.png "Generating Client Credentials") + +### Token Usage + +Include the generated token in the Authorization header of API requests: + +- **In Postman or similar tools**: + - Use the Bearer Token in the Authorization tab. + - Add the token generated in the above step. + +--- + +## Client Role Management + +### Restricting Client Access + +1. Open Keycloak Admin Console. +2. Navigate to the **Client Scopes** section for the FHIR resource. +3. Update roles and permissions to enforce restricted access. + +### Disabling Authentication (Development Only) + +- Allow anonymous access via Keycloak settings. +- Update the OpenHIM channel to bypass authentication temporarily. + +--- + +## API Testing + +### Testing FHIR Requests + +- Use tools like Postman or cURL. +- Add the Bearer token to the Authorization header. + +#### Example Request + +```bash +curl -X GET \ +-H "Authorization: Bearer " \ +"http://localhost:5001/fhir/Encounter" +``` + +### Verifying Responses + +- Ensure that responses comply with FHIR standards and contain the required patient data. + +--- + +## References + +- **GitHub Pull Request**: FHIR Info Gateway Integration +- **Documentation Commands**: + + ```bash + ./instant-linux package init -n fhir-info-gateway --dev + + ``` diff --git a/documentation/packages/fhir-info-gateway/images/GeneratingClientCredentials.png b/documentation/packages/fhir-info-gateway/images/GeneratingClientCredentials.png new file mode 100644 index 00000000..a93ead01 Binary files /dev/null and b/documentation/packages/fhir-info-gateway/images/GeneratingClientCredentials.png differ diff --git a/documentation/packages/fhir-info-gateway/images/NewClient.png b/documentation/packages/fhir-info-gateway/images/NewClient.png new file mode 100644 index 00000000..2883cc6f Binary files /dev/null and b/documentation/packages/fhir-info-gateway/images/NewClient.png differ diff --git a/documentation/packages/fhir-info-gateway/images/RouteConfiguration.png b/documentation/packages/fhir-info-gateway/images/RouteConfiguration.png new file mode 100644 index 00000000..6b0a8740 Binary files /dev/null and b/documentation/packages/fhir-info-gateway/images/RouteConfiguration.png differ diff --git a/documentation/packages/fhir-info-gateway/images/screenshot-localhost_9088-2024_12_17-14_16_13 (1).png b/documentation/packages/fhir-info-gateway/images/screenshot-localhost_9088-2024_12_17-14_16_13 (1).png new file mode 100644 index 00000000..d7da669e Binary files /dev/null and b/documentation/packages/fhir-info-gateway/images/screenshot-localhost_9088-2024_12_17-14_16_13 (1).png differ diff --git a/documentation/packages/fhir-info-gateway/images/screenshot-localhost_9088-2024_12_17-14_48_35 (1).png b/documentation/packages/fhir-info-gateway/images/screenshot-localhost_9088-2024_12_17-14_48_35 (1).png new file mode 100644 index 00000000..3f211e93 Binary files /dev/null and b/documentation/packages/fhir-info-gateway/images/screenshot-localhost_9088-2024_12_17-14_48_35 (1).png differ diff --git a/documentation/packages/fhir-info-gateway/images/userUuid.png b/documentation/packages/fhir-info-gateway/images/userUuid.png new file mode 100644 index 00000000..5aab148c Binary files /dev/null and b/documentation/packages/fhir-info-gateway/images/userUuid.png differ diff --git a/fhir-info-gateway/docker-compose.dev.yml b/fhir-info-gateway/docker-compose.dev.yml new file mode 100644 index 00000000..7b96e520 --- /dev/null +++ b/fhir-info-gateway/docker-compose.dev.yml @@ -0,0 +1,8 @@ +version: '3.9' + +services: + fhir-info-gateway: + ports: + - target: 8080 + published: 8880 + mode: host diff --git a/fhir-info-gateway/docker-compose.yml b/fhir-info-gateway/docker-compose.yml new file mode 100644 index 00000000..27658a50 --- /dev/null +++ b/fhir-info-gateway/docker-compose.yml @@ -0,0 +1,33 @@ +version: "3.9" +services: + fhir-info-gateway: + image: ${FHIR_INFO_GATEWAY_IMAGE} + networks: + openhim: + keycloak: + default: + environment: + TOKEN_ISSUER: ${KC_API_URL}/realms/${KC_REALM_NAME} + ACCESS_CHECKER: ${ACCESS_CHECKER} + PROXY_TO: ${GATEWAY_MPI_PROXY_URL} + BACKEND_TYPE: ${BACKEND_TYPE} + RUN_MODE: ${RUN_MODE} + deploy: + replicas: ${FHIR_INFO_GATEWAY_INSTANCES} + placement: + max_replicas_per_node: ${FHIR_INFO_GATEWAY_MAX_REPLICAS_PER_NODE} + resources: + limits: + cpus: ${FHIR_INFO_GATEWAY_CPU_LIMIT} + memory: ${FHIR_INFO_GATEWAY_MEMORY_LIMIT} + reservations: + cpus: ${FHIR_INFO_GATEWAY_CPU_RESERVE} + memory: ${FHIR_INFO_GATEWAY_MEMORY_RESERVE} +networks: + openhim: + name: openhim_public + external: true + keycloak: + name: keycloak_public + external: true + default: diff --git a/fhir-info-gateway/importer/docker-compose-smart_keycloak.yml b/fhir-info-gateway/importer/docker-compose-smart_keycloak.yml new file mode 100644 index 00000000..b60c6bcd --- /dev/null +++ b/fhir-info-gateway/importer/docker-compose-smart_keycloak.yml @@ -0,0 +1,18 @@ +version: "3.9" + +services: + smart-config: + image: jembi/keycloak-config:v0.0.1 + networks: + keycloak: + environment: + KEYCLOAK_BASE_URL: ${KC_API_URL} + KEYCLOAK_USER: ${KC_ADMIN_USERNAME} + KEYCLOAK_PASSWORD: ${KC_ADMIN_PASSWORD} + KEYCLOAK_REALM: ${KC_REALM_NAME} + command: [ "-configFile", "config/backend-services-config.json" ] + +networks: + keycloak: + name: keycloak_public + external: true diff --git a/fhir-info-gateway/importer/docker-compose.config.yml b/fhir-info-gateway/importer/docker-compose.config.yml new file mode 100644 index 00000000..b2e4ff32 --- /dev/null +++ b/fhir-info-gateway/importer/docker-compose.config.yml @@ -0,0 +1,36 @@ +version: "3.9" +services: + update-keycloak-config: + image: node:erbium-alpine + environment: + KEYCLOAK_SERVER_URL: ${KC_API_URL} + KEYCLOAK_REALM: ${KC_REALM_NAME} + KEYCLOAK_ADMIN_USER: ${KC_ADMIN_USERNAME} + KEYCLOAK_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD} + command: sh -c "cd / && npm i axios && node keycloakConfig.js" + configs: + - source: keycloak-config-importer-updateConfig.js + target: /keycloakConfig.js + - source: keycloak-config-importer-updateConfig.json + target: /keycloak-config.json + deploy: + replicas: 1 + restart_policy: + condition: none + networks: + keycloak: +configs: + keycloak-config-importer-updateConfig.js: + file: ./update-keycloak-config.js + name: keycloak-config-importer-updateConfig.js-${keycloak_config_importer_updateConfig_js_DIGEST:?err} + labels: + name: keycloakConfig + keycloak-config-importer-updateConfig.json: + file: ./keycloak-config.json + name: keycloak-config-importer-updateConfig.json-${keycloak_config_importer_updateConfig_json_DIGEST:?err} + labels: + name: keycloakConfigJson +networks: + keycloak: + name: keycloak_public + external: true diff --git a/fhir-info-gateway/importer/keycloak-config.json b/fhir-info-gateway/importer/keycloak-config.json new file mode 100644 index 00000000..95ea4dce --- /dev/null +++ b/fhir-info-gateway/importer/keycloak-config.json @@ -0,0 +1,231 @@ +{ + "clientScopes": { + "system/*.rs": { + "protocol": "openid-connect", + "description": "Read access to all resources", + "mappers": { + "Audience Mapper": { + "protocol": "openid-connect", + "protocolmapper": "oidc-audience-mapper", + "config": { + "access.token.claim": "true" + } + } + }, + "role": { + "id": "admin", + "name": "administrator", + "description": "Has full access to all resources" + } + }, + + "system/Patient.cruds": { + "protocol": "openid-connect", + "description": "Read access to all data", + "mappers": { + "Audience Mapper": { + "protocol": "openid-connect", + "protocolmapper": "oidc-audience-mapper", + "config": { + "access.token.claim": "true" + } + } + }, + "role": { + "id": "admin", + "name": "administrator", + "description": "Has full access to all resources" + } + }, + "system/Patient.cud": { + "protocol": "openid-connect", + "description": "Read and write access to all Patient", + "attributes": { + "include.in.token.scope": "false" + }, + "mappers": { + "Audience Mapper": { + "protocol": "openid-connect", + "protocolmapper": "oidc-audience-mapper", + "config": { + "access.token.claim": "true" + } + } + }, + "role": { + "id": "manager", + "name": "manager", + "description": "Has limited access to all resources" + } + }, + "system/Patient.rs": { + "protocol": "openid-connect", + "description": "Read access to all Patient", + "mappers": { + "Audience Mapper": { + "protocol": "openid-connect", + "protocolmapper": "oidc-audience-mapper", + "config": { + "access.token.claim": "true" + } + } + }, + "role": { + "id": "user", + "name": "user", + "description": "Has read access to all resources" + } + }, + "system/Encounter.rs": { + "protocol": "openid-connect", + "description": "Read access to all Encounter data", + "mappers": { + "Audience Mapper": { + "protocol": "openid-connect", + "protocolmapper": "oidc-audience-mapper", + "config": { + "access.token.claim": "true" + } + } + }, + "role": { + "id": "user", + "name": "user", + "description": "Has read access to all resources" + } + }, + "system/Observation.rs": { + "protocol": "openid-connect", + "description": "Read access to all Observation data", + "mappers": { + "Audience Mapper": { + "protocol": "openid-connect", + "protocolmapper": "oidc-audience-mapper", + "config": { + "access.token.claim": "true" + } + } + }, + "role": { + "id": "user", + "name": "user", + "description": "Has read access to all resources" + } + }, + "system/Encounter.cruds": { + "protocol": "openid-connect", + "description": "Read, write and search access to all Encounter data", + "mappers": { + "Audience Mapper": { + "protocol": "openid-connect", + "protocolmapper": "oidc-audience-mapper", + "config": { + "access.token.claim": "true" + } + } + }, + "role": { + "id": "admin", + "name": "administrator", + "description": "Has full access to all resources" + } + }, + "system/Encounter.cud": { + "protocol": "openid-connect", + "description": "Read and write access to all Encounter data", + "attributes": { + "include.in.token.scope": "false" + }, + "mappers": { + "Audience Mapper": { + "protocol": "openid-connect", + "protocolmapper": "oidc-audience-mapper", + "config": { + "access.token.claim": "true" + } + } + }, + "role": { + "id": "manager", + "name": "manager", + "description": "Has limited access to all resources" + } + }, + "system/Observation.cruds": { + "protocol": "openid-connect", + "description": "Read access to all Observation data", + "mappers": { + "Audience Mapper": { + "protocol": "openid-connect", + "protocolmapper": "oidc-audience-mapper", + "config": { + "access.token.claim": "true" + } + } + }, + "role": { + "id": "admin", + "name": "administrator", + "description": "Has full access to all resources" + } + }, + "system/Observation.cud": { + "protocol": "openid-connect", + "description": "Read and write access to all Observation data", + "attributes": { + "include.in.token.scope": "false" + }, + "mappers": { + "Audience Mapper": { + "protocol": "openid-connect", + "protocolmapper": "oidc-audience-mapper", + "config": { + "access.token.claim": "true" + } + } + }, + "role": { + "id": "manager", + "name": "manager", + "description": "Has limited access to all resources" + } + } + }, + + "client": { + "protocol": "openid-connect", + "clientId": "emr", + "name": "EMR user", + "description": "", + "publicClient": false, + "authorizationServicesEnabled": false, + "serviceAccountsEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "standardFlowEnabled": true, + "frontchannelLogout": true, + "alwaysDisplayInConsole": false, + "attributes": { + "oauth2.device.authorization.grant.enabled": false, + "oidc.ciba.grant.enabled": false + } + }, + "groups": { + "fhirUser": {} + }, + "defaultGroup": "fhir-user-group", + "defaultUser": { + "username": "fhirUser", + "firstName": "FHIR", + "lastName": "User", + "email": "fhir@jembi.org", + "emailVerified": false, + "enabled": true, + "groups": ["fhirUser"] + }, + "resetPassword": { + "temporary": false, + "type": "password", + "value": "dev_password_only" + } +} diff --git a/fhir-info-gateway/importer/update-keycloak-config.js b/fhir-info-gateway/importer/update-keycloak-config.js new file mode 100644 index 00000000..6c84bc18 --- /dev/null +++ b/fhir-info-gateway/importer/update-keycloak-config.js @@ -0,0 +1,463 @@ +const axios = require("axios"); +const fs = require("fs"); + +// Load the JSON payload +const payload = require("./keycloak-config.json"); +const { get } = require("http"); + +const serverUrl = + process.env.KEYCLOAK_SERVER_URL || "http://192.168.100.57:9088"; +const adminUser = process.env.KEYCLOAK_ADMIN_USER || "admin"; +const adminPassword = + process.env.KEYCLOAK_ADMIN_PASSWORD || "dev_password_only"; +const adminClientId = process.env.KEYCLOAK_ADMIN_CLIENT_ID || "admin-cli"; +const realm = process.env.KEYCLOAK_REALM || "platform-realm"; +const serviceAccountUser = + process.env.KEYCLOAK_SERVICE_ACCOUNT_USER || "service-account"; // Add service account user + +// Function definitions +async function getAdminToken( + keycloakBaseUrl, + realm, + clientId, + adminUser, + adminPassword +) { + try { + const tokenResponse = await axios.post( + `${keycloakBaseUrl}/realms/master/protocol/openid-connect/token`, + new URLSearchParams({ + grant_type: "password", + client_id: clientId, + username: adminUser, + password: adminPassword, + }), + { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + } + ); + + return tokenResponse.data.access_token; + } catch (error) { + console.error( + "Error fetching admin token:", + error.response ? error.response.data : error.message + ); + throw error; + } +} + +async function getRoleByName(roleName, keycloakBaseUrl, realm, adminToken) { + try { + const response = await axios.get( + `${keycloakBaseUrl}/admin/realms/${realm}/roles`, + { + headers: { + Authorization: `Bearer ${adminToken}`, + "Content-Type": "application/json", + }, + } + ); + + const role = response.data.find((r) => r.name === roleName); + return role ? role.id : null; + } catch (error) { + console.error( + "Error fetching role by name:", + error.response ? error.response.data : error.message + ); + throw error; + } +} + +async function getOrCreateClient(client, keycloakBaseUrl, realm, adminToken) { + try { + let clientResponse = await axios.get( + `${keycloakBaseUrl}/admin/realms/${realm}/clients?clientId=${client.clientId}`, + { + headers: { + Authorization: `Bearer ${adminToken}`, + "Content-Type": "application/json", + }, + } + ); + + if (clientResponse.data.length > 0) { + // Client exists, update it + const clientId = clientResponse.data[0].id; + await axios.put( + `${keycloakBaseUrl}/admin/realms/${realm}/clients/${clientId}`, + client, + { + headers: { + Authorization: `Bearer ${adminToken}`, + "Content-Type": "application/json", + }, + } + ); + console.log(`Updated client: ${client.clientId}`); + } else { + // Client does not exist, create a new one + clientResponse = await axios.post( + `${keycloakBaseUrl}/admin/realms/${realm}/clients`, + client, + { + headers: { + Authorization: `Bearer ${adminToken}`, + "Content-Type": "application/json", + }, + } + ); + console.log(`Created client: ${client.clientId}`); + } + return clientResponse.data; + } catch (error) { + console.error( + "Error creating or updating client:", + error.response ? error.response.data : error.message + ); + //throw error; + } +} + +async function processKeycloakPayload( + payload, + keycloakBaseUrl, + realm, + adminToken +) { + const { clientScopes, defaultUser, client, defaultGroup, resetPassword } = + payload; + + if (!clientScopes) { + throw new Error("clientScopes is not defined in the payload"); + } + + await Promise.all( + Object.entries(clientScopes).map(async ([scopeName, scope]) => { + if (!scope) { + console.error(`Scope is undefined for scopeName: ${scopeName}`); + return; + } + + console.log(`Processing scope: ${scopeName}`); + + const { role } = scope; + + const { name, description } = role; + + let roleId; + + try { + const clientScopeResponse = await axios.get( + `${keycloakBaseUrl}/admin/realms/${realm}/client-scopes`, + { + headers: { + Authorization: `Bearer ${adminToken}`, + }, + } + ); + + let clientScope = clientScopeResponse.data.find( + (cs) => cs.name === scopeName + ); + + if (!clientScope) { + // Client scope does not exist, create a new one + const newClientScopeResponse = await axios.post( + `${keycloakBaseUrl}/admin/realms/${realm}/client-scopes`, + scope, + { + headers: { + Authorization: `Bearer ${adminToken}`, + "Content-Type": "application/json", + }, + } + ); + clientScope = newClientScopeResponse.data; + } + + if (!clientScope || !clientScope.id) { + throw new Error(`Client scope ${scopeName} does not have a valid ID`); + } else { + // Map scopes to the client + const clientResponse = await getOrCreateClient( + client, + keycloakBaseUrl, + realm, + adminToken + ); + await axios.put( + `${keycloakBaseUrl}/admin/realms/${realm}/clients/${clientResponse[0].id}/default-client-scopes/${clientScope.id}`, + {}, + { + headers: { + Authorization: `Bearer ${adminToken}`, + "Content-Type": "application/json", + }, + } + ); + } + roleId = await getRoleByName(name, keycloakBaseUrl, realm, adminToken); + // Map the created role to the client scope + await axios.post( + `${keycloakBaseUrl}/admin/realms/${realm}/client-scopes/${clientScope.id}/scope-mappings/realm`, + + [{ id: roleId, name }], + { + headers: { + Authorization: `Bearer ${adminToken}`, + "Content-Type": "application/json", + }, + } + ); + console.log(`Mapped role ${name} to client scope ${scopeName}`); + } catch (error) { + console.error("Error processing scope:", error); + } + }) + ); + + // Create or update the service-account user + let userResponse, user, createdgroupResponse; + try { + let groupResponse = await axios.get( + `${keycloakBaseUrl}/admin/realms/${realm}/groups?search=${defaultGroup}`, + { + headers: { + Authorization: `Bearer ${adminToken}`, + "Content-Type": "application/json", + }, + } + ); + let groupId = ""; + if (groupResponse.data.length > 0) { + // Group exists, update it + groupId = groupResponse.data[0].id; + createdgroupResponse = await axios.put( + `${keycloakBaseUrl}/admin/realms/${realm}/groups/${groupId}`, + { + name: defaultGroup, + }, + { + headers: { + Authorization: `Bearer ${adminToken}`, + "Content-Type": "application/json", + }, + } + ); + } else { + // Group does not exist, create a new one + createdgroupResponse = await axios.post( + `${keycloakBaseUrl}/admin/realms/${realm}/groups`, + { + name: defaultGroup, + }, + { + headers: { + Authorization: `Bearer ${adminToken}`, + "Content-Type": "application/json", + }, + } + ); + let createdGroup = await axios.get( + `${keycloakBaseUrl}/admin/realms/${realm}/groups?search=${defaultGroup}`, + { + headers: { + Authorization: `Bearer ${adminToken}`, + "Content-Type": "application/json", + }, + } + ); + console.log(`Created group: `, createdGroup); + groupId = createdGroup.data[0].id; + } + + const createdGroup = createdgroupResponse.data[0]; + const usersResponse = await axios.get( + `${keycloakBaseUrl}/admin/realms/${realm}/users`, + { + headers: { + Authorization: `Bearer ${adminToken}`, + "Content-Type": "application/json", + }, + } + ); + + const users = usersResponse.data; + user = users.find((u) => u.username === defaultUser.username.toLowerCase()); + + if (user) { + // User exists, update it + const userId = user.id; + userResponse = await axios.put( + `${keycloakBaseUrl}/admin/realms/${realm}/users/${userId}`, + defaultUser, + { + headers: { + Authorization: `Bearer ${adminToken}`, + "Content-Type": "application/json", + }, + } + ); + console.log(`Updated user: ${defaultUser.username}`); + } else { + // User does not exist, create a new one + userResponse = await axios.post( + `${keycloakBaseUrl}/admin/realms/${realm}/users`, + defaultUser, + { + headers: { + Authorization: `Bearer ${adminToken}`, + "Content-Type": "application/json", + }, + } + ); + console.log(`Created user: ${defaultUser.username}`); + } + + const createdUser = userResponse.data; + console.log("here", user); + // Reset the password + const newPass = await axios.put( + `${keycloakBaseUrl}/admin/realms/${realm}/users/${ + userResponse.id ? userResponse.id : user.id + }/reset-password`, + resetPassword, + { + headers: { + Authorization: `Bearer ${adminToken}`, + "Content-Type": "application/json", + }, + } + ); + console.log(`Reset password for user ${createdUser}`, newPass.data); + // Step 5: Add service-account user to the group + await axios.put( + `${keycloakBaseUrl}/admin/realms/${realm}/users/${ + createdUser.id ? createdUser.id : user.id + }/groups/${groupId}`, + {}, + { + headers: { + Authorization: `Bearer ${adminToken}`, + "Content-Type": "application/json", + }, + } + ); + console.log(`Added ${createdUser} to group ${createdgroupResponse}`); + const uniqueRolesArray = await getUniqueRolesArray(payload); + for (const role of uniqueRolesArray) { + const roleID = await getRoleByName( + role.name, + keycloakBaseUrl, + realm, + adminToken + ); + console.log(roleID); + const roleMapping = await axios.post( + `${keycloakBaseUrl}/admin/realms/${realm}/groups/${groupId}/role-mappings/realm`, + [ + { + id: roleID, + clientRole: false, + composite: false, + containerId: realm, + name: role.name, + description: role.description, + }, + ], + { + headers: { + Authorization: `Bearer ${adminToken}`, + "Content-Type": "application/json", + }, + } + ); + console.log(`Added role mapping to group ${roleMapping}`, role); + } + } catch (error) { + console.error( + "Error creating or updating user:", + error.response ? error.response.data : error.message + ); + throw error; + } +} +async function getUniqueRolesArray(payload) { + const rolesSet = new Set(); + const { clientScopes } = payload; + + for (const key in clientScopes) { + if (clientScopes[key].role) { + rolesSet.add(JSON.stringify(clientScopes[key].role)); + } + } + + // Convert Set to Array and parse back to objects + const uniqueRolesArray = Array.from(rolesSet).map((role) => JSON.parse(role)); + return uniqueRolesArray; +} +// Call the function and handle the result +async function main() { + try { + const adminToken = await getAdminToken( + serverUrl, + realm, + adminClientId, + adminUser, + adminPassword + ); + const client = payload.client; + const createorupdateClient = await getOrCreateClient( + client, + serverUrl, + realm, + adminToken + ); + console.log(createorupdateClient); + const uniqueRolesArray = await getUniqueRolesArray(payload); + + for (const role of uniqueRolesArray) { + const { name } = role; + let roleId = await getRoleByName(name, serverUrl, realm, adminToken); + + if (roleId) { + // Role exists, update it + await axios.put( + `${serverUrl}/admin/realms/${realm}/roles-by-id/${roleId}`, + role, + { + headers: { + Authorization: `Bearer ${adminToken}`, + "Content-Type": "application/json", + }, + } + ); + console.log(`Updated role: ${name}`); + } else { + // Role does not exist, create a new one + const roleResponse = await axios.post( + `${serverUrl}/admin/realms/${realm}/roles`, + role, + { + headers: { + Authorization: `Bearer ${adminToken}`, + "Content-Type": "application/json", + }, + } + ); + roleId = roleResponse.data.id; + console.log(`Created role: ${name}`); + } + } + await processKeycloakPayload(payload, serverUrl, realm, adminToken); + console.log("Keycloak payload processed successfully"); + } catch (error) { + console.error("Error processing Keycloak payload:", error); + } +} + +main(); diff --git a/fhir-info-gateway/package-metadata.json b/fhir-info-gateway/package-metadata.json new file mode 100644 index 00000000..4e11abc3 --- /dev/null +++ b/fhir-info-gateway/package-metadata.json @@ -0,0 +1,25 @@ +{ + "id": "fhir-info-gateway", + "name": "FHIR Info Gateway", + "description": "Implement the FHIR Info Gateway as a platform package which sits between the OpenHIM and MPI Mediator and any other direct FHIR access", + "type": "infrastructure", + "version": "0.0.1", + "dependencies": ["mpi-mediator"], + "environmentVariables": { + "GATEWAY_MPI_PROXY_URL": "http://mpi-mediator:3000/fhir", + "ACCESS_CHECKER": "patient", + "RUN_MODE": "DEV", + "FHIR_INFO_GATEWAY_IMAGE": "jembi/fhir-info-gateway:scope-checker", + "BACKEND_TYPE": "HAPI", + "KC_API_URL": "http://identity-access-manager-keycloak:9088", + "KC_REALM_NAME": "platform-realm", + "KC_ADMIN_PASSWORD": "dev_password_only", + "KC_ADMIN_USERNAME": "admin", + "FHIR_INFO_GATEWAY_INSTANCES": "1", + "FHIR_INFO_GATEWAY_MAX_REPLICAS_PER_NODE": "1", + "FHIR_INFO_GATEWAY_CPU_LIMIT": "0", + "FHIR_INFO_GATEWAY_MEMORY_LIMIT": "2G", + "FHIR_INFO_GATEWAY_CPU_RESERVE": "0.05", + "FHIR_INFO_GATEWAY_MEMORY_RESERVE": "500M" + } +} diff --git a/fhir-info-gateway/swarm.sh b/fhir-info-gateway/swarm.sh new file mode 100644 index 00000000..9dee3de1 --- /dev/null +++ b/fhir-info-gateway/swarm.sh @@ -0,0 +1,85 @@ +#!/bin/bash + +declare ACTION="" +declare MODE="" +declare COMPOSE_FILE_PATH="" +declare UTILS_PATH="" +declare STACK="fhir-info-gateway" + +function init_vars() { + ACTION=$1 + MODE=$2 + + COMPOSE_FILE_PATH=$( + cd "$(dirname "${BASH_SOURCE[0]}")" || { + echo "Failed to change directory" + exit 1 + } + pwd -P + ) + + UTILS_PATH="${COMPOSE_FILE_PATH}/../utils" + + + readonly ACTION + readonly MODE + readonly COMPOSE_FILE_PATH + readonly UTILS_PATH + readonly STACK +} + +# shellcheck disable=SC1091 +function import_sources() { + source "${UTILS_PATH}/docker-utils.sh" + source "${UTILS_PATH}/log.sh" +} + +function initialize_package() { + local package_dev_compose_filename="" + if [[ "${MODE}" == "dev" ]]; then + log info "Running package in DEV mode" + package_dev_compose_filename="docker-compose.dev.yml" + else + log info "Running package in PROD mode" + fi + + ( + docker::deploy_service $STACK "${COMPOSE_FILE_PATH}" "docker-compose.yml" "$package_dev_compose_filename" "importer/docker-compose-smart_keycloak.yml" + + if [[ "${ACTION}" == "init" ]]; then + docker::deploy_config_importer $STACK "$COMPOSE_FILE_PATH/importer/docker-compose.config.yml" "update-keycloak-config" "fhirinfo" + fi + docker service rm fhir-info-gateway_smart-config >> /dev/null 2>&1 + ) || { + log error "Failed to deploy package" + exit 1 + } + +} + +function destroy_package() { + docker::stack_destroy "$STACK" +} + +main() { + init_vars "$@" + import_sources + + if [[ "${ACTION}" == "init" ]] || [[ "${ACTION}" == "up" ]]; then + log info "Running package in Single node mode" + + initialize_package + elif [[ "${ACTION}" == "down" ]]; then + log info "Scaling down package" + + docker::scale_services "$STACK" 0 + elif [[ "${ACTION}" == "destroy" ]]; then + log info "Destroying package" + + destroy_package + else + log error "Valid options are: init, up, down, or destroy" + fi +} + +main "$@" diff --git a/interoperability-layer-openhim/docker-compose.yml b/interoperability-layer-openhim/docker-compose.yml index d39b0ac5..2b7340c6 100644 --- a/interoperability-layer-openhim/docker-compose.yml +++ b/interoperability-layer-openhim/docker-compose.yml @@ -25,6 +25,10 @@ services: - api_openid_clientId=${KC_OPENHIM_CLIENT_ID} - api_openid_clientSecret=${KC_OPENHIM_CLIENT_SECRET} - openhimConsoleBaseUrl=${OPENHIM_CONSOLE_BASE_URL} + - authentication_enableJWTAuthentication=true + - authentication_jwt_jwksUri=${KC_API_URL}/realms/${KC_REALM_NAME}/protocol/openid-connect/certs + - authentication_jwt_algorithms=RS256 + - authentication_jwt_issuer=${KC_FRONTEND_URL}/realms/${KC_REALM_NAME} deploy: replicas: ${OPENHIM_CORE_INSTANCES} placement: