From 4ab16a5ae60049de1035c8279cdc83afac3e5a0a Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:23:33 +0000 Subject: [PATCH] [GPCAPIM-278]: Build a preview proxy instance By * concatingating the openapi and x-nhsd-apim yamls to for a template specification, * inserting the API variables/secrets into that template, * and using the Proxygen CLI tool within the preview-env workflow to deploy an instance defined by that specification. --- .../proxy/configure-proxygen/action.yaml | 41 ++++++++++++++++ .../actions/proxy/deploy-proxy/action.yaml | 48 +++++++++++++++++++ .../actions/proxy/tear-down-proxy/action.yaml | 35 ++++++++++++++ .github/workflows/preview-env.yml | 33 ++++++++++++- bruno/README.md | 32 +++++++++++++ .../collections/preview-env/.env.template | 5 ++ .../preview-env/Access_Structured_Record.bru | 31 ++++++++++++ .../collections/preview-env/bruno.json | 9 ++++ .../collections/preview-env/collection.bru | 32 +++++++++++++ bruno/gateway-api/workspace.yml | 14 ++++++ gateway-api/openapi.yaml | 25 +++------- proxygen/README.md | 33 +++++++++++++ proxygen/credentials.template.yaml | 7 +++ proxygen/settings.template.yaml | 3 ++ proxygen/x-nhsd-apim.template.yaml | 13 +++++ .../config/vocabularies/words/accept.txt | 2 + 16 files changed, 343 insertions(+), 20 deletions(-) create mode 100644 .github/actions/proxy/configure-proxygen/action.yaml create mode 100644 .github/actions/proxy/deploy-proxy/action.yaml create mode 100644 .github/actions/proxy/tear-down-proxy/action.yaml create mode 100644 bruno/README.md create mode 100644 bruno/gateway-api/collections/preview-env/.env.template create mode 100644 bruno/gateway-api/collections/preview-env/Access_Structured_Record.bru create mode 100644 bruno/gateway-api/collections/preview-env/bruno.json create mode 100644 bruno/gateway-api/collections/preview-env/collection.bru create mode 100644 bruno/gateway-api/workspace.yml create mode 100644 proxygen/README.md create mode 100644 proxygen/credentials.template.yaml create mode 100644 proxygen/settings.template.yaml create mode 100644 proxygen/x-nhsd-apim.template.yaml diff --git a/.github/actions/proxy/configure-proxygen/action.yaml b/.github/actions/proxy/configure-proxygen/action.yaml new file mode 100644 index 00000000..bbde6aaa --- /dev/null +++ b/.github/actions/proxy/configure-proxygen/action.yaml @@ -0,0 +1,41 @@ +name: Configure Proxygen +description: Install yq for yaml, install proxygen-cli and configure the account + +inputs: + proxygen-key-secret: + description: 'Proxygen private key secret' + required: true + proxygen-key-id: + description: 'Proxygen key ID' + required: true + proxygen-api-name: + description: 'Proxygen API name' + required: true + proxygen-client-id: + description: 'Proxygen client ID' + required: true + +runs: + using: composite + steps: + - name: Install yq for YAML template processing + uses: mikefarah/yq@2be0094729a1006f61e8339ce9934bfb3cbb549f # v4.52.2 + + - name: Install Proxygen CLI + shell: bash + run: | + pip install proxygen-cli + proxygen --version + + - name: Configure proxygen account details + shell: bash + working-directory: proxygen + run: | + cp settings.template.yaml $HOME/.proxygen/settings.yaml + yq eval '.api = "${{ inputs.proxygen-api-name }}"' -i $HOME/.proxygen/settings.yaml + + printf "%s" "${{ inputs.proxygen-key-secret }}" > /tmp/proxygen_private_key.pem + cp credentials.template.yaml $HOME/.proxygen/credentials.yaml + yq eval '.private_key_path = "/tmp/proxygen_private_key.pem"' -i $HOME/.proxygen/credentials.yaml + yq eval '.key_id = "${{ inputs.proxygen-key-id }}"' -i $HOME/.proxygen/credentials.yaml + yq eval '.client_id = "${{ inputs.proxygen-client-id }}"' -i $HOME/.proxygen/credentials.yaml diff --git a/.github/actions/proxy/deploy-proxy/action.yaml b/.github/actions/proxy/deploy-proxy/action.yaml new file mode 100644 index 00000000..1a0e3968 --- /dev/null +++ b/.github/actions/proxy/deploy-proxy/action.yaml @@ -0,0 +1,48 @@ +name: Deploy API Proxy +description: Deploy the API proxy instance using Proxygen + +inputs: + mtls-secret-name: + description: 'mTLS secret name for the proxy' + required: true + target-url: + description: 'Target URL to which the proxy will forward requests' + required: true + proxy-base-path: + description: 'A unique base path for the proxy instance' + required: true + proxygen-key-secret: + description: 'Proxygen private key secret' + required: true + proxygen-key-id: + description: 'Proxygen key ID' + required: true + proxygen-api-name: + description: 'Proxygen API name' + required: true + proxygen-client-id: + description: 'Proxygen client ID' + required: true + +runs: + using: composite + steps: + - name: Configure Proxygen + uses: ./.github/actions/proxy/configure-proxygen + with: + proxygen-key-secret: ${{ inputs.proxygen-key-secret }} + proxygen-key-id: ${{ inputs.proxygen-key-id }} + proxygen-api-name: ${{ inputs.proxygen-api-name }} + proxygen-client-id: ${{ inputs.proxygen-client-id }} + + - name: Inject secrets into openapi.yaml for deploying proxy + shell: bash + run: | + cat gateway-api/openapi.yaml proxygen/x-nhsd-apim.template.yaml > /tmp/proxy-specification.yaml + + yq eval '.x-nhsd-apim.target.url = "${{ inputs.target-url }}" | .x-nhsd-apim.target.security.secret = "${{ inputs.mtls-secret-name }}"' -i /tmp/proxy-specification.yaml + + - name: Deploy API proxy + shell: bash + run: | + proxygen instance deploy internal-dev ${{ inputs.proxy-base-path }} /tmp/proxy-specification.yaml --no-confirm diff --git a/.github/actions/proxy/tear-down-proxy/action.yaml b/.github/actions/proxy/tear-down-proxy/action.yaml new file mode 100644 index 00000000..97f0536b --- /dev/null +++ b/.github/actions/proxy/tear-down-proxy/action.yaml @@ -0,0 +1,35 @@ +name: Tear Down API Proxy +description: Delete the API proxy instance using Proxygen + +inputs: + proxy-base-path: + description: 'A unique base path for the proxy instance' + required: true + proxygen-key-secret: + description: 'Proxygen private key secret' + required: true + proxygen-key-id: + description: 'Proxygen key ID' + required: true + proxygen-api-name: + description: 'Proxygen API name' + required: true + proxygen-client-id: + description: 'Proxygen client ID' + required: true + +runs: + using: composite + steps: + - name: Configure Proxygen + uses: ./.github/actions/proxy/configure-proxygen + with: + proxygen-key-secret: ${{ inputs.proxygen-key-secret }} + proxygen-key-id: ${{ inputs.proxygen-key-id }} + proxygen-api-name: ${{ inputs.proxygen-api-name }} + proxygen-client-id: ${{ inputs.proxygen-client-id }} + + - name: Tear down preview API proxy + shell: bash + run: | + proxygen instance delete internal-dev ${{ inputs.proxy-base-path }} --no-confirm diff --git a/.github/workflows/preview-env.yml b/.github/workflows/preview-env.yml index a1a6750e..2c3b11cd 100644 --- a/.github/workflows/preview-env.yml +++ b/.github/workflows/preview-env.yml @@ -155,6 +155,35 @@ jobs: ECS_CLUSTER=$(jq -r '.ecs_cluster_name.value' tf-output.json) echo "ecs_cluster=$ECS_CLUSTER" >> $GITHUB_OUTPUT + - name: Get proxygen machine user details + id: proxygen-machine-user + uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802 + with: + secret-ids: /cds/gateway/dev/proxygen/proxygen-key-secret + name-transformation: lowercase + + - name: Deploy preview API proxy + if: github.event.action != 'closed' + uses: ./.github/actions/proxy/deploy-proxy + with: + mtls-secret-name: ${{ vars.PREVIEW_ENV_MTLS_SECRET_NAME}} + target-url: ${{ steps.tf-output.outputs.preview_url }} + proxy-base-path: 'clinical-data-gateway-api-poc-pr-${{ github.event.pull_request.number }}' + proxygen-key-secret: ${{ env._cds_gateway_dev_proxygen_proxygen_key_secret }} + proxygen-key-id: ${{ vars.PREVIEW_ENV_PROXYGEN_KEY_ID }} + proxygen-api-name: ${{ vars.PROXYGEN_API_NAME }} + proxygen-client-id: ${{ vars.PREVIEW_ENV_PROXYGEN_CLIENT_ID }} + + - name: Tear down preview API proxy + if: github.event.action == 'closed' + uses: ./.github/actions/proxy/tear-down-proxy + with: + proxy-base-path: 'clinical-data-gateway-api-poc-pr-${{ github.event.pull_request.number }}' + proxygen-key-secret: ${{ env._cds_gateway_dev_proxygen_proxygen_key_secret }} + proxygen-key-id: ${{ vars.PREVIEW_ENV_PROXYGEN_KEY_ID }} + proxygen-api-name: ${{ vars.PROXYGEN_API_NAME }} + proxygen-client-id: ${{ vars.PREVIEW_ENV_PROXYGEN_CLIENT_ID }} + # ---------- Ensure re-deployment (PR updated) ---------- - name: Force ECS service redeployment if: github.event.action == 'synchronize' @@ -263,6 +292,7 @@ jobs: script: | const alb = '${{ steps.tf-output.outputs.target_group }}'; const url = '${{ steps.tf-output.outputs.preview_url }}'; + const proxy_url = 'https://internal-dev.api.service.nhs.uk/clinical-data-gateway-api-poc-pr-${{ github.event.pull_request.number }}'; const cluster = '${{ steps.tf-output.outputs.ecs_cluster }}'; const service = '${{ steps.tf-output.outputs.ecs_service }}'; const owner = context.repo.owner; @@ -303,7 +333,8 @@ jobs: const lines = [ '**Deployment Complete**', `- Preview URL: [${url}](${url}) — [Health endpoint](${url}/health)`, - `- Smoke Test: ${smokeReadable} (HTTP ${smokeStatus})`, + ` - Smoke Test: ${smokeReadable} (HTTP ${smokeStatus})`, + `- Proxy URL: [${proxy_url}](${proxy_url})`, `- ECS Cluster: \`${cluster}\``, `- ECS Service: \`${service}\``, `- ALB Target: \`${alb}\``, diff --git a/bruno/README.md b/bruno/README.md new file mode 100644 index 00000000..fd49fd8b --- /dev/null +++ b/bruno/README.md @@ -0,0 +1,32 @@ +# Bruno + +## `gateway-api` Workspace + +### Preview Environment + +#### Environment Setup + +The collection pulls in secrets from a `.env` file from the top level of the collection, `bruno/gateway-api/preview-env`. To reference these variables within the collection you use `{{process.env.}}`, where `` is the environment variable name in `.env`. + +There is a template `.env` file, `bruno/gateway-api/collections/preview-env/.env.template`, to fill in as described below. + +##### Test application + +The proxy for Gateway API is hosted in Apigee. In order to call an Apigee proxy, a consumer of the API needs an Apigee application. As such, we need an Apigee application through which we can test our API. A static test application has been created for this purpose. You can view its details by going through In order to view its details, go to [the Clinical Data Sharing APIs applications](https://dos-internal.ptl.api.platform.nhs.uk/). when making a call to the API through the proxy, the test applications API key and secret are fed in to the OAuth 2.0 journey as the `CLIENT_KEY` and `CLIENT_SECRET` respectively. As such, you will need a `bruno/gateway-api/preview-preview-env/.env` file containing + +```plaintext +CLIENT_ID= +CLIENT_SECRET= +``` + +Bruno then uses these values when making an auth journey for you. + +Given the API is currently set up with CIS2 user-restricted access, and with the above set, when a HTTP request is sent, you will be prompted for username. [Here is a list of available test users](https://digital.nhs.uk/developer/guides-and-documentation/security-and-authorisation/testing-apis-with-our-mock-authorisation-service#test-users-for-cis2-authentication). + +##### Proxy instance + +The proxy base path defines to which proxy instance your request will be directed. For preview environments, the proxy base path has the GitHub PR number appended to it. As such you will need to add this to your `.env` file so that Bruno can correctly build the URL. + +```plaintext +PR_NNUMBER= +``` diff --git a/bruno/gateway-api/collections/preview-env/.env.template b/bruno/gateway-api/collections/preview-env/.env.template new file mode 100644 index 00000000..b6bcdce9 --- /dev/null +++ b/bruno/gateway-api/collections/preview-env/.env.template @@ -0,0 +1,5 @@ +# See README.md +PR_NUMBER= + +CLIENT_ID= +CLIENT_SECRET= diff --git a/bruno/gateway-api/collections/preview-env/Access_Structured_Record.bru b/bruno/gateway-api/collections/preview-env/Access_Structured_Record.bru new file mode 100644 index 00000000..97321089 --- /dev/null +++ b/bruno/gateway-api/collections/preview-env/Access_Structured_Record.bru @@ -0,0 +1,31 @@ +meta { + name: Access Record Structured + type: http + seq: 1 +} + +post { + url: https://{{apigee_env}}.api.service.nhs.uk/{{proxy_base_path}}/patient/$gpc.getstructuredrecord + body: json + auth: inherit +} + +body:json { + { + "resourceType": "Parameters", + "parameter": [ + { + "name": "patientNHSNumber", + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999" + } + } + ] + } +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/gateway-api/collections/preview-env/bruno.json b/bruno/gateway-api/collections/preview-env/bruno.json new file mode 100644 index 00000000..7070d436 --- /dev/null +++ b/bruno/gateway-api/collections/preview-env/bruno.json @@ -0,0 +1,9 @@ +{ + "version": "1", + "name": "preview-env", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ] +} diff --git a/bruno/gateway-api/collections/preview-env/collection.bru b/bruno/gateway-api/collections/preview-env/collection.bru new file mode 100644 index 00000000..b7acd019 --- /dev/null +++ b/bruno/gateway-api/collections/preview-env/collection.bru @@ -0,0 +1,32 @@ +headers { + Ssp-TraceID: test-teace-id + ODS-from: test-ods-code +} + +auth { + mode: oauth2 +} + +auth:oauth2 { + grant_type: authorization_code + callback_url: https://www.example.com/callback + authorization_url: https://internal-dev.api.service.nhs.uk/oauth2-mock/authorize + access_token_url: https://internal-dev.api.service.nhs.uk/oauth2-mock/token + refresh_token_url: + client_id: {{process.env.CLIENT_ID}} + client_secret: {{process.env.CLIENT_SECRET}} + scope: + state: {{$guid}} + pkce: false + credentials_placement: body + credentials_id: Mock Auth Token + token_placement: header + token_header_prefix: Bearer + auto_fetch_token: true + auto_refresh_token: false +} + +vars:pre-request { + apigee_env: internal-dev + proxy_base_path: clinical-data-gateway-api-poc-pr-{{process.env.PR_NUMBER}} +} diff --git a/bruno/gateway-api/workspace.yml b/bruno/gateway-api/workspace.yml new file mode 100644 index 00000000..b9f82f33 --- /dev/null +++ b/bruno/gateway-api/workspace.yml @@ -0,0 +1,14 @@ +opencollection: 1.0.0 +info: + name: "gateway" + type: workspace + +collections: + - name: "steel_thread" + path: "collections\\steel_thread" + - name: "steel_thread" + path: "collections\\preview-env" + +specs: + +docs: '' diff --git a/gateway-api/openapi.yaml b/gateway-api/openapi.yaml index 1d03ded7..b9c73434 100644 --- a/gateway-api/openapi.yaml +++ b/gateway-api/openapi.yaml @@ -8,11 +8,17 @@ info: servers: - url: http://localhost:5000 description: Local development server +components: + securitySchemes: + nhs-cis2-aal3: + $ref: https://proxygen.ptl.api.platform.nhs.uk/components/securitySchemes/nhs-cis2-aal3 paths: /patient/$gpc.getstructuredrecord: post: summary: Get structured record description: Returns a FHIR Bundle containing patient structured record + security: + - nhs-cis2-aal3: [] operationId: getStructuredRecord parameters: - in: header @@ -241,22 +247,3 @@ paths: diagnostics: type: string example: "Internal server error" - /health: - get: - summary: Health check - description: Returns the health status of the API - operationId: healthCheck - responses: - '200': - description: Service is healthy - content: - application/json: - schema: - type: object - properties: - status: - type: string - example: "healthy" - required: - - status - diff --git a/proxygen/README.md b/proxygen/README.md new file mode 100644 index 00000000..825ed0ab --- /dev/null +++ b/proxygen/README.md @@ -0,0 +1,33 @@ +# Proxygen + +Proxygen is the tool created by the API Platform team to support the deployment of NHS APIs. + +We use this tool in the pipelines (and manually) to create, destroy and interact more generally with the proxy instances. + +For more information on Proxygen, [read the docs](https://nhsd-confluence.digital.nhs.uk/spaces/APM/pages/375329782/Proxygen). + +Proxygen needs: + +* a settings file stating which API we are attempting to update; +* a credentials file to authenticate us as the owner/maintainer of the API; +* a specification file that outlines the behaviour of the proxy. + +## Settings File + +This is stored at `proxygen/settings.yaml` and is read by the Proxygen command line tool when it has been requested to make updates to an API. + +## Credentials File + +A template is stored at `proxygen/credentials.template.yaml` where the `` needs to be inserted. This is a path to a file that holds the secret that identifies us as the owner/maintainer of the API. + +During the GitHub workflows, the secret is pulled from AWS secrets manager, written to a file and the path to that file is inserted in to `credentials.template.yaml`. + +## Specification file + +Proxygen deploys an instance of a proxy using a specification file. This is of the OpenAPI format with a custom extension, `x-nhsd-apim` which provide Proxygen with information as to how the proxy should behave. This includes: + +* the target endpoint, to which it will forward traffic; +* the scopes that a user needs in order to access the proxy's endpoint; +* a key that the points to the mTLS certificate which the targeted backend expects to be used. + +A template, `proxygen/x-nhsd-api.tempalte.yaml`, is concatenated with the general OpenAPI specification for the API, `gateway-api/openapi.yaml`, and the key to the mTLS certificate to be used for that proxy is written in. All of which is then written to a file and the path to that file is passed to Proxygen to deploy the proxy in the stated environment. diff --git a/proxygen/credentials.template.yaml b/proxygen/credentials.template.yaml new file mode 100644 index 00000000..2e686880 --- /dev/null +++ b/proxygen/credentials.template.yaml @@ -0,0 +1,7 @@ +base_url: https://identity.prod.api.platform.nhs.uk/realms/api-producers +client_id: +client_secret: '' +key_id: +password: '' +private_key_path: +username: '' diff --git a/proxygen/settings.template.yaml b/proxygen/settings.template.yaml new file mode 100644 index 00000000..bca412ac --- /dev/null +++ b/proxygen/settings.template.yaml @@ -0,0 +1,3 @@ +api: +endpoint_url: https://proxygen.prod.api.platform.nhs.uk +spec_output_format: yaml diff --git a/proxygen/x-nhsd-apim.template.yaml b/proxygen/x-nhsd-apim.template.yaml new file mode 100644 index 00000000..e8cba5a1 --- /dev/null +++ b/proxygen/x-nhsd-apim.template.yaml @@ -0,0 +1,13 @@ +x-nhsd-apim: + monitoring: true + access: + - title: User Restricted + grants: + nhs-cis2-aal3: [] + target: + type: external + healthcheck: /health + url: + security: + type: mtls + secret: diff --git a/scripts/config/vale/styles/config/vocabularies/words/accept.txt b/scripts/config/vale/styles/config/vocabularies/words/accept.txt index 031d70f7..86d5c9f9 100644 --- a/scripts/config/vale/styles/config/vocabularies/words/accept.txt +++ b/scripts/config/vale/styles/config/vocabularies/words/accept.txt @@ -1,4 +1,5 @@ anonymised +Apigee APIs base_url binutils @@ -23,6 +24,7 @@ OAuth Octokit onboarding Podman +Proxygen [Pp]ytest Python repos