An openEHR repository is a good place to keep a clinical record and an awkward thing to hand to an app developer. The people who want the data, a patient app, a referral service, a regional portal, expect a FHIR API protected by OAuth scopes they already understand. The repository speaks archetype paths and AQL, and its own access control knows nothing about SMART on FHIR.
There are two ways to close that gap. Copy the data into a FHIR server and keep the copy current, or put a thin FHIR facade in front of the repository that translates each request as it arrives. This article builds the second: an ASP.NET Core minimal API that answers FHIR R4 Observation requests by running AQL against EHRbase, and that enforces SMART scopes carried in Microsoft Entra ID access tokens. It follows on from FHIR and openEHR: when to use which and the comparison of AQL, FHIR search and SQL on FHIR, and uses the same synthetic patients.
The claim the article defends is that most of the design effort in a facade goes into what it refuses. A facade that accepts every search parameter and ignores the ones it cannot translate, or that treats every scope it does not understand as permission, returns data the caller should not have received, and nothing in the response says so. The code below is small because it is narrow: one resource type, read only, one patient per request, and an explicit error for everything else.
The facade targets .NET 10. It was built on the .NET 10 SDK container image and run in Docker against EHRbase with synthetic data, where a Python test client and a browser exercised it with real signed tokens. Those tokens were development tokens signed with a shared secret, not tokens from Entra ID, and nothing was deployed to Azure. The Azure sections describe a design; the code sections describe something that ran. The details are under How this was checked.
The live checks in this article were repeated on 12 November 2026 against EHRbase 2.39.0. The results table, the summary image and the list of what the repository accepted and rejected are from that run.

Facade or second store
The decision comes first, because a facade is the wrong tool for several problems that look similar.
| FHIR facade (this article) | Replicate into a FHIR server | Vendor FHIR bridge | |
|---|---|---|---|
| How it works | Translates each FHIR request into AQL at request time | Events or batch jobs map compositions to resources and load them into a FHIR service | A product feature or plug-in of the repository exposes FHIR |
| Advantages | One copy of the record. No replication lag. Small surface to secure and audit | Full FHIR search, _include, bulk export, and no load on the repository from FHIR clients | Least code to own |
| Disadvantages | Only the searches you implement. Every request costs a repository query | Two stores to reconcile. A mapping pipeline to run, monitor and replay | Coverage and mapping choices are the vendor’s, and differ between products |
| Main risk | A wrong or silently partial translation | The copy drifts from the source and nobody notices | Lock-in to behaviour you cannot inspect |
| Cost drivers | One small API. Repository capacity sized for the extra read load | A FHIR service, the pipeline, storage for a second copy | Licence or support terms |
| Operations | One more stateless service | A data pipeline with its own failure modes and backlog | Depends on the product |
Choose the facade when the FHIR surface is narrow and mostly read, when the answer must reflect the record as it is now, and when a second copy of clinical data is a cost you would rather not carry. Choose replication when consumers need broad FHIR search or analytics, or when the repository must be protected from unpredictable client load. The two also combine: a facade for a handful of real-time reads, and replication behind it for everything else.
What the facade serves, and what it refuses
The sample serves blood pressure readings recorded with the openEHR archetype blood_pressure.v2 as FHIR Observation resources coded with LOINC 85354-9.
| Request | Behaviour |
|---|---|
GET /fhir/metadata | CapabilityStatement listing exactly what is supported. Public |
GET /fhir/Observation?patient=p1&code=http://loinc.org|85354-9 | One patient’s readings from one AQL query. Optional date (ge, lt), _sort (date, -date), _count and _offset |
GET /fhir/Observation/{id} | One reading |
| Any other search parameter | HTTP 400 with an OperationOutcome that names the parameter |
A date without a time and offset | HTTP 400. A bare date is ambiguous across time zones |
| A reading the mapper cannot represent | HTTP 500 for the whole request. Nothing is dropped or guessed |
| The repository is unavailable | HTTP 502 with no backend detail |
FHIR allows a server to ignore search parameters it does not know unless the client asks for strict handling. For a facade over clinical data that default is unsafe: a client that sends category=laboratory and gets every observation back has no way to tell the filter was dropped. The previous article found the same class of failure in a real server, where an operation returned every observation with HTTP 200 while ignoring its parameters. This facade is strict whatever the client asks for.
The patient parameter is mandatory. That keeps every request inside one patient’s record, which is what makes the authorization rule simple enough to state in one sentence and test completely.
One request, end to end
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#FFF3E0','primaryTextColor':'#232F3E','primaryBorderColor':'#FF9900','lineColor':'#232F3E','signalColor':'#0073BB','signalTextColor':'#0F172A','actorBkg':'#FFF3E0','actorBorder':'#FF9900','actorTextColor':'#232F3E','noteBkgColor':'#FFF8E7','noteBorderColor':'#FF9900','noteTextColor':'#232F3E'}}}%%
sequenceDiagram
participant C as FHIR client
participant E as Entra ID
participant F as FHIR facade
participant R as EHRbase
C->>E: Request a token for the facade API
E-->>C: Access token with scp and fhirUser
C->>F: GET /fhir/Observation?patient=p1
F->>F: Validate signature, issuer, audience, expiry
F->>F: Parse strictly, check scope against patient
F->>R: POST /query/aql with query_parameters
R-->>F: Result set of columns and rows
F->>F: Map rows to Observation, write audit event
F-->>C: Bundle of type searchsetThree things in that flow are worth noticing. The facade never forwards the caller’s token to the repository: the token’s audience is the facade, and the repository is reached over a private network path. Authorization happens before the repository is touched, so a refused request costs no query. And the audit event is written by the component that made the decision, with the patient, the caller and the basis for access in one record.
SMART scopes when the issuer is Entra ID
SMART App Launch 2.2 writes a resource scope as a context, a resource type and a set of permission letters: patient/Observation.rs allows read and search of observations for the patient in context. The letters must be an in-order subset of cruds, and the version 1 form patient/Observation.read means the same as .rs. Three properties of Entra ID shape how those scopes arrive.
- The slash. Entra ID does not accept a forward slash in a scope name. Azure’s own FHIR service documents the substitution it accepts, a period for the slash and
allfor the asterisk, sopatient/*.readbecomespatient.all.read. The parser below accepts both spellings and normalises them. - Two claims. Scopes a user consented to arrive in
scp, as one space-separated string. Permissions granted to an application for the client credentials flow arrive inroles. A backend service calling the facade therefore presents itssystem/scope inroles. - The patient. SMART returns the launch
patientbeside the access token, andfhirUserin the ID token. Neither reaches a resource server. The access token has to carry the link between the signed-in person and a Patient, and the convention Azure’s FHIR service uses is afhirUserclaim holding the URL of that Patient resource. The facade reads the same claim.
Putting fhirUser into an Entra ID access token takes configuration this article did not test: either a claim sourced from a directory attribute, or a custom claims provider that calls your own API when the token is issued. Whichever is used, that attribute is the access control. Whoever can set it for an account can read the record it points at, so the process that links an identity to a patient deserves more scrutiny than the code that reads the claim.
using System.Text.RegularExpressions;
namespace FhirFacade.Auth;
public enum SmartContext
{
Patient,
User,
System,
}
[Flags]
public enum SmartPermissions
{
None = 0,
Create = 1,
Read = 2,
Update = 4,
Delete = 8,
Search = 16,
All = Create | Read | Update | Delete | Search,
}
/// <summary>
/// One SMART on FHIR resource scope. Accepts SMART v1 (<c>patient/Observation.read</c>) and v2
/// (<c>patient/Observation.rs</c>), and the spelling Microsoft Entra ID needs because it does not accept a
/// forward slash in a scope name: <c>patient.Observation.rs</c>, with <c>all</c> standing in for <c>*</c>.
/// </summary>
public sealed partial record SmartScope(SmartContext Context, string Resource, SmartPermissions Permissions)
{
public const string AnyResource = "*";
// The permission letters of SMART v2 must appear in the order c, r, u, d, s.
[GeneratedRegex(@"^(?<context>patient|user|system)[/.](?<resource>\*|all|[A-Z][A-Za-z]+)\.(?<permission>\*|all|read|write|c?r?u?d?s?)$",
RegexOptions.CultureInvariant)]
private static partial Regex Pattern();
/// <summary>
/// Returns false for anything that is not a plain resource scope. That includes SMART v2 scopes with a
/// query constraint (<c>...rs?category=...</c>): this facade cannot enforce the constraint, so the scope
/// grants nothing instead of being read as unconstrained.
/// </summary>
public static bool TryParse(string value, out SmartScope? scope)
{
scope = null;
Match match = Pattern().Match(value);
string permission = match.Groups["permission"].Value;
if (!match.Success || permission.Length == 0)
{
return false;
}
SmartPermissions permissions = permission switch
{
"*" or "all" => SmartPermissions.All,
"read" => SmartPermissions.Read | SmartPermissions.Search,
"write" => SmartPermissions.Create | SmartPermissions.Update | SmartPermissions.Delete,
_ => permission.Aggregate(SmartPermissions.None, (acc, letter) => acc | letter switch
{
'c' => SmartPermissions.Create,
'r' => SmartPermissions.Read,
'u' => SmartPermissions.Update,
'd' => SmartPermissions.Delete,
_ => SmartPermissions.Search,
}),
};
string resource = match.Groups["resource"].Value;
scope = new SmartScope(
Enum.Parse<SmartContext>(match.Groups["context"].Value, ignoreCase: true),
resource is "all" ? AnyResource : resource,
permissions);
return true;
}
public bool Covers(string resourceType, SmartPermissions needed) =>
(Resource == AnyResource || Resource == resourceType) && (Permissions & needed) == needed;
}
A scope with a query constraint, such as patient/Observation.rs?category=laboratory, is valid SMART. The facade cannot enforce the constraint, so the parser rejects the whole scope and it grants nothing. The alternative, stripping the constraint and honouring the rest, would widen what the user consented to.
using System.Security.Claims;
using System.Text.RegularExpressions;
namespace FhirFacade.Auth;
public sealed record AccessDecision(bool Allowed, string Reason, SmartContext? GrantedBy = null);
/// <summary>Decides whether the caller may perform one interaction on one patient's data.</summary>
public static partial class SmartAccess
{
// Entra ID puts delegated scopes in "scp" and application permissions in "roles".
// SMART authorization servers use "scope".
private static readonly string[] SpaceSeparatedScopeClaims = ["scp", "scope"];
private static readonly string[] PatientIdentityClaims = ["fhirUser", "extension_fhirUser"];
[GeneratedRegex(@"(?:^|/)Patient/(?<id>[A-Za-z0-9\-\.]{1,64})$", RegexOptions.CultureInvariant)]
private static partial Regex PatientReference();
public static IReadOnlyList<SmartScope> ScopesOf(ClaimsPrincipal user)
{
IEnumerable<string> raw = user.Claims
.Where(claim => SpaceSeparatedScopeClaims.Contains(claim.Type))
.SelectMany(claim => claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries))
.Concat(user.Claims.Where(claim => claim.Type == "roles").Select(claim => claim.Value));
List<SmartScope> scopes = [];
foreach (string value in raw)
{
if (SmartScope.TryParse(value, out SmartScope? scope))
{
scopes.Add(scope!);
}
}
return scopes;
}
/// <summary>The patient the token was issued to, taken from the fhirUser claim. Null for any other kind of user.</summary>
public static string? PatientOf(ClaimsPrincipal user)
{
foreach (string type in PatientIdentityClaims)
{
Match match = PatientReference().Match(user.FindFirst(type)?.Value ?? "");
if (match.Success)
{
return match.Groups["id"].Value;
}
}
return null;
}
public static AccessDecision Evaluate(
ClaimsPrincipal user, string resourceType, SmartPermissions needed, string patientId)
{
List<SmartScope> covering = [.. ScopesOf(user).Where(scope => scope.Covers(resourceType, needed))];
if (covering.Any(scope => scope.Context == SmartContext.System))
{
return new AccessDecision(true, "system scope", SmartContext.System);
}
if (covering.Any(scope => scope.Context == SmartContext.Patient))
{
string? tokenPatient = PatientOf(user);
if (tokenPatient is null)
{
return new AccessDecision(false, "patient scope without a fhirUser claim that names a Patient");
}
return string.Equals(tokenPatient, patientId, StringComparison.Ordinal)
? new AccessDecision(true, "patient scope", SmartContext.Patient)
: new AccessDecision(false, "the token was issued for a different patient");
}
// A user/ scope means "whatever this user may see". Deciding that needs a source of care
// relationships, which this facade does not have, so it refuses instead of granting every patient.
return covering.Any(scope => scope.Context == SmartContext.User)
? new AccessDecision(false, "user-level scopes are not supported by this server")
: new AccessDecision(false, "the token carries no scope that allows this interaction");
}
}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#FFF3E0','primaryTextColor':'#232F3E','primaryBorderColor':'#FF9900','lineColor':'#232F3E','clusterBkg':'#F8FAFC','clusterBorder':'#CBD5E1','edgeLabelBackground':'#FFFFFF','nodeTextColor':'#232F3E'}}}%%
flowchart TD
A[Scopes read from scp, scope and roles] --> B{Does any scope cover
Observation and this interaction?}
B -- "No" --> X1[403: no usable scope]
B -- "Yes" --> S{Is one of them
a system/ scope?}
S -- "Yes" --> OK1[Allow as system]
S -- "No" --> D{Is one of them
a patient/ scope?}
D -- "No" --> X2[403: user/ scopes are refused]
D -- "Yes" --> E{Does fhirUser
name a Patient?}
E -- "No" --> X3[403: no patient identity]
E -- "Yes" --> F{Same patient as
the data requested?}
F -- "No" --> X4[403 on search, 404 on read]
F -- "Yes" --> OK2[Allow as patient]
classDef ok fill:#F0FFF4,stroke:#1D8102,stroke-width:2px,color:#145202
classDef bad fill:#FEF2F2,stroke:#D13212,stroke-width:2px,color:#991B1B
class OK1,OK2 ok
class X1,X2,X3,X4 badSMART defines user/ scopes as access to whatever the signed-in user may see. Enforcing that needs a source of truth for which clinician has a legitimate relationship with which patient. A facade without one has two choices: refuse the scope, or treat it as access to everyone. This sample refuses. If you need clinician access, add the relationship check first, and treat break-glass access as a separate, audited path.
One AQL query per search
A patient search becomes a single AQL statement. The patient is matched on the subject recorded in each EHR’s status object, so the facade needs no separate lookup from patient identifier to EHR identifier and holds no mapping table of its own.
using System.Globalization;
namespace FhirFacade.OpenEhr;
/// <summary>An AQL statement and the values that travel beside it as query_parameters.</summary>
public sealed record AqlQuery(string Text, IReadOnlyDictionary<string, string> Parameters);
/// <summary>
/// Builds the two AQL statements the facade issues against archetype blood_pressure.v2.
/// Both were run against a live EHRbase; see the README for what that repository accepted and rejected.
/// </summary>
public static class BloodPressureQuery
{
public const string Archetype = "openEHR-EHR-OBSERVATION.blood_pressure.v2";
private const string Time = "o/data[at0001]/events[at0006]/time/value";
private const string Data = "o/data[at0001]/events[at0006]/data[at0003]";
private const string SelectFrom =
"SELECT e/ehr_id/value AS ehr_id, " +
"e/ehr_status/subject/external_ref/id/value AS patient_id, " +
"c/uid/value AS composition_uid, " +
Time + " AS effective, " +
Data + "/items[at0004]/value/magnitude AS systolic, " +
Data + "/items[at0004]/value/units AS systolic_units, " +
Data + "/items[at0005]/value/magnitude AS diastolic, " +
Data + "/items[at0005]/value/units AS diastolic_units " +
"FROM EHR e CONTAINS COMPOSITION c CONTAINS OBSERVATION o[" + Archetype + "]";
/// <summary>One patient's readings. The patient is matched on the subject recorded in the EHR status.</summary>
public static AqlQuery Search(
string patientId, string subjectNamespace, DateTimeOffset? from, DateTimeOffset? before,
bool newestFirst, int limit, int offset)
{
ArgumentOutOfRangeException.ThrowIfLessThan(limit, 1);
ArgumentOutOfRangeException.ThrowIfNegative(offset);
Dictionary<string, string> parameters = new()
{
["patient_id"] = patientId,
["namespace"] = subjectNamespace,
};
string where = " WHERE e/ehr_status/subject/external_ref/id/value = $patient_id" +
" AND e/ehr_status/subject/external_ref/namespace = $namespace";
if (from is not null)
{
where += " AND " + Time + " >= $from";
parameters["from"] = Instant(from.Value);
}
if (before is not null)
{
where += " AND " + Time + " < $before";
parameters["before"] = Instant(before.Value);
}
// EHRbase rejects a parameter in LIMIT and OFFSET. Both are integers this method has range-checked,
// so writing them into the text cannot inject anything.
string page = string.Create(CultureInfo.InvariantCulture, $" LIMIT {limit} OFFSET {offset}");
// The composition uid breaks ties, so two readings with the same time keep their order between pages.
string order = " ORDER BY " + Time + (newestFirst ? " DESC" : " ASC") + ", c/uid/value ASC";
return new AqlQuery(SelectFrom + where + order + page, parameters);
}
/// <summary>One composition, addressed by EHR and by the full version identifier.</summary>
public static AqlQuery ByComposition(Guid ehrId, string compositionVersionUid) => new(
SelectFrom + " WHERE e/ehr_id/value = $ehr_id AND c/uid/value = $composition_uid",
new Dictionary<string, string>
{
["ehr_id"] = ehrId.ToString("D"),
["composition_uid"] = compositionVersionUid,
});
private static string Instant(DateTimeOffset value) =>
value.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture);
}
Each choice in that file came from running the query against a live EHRbase repository, not from the specification alone.
| Tried | What EHRbase did | Consequence |
|---|---|---|
Filter on e/ehr_status/subject/external_ref/id/value and namespace | Worked | One query per search, no lookup call |
LIMIT $n as a parameter | Parse error: an integer literal is expected | The facade range-checks two integers and writes them into the text. Every other value is a parameter |
ORDER BY time, then c/uid/value | Worked | A tie-breaker keeps paging stable when two readings share a time |
c/uid/value LIKE a prefix | “Not implemented: LIKE on /uid/value is not supported” | A composition is matched only by its full version identifier |
HEAD on the composition endpoint | 200 with the version identifier in ETag | A read resolves the latest version first, then runs the same query with an equality filter |
| Result set metadata | meta._executed_aql echoes the query with parameter values written in | Response bodies from the repository are never logged |
The last row matters for privacy. The patient identifier is sent as a parameter so that it stays out of query text and query logs, and the repository then returns it inside the executed statement. A facade that logs backend responses on error would write identifiers into its application log.
From rows to resources
The two request handlers hold the flow together. They are shown as an excerpt of FacadeApp.cs; the whole file, with the capability statement and the error handler, is in the repository linked at the end.
private static async Task<IResult> SearchAsync(
HttpContext context, OpenEhrClient openEhr, IOptions<FhirOptions> fhirOptions,
IOptions<OpenEhrOptions> openEhrOptions, ILoggerFactory loggers, CancellationToken cancellationToken)
{
ILogger logger = loggers.CreateLogger("FhirFacade.Audit");
if (!SearchRequest.TryParse(context.Request.Query, fhirOptions.Value, out SearchRequest? search, out string error))
{
return FhirResults.Outcome(StatusCodes.Status400BadRequest, "invalid", error);
}
AccessDecision decision = SmartAccess.Evaluate(context.User, ResourceType, SmartPermissions.Search, search!.PatientId);
if (!decision.Allowed)
{
AuditLog.Denied(logger, "search", search.PatientId, Subject(context.User), Client(context.User), decision.Reason);
return FhirResults.Outcome(StatusCodes.Status403Forbidden, "forbidden", decision.Reason);
}
// Ask for one row more than the page holds: its presence is what says there is a next page.
IReadOnlyList<BloodPressureRow> rows = await openEhr.QueryAsync(
BloodPressureQuery.Search(search.PatientId, openEhrOptions.Value.SubjectNamespace,
search.From, search.Before, search.NewestFirst, search.Count + 1, search.Offset),
cancellationToken);
if (rows.Any(row => row.PatientId != search.PatientId))
{
// The query filters on the patient. A row for anyone else means the filter did not hold.
throw new MappingException("the repository returned a row for a different patient");
}
IReadOnlyList<JsonObject> observations = ObservationMapper.ToObservations(rows.Take(search.Count));
string baseUrl = fhirOptions.Value.PublicBaseUrl!.ToString().TrimEnd('/');
JsonArray links = [Link("self", $"{baseUrl}/{ResourceType}?{search.ToQueryString(search.Offset)}")];
if (rows.Count > search.Count)
{
links.Add(Link("next", $"{baseUrl}/{ResourceType}?{search.ToQueryString(search.Offset + search.Count)}"));
}
JsonArray entries = [];
foreach (JsonObject observation in observations)
{
entries.Add(new JsonObject
{
["fullUrl"] = $"{baseUrl}/{ResourceType}/{(string)observation["id"]!}",
["resource"] = observation,
["search"] = new JsonObject { ["mode"] = "match" },
});
}
AuditLog.Allowed(logger, "search", search.PatientId, Subject(context.User), Client(context.User),
decision.Reason, observations.Count);
return FhirResults.Resource(new JsonObject
{
["resourceType"] = "Bundle",
["type"] = "searchset",
["link"] = links,
["entry"] = entries,
});
}
private static async Task<IResult> ReadAsync(
string id, HttpContext context, OpenEhrClient openEhr, ILoggerFactory loggers, CancellationToken cancellationToken)
{
ILogger logger = loggers.CreateLogger("FhirFacade.Audit");
IResult notFound = FhirResults.Outcome(StatusCodes.Status404NotFound, "not-found", $"{ResourceType}/{id} was not found.");
// Without any scope that allows a read there is no reason to touch the repository at all.
if (!SmartAccess.ScopesOf(context.User).Any(scope => scope.Covers(ResourceType, SmartPermissions.Read)))
{
const string reason = "the token carries no scope that allows this interaction";
AuditLog.Denied(logger, "read", "unknown", Subject(context.User), Client(context.User), reason);
return FhirResults.Outcome(StatusCodes.Status403Forbidden, "forbidden", reason);
}
if (!ObservationMapper.TryParseLogicalId(id, out Guid ehrId, out Guid compositionId))
{
return notFound;
}
string? versionUid = await openEhr.LatestVersionUidAsync(ehrId, compositionId, cancellationToken);
if (versionUid is null)
{
return notFound;
}
IReadOnlyList<BloodPressureRow> rows = await openEhr.QueryAsync(
BloodPressureQuery.ByComposition(ehrId, versionUid), cancellationToken);
if (rows.Count == 0)
{
return notFound; // The composition exists but holds no blood pressure.
}
JsonObject observation = ObservationMapper.ToObservations(rows)[0];
string patientId = rows[0].PatientId!;
AccessDecision decision = SmartAccess.Evaluate(context.User, ResourceType, SmartPermissions.Read, patientId);
if (!decision.Allowed)
{
// Another patient's reading is reported as not found, so the answer does not confirm it exists.
AuditLog.Denied(logger, "read", patientId, Subject(context.User), Client(context.User), decision.Reason);
return notFound;
}
AuditLog.Allowed(logger, "read", patientId, Subject(context.User), Client(context.User), decision.Reason, 1);
return FhirResults.Resource(observation);
}
Four mapping decisions sit behind ObservationMapper, and each one is a refusal:
- Units. A value whose unit is not
mm[Hg]fails the request. A reading stored in kilopascals must never be relabelled as millimetres of mercury. - Time. An event time without an offset fails the request. Assuming UTC would move a reading by hours for some patients.
- Identity. The logical id is the EHR identifier followed by the composition identifier, each as 32 hexadecimal digits. That is exactly the 64 characters FHIR allows for an id, and it lets a read address the composition directly. The cost is that one composition can yield only one Observation, so a composition holding two blood pressure readings fails the request instead of producing two resources with the same id.
- Patient. Every row must belong to the patient that was asked for. The query already filters on the patient; the handler checks again and fails the request if the repository disagrees.
Returning the readings that mapped and dropping the one that did not would give a clinician a list that looks complete and is not. An error is visible and can be acted on; a missing reading is not. Under GDPR the audit events are personal data too: they name a patient and a caller, so the log store needs access control, a retention period and a lawful basis of its own. This sample is not clinically validated and uses synthetic data only.
Token validation
Token validation is the only part of the host that differs between a workstation and Azure, so it lives alone in Program.cs. In Azure the signing keys and issuer come from the Entra ID tenant’s OpenID Connect metadata. For local testing a shared secret stands in for the identity provider, and the host refuses to start with that secret set unless the environment is Development.
using System.Text;
using FhirFacade;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
AuthOptions auth = builder.Configuration.GetSection("Auth").Get<AuthOptions>() ?? new AuthOptions();
bool useDevelopmentKey = !string.IsNullOrEmpty(auth.DevelopmentSigningSecret);
if (string.IsNullOrEmpty(auth.Audience))
{
throw new InvalidOperationException("Auth:Audience is required.");
}
if (useDevelopmentKey && !builder.Environment.IsDevelopment())
{
throw new InvalidOperationException("Auth:DevelopmentSigningSecret is only honoured in the Development environment.");
}
if (!useDevelopmentKey && string.IsNullOrEmpty(auth.Authority))
{
throw new InvalidOperationException("Auth:Authority is required.");
}
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options =>
{
// Keep the claim names as issued ("scp", "roles", "fhirUser") instead of the legacy WS-* names.
options.MapInboundClaims = false;
options.Audience = auth.Audience;
if (useDevelopmentKey)
{
// Local testing without an identity provider: tokens signed with a shared secret by the test client.
options.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = auth.DevelopmentIssuer,
ValidAudience = auth.Audience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(auth.DevelopmentSigningSecret!)),
ValidAlgorithms = [SecurityAlgorithms.HmacSha256],
};
}
else
{
// Signing keys and the issuer come from the authority's OpenID Connect metadata.
options.Authority = auth.Authority;
options.TokenValidationParameters.ValidAlgorithms = [SecurityAlgorithms.RsaSha256];
}
});
builder.Services.AddFacade(builder.Configuration);
WebApplication app = builder.Build();
app.MapFacade();
app.Run();
MapInboundClaims = false keeps the claim names as issued. Without it the handler renames some claims to legacy URIs, and code that looks for scp or roles finds nothing. Entra ID version 2.0 access tokens carry the API’s client id in aud, so Auth:Audience is that client id; set the API registration to issue version 2.0 tokens so the audience and issuer formats are the ones the configuration expects.
A Python test client
The client mints tokens shaped like the ones Entra ID issues, calls the facade and checks the answers. This is the function that builds a token; the full script runs twenty checks and writes a JSON report.
def mint(secret: str, *, scp: str | None = None, roles: list[str] | None = None, patient: str | None = None,
audience: str = AUDIENCE, lifetime: int = 300) -> str:
"""A token shaped like the ones Microsoft Entra ID issues: scp for delegated scopes, roles for applications."""
now = int(time.time())
claims: dict = {"iss": ISSUER, "aud": audience, "sub": "test-subject", "azp": "test-client",
"iat": now, "nbf": now - 5, "exp": now + lifetime}
if scp is not None:
claims["scp"] = scp
if roles is not None:
claims["roles"] = roles
if patient is not None:
claims["fhirUser"] = f"https://facade.example/fhir/Patient/{patient}"
return jwt.encode(claims, secret, algorithm="HS256")
Run against the facade in Docker with EHRbase behind it, the client passed all twenty checks. A second pass from a browser added forged tokens.
| Request | Result |
|---|---|
Patient p1, patient/Observation.rs, own readings | 200: 142/88, 146/90, 150/94, newest first, all for Patient/p1 |
Same with the Entra spelling patient.Observation.read | 200, three readings |
| Patient p1 asks for p2 | 403: the token was issued for a different patient |
user/Observation.rs | 403: user-level scopes are not supported |
patient/Observation.rs?category=vital-signs | 403: no usable scope |
patient/Observation.r, search | 403. The same token reads a single reading by id: 200 |
system/Observation.rs in roles, patient p2 | 200: 128/82, 152/96 |
| Patient with no readings, and an unknown patient | 200 with an empty searchset |
category=vital-signs as a search parameter | 400: the parameter is named in the OperationOutcome |
date=ge2026-09-01 | 400: no time or offset |
_count=2, then the next link | Two readings and a link, then one reading and no link |
| Read by id with another patient’s token | 404, not 403, so the answer does not confirm the reading exists |
Expired token, wrong signing key, wrong audience, wrong issuer, alg: none | 401 each |

Offline: a self-test hosts the facade on a loopback port with test authentication and a stub repository that replays rows recorded from EHRbase. It ran on the .NET 10 SDK and passed 43 of 43 checks, including the failures that are hard to provoke on a real repository: a reading in kilopascals, a time without an offset, two readings in one composition and a repository error. Live: the API was built on the .NET 10 SDK image with the real JWT bearer package and run in Docker against EHRbase, where the Python client passed 20 of 20 and the browser pass behaved as in the table. Not checked: tokens issued by Entra ID, any Azure deployment, behaviour under load, the 502 path on the real host, and the output against a FHIR validator or the vital signs profile. The C# and Python listings are the files that ran, fetched from the repository commit.
Deploying it on Azure Container Apps
Nothing in this section was deployed, and the sample has no infrastructure code for that reason. What follows is the placement the code was written for, with the settings that carry the security argument.
| Component | Placement | Why |
|---|---|---|
| FHIR facade | Container app with external ingress, HTTPS only, at least two replicas | The only public entry point. Stateless, so replicas are interchangeable |
| EHRbase | Container app in the same environment with internal ingress | Reachable by name from the facade and from nothing outside the environment |
| PostgreSQL | Azure Database for PostgreSQL Flexible Server with private access | Holds the record. Its backup and recovery settings define the recovery point for the whole system |
| Secrets | Key Vault, referenced from the container apps through a managed identity | No database password in app configuration or images |
| Identity | An Entra ID app registration for the API, exposing the scopes and application permissions | Issues the tokens the facade validates |
| Audit and logs | Log Analytics workspace, with audit events routed to a table with its own access and retention | Audit records outlive application logs and have a different audience |
Two details of Container Apps ingress are easy to get wrong. Internal ingress means the app’s own address is reachable only inside the environment, but Microsoft’s documentation notes that an app can still receive outside traffic if an environment-level HTTP route names it as a target; keep the repository out of any such route, or give it an environment of its own. And the ingress setting describes visibility relative to the environment, not the internet: in an environment created as internal, even an app with external ingress is published only on a private load balancer, which is the right shape when an API gateway or Front Door sits in front.
The sample sends no credentials to EHRbase, because the local repository ran with its own authentication switched off on a loopback port. Network isolation alone is a single control. In a real deployment, turn on the repository’s own authentication as well and give the facade a credential for it from Key Vault, so that a workload that lands inside the environment by mistake still cannot query the record.
Container Apps also offers built-in authentication at the ingress. It can reject requests without a valid token before they reach the container, which is a useful first filter. It does not replace the validation in Program.cs: scope and patient checks need the claims, and an API that trusts headers set by a platform feature is one configuration change away from trusting anyone.
Cost
Three components make up the bill, and they are far from equal. The facade itself is the smallest item: a stateless API with two small replicas. The repository and its database are sized by the record, and the facade adds read load to both, one AQL query per search and one metadata call plus one query per read. Compared with replication into a FHIR service, the facade avoids a second data store and a pipeline, and pays for it with repository capacity at request time. If FHIR traffic grows faster than clinical traffic, that trade reverses, and that is the signal to add a replicated read store behind the same API.
Running it in production
- Availability. The facade can be no more available than the repository and its database. Recovery time and recovery point are properties of PostgreSQL’s configuration; the facade holds no state to recover.
- Timeouts and retries. The repository client has a ten second timeout and no retry. Every call the facade makes is a read, so retries are safe to add; bound them, and add a circuit breaker so a struggling repository is not hit harder.
- Load protection. FHIR clients page and poll. Put a per-client rate limit in front of the facade, at the gateway or with ASP.NET Core’s rate limiting middleware, so one integration cannot starve clinical users of the repository.
- Observability. Trace each request from ingress to AQL call, and keep patient identifiers out of span attributes and metric labels. Diagnostic logs and audit events are different products with different readers.
- Conformance. The output was not run through a FHIR validator. Before a consumer depends on it, validate the resources against the profile your programme mandates, in the build pipeline.
- Support lifecycle. .NET 10 is the current long-term support release. The facade uses the framework and one authentication package, so moving it to a later release is a change to the project file and the two image tags.
- Latency. On one laptop with one caller, a search took about 70 ms end to end. That is an observation, not a benchmark.
When a facade is the wrong answer
- Consumers need search across patients,
_include, or bulk export. Each is a project when translated to AQL one request at a time. - The FHIR model you must serve needs data from several archetypes joined per resource, and the repository cannot do that in one query.
- Writes are required. A write through a facade has to produce a valid composition against a template, with its own validation and error mapping; treat that as a separate design.
- The repository cannot absorb the read load, or its availability target is lower than the API’s.
Key takeaways
- A FHIR facade over openEHR is a translation service and a policy enforcement point. Keep it narrow, and make everything outside that scope an explicit error.
- With Entra ID as the issuer, expect SMART scopes spelled with dots, user scopes in
scp, application permissions inroles, and the patient link in afhirUserclaim you have to provision. - Refuse what you cannot enforce: query-constrained scopes,
user/scopes without a care-relationship source, and search parameters you do not translate. - Fail a whole request when one reading cannot be mapped. A visible error is safer than an incomplete list.
- Test against the real repository. Every decision in the AQL layer was settled by what EHRbase accepted and rejected, not by the specification.
If you run a facade like this over a different openEHR repository, the differences in AQL support would make a useful comment below.
Glossary
- AQL— Archetype Query Language, the query language of openEHR repositories.
- Archetype— A reusable openEHR model of one clinical concept, such as a blood pressure measurement.
- Composition— The openEHR unit of committed clinical content, comparable to a document. It has a version identifier.
- EHR status— The openEHR object that records, among other things, the subject of an EHR: the link to the patient identifier.
- EHRbase— An open-source openEHR clinical data repository.
- Facade— A service that presents one interface, here FHIR, over a system that natively offers another.
- fhirUser— A claim holding the URL of the FHIR resource that represents the signed-in user.
- Internal ingress— An Azure Container Apps setting that makes an app reachable only from inside its environment.
- OperationOutcome— The FHIR resource a server returns to describe an error.
- SMART scope— An OAuth 2.0 scope in the SMART on FHIR syntax, such as
patient/Observation.rs. - scp and roles— The Entra ID access token claims that carry delegated scopes and application permissions.
References & Further Reading
- → Sample code for this article— The facade, the self-test and the Python test client
- → SMART App Launch 2.2: Scopes and Launch Context— Scope syntax, permission letters, fhirUser and launch context
- → FHIR R4 Search— Search parameters and how servers handle ones they do not support
- → Microsoft Entra access token claims reference— aud, scp, roles, azp and the differences between token versions
- → Azure Health Data Services: identity provider configuration for the FHIR service— The scp and fhirUser requirements and the dotted scope spelling
- → Microsoft Entra custom claims provider overview— Adding claims from an external system when a token is issued
- → Firely Server: Microsoft Entra ID— Another FHIR server’s account of the forward slash restriction in scope names
- → Ingress in Azure Container Apps— External and internal ingress and their caveats
- → openEHR REST API specification— The query and composition endpoints the facade calls
- → EHRbase on GitHub— The repository used for the live run
- → Mapping openEHR Medication Orders to FHIR— The batch counterpart to this article: AQL in, transaction Bundle out
Discover more from C4: Container, Code, Cloud & Context
Subscribe to get the latest posts sent to your email.