Backend for FlexForms — a multi-tenant, template-driven form platform for GOV.UK services.
Tenants (products such as Transfers, Visits, LSRP) share one API. Each tenant’s configuration, auth, connection strings, and form templates are stored in the database and resolved per request. The companion frontend is flexforms-web.
- Multi-tenant SaaS — TenantConfig database + per-tenant EA data; hostname /
X-Tenant-ID/ Origin resolution - JSON template engine — Versioned schemas rendered by the Web form engine
- Roles & permissions — SuperAdmin (platform), Admin / User / custom roles (tenant), claim-based grants
- Token exchange — DfE Sign-In / Entra SSO / test / internal service → tenant-scoped API JWT
- Secure files — Azure File Share + ClamAV scan via Azure Service Bus
- GOV.UK Notify — Email for submit, invites, feedback
- Real-time notifications — Azure SignalR
- Audit — SQL Server temporal tables on
eaentities - Redis + memory cache — Tenant-prefixed keys
- NSwag Api.Client — Strongly typed .NET client for Web and other consumers
Clean Architecture / DDD:
| Layer | Project | Purpose |
|---|---|---|
| Presentation | GovUK.Dfe.FlexForms.Api |
REST, SignalR, auth, middleware, Swagger |
| Application | GovUK.Dfe.FlexForms.Application |
MediatR CQRS, validators, consumers, domain event handlers |
| Domain | GovUK.Dfe.FlexForms.Domain |
Aggregates, tenancy entities, interfaces, role rules |
| Infrastructure | GovUK.Dfe.FlexForms.Infrastructure |
EF Core, migrations, tenant config provider, encryptor |
| Utilities | GovUK.Dfe.FlexForms.Utils |
Shared helpers |
| Client SDK | GovUK.Dfe.FlexForms.Api.Client |
Generated HTTP client + token exchange handlers |
flowchart LR
subgraph Clients
Web["FlexForms Web"]
Platform["Platform callers<br/>(MI / SP)"]
end
subgraph Azure
SB["Azure Service Bus"]
FS["Azure File Share"]
ASR["Azure SignalR"]
Redis["Redis"]
SQL_TC["SQL: TenantConfig"]
SQL_EA["SQL: EA data<br/>(shared or per-tenant)"]
end
subgraph External
Notify["GOV.UK Notify"]
ClamAV["ClamAV / file-scanner"]
IdP["DfE Sign-In / Entra"]
end
subgraph API["FlexForms API"]
MW["TenantResolutionMiddleware"]
Ctrl["Controllers"]
Hub["NotificationHub"]
App["Application / MediatR"]
Dom["Domain"]
Infra["Infrastructure"]
TCP["DatabaseTenantConfigurationProvider"]
end
Web -->|REST + X-Tenant-ID| MW
Web -->|WebSocket| Hub
Platform -->|PlatformBearer| Ctrl
MW --> TCP
TCP --> SQL_TC
MW --> Ctrl
Ctrl --> App
App --> Dom
App --> Infra
Infra --> SQL_EA
Infra --> Redis
Hub --> ASR
App --> SB
App --> FS
App --> Notify
ClamAV --> SB
IdP -.->|tokens exchanged| Ctrl
| Database | EF context | Schema | Contents |
|---|---|---|---|
| TenantConfig | TenantConfigDbContext |
tenantconfig |
Tenants, settings JSON, hostnames, frontend origins, principals |
| EA | ExternalApplicationsContext |
ea |
Users, roles, memberships, templates, applications, files, permissions |
Host always uses ConnectionStrings:TenantConfigDatabase. Each tenant’s EA connection comes from TenantSettings category ConnectionStrings (Target Shared/Api) → DefaultConnection. Tenants may share one EA database or use isolated DBs.
sequenceDiagram
participant Client
participant MW as TenantResolutionMiddleware
participant TCP as TenantConfigurationProvider
participant TC as TenantConfig DB
Client->>MW: Request
alt X-Tenant-ID header present
MW->>TCP: GetTenant(Guid)
else Origin header
MW->>TCP: GetTenantByOrigin
else
MW-->>Client: 400 Tenant required
end
TCP->>TC: Cached catalogue
MW->>MW: ITenantContextAccessor.CurrentTenant
Note over MW: Bypasses: /swagger, /health,<br/>/v1/tenant-config, /v1/host-config
- Prefer
X-Tenant-ID(GUID). - Else map
Origin→TenantFrontendOrigins. - Set scoped
ITenantContextAccessorand use that tenant’s EA connection string.
Hostname resolve (for Web bootstrap): GET /v1/tenant-config/resolve?hostname= uses TenantHostnames (no scheme).
| Table | Purpose |
|---|---|
Tenants |
Id, Name, IsActive |
TenantSettings |
Category × Target (Shared / Api / Web) JSON; IsSecret encrypted |
TenantHostnames |
Host → tenant (e.g. transfers.dev-flexforms…) |
TenantFrontendOrigins |
CORS origins |
TenantPrincipals |
Managed Identity / SP / API key object id → tenant (config consume) |
| Target | Used by |
|---|---|
Shared |
Merged into both Api and Web config snapshots |
Api |
API runtime (DatabaseTenantConfigurationProvider, target Api) |
Web |
Consumed by Web via GET /v1/tenant-config/tenants/{id}?target=Web |
Common categories: ConnectionStrings, AzureAd, DfESignIn, EntraSso, Authorization, ApplicationTemplates, Email, FileStorage, FormEngine (Web), Layout (Web), InternalServiceAuth, …
Secrets (IsSecret = 1) are encrypted with ASP.NET Data Protection.
DatabaseTenantConfigurationProvider (hosted service):
- Loads active tenants + settings on a timer (~60s) and on
POST /v1/admin/tenants/refresh - Decrypts secrets, flattens JSON into
IConfigurationonTenantConfiguration - Indexes by tenant Id and frontend origin
- Notifies auth registry / OIDC reloaders on change
Tests / codegen can use TenantConfigSource=AppSettings + OptionsTenantConfigurationProvider.
Maps Azure AD oid / appid of a workload identity to a tenant. Used when Web (or another service) calls GET /v1/tenant-config — the tenant is resolved from the caller’s JWT, never trusted from a client-supplied id alone.
| Scheme | Use |
|---|---|
CompositeScheme |
Default; dispatches ApiKey / mTLS / TenantBearer |
TenantBearer |
User JWTs (HS256 from TokenSettings) + Entra service tokens |
ApiKey |
X-Api-Key |
Mtls |
Client certificate |
PlatformBearer |
Platform Entra app (Platform:AzureAd) for host-config / tenant-config ops |
HubCookie |
Short-lived cookie for SignalR |
POST /v1/tokens/exchange (policy ServiceCallers):
- Caller presents a machine credential + subject IdP token (DfE Sign-In / Entra SSO / test / internal headers).
- API validates the subject, finds or creates
User, ensuresTenantMembership. - Issues a tenant-scoped user JWT with role + permission claims.
Web’s Api.Client uses this on every user session (RequestTokenExchange).
| Role | Scope | Notes |
|---|---|---|
| SuperAdmin | Platform | Well-known global role id / name SuperAdmin. Tenant Settings UI/API. Not tenant-assignable. |
| Admin | Tenant | Per-tenant Roles row (TenantId set). Full tenant admin. Assignable by SuperAdmin. |
| User | Tenant | Default self-registration membership. |
| Custom | Tenant | Named roles + RolePermissions. |
| Caseworker | Legacy | Not assignable; prefer custom roles. |
Important: Global Roles row named Admin with TenantId = NULL is the platform SuperAdmin shell (RoleConstants.AdminRoleId). Tenant Admin assignment must use the tenant-scoped Admin RoleId, never that global id.
Source of truth for “who is Admin in this tenant”: TenantMemberships → tenant role. Token exchange elevates to SuperAdmin when Users.RoleId is the platform admin GUID.
Format: {ResourceType}:{ResourceKey}:{AccessType}
Examples: Template:Any:Manage, User:Any:Manage, Template:{guid}:Read.
Merged from RolePermissions + user Permissions overrides (UserPermissionClaimProvider). Evaluated by PermissionClaimEvaluator / policy handlers (CanManageUsers, CanCreateTemplate, …).
Bearer claim tenant_id must match the resolved request tenant. Cross-tenant tokens are rejected.
erDiagram
User ||--o{ TenantMembership : has
Role ||--o{ TenantMembership : grants
Role ||--o{ RolePermission : defines
User ||--o{ Permission : overrides
User ||--o{ Application : creates
Template ||--o{ TemplateVersion : versions
Template ||--o{ TemplatePermission : access
TemplateVersion ||--o{ Application : used_by
Application ||--o{ ApplicationResponse : answers
Application ||--o{ File : attachments
Template }o--|| TenantHint : TenantId
User {
guid UserId PK
guid RoleId FK
string Email
string ExternalProviderId
}
Role {
guid RoleId PK
string Name
guid TenantId "null = global"
bit IsSystem
}
TenantMembership {
guid Id PK
guid TenantId
guid UserId
guid RoleId
bit IsActive
}
Template {
guid TemplateId PK
string Name
guid TenantId
bit IsLive
}
Templates belong to a tenant via Template.TenantId and/or TenantSettings HostMappings (ApplicationTemplates / Web Template). Catalogue logic: TenantTemplateCatalogue.
| Area | Prefix | Examples |
|---|---|---|
| Applications | /v1/applications, /v1/me/applications |
Create, responses, submit, contributors, files |
| Templates | /v1/templates |
CRUD versions, live flag, grant-all-users |
| Users | /v1/users |
Register, assign role, tenant users, permissions |
| Roles | /v1/roles |
Custom roles + RolePermissions |
| Tokens | /v1/tokens/exchange |
IdP → API JWT |
| Notifications | /v1/notifications |
Redis-backed notifications |
| Tenant admin | /v1/admin/tenants |
Refresh, list, seed, get/upsert settings |
| Tenant config | /v1/tenant-config |
Consume config, resolve hostname, get by id |
| Host config | /v1/host-config |
Platform bootstrap for Web |
| Hub auth | hub ticket endpoints | SignalR cookie bridge |
| Feedback | /v1/userfeedback |
Support / feedback emails |
Swagger: https://localhost:7089/swagger (see launchSettings.json).
flowchart LR
Upload["Upload file command"] --> FS["Azure File Share"]
Upload --> Pub["Publish ScanRequestedEvent"]
Pub --> SB["Service Bus topic"]
SB --> Scanner["rsd-file-scanner-function"]
Scanner --> ClamAV["ClamAV API"]
Scanner --> SB2["ScanResultEvent"]
SB2 --> Consumer["ScanResultConsumer"]
Consumer --> Meta["Update File scan status"]
- Shared Service Bus namespace and SignalR resource for all tenants; tenant stamped on messages (
TenantAwareEventPublisher/TenantContextConsumeFilter). - File storage is tenant-aware (
TenantAwareFileStorageService).
- MediatR commands/queries with FluentValidation, rate limiting, exception behaviours.
- Feature folders:
Applications,Templates,Users,Roles,TenantAdmin,TenantConfig,Notifications,Consumers. - Domain events → handlers (email, scan request, cache invalidation).
- Key services:
TenantMembershipService,TenantRoleService,RolePermissionService,ClaimBasedPermissionCheckerService,TenantTemplateCatalogue.
- .NET 10 SDK
- Access to TenantConfig SQL (+ EA SQL if not using LocalDB)
- Redis (or configure memory-only for smoke tests)
- User secrets for platform Entra + connection strings
| Key | Purpose |
|---|---|
ConnectionStrings:TenantConfigDatabase |
TenantConfig SQL |
TenantConfigSource |
Database (default) or AppSettings |
Platform:AzureAd |
Platform Bearer for host/tenant-config |
MassTransit / Service Bus |
Messaging (or SkipMassTransit for codegen) |
DataProtection |
Secret settings encryption |
Per-tenant secrets and connections live in TenantConfig, not only in appsettings.
dotnet run --project src/GovUK.Dfe.FlexForms.ApiHTTPS: https://localhost:7089
After editing TenantSettings in SQL, call:
POST /v1/admin/tenants/refresh(as an interactive Admin/SuperAdmin user JWT), or wait for the provider refresh interval.
# EA schema (ea)
dotnet ef migrations add <Name> --project src/GovUK.Dfe.FlexForms.Infrastructure --context ExternalApplicationsContext
# TenantConfig schema
dotnet ef migrations add <Name> --project src/GovUK.Dfe.FlexForms.Infrastructure --context TenantConfigDbContext --output-dir Migrations/TenantConfigSee scripts/ for TenantConfig import helpers (Web/Api settings upsert). Ensure HostMappings only list that tenant’s template GUIDs on shared EA databases.
| Concern | Behaviour |
|---|---|
| Tenant isolation | Middleware + tenant_id claim match + membership checks |
| Config consume | Principal → TenantPrincipals (no client-chosen tenant) |
| Secret settings | Encrypted at rest; SuperAdmin-only read/write decrypted values |
| Admin APIs | Interactive user JWT required where noted (not pure machine tokens) |
| CORS | Only TenantFrontendOrigins |
| Platform ops | PlatformBearer + Entra app roles (Platform.Host.Read, Platform.TenantConfig.Read) |
| Permissions | Claim policies; Admin bypass within tenant |
| Repo | Role |
|---|---|
| flexforms-web | Razor Pages UI + form engine |
| rsd-file-scanner-function | AV scan worker |
| rsd-clamav-api | ClamAV sidecar/API |
| DfE.CoreLibs | Shared contracts, security, caching |
dotnet test GovUK.Dfe.FlexForms.Api.slnUnit + integration projects under src/Tests/.