|
| 1 | +--- |
| 2 | +applyTo: "proxies/**" |
| 3 | +description: "Apigee proxy architecture, request flow, policies, shared flows, and how to modify proxy configuration" |
| 4 | +--- |
| 5 | + |
| 6 | +# Apigee Proxy Architecture |
| 7 | + |
| 8 | +The `proxies/` directory contains two independent Apigee API proxy bundles that share the same base path (`{{ SERVICE_BASE_PATH }}` → `/referrals`). |
| 9 | + |
| 10 | +## `proxies/live/` — production proxy |
| 11 | + |
| 12 | +Forwards requests to the real e-RS backend. This is the most policy-heavy part of the repo. |
| 13 | + |
| 14 | +### Layout |
| 15 | + |
| 16 | +| Path | Purpose | |
| 17 | +|---|---| |
| 18 | +| `apiproxy/ers.xml` | Root proxy descriptor | |
| 19 | +| `apiproxy/proxies/default.xml` | ProxyEndpoint — inbound routing (`_ping`, `_status`, catch-all to target) | |
| 20 | +| `apiproxy/targets/ers-target.xml` | TargetEndpoint — outbound to backend via `{{ ERS_TARGET_SERVER }}`, plus all auth/error handling | |
| 21 | +| `apiproxy/policies/` | ~60 XML policy files (see breakdown below) | |
| 22 | +| `apiproxy/resources/jsc/` | Inline JavaScript used by policies (`IsFhirR4Path.js`, `SetCurrentTimestamp.js`, `SetStatusResponse.js`) | |
| 23 | + |
| 24 | +### Request flow (PreFlow on TargetEndpoint) |
| 25 | + |
| 26 | +1. `javascript.IsFhirR4Path` — sets `isFhirR4Path` boolean by matching `/FHIR/R4/` in the path suffix; this flag drives R4-vs-STU3 branching throughout |
| 27 | +2. `OauthV2.VerifyAccessToken` — validates OAuth2 token; accepted scopes: `app:level3`, `user-nhs-id:aal3`, `user-nhs-id:aal2` |
| 28 | +3. `FlowCallout.ExtendedAttributes` + `FlowCallout.EUOAllowlistVerify` — (user-restricted only, excluding `/FHIR/R4/PractitionerRole`) validates the end-user organisation ODS code against an allowlist |
| 29 | +4. ASID validation — `RaiseFault.MissingAsid` if `app.asid` is empty; then `AssignMessage.PopulateAsidFromApp` + `AssignMessage.SetAsidHeader` copy the ASID onto the request |
| 30 | +5. `AssignMessage.AddBaseUrlHeader` — adds the base URL header for the backend |
| 31 | +6. `FlowCallout.ApplyRateLimiting` — spike arrest + quota enforcement (delegates to external shared flow — rate/quota values are NOT in this repo) |
| 32 | + |
| 33 | +### Shared flows (external dependencies) |
| 34 | + |
| 35 | +Several `FlowCallout.*` policies delegate to shared flows that are NOT defined in this repo: |
| 36 | +- `ApplyRateLimiting` — spike arrest and quota enforcement (rate/quota values configured externally) |
| 37 | +- `ExtendedAttributes` — retrieves extended app attributes |
| 38 | +- `EUOAllowlistVerify` — validates ODS code against the end-user organisation allowlist |
| 39 | +- `LogToSplunk` — audit logging |
| 40 | + |
| 41 | +These shared flows are deployed separately to the Apigee environment. This repo only controls *when* they are called and *how* their errors are handled. |
| 42 | + |
| 43 | +### Conditional flows (TargetEndpoint) |
| 44 | + |
| 45 | +- **`user-restricted-flow`** (`accesstoken.auth_type == "user"`) — rejects app-only business functions (`AUTHORISED_APPLICATION`), swaps NHSD headers from external to internal naming, sets AAL/IAL/AMR headers, and enforces IAL ≥ 3 |
| 46 | +- **`app-restricted-flow`** (`accesstoken.auth_type == "app"`) — rejects any manually-set `x-ers-*` headers (403), then sets fixed app-restricted values for ODS, business function, user-id, and access-mode |
| 47 | +- **`undefined-flow`** — catch-all that returns 403 (should never trigger) |
| 48 | + |
| 49 | +Both flows finish with `AssignMessage.Swap.CorrelationHeader` which converts `X-Correlation-ID` → `NHSD-Correlation-ID` for the backend. |
| 50 | + |
| 51 | +### Header transformation mapping |
| 52 | + |
| 53 | +The proxy swaps external consumer-facing headers to internal backend headers: |
| 54 | + |
| 55 | +| External header (consumer sends) | Internal header (backend receives) | |
| 56 | +|---|---| |
| 57 | +| `X-Correlation-ID` | `NHSD-Correlation-ID` (appended with `.{messageid}`) | |
| 58 | +| `nhsd-end-user-organisation-ods` | `x-ers-ods-code` | |
| 59 | +| `nhsd-ers-business-function` | `x-ers-business-function` | |
| 60 | +| `nhsd-ers-comm-rule-org` | `x-ers-comm-rule-org` | |
| 61 | +| `nhsd-ers-file-name` | `x-ers-file-name` | |
| 62 | +| `nhsd-ers-referral-id` | `x-ers-referral-id` | |
| 63 | +| `NHSD-eRS-On-Behalf-Of-User-ID` | `x-ers-on-behalf-of-user-id` | |
| 64 | + |
| 65 | +Additional headers set by the proxy (not from consumer input): |
| 66 | +- `x-ers-access-mode` — `user` or `app` |
| 67 | +- `x-ers-user-id` — from OAuth token (user) or app config (app) |
| 68 | +- `x-ers-authentication-assurance-level` — from token |
| 69 | +- `x-ers-id-assurance-level` — from token |
| 70 | +- `x-ers-amr` — authentication method reference from token |
| 71 | + |
| 72 | +### Response flow |
| 73 | + |
| 74 | +- Sets `X-Request-ID` flag, swaps `x_ers_transaction_id` to the response, removes `nhsd-correlation-id` from the response |
| 75 | + |
| 76 | +### FaultRules (TargetEndpoint) |
| 77 | + |
| 78 | +Error responses are FHIR-version-aware — `isFhirR4Path` selects between R4 and pre-R4 `OperationOutcome` response shapes. Handled faults: |
| 79 | +- OAuth token failures (scope errors → AAL insufficient) |
| 80 | +- Spike arrest / quota exceeded (rate limiting) |
| 81 | +- Insufficient IAL (identity assurance level < 3) |
| 82 | +- Missing ASID |
| 83 | +- Invalid business function |
| 84 | +- ODS header missing / not in partner allowlist |
| 85 | +- EUO allowlist internal errors (500) |
| 86 | + |
| 87 | +### Backend connection |
| 88 | + |
| 89 | +- Target server: `{{ ERS_TARGET_SERVER }}` (defaults to `e-referrals-service-api`) |
| 90 | +- Backend path: `/ers-api` |
| 91 | +- TLS enabled; conditional truststore for feature-test (`--ft-`) environments |
| 92 | +- I/O timeout: 180 seconds |
| 93 | +- Jinja2 templating (`{{ }}` placeholders) is resolved at build time by `scripts/build_proxy.sh` |
| 94 | + |
| 95 | +### Policy naming conventions |
| 96 | + |
| 97 | +- `AssignMessage.Set.*` — set a header/variable to a fixed value |
| 98 | +- `AssignMessage.Swap.*` — rename/transform a header between external and internal naming |
| 99 | +- `AssignMessage.Remove.*` — strip a header |
| 100 | +- `AssignMessage.SetOperationOutcome*` — prepare FHIR OperationOutcome error variables |
| 101 | +- `RaiseFault.*` — return an HTTP error status |
| 102 | +- `FlowCallout.*` — delegate to a shared flow |
| 103 | +- `KeyValueMapOperations.*` — read from Apigee KVM stores |
| 104 | + |
| 105 | +## `proxies/sandbox/` — sandbox proxy |
| 106 | + |
| 107 | +Lightweight proxy that forwards to the sandbox container (Hapi.js mock server) via Apigee hosted targets. |
| 108 | + |
| 109 | +### Key differences from live |
| 110 | + |
| 111 | +- Only ~11 policies (vs ~60 in live) — no OAuth, no ASID, no rate limiting, no header swapping |
| 112 | +- Adds CORS preflight handling (`AssignMessage.AddCors` on OPTIONS) |
| 113 | +- Uses `DecodeJWT.FromJWTHeader` to decode (but not validate) the JWT for inspection |
| 114 | +- Target is `{{ HOSTED_TARGET_CONNECTION }}` — the sandbox container deployed alongside the proxy |
| 115 | +- No fault rules or auth enforcement |
| 116 | + |
| 117 | +### Shared structure |
| 118 | + |
| 119 | +Both proxies share `_ping` and `_status` flows — `_ping` returns a canned response directly (no backend call), `_status` uses an API key from KVM + `ServiceCallout.CallHealthcheckEndpoint`. |
| 120 | + |
| 121 | +## Proxy build process |
| 122 | + |
| 123 | +`scripts/build_proxy.sh` copies both proxy bundles into `build/proxies/`, and for sandbox, rsyncs the entire `sandbox/` app into `build/proxies/sandbox/apiproxy/resources/hosted/` so Apigee can run it as a hosted target. |
| 124 | + |
| 125 | +Jinja2 template variables (e.g. `{{ SERVICE_BASE_PATH }}`, `{{ ERS_TARGET_SERVER }}`, `{{ HOSTED_TARGET_CONNECTION }}`) are resolved during the CI/CD pipeline deployment step. |
| 126 | + |
| 127 | +## Infrastructure |
| 128 | + |
| 129 | +- **Apigee** API gateway managed via Terraform (`terraform/main.tf`) |
| 130 | +- Uses the `api-platform-service-module` from NHSDigital |
| 131 | +- Backend: Azure (`azurerm` state backend) |
| 132 | +- Proxy type auto-selects `sandbox` or `live` based on Apigee environment name |
| 133 | + |
| 134 | +## Adding or modifying a proxy policy |
| 135 | + |
| 136 | +1. Create/edit the XML file in `proxies/live/apiproxy/policies/` (or `sandbox/`) |
| 137 | +2. Reference it by `<Name>` in the appropriate flow in `proxies/default.xml` or `ers-target.xml` |
| 138 | +3. If it uses JavaScript, add the `.js` file to `apiproxy/resources/jsc/` and reference via `<ResourceURL>jsc://filename.js</ResourceURL>` |
| 139 | +4. Run `make lint` — validates all proxy XML via `scripts/xml_validator.py` |
| 140 | +5. Remember to handle both R4 and pre-R4 paths if the policy produces FHIR error responses |
0 commit comments