Service Architecture
- Web Service: Blazor Server UI with authentication (Port 8080)
- Application Service: REST API with JWT authentication (Port 8081)
- MongoDB: Document database (Port 27017)
- Redis: Cache and messaging (Port 6379)
The Application Service is a separate ASP.NET Core WebAPI process that handles data-oriented requests. The web application communicates with it via HTTP using JWT authentication, automatically forwarding the current organisation context.
API Documentation
- Health Checks: System Health Status
- Infrastructure: the Infrastructure Monitor at
/Platform/Infrastructure, for platform administrators
Scoped Controller Pattern
API controllers are categorised into two scopes using attributes and base classes:
Organisation-Scoped
Inherit from OrganisationApiController and are marked with [ControllerOrganisationScoped]. These require an X-Organisation-Id header. The ApiTenantResolutionMiddleware resolves the organisation, sets the tenant context, and all IRepository<T> queries are automatically scoped to that org's database.
public class MyController : OrganisationApiController{ // CurrentOrganisationName, CurrentOrganisationId available // IRepository<T> queries scoped to org's database}Platform-Scoped
Inherit from PlatformApiController and are marked with [ControllerPlatformScoped]. No organisation context is needed. Used for authentication endpoints and platform-level operations.
[AllowAnonymous]public class TokenController : PlatformApiController{ // No tenant resolution, uses IPlatformRepository<T>}Authentication
Service-to-Service (Web → API)
The web application authenticates using client credentials. The JwtAuthenticationHandler handles this automatically on every request: it requests a token from IJwtTokenApiClient.GetServiceTokenAsync — which caches one token per organisation — and stamps the matching X-Organisation-Id header.
Each issued JWT is bound to a specific organisation via an org_id claim. The API rejects any request where the bearer token's org_id does not match the X-Organisation-Id header — a leaked token can only act on the organisation it was minted for. Tokens minted without an OrganisationId are usable only against [ControllerPlatformScoped] endpoints.
POST /api/token{ "ClientId": "blazorblueprint-web", "ClientSecret": "...", "OrganisationId": "<org-guid>" // optional; required for [ControllerOrganisationScoped] calls}# Subsequent requests:Authorization: Bearer <jwt-with-org_id-claim>X-Organisation-Id: <same-org-guid>Configuration: Client credentials are stored in appsettings.json under Services:InternalApi. Both base controllers use [Authorize(Policy = "ServiceClient")], so any future co-tenant of the JWT secret can't reach the API without the ServiceClient role.
Writing your own controller — two rules that fail silently
Both base classes above carry their authorization already. The rules below bite when you add a controller outside them — a host controller in the Web app, or anything not inheriting the pair — which is the normal thing to do for a public or partner endpoint.
⚠️ 1. A controller with no authorization attribute is anonymous by default, not
by decision. There is no fallback authorization policy in the Web host, so omitting the
attribute does not fail the build, the boot, or the first request — it publishes the endpoint.
Say which you meant: an [Authorize…] attribute, or an explicit
[AllowAnonymous]. ControllerAuthorizationCoverageTests enforces exactly
that for plugin controllers, so "I forgot" and "I meant it" look different in the diff.
⚠️ 2. The membership wall does not cover /api.
OrgAccessMiddleware — the gate that keeps a signed-in non-member out of an
organisation — is a denylist of page root segments
(/admin, /apps, /plugin, /notifications), and
/api is deliberately not among them. So a host controller that serves tenant data
gets no membership check from the pipeline at all: being signed in is not being
a member, and the claim on the request is the only thing standing there. Put
[AuthorizeOrganisationMember] on it.
MEMBER is a real tenancy check rather than "signed in": it is minted from the caller's
membership of the organisation this request resolved to, and only from an Active
one — Suspended and Invited fall through to the non-member branch. It admits
PLATFORMADMIN as well, unlike [AuthorizeOrganisationAdmin], and the split is
about what the surface does rather than who is asking: reading a tenant's file while supporting them
is the point of Features:PlatformAdminOrgImpersonation; changing that tenant's settings,
members or payment credentials is not.
💡 Never compose the role list by hand.
[Authorize(Roles = MEMBER + "," + PLATFORMADMIN)] produces the identical result
today and is a copy of a rule that lives somewhere else, so it does not move when the rule does.
A source-level guard fails the build on one — reflection genuinely cannot tell the two apart,
since both yield the same Roles string at run time.
SignalR hubs are a third case, and middleware cannot help them
A hub invocation is a frame on a socket opened once, so the HTTP pipeline runs for the handshake and
never again. That is why OrgAccessMiddleware, the consent gate and the private-workspace
gate all skip every declared hub path — declaring a hub path removes walls. A hub
class therefore states its own tenancy, and a global hub filter enforces it per connection:
[HubOrganisationScoped] // one organisation, members only[HubOrganisationScoped(AllowAnonymous = true)] // one organisation, public widget[HubPlatformScoped] // deployment-wide, no tenant
The filter resolves the organisation once per connection, verifies active membership, re-checks the
user's security stamp, and refuses on any failure — then sets the tenant on every invocation scope,
which is what makes IRepository<T> work inside a hub method at all. A plugin
hub declaring neither attribute is refused at connect. Do not take an
organisationId argument from the client and check it by hand; that is the pattern this
replaced, and the version of it that forgot the check subscribed callers to other tenants' data.
Current Endpoints
| Method | Route | Scope | Description |
|---|---|---|---|
POST |
/api/token |
Platform | Client credentials token exchange |
GET |
/api/organisations/current |
Organisation | Trimmed view of the organisation the JWT is bound to |
That is the whole shipped surface, on purpose. The Application Service is a worked example of the
pattern — token exchange, org-bound JWT, scoped controllers, tenant-resolved repositories — not a
product API. Its own endpoints are a worked example; delete them and add your own controllers
inheriting the two base classes, which live in BlazorBlueprint.Api.Shared so that a
plugin — or a second API service — can inherit them too. The example plugin ships one
endpoint of its own that way, the cached /api/plugin/weather/api-cache, served by
this host under Full and by the Web host under Lite. A weather endpoint used to sit in the
table above, as a host endpoint, until the example moved into a plugin — which is the same
migration a buyer makes with their own.
Errors & Status Codes
| Status | When |
|---|---|
401 |
Missing, expired or invalid bearer token; bad client credentials at /api/token. |
403 |
Valid token without the ServiceClient policy, or a token whose org_id does not match the X-Organisation-Id header. |
400 |
An [ControllerOrganisationScoped] request with no X-Organisation-Id header, or an unresolvable organisation. |
404 |
The entity does not exist in this tenant — indistinguishable, deliberately, from existing in another one. |
⚠️ Keep tenant-mismatch failures indistinguishable from ordinary auth failures. A
response that says "wrong organisation" rather than "unauthorised" confirms that an organisation id
exists, which is an enumeration oracle. The same rule already governs the webhook endpoint, where every
auth-related failure collapses to 401.
Error bodies are ASP.NET Core problem details. Keep them that way in your own controllers, and keep exception detail out of them — an internal message reaching a client is both an information leak and a support burden. Log server-side; return something a caller can act on.
Versioning & Rate Limiting
Versioning does not ship, and that is a deliberate omission rather than an oversight. The service is internal — the web application is the only authorised caller — so there is no third-party contract to version. Adding one before you need it would be shipping you machinery to maintain.
Rate limiting is a different matter, and some of it already ships.
/api/token is limited to 10 requests per minute, partitioned on client IP and the
requested organisation id together. That endpoint mints a token from
Security:WebApp:ClientSecret on an unauthenticated request, so it is the one surface here a
brute-forcer can reach; the composite key is what stops an attacker minting a fresh budget per invented
organisation id. Nothing else in this service is throttled, because nothing else is reachable without a
token.
You need versioning, and rate limiting on more than the token endpoint, the moment you expose this service to anyone else. When you do:
- Versioning — the conventional route prefix (
/api/v1/…) is the least surprising choice; both base controllers are unaffected by it. - Rate limiting — use the shared rate-limit store rather than an in-memory counter. A per-process counter lets N instances allow N× the requests, and your load balancer will spread an attacker's traffic across them for free. The store is Redis-backed when Redis is present, in-memory otherwise, and it is what the existing anonymous-surface guards already use.
- Authentication — the client-credentials flow already issues per-organisation tokens, so a third-party integration gets its own client rather than sharing the web application's.
Outbound calls are the opposite story: every provider call already goes through a shared policy stack with timeout, retry, circuit breaker, per-provider rate limiting and an audit row. That is configured, not hand-rolled per call site.
Ready to Build?
Build against the service architecture and its APIs. The complete source is free to use — including commercially — while your business earns under £100k/year. Past that, a one-time commercial licence (from £499, excl. VAT) applies. Every tier ships the same product; nothing is feature-gated.
🔓 Source-available • Free under £100k/yr revenue • Commercial licence (from £499) above that • Full source code included