Architecture Overview: CommandCenter Platform with BFF Gateway#
Current Architecture Context diagram
flowchart TB
user(user)
user--personal data-->bff
subgraph external [Internet]
direction LR
entraId[Entra ID]
bff((YARP Gateway\nFront Door BFF))
react((React\nWeb UI))
bff-->react
react-.api calls.->bff
end
subgraph dmz [Internal Network]
direction LR
ldb[|borders:tb|legacy db]
servers((Servers API))
bff--api routes-->api
db<-->api
api-.->legacy-api
api-.->entraId
api-->mediation
subgraph cc-api [CommandCenter API]
direction LR
db[|borders:tb|database]
api((CommandCenter\nAPI))
end
subgraph legacy-api [Legacy CC]
direction LR
legacy((Legacy API))
legacy-service((CC Service))
bff--legacy api routes-->legacy
legacy<-->ldb
legacy-service<-->ldb
end
subgraph mediation [Mediation Layer]
direction LR
servers-.->ldb
end
subgraph benefit [BenefitManager]
direction LR
db-customer[|borders:tb|customer db]
bm-service((BenefitManager\nService))
bm-app((BenefitManager\nWeb))
api -- "`versioned req/res`" --> bm-app
bm-service <--> db-customer
bm-app <--> db-customer
end
end
%% Element type definitions
classDef boundary fill:none
dmz:::boundary
external:::boundary
classDef apis stroke-dasharray: 5 5
legacy-api:::apis
mediation:::apis
cc-api:::apis
benefit:::apis
This architecture describes a layered enterprise web platform built around the Backend-for-Frontend (BFF) pattern, separating an internet-facing edge from an internal network that hosts both a modern API and a legacy system in mid-migration. The picture that emerges is a classic strangler-fig setup: a new CommandCenter API gradually taking over responsibilities from a Legacy CC system, with a mediation layer and a domain-specific BenefitManager module living alongside them.
The Internet-Facing Edge#
The user's entry point is a YARP Gateway acting as a Front Door BFF. All personal data flows from the user into this gateway, which serves two roles at once: it delivers the React Web UI to the browser, and it receives the API calls that the React app makes back to it. This is the security-relevant design decision of the whole architecture — the browser never talks to any backend service directly. The BFF is the single choke point where authentication, token handling, request routing, and header sanitization can be enforced, which is exactly what YARP (Yet Another Reverse Proxy, the .NET reverse-proxy library) is built for.
Entra ID sits in the internet zone as the identity provider. Notably, the dashed line shows the CommandCenter API calling out to Entra ID — suggesting the internal API validates tokens or queries directory/graph data itself, rather than identity being handled exclusively at the edge.
The Internal Network#
Everything behind the gateway lives in an internal network zone (labeled as a DMZ-style boundary), which the BFF reaches through two distinct routing rules:
CommandCenter API is the modern core. The BFF forwards standard API routes here, and the API owns its own database with bidirectional read/write access. From this hub, three outbound relationships fan out: a dashed (loosely coupled or transitional) dependency on the Legacy API, the dashed call to Entra ID, and a solid dependency on the Mediation Layer.
Legacy CC is the system being phased out. Importantly, the BFF routes legacy API routes directly to the Legacy API — meaning the old system is still serving live traffic for some endpoints, not just being called internally. Both the Legacy API and a background CC Service share read/write access to the legacy db. That shared-database pattern between an API and a service is typical of older architectures and is usually one of the harder things to untangle during migration.
Mediation Layer contains a Servers API with a dashed connection into the legacy database. Its role appears to be an anti-corruption or adapter layer: it gives the modern CommandCenter API a controlled, indirect path to legacy data without coupling the new API directly to the old schema. The dashed lines reinforce that this is intended as a temporary or loosely bound bridge.
BenefitManager is a self-contained business domain with three parts: a BenefitManager Web front-end, a BenefitManager Service, and a dedicated customer db that both components read and write. The CommandCenter API communicates with BenefitManager Web through explicitly versioned request/response contracts — a deliberate choice that decouples the two systems' release cycles and signals that BenefitManager is treated as an independent product with its own lifecycle rather than an internal module.
Key Design Characteristics#
A few properties are worth calling out:
- Single ingress, zero direct backend exposure. All user traffic funnels through the YARP BFF; internal services are unreachable from the internet. Personal data crosses the boundary exactly once, at a point where it can be inspected and protected.
- Strangler-fig migration in progress. Solid arrows mark the target-state paths (BFF → CommandCenter API → its own database), while dashed arrows mark transitional dependencies (API → Legacy, Mediation → legacy db). The diagram's dashed subgraph borders on Legacy CC, Mediation, and the API groups visually echo this "boundary in flux" status.
- Database-per-context, with one legacy exception. CommandCenter, BenefitManager, and Legacy each own their data store. The only shared database is the legacy one — shared between Legacy API, CC Service, and (indirectly) the Servers API — which is precisely where the migration risk concentrates.
- Contract versioning at the seam. The versioned req/res link between CommandCenter and BenefitManager is the formal integration contract that lets the two evolve independently.
In short: a YARP-fronted React application backed by a modern CommandCenter API, coexisting with a legacy system through direct legacy routing, a mediation/adapter layer, and a versioned contract to an autonomous BenefitManager domain — an architecture clearly designed to migrate away from the legacy core incrementally while keeping a single hardened entry point for all user traffic.
Authentication & Authorization Flow: OIDC Authorization Code + PKCE, and a gateway-signed user token#
Participants
- The browser and the SPA
- The gateway (BFF: YARP + ASP.NET Core)
- The identity provider: Entra ID today, or Keycloak, One Identity, any OpenID Connect provider
(
docs/identity-provider.md) - The backends (WebApi, the main API) and OPA
sequenceDiagram
participant Browser as Browser + SPA
participant BFF as Gateway (BFF)
participant IdP as Identity provider
participant API as Backend (WebApi)
participant OPA
Note over Browser, BFF: Sign-in (the user is not signed in)
Browser->>BFF: GET /.login?returnUrl=/…
BFF->>Browser: 302 to the provider's authorize endpoint (client_id, redirect_uri, scope, state, nonce, code_challenge, response_mode=form_post)
Browser->>IdP: authorize: the user signs in there
IdP->>Browser: form POST back to the gateway's /signin-oidc (code, state)
Browser->>BFF: POST /signin-oidc
BFF->>IdP: token request: code, code_verifier, and a client secret or a client assertion (certificate, or a managed identity's token)
IdP-->>BFF: ID token
BFF->>BFF: validates it, maps the provider's roles to internal roles (role map), sets the session cookie
BFF->>Browser: 302 back to the SPA, with an HttpOnly session cookie
Note over Browser, API: An API call
Browser->>BFF: GET /env/{env}/api/… (session cookie, X-CSRF: 1)
BFF->>BFF: signs a 5-minute user token (ES256): who the user is, their internal roles
BFF->>API: forwarded request, Authorization: Bearer <user token>
API->>API: validates it against the gateway's key set (/.well-known/cc-internal/jwks)
API->>OPA: may this user do this? (the action and resource, never a value)
OPA-->>API: allow, or deny with reasons
API-->>BFF: 200, or 401 / 403 / 503
BFF-->>Browser: the response
The gateway is the security choke point. It implements the token handler pattern: the browser
never sees, stores or sends an OAuth token. The SPA's only credential is an opaque HttpOnly session
cookie, and every proxied call must carry X-CSRF: 1.
Phase 1: signing in#
The SPA loads without a user and sends the browser to GET /.login (a local returnUrl only, so it
cannot be an open redirect). From there the gateway drives the sign-in, not the browser.
Phase 2: authorization code flow with PKCE#
The gateway redirects to the provider's authorize endpoint with its client_id, redirect_uri,
the scopes, state, nonce and a code_challenge, and asks for the answer as a form POST.
- PKCE on a confidential client. Even with a client credential the gateway sends a
code_challenge/code_verifierpair, as OAuth 2.1 requires. It binds the code to whoever started the sign-in. stateandnonceprotect the callback against forged answers and replayed ID tokens.
The user signs in at the provider; their credentials never reach the gateway or the SPA.
Phase 3: redeeming the code, and the session#
At /signin-oidc the gateway posts the code and the code_verifier to the provider's token
endpoint, proving who it is with one of three client credentials (docs/identity-provider.md):
- a client secret;
- a JWT signed with its certificate (
private_key_jwt); - or, with Entra ID, a token from its managed identity (a federated credential, so no secret exists anywhere).
It validates the ID token (signature, issuer, audience, expiry, nonce), turns the provider's roles
into internal roles with the role map (/config/access), and sets an HttpOnly, Secure, SameSite
Strict session cookie. It keeps no OAuth token: there is nothing to refresh and nothing to leak.
Phase 4: API calls through the gateway#
The SPA calls relative paths such as /env/{env}/api/…; the browser attaches the cookie (same
origin, so no CORS). The gateway checks X-CSRF, asks OPA whether the user may reach that
environment, and picks that environment's backend (docs/environments.md).
Phase 5: the user token the gateway signs#
On clusters marked UserAssertion, the gateway removes whatever Authorization the browser sent
and attaches a user token it signs itself:
- ES256, iss=commandcenter-bff, aud=commandcenter-internal, valid five minutes;
- claims: the user's id and name, their internal roles, the provider, the session, and the
environment.
The backends trust the gateway, not the provider. That is why switching from Entra ID to Keycloak
only changes the gateway. The signing key is rotated on /config/credentials
(docs/authorization.md).
Phase 6: validation at the backend, and OPA#
Each backend validates the user token against the gateway's published key set, which it re-reads
every five minutes: signature, issuer, audience and expiry. A protected endpoint then asks OPA
whether this user may do this action on this resource. OPA fails closed: a timeout or error is a
denial, and when it cannot be asked the answer is 503 (docs/authorization.md).
Why this design holds together#
| Concern | Where it's solved |
|---|---|
| Token exposure to the browser | None: the token handler pattern, an HttpOnly cookie only |
| CSRF | X-CSRF: 1 on every proxied request, Sec-Fetch-Site, SameSite cookie |
| Code interception | PKCE, even on the confidential client |
| Which provider | The gateway only (Authentication:Oidc); backends never see it |
| User identity at the backend | The gateway-signed user token, five minutes, one audience |
| Who may do what | OPA, deciding with the internal roles, failing closed |
The net effect: the SPA is a pure UI with no security machinery. The gateway is the single OpenID Connect client and the only issuer the backends trust, and OPA decides every protected action. No bearer token ever crosses into the browser.