What Is This Thing?
You see one website. It is really nine small programs passing your click down a line. Let us follow a single click from button to answer.
The illusion of one app
You log in, click Departments for your company, and a list appears. It feels like one tidy website.
It is not. It is nine separate programs, and your click is a baton passed between them.
You prove who you are once, and the website remembers it for the rest of your visit.
Behind the scenes it quietly sends off a request for the list that belongs to your company.
A fraction of a second later, the departments show up on your screen as if by magic.
A back-office business platform — it keeps track of employees and departments — rebuilt as small independent programs. The behind-the-scenes parts are built with .NET 10 and what you see in your browser is a Vue 3 browser app.
One knife, or a knife block?
There are two ways to build a product this size. The difference is the whole point of this course.
The Swiss-Army knife
A monolith: one program does everything passably. Handy — but if the hinge breaks, you lose every tool at once.
The knife block
A set of microservices: one blade per job, each replaceable on its own. If one breaks, the rest keep working.
VS Platform is the knife block. Each program has one job and stays out of the others' business.
Giving each program one clear job — and only that job — is one of the most important ideas in software. Engineers call it separation of concerns.
Splitting one program into nine means there is now a network between them — and networks can be slow or fail. Taming that wiring is most of what this course is about.
The 30-second tour of the new stack
Here is the whole cast. One line each — we meet them properly in Module 2.
What you see and click. Asks for data; stores nothing important.
The single front door. Checks your badge, reads which company, swaps your badge for a ticket.
The ID checker. Proves who you are at login. Off-the-shelf software.
Stamps a "capability ticket" listing exactly what you may do.
The rulebook. Looks up what you can do in this company.
Does the real work: reads and writes the business data.
The filing cabinet. Each company gets its own private drawer.
The sticky-note memory that remembers recent answers for a few seconds.
Trace the click
Meet Dana. She works at Org Alpha and just clicked Departments. Think of her click as a baton in a relay race — handed from runner to runner until the answer comes back.
Step through it one handoff at a time.
(a) Which company Dana is working in came from the web address, not from her password. (b) No single program in that relay knew everything — each one did its one job and passed the baton.
This is real code, not magic
When Dana's click reaches the Business API, it lands on the exact piece of code below. This is the real file, unedited — you could open it yourself and see the same thing.
[HttpGet]
[RequireVsCapability("departments", "read")]
public async Task<ActionResult<IEnumerable<DepartmentDTO>>> GetAll(string orgId)
{
var items = await listCache.GetOrCreateAsync(
ListCacheKey(orgId),
ListCacheOptions,
async cancellationToken =>
{
await using var dbContext = await dbFactory.CreateReadAsync(orgId, cancellationToken);
return await dbContext.Departments
.Select(d => new DepartmentDTO(d.ID, d.DepartmentName))
.AsNoTracking()
.ToListAsync(cancellationToken);
},
HttpContext?.RequestAborted ?? CancellationToken.None);
return Ok(items ?? []);
}
This is the spot Dana's "show me the list" request lands on.
The bouncer line: you may only run this if your capability ticket says departments.read.
The company Dana is acting in (orgId) arrives from the web address — not from her login.
Start fetching the list of departments.
First check the sticky-note memory (the cache); only do the slow work if the answer isn't already there.
Open this one company's private drawer of the database (its schema).
Read each department and keep just its ID and name.
Send the finished list back to Dana's screen (or an empty list if there are none).
That single bouncer line — RequireVsCapability — is the whole new security idea in miniature. The rest of the course unpacks how that ticket got made.
Check your map
No memorizing — just think it through. Knowing the shape lets you tell an API assistant where a change or a bug probably belongs.
A teammate says, "The company you belong to is baked into your password." Is that how VS Platform works?
After login, suddenly every page for every company fails to load. Which program is the smartest first place to look?
You want the departments list to feel instant when Dana clicks it a second time. Which character is already helping with that?
You ask an AI to add logic that reads the company from the URL on every incoming request. Where should it live?
You have the shape. In Module 2, "Meet the Cast," we introduce each of these programs properly — what each one owns, and what it refuses to do.
Meet the Cast
Nine repositories, one product. Here is each program, what it owns, and — just as important — what it deliberately does not know.
Nine folders, one product
In Module 1 the baton flew past a blur of programs. Let us slow down and shake hands with each one.
The whole platform is split across nine separate repositories. Think of a theatre company: some people are on stage during the show, others build the set and run the lights. Same here — some folders run live; others are the crew behind the scenes.
Keeping each job in its own repository means a change to the login screen can never accidentally break the database code. Separate folders, separate blast radius.
The runtime cast — who actually runs
These six programs are runtime programs — alive and answering requests while you use the app — plus two data stores that remember things. The trick to understanding each one is to notice what it refuses to know.
Shows you the screen and captures your clicks. Refuses to know: your password, or anything in the database — it just asks, politely, over the network.
Proves you really are you at login. Refuses to know: which company you picked, or what you are allowed to do.
The single front door. Checks your badge and reads which company you want from the web address. Refuses to know: what is inside the database.
Stamps a ticket listing exactly what you may do. Refuses to know: the answer itself — it asks the rulebook first.
The rulebook. Looks up each company's groups and capabilities and answers yes or no. Refuses to know: how to check a password.
Does the real work — reads and writes employees and departments. Refuses to know: passwords. It trusts the ticket.
The filing cabinet that stores everything. Refuses to do: let one company peek at another — each gets its own private drawer.
Sticky-note memory that remembers recent answers for a few seconds. Refuses to be: the source of truth — it is just a fast copy.
The supporting cast — the crew behind the scenes
These four are not on stage during the show, but the show could not happen without them. They are the set builders, the stage manager, and the rehearsal crew.
packages
A shared toolbox every service reuses: login plumbing, caching, and the per-company database base.
orchestrator
The stage manager. One command boots the whole stack on a laptop — an orchestrator we meet in Module 7.
identity
The recipe that sets up the Keycloak
realm
(named vs-platform) — users, login rules, the works.
tests
The safety net: a robot that opens the app and checks it still works after every change (also Module 7).
A day in the life
Enough description — let the cast introduce themselves. Play the group chat and watch each one explain its single job, in its own words.
Each program owns exactly one job — and the power of the design is in what each one refuses to do. The API never checks passwords; the gateway never reads the database; Keycloak never knows which company you picked. Engineers call this "separation of concerns."
Reading the real code
A service is just a tiny program with a couple of jobs. Here is the Business API's entire list of what it stores — only two things.
public class OrgAppDbContext(DbContextOptions<OrgAppDbContext> options) : TenantSchemaDbContext(options)
{
public DbSet<Employee> Employees => Set<Employee>();
public DbSet<Department> Departments => Set<Department>();
}
This is the Business API's DbContext — its whole map of what it stores.
It inherits from TenantSchemaDbContext, a shared base from the toolbox (packages) that automatically points every query at the right company's private drawer (Module 6).
It stores Employees…
…and Departments. That is the entire list.
End of the program. Two kinds of data — nothing about passwords, companies, or tickets.
File: VSPlatform.Service.Api/Data/OrgAppDbContext.cs in the vs-plat-app repository. Open it yourself and you will see exactly these lines.
Your turn: who owns what?
Drag each program onto the job it owns. The whole point of the cast is that each job has exactly one owner.
Proves you really are who you say you are
The single front door every request passes through
Stamps a ticket listing exactly what you may do
The rulebook that knows each company's groups and capabilities
Reads and writes the employees and departments
Quick check: name the owner
Naming the right owner is the whole skill — when you tell an AI assistant "add this to the Business API, not the gateway," you save yourself from messy, wrong code.
You want to add a "projects" feature with its own list. Which program gets the new code?
Marketing asks to change the company logo on the login screen. Which character owns that?
Dana is missing the departments.read capability and gets blocked. Which program's records would you check first?
Now that you have met the cast, Module 3 zooms into the very first step — logging in — and the big new idea that your login proves who you are but says nothing about which company you are working in.
Who Are You?
Logging in proves who you are. It says nothing about which company you are working in — and that separation is the biggest idea in the new design.
One login, then what?
When Dana logs in, what does the system actually learn about her? Less than you might think — and that is on purpose.
Clicking "Sign in" doesn't check Dana's password in our app at all. The browser hands her off to Keycloak, a specialist whose only job is proving identity. Here is the hand-off dance, step by step.
The browser app doesn't check her password itself. It bounces her — a redirect — over to Keycloak's login page.
This is the OIDC login flow. Dana is now on Keycloak's turf, not ours.
She proves she is Dana. The browser app never sees her password — only Keycloak does. The exchange is protected by PKCE, a tamper-proof seal on the round trip.
It hands the browser a token — a digitally-signed slip that says "this really is Dana." Signed means it cannot be forged.
From now on, the browser tucks this token into every request, so the system knows it is still Dana — no need to log in again.
Keycloak is a passport office. It proves who you are in a way no one can fake. It does not say which office you are visiting today — that comes later, from the floor button you press.
The passport — and what it does not contain
Here is the actual setup in the browser app that wires up that login dance. Notice what is missing: nowhere does it pick a company.
export const userManager = new UserManager({
authority,
client_id: clientId,
redirect_uri: `${window.location.origin}/auth/callback`,
post_logout_redirect_uri: `${window.location.origin}/`,
response_type: 'code',
scope: 'openid profile email vs-context vs-platform-gateway',
stateStore: new WebStorageStateStore({ store: browserStorage }),
userStore: new WebStorageStateStore({ store: browserStorage }),
});
Set up the thing that manages Dana's login.
authority is the address of Keycloak — who we trust to vouch for people.
client_id is our app's name tag, so Keycloak knows which app is asking.
redirect_uri: where Keycloak sends Dana back after she proves herself.
Where to send her after logging out — back to the home page.
'code' means use the secure "authorization code" flow (the safe modern way to log in).
The scope lists what we want: basic identity, plus a token addressed to the gateway. (More on that next.)
Remember the in-progress login safely in the browser.
Remember the logged-in user safely in the browser.
Done — login is configured.
The token Keycloak returns is addressed to the gateway 🚦 — that is what vs-platform-gateway in the scope means. The token's audience is the front door, and only the front door.
There is no company. No "Org Alpha." No list of what Dana is allowed to do. This token proves identity and nothing else. That absence is the whole point of the new design.
The company comes from the address
So if the login token doesn't say which company Dana is in, where does that come from? The web address. Pressing a floor button — navigating to a URL — is how you choose the office.
Every page that needs a company lives under /orgs/{orgId}/…. A router guard reads that company straight out of the address on every navigation:
const orgId = typeof to.params.orgId === 'string' ? to.params.orgId : undefined;
if (to.meta.requiresOrg) {
if (!orgId) {
next({ name: 'home', params: { orgId: DEV_DEFAULT_ORG_ID } });
return;
}
authStore.setActiveOrgFromRoute(orgId);
}
Pull the company id out of the web address — that's the route parameter orgId.
Does this page actually require a company?
If yes, but the address has no company id…
…send Dana to a sensible default company instead.
And stop — don't load a company page without a company.
(end of the "no company" case)
Otherwise: remember this company as the active one.
(end of the check)
And every single request to the server is built around that company id, by one tiny helper:
export function orgApiPath(orgId: string, resource: string): string {
const normalizedResource = resource.replace(/^\/+/, '');
return `/orgs/${orgId}/${normalizedResource}`;
}
A helper that builds a server address for a given company and thing.
Tidy up the "thing" name (strip any leading slashes).
Hand back /orgs/<company>/<thing> — the company is baked into the address.
Done.
So when Dana opens the Departments page for Org Alpha, the browser asks the server for /orgs/11111111-1111-4111-8111-111111111111/departments. The company rides along in the address — on every call.
Your login token says "this is Dana" and nothing about Org Alpha. The company is read from the URL on every request. This is called route-scoped org context — and switching company is just navigating to a different address.
Old way vs new way
The old design stuffed the company and Dana's permissions right into her login token — a pattern called the claim "projection" model. The new design strips the token down to bare identity. Here they are side by side.
OLD — "projection"
The login token carried everything.
vs-contextOrg id, org name, and a version — stamped into the token at login.permissionsA list of what Dana could do, per service, frozen in the token.re-loginSwitching company meant a new or refreshed token.The token was the source of truth for who-can-do-what. Stale the moment an admin changed anything.
NEW — "route-scoped"
The login token only proves identity.
identity"This is Dana." That's it. No company, no permissions./orgs/{orgId}The company comes from the URL, per request.Authz📖 The rulebook decides what Dana can do, live from the database, per request.One mapper was kept on purpose: a single gateway.access permission, so the front door can reject a stranger instantly without a database lookup.
Separating "who you are" (Keycloak 🪪) from "what you can do here" (Authz 📖, per request) means permissions can change the instant an admin updates them — no re-login. This is why a login bug and a permissions bug are different bugs in different programs.
Switching company is free
Because the company lives in the address, clicking "Org Beta" doesn't log Dana out or fetch a new token. It just changes the floor button — the URL.
Org Alpha/orgs/11111111-1111-4111-8111-111111111111/…
Org Beta/orgs/22222222-2222-4222-8222-222222222222/…
One login, one identity token. The only thing that changed is the address. Instant, no re-login — and that is the payoff of keeping the company out of the token.
Check your understanding
No memorizing — just think through each scenario using what you've seen.
An admin removes Dana from a group at 2:00pm. Under the new design, does Dana need to log out and back in for that to take effect?
Dana is logged in, but the URL has no company id. What does the app do?
True or false: in the new design, your login token contains the list of everything you are allowed to do.
A user reports the "switch company" button does nothing. Based on the new design, where would you look first?
Dana's identity token now heads to the gateway 🚦 — the single front door. Module 4 follows the badge-swap that turns "who you are" into a capability ticket listing exactly what you may do.
The Front Door
Every request enters through one program: the Gateway. It checks your badge, reads which company you want, and swaps your badge for a capability ticket.
One address, many doors
Dana’s request leaves the browser. The very first program it meets is the Gateway — and it never gets past the lobby without showing a badge.
Think of an office building with one staffed lobby. There are dozens of rooms inside, but everyone enters through the same desk. The Gateway is a reverse proxy (built on a library called YARP): the only program exposed to the outside world. Everything else hides safely behind it.
Check the badge, read which floor you want, then swap your badge for a visitor sticker before letting you in.
Does this caller carry gateway.access? No badge, no entry.
Which company is Dana acting in? The Gateway reads it from the web address, not from her login.
Exchange the login badge for a capability ticket, then hand only the ticket to the backend.
The bouncer at the door
Before anything else happens, the Gateway looks at one thing: does your badge carry
gateway.access? If it does not, you are turned away on the spot — no
database
lookup needed. The answer is already written on the badge by Keycloak (the one mapper kept from Module 3).
else if (!CallerHasGatewayAccess(httpContext.User, authorizationHeader))
{
await VsAuthProblems.WriteProblemAsync(
httpContext,
StatusCodes.Status403Forbidden,
"Forbidden",
"The caller does not have gateway.access.",
"missing-gateway-access");
return;
}
First, check the badge: does this caller actually carry gateway.access?
If it does not…
…write back a polite refusal…
…to this very request…
…stamped 403 Forbidden — “you may not.”
With a short, human reason…
…and a machine-readable code so tools can spot it.
Then stop. The request never reaches any backend.
This check happens at the door, before a single business service is touched. That is exactly why “everything is 403” bugs almost always live right here.
Reading the company, then the badge-swap
Once the badge passes, the Gateway figures out which company Dana is acting in by
reading it straight from the web address (/orgs/{org}/Departments). Then comes the
clever part: it performs a
token exchange —
trading Dana’s login badge for a fresh
capability ticket
— before handing the request to the backend. Watch it happen.
And here is the actual swap. Each forwarded request runs through a small transform that clears the incoming header and attaches the new ticket instead.
transformContext.AddRequestTransform(requestContext =>
{
requestContext.ProxyRequest.Headers.Authorization = null;
if (requestContext.HttpContext.Items[ExchangedTokenItemKey] is string accessToken &&
!string.IsNullOrWhiteSpace(accessToken))
{
requestContext.ProxyRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
}
return ValueTask.CompletedTask;
});
For every request we forward, run this little cleanup step.
First, throw away the incoming badge entirely (= null). The backend will never see it.
Did we successfully mint a capability ticket earlier for this request?
…and is it actually a real, non-empty ticket?
If so…
…attach the fresh ticket as a Bearer token — the badge-swap is complete.
Done — send it on its way.
By stripping the login badge and forwarding only a narrow ticket, the Gateway makes sure no business service ever holds Dana’s password-equivalent. If a backend leaks, the most it could leak is a ticket good for one job, in one company, for a few minutes.
Self-updating map vs printed map
How does the Gateway know which backend to forward each request to? It keeps a map of routes. The big design change in this platform is where that map comes from.
OLD — self-updating map
Each backend advertised its own route. The Gateway watched the cluster and rewired its map live as services came and went. Clever — but the map was scattered, and that always-running watch loop was one more moving part that could quietly fail.
NEW — printed map
The whole route map is plain configuration handed to the Gateway at deploy time. Adding an API is a config change, not a code change. If the config is wrong, the Gateway refuses to start (fail-fast) rather than misbehaving quietly.
Here is the route map being loaded from configuration — and validated — before the Gateway serves a single request.
public static IReadOnlyList<GatewayRoute> LoadRoutes(IConfiguration configuration)
{
var configured = configuration.GetSection("Gateway:Routes").Get<GatewayRoute[]>();
if (configured is not { Length: > 0 })
{
throw new InvalidOperationException("Gateway:Routes must define at least one route.");
}
return Validate(configured.Select(route => route.Normalize()).ToArray());
}
When the Gateway starts up, build its list of routes…
…by reading the Gateway:Routes section out of the configuration file.
Is the list empty (or missing)?
If so, refuse to start — loudly say “you must define at least one route.”
Otherwise, tidy up every route and validate them all before serving a single request.
A single route in that map is just a small, readable entry — for example:
/api → vs-platform-api
Requests starting with /api are forwarded to the Business API service.
OrganizationContext: required
This route only works inside a company URL — the company must be present in the address.
A verdict, not a crash
Things go wrong: a missing gateway.access, a route that cannot be resolved, a token
exchange that fails. A good front door does not collapse in a heap when that happens.
Instead of dumping a
stack trace,
the Gateway returns a clear “problem” response — an HTTP status and a short, named reason like
missing-gateway-access. You get a verdict you can act on, not a crash you have to decode.
403 Forbidden, reason missing-gateway-access — turned away at the door.
A clear “no such door here” problem response — not a silent hang.
If CTS cannot mint a ticket, the Gateway stops here — it never forwards an unauthorized request.
Check your understanding
No memorizing — just think through what you would actually do.
A backend service logs that it received a token addressed to it with a short list of capabilities — but never Dana’s original login token. Why does it never see her login token?
Your team ships a brand-new reporting API. Under the new static-routing design, what has to change for the Gateway to route to it?
Every request from everyone is coming back 403 “missing gateway.access”. Where do you look first — the database, or the badge the Gateway checks at the door?
When the Gateway needs to know which company Dana is acting in, where does it get that from?
You have seen the Gateway ask CTS for a capability ticket. Module 5 steps inside that booth — CTS and the rulebook (Authz) — and the capabilities model that decides exactly what Dana may do.
What Can You Do?
The badge proves who you are. The ticket says what you may do. Here is how CTS and the rulebook decide — one fine-grained capability at a time.
The keys on the wristband
Think of a theme park. The turnstile out front proves you bought a ticket — but it never decides which rides you can go on. Each ride checks your wristband for the exact band-color it needs.
In our platform, that band-color is a capability: a precise service.action string. The old design used coarse roles and permissions like "manager." The new design replaces them with many small, exact keys.
Instead of one big label that means "trust this person with a lot," you carry a precise list of tiny keys. Each key unlocks exactly one door.
gateway.access
Get through the front door at all — the wristband that lets you into the park.
departments.read
See the department list (this is the one Dana needs).
departments.write
Add, rename, or delete a department.
employees.read
View employee records.
employees.write
Create, update, or delete employee records.
Authentication is "who are you?" (the turnstile). Authorization is "what may you do?" (each ride). Capabilities are how the platform answers the second question. Granting only the exact keys someone needs — and no more — is called least privilege.
How a ride checks your wristband
Before any endpoint runs, the Business API runs a small gatekeeper that asks one question: does this ticket allow the capability this ride requires? This gatekeeper is written as an attribute on the endpoint.
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
var authorization = context.HttpContext.RequestServices.GetRequiredService<IVsAuthorization>();
var resource = VsResource.FromHttpContext(context.HttpContext, CapabilityId);
var decision = await authorization.AuthorizeAsync(CapabilityId, resource, context.HttpContext.RequestAborted);
if (!decision.IsAllowed)
{
await decision.ToProblem().ExecuteAsync(context.HttpContext);
return;
}
await next();
}
Run this gatekeeper right before the endpoint does its work.
Grab the helper that knows how to check capabilities.
Figure out exactly which thing is being touched — and which capability that requires.
Ask: does this ticket allow that capability for that thing? Wait for the yes/no.
If the answer is no...
...send back a polite "not allowed" and stop — the endpoint never runs.
Otherwise, let the request continue to the real work.
Every service also publishes its own catalog — the list of capabilities it understands. It is the menu of band-colors that this part of the park even knows how to check.
public static readonly ICapabilityCatalog Catalog = CapabilityCatalog.FromDescriptors([
new CapabilityDescriptor(EmployeesRead, "employees", "read", null, "Read employee records."),
new CapabilityDescriptor(EmployeesWrite, "employees", "write", null, "Create, update, and delete employee records."),
new CapabilityDescriptor(DepartmentsRead, "departments", "read", null, "Read department records."),
new CapabilityDescriptor(DepartmentsWrite, "departments", "write", null, "Create, update, and delete department records.")
]);
Here is the full list of capabilities this service recognizes.
Reading employees, with a plain-English description.
Creating, updating, and deleting employees.
Reading departments — the one Dana is using.
Changing departments. Notice read and write are separate keys.
That is the whole menu of band-colors this ride knows how to check.
The negotiation
The booth that prints your wristband is CTS — the Capability Token Service. Before it stamps a ticket, it phones the rulebook (Authz) to ask which bands Dana has earned for this park. Watch the whole conversation unfold.
Where the yes/no really comes from
The rest of the platform asks the rulebook exactly one question, through a single door. Notice how narrow the contract is — there is just one way to ask "what may this person do here?"
v1.MapPost("/capabilities/resolve", async (
CapabilityResolveRequest request,
ICapabilityResolver resolver,
CancellationToken cancellationToken) =>
{
var response = await resolver.ResolveAsync(request, cancellationToken);
return Results.Ok(response);
})
There is exactly one address for asking the rulebook a question.
It receives a request: who, in which company, for which action.
Plus the helper that knows how to look the answer up.
(A safety handle to cancel if the request is abandoned.)
Hand the question to the resolver and wait for the answer.
Send back the list of capabilities this person has here.
Behind that door, the rulebook does the real lookup. People are placed into groups; groups hold capabilities. To find what Dana can do, it gathers every capability granted by every group she belongs to.
var capabilityIds = await orgDb.UserGroups.AsNoTracking()
.Where(row => row.Subject == membership.Subject)
.Join(
orgDb.GroupCapabilities.AsNoTracking(),
userGroup => userGroup.GroupCode,
groupCapability => groupCapability.GroupCode,
(_, groupCapability) => groupCapability.CapabilityId)
.Distinct()
.ToArrayAsync(cancellationToken);
Look up the live records for this company...
...find the groups this exact person belongs to.
Then connect each group...
...to the table that says which capabilities a group grants...
...matching them up by the group's code.
Collect the capability from each match.
Drop duplicates — this combining of all the lists is called a union.
That final list is everything this person can do here.
This lookup happens per request, against the company's own live records. Change Dana's groups and her next ticket reflects it — nothing is baked into her login.
Claimed vs Authorized
Here is the lesson that matters most. CTS mints a signed token and only includes the capabilities the rulebook actually returned. If the rulebook returns none, the answer is simply "forbidden" — there is no ticket to carry.
foreach (var capability in capabilities)
{
claims.Add(new Claim("capabilities", capability));
}
For each capability the rulebook approved...
...stamp it onto the ticket as a claim.
No approved capabilities means an empty ticket — effectively a no.
Carrying a perfectly signed, valid ticket is not the same as being allowed. Every backend independently re-checks the specific capability it needs for the specific action and resource. A valid ticket for departments.read is still a flat "no" when you try to delete a department — because that needs departments.write, which is not on the ticket.
A small piece of history: an old capability_version idea was removed in favor of a passthrough authz_version that is currently inert — it rides along but does nothing yet.
Think it through
No memorizing — just reason about how capabilities, the rulebook, and the ticket fit together.
Dana's ticket is valid and signed, and it lists employees.read. She calls the delete-employee endpoint. What happens, and which program decides?
You want a "read-only auditor" who can view everything but change nothing. In capability terms, what do you grant?
Where is the actual yes/no computed — frozen into Dana's login token, or looked up live when CTS asks?
Two companies, same person. Could Dana be a manager in one and a read-only viewer in the other?
The rulebook keeps saying "Org Alpha's private records." Module 6 goes inside the filing cabinet: per-company database schemas, the reconciler that sets them up, and the cache that makes repeat answers instant.
One Database, Many Companies
Every company shares one database but lives in its own private drawer. Here is how the platform keeps them apart, sets them up, and makes repeat questions instant.
One vault, many locked boxes
Picture a bank vault full of safe-deposit boxes. There is one vault — one big steel room — but every customer has their own locked box inside it. The same teller serves everyone, yet can only open the box whose key it was handed for that visit.
That is exactly how the platform stores data. There is one database, but each company gets its own private schema to keep its data sealed off from everyone else's. This design is called being multi-tenant, and each company is a tenant.
Org Alpha and Org Beta both store "departments." How does the platform guarantee Dana never sees Org Beta's data — even though they share one database? Keep that question in mind; this whole module answers it.
Here is the layout: one shared platform drawer that holds the master catalog and the company directory, plus one private org_<id> drawer per company.
platform (shared)
The vault's front office. Holds the master catalog of what anyone can do, plus the directory that maps each company to its private drawer.
Org Alpha's drawer
org_11111111-1111-4111-8111-111111111111 — Dana's company. Only Org Alpha's departments and people live here.
Org Beta's drawer
org_22222222-2222-4222-8222-222222222222 — a different company, fully sealed off. Dana has no key to this one.
The directory and the private drawers
When the database is first built, a startup script carves out the shared platform area and one drawer per company. Here is the real code that creates them.
CREATE SCHEMA IF NOT EXISTS platform;
CREATE SCHEMA IF NOT EXISTS template_default;
CREATE SCHEMA IF NOT EXISTS "org_11111111-1111-4111-8111-111111111111";
CREATE SCHEMA IF NOT EXISTS "org_22222222-2222-4222-8222-222222222222";
Make the shared platform drawer — but only if it does not already exist.
Make a blank "template" drawer used as the mould for new companies.
Carve out Org Alpha's private drawer.
Carve out Org Beta's private drawer. Same building, separate locked boxes.
Inside the shared area sits the directory itself — the org_registry table — which says "company X lives in drawer X."
CREATE TABLE IF NOT EXISTS platform.org_registry (
org_id text PRIMARY KEY,
org_name text NOT NULL,
schema_name text NOT NULL UNIQUE,
status text NOT NULL,
authz_version text NOT NULL,
primary_route_prefix text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
Create the company directory inside the shared platform drawer.
Each company's id is its unique key — no two rows share one.
A friendly name for the company.
Which drawer this company lives in — and no two companies may share a drawer.
Is the company live, paused, etc.
Which version of the rulebook this company is on.
The URL prefix that routes to this company.
When the row was first written.
When it was last touched (stamped automatically).
End of the table definition.
So the shared area is small but crucial: a master catalog of capabilities, plus this lookup table. Everything else lives in the private per-company drawers.
How a query stays in the right drawer
Here is the magic that ties it together. Before the platform runs any read or write, it hands the database one short instruction: "for this operation, look in THIS company's drawer first." That instruction is a setting called the search_path. This single line is the heart of the whole isolation story.
private string SearchPathSql => "SET LOCAL search_path TO " + QuoteSchema(SchemaName!) + ", platform";
Build the instruction that says: for this operation only, look in THIS company's drawer first, then fall back to the shared platform area.
Because it is SET LOCAL, the setting lasts only for the current transaction and cannot leak into the next request — so the next visitor never inherits this company's key.
The word
transaction
matters here: SET LOCAL applies only inside the current bundle of work and is forgotten the instant it ends. One request can never poison the next.
Because the drawer is selected automatically by search_path, the Business API code can write plain queries like "give me the departments" and trust they only ever touch the right company. The OrgAppDbContext you met earlier inherits this behavior; the factory reads the company id from the /orgs/{orgId}/… URL and picks the matching drawer for you.
Setting it all up: the Reconciler
📖 The Auth Reconciler is a one-shot tool — not an always-on service. You run it, it builds the database to match what it should look like, and then it stops. Its superpower is being safe to run again and again. Here is what one run does.
Make sure the front-office drawer and its tables exist.
Fill in the master list of capabilities and the directory of companies.
Look up which drawers belong to live companies.
Create each company's tables, then seed its groups, group-to-capability links, and memberships.
In code, that run is almost as readable as the steps above. Each line is one phase.
await ReferenceOrgMigrationOrchestrator.MigratePlatformSchemaAsync(connectionString, cts.Token);
await SeedPlatformDataAsync(connectionString, logger, cts.Token);
var schemaNames = await ReferenceOrgMigrationOrchestrator.ResolveOrgSchemaNamesAsync(connectionString, bootstrapNames, cts.Token);
await SampleTenantSchemaMigrator.MigrateOrgSchemasAsync(connectionString, schemaNames, logger, cts.Token);
await SeedOrgDataAsync(connectionString, schemaNames, logger, cts.Token);
Build the shared front-office area (a migration).
Fill the shared area with its catalog and company list ( seed it).
Find each company's drawer name.
Build the tables inside every company's drawer.
Fill each drawer with its starting data.
Every insert is "do nothing if it already exists," and a database lock makes sure only one run happens at a time. That combination makes the reconciler idempotent: run it once or ten times in a row and you always end up in the same clean state.
The sticky-note memory
⚡ Some questions get asked over and over — like "what are this company's departments?" Rather than open the drawer every single time, the Business API jots the answer on a sticky note (a cache, held in Redis) and reads the note next time. But there is a catch: when the underlying data changes, the note is now wrong.
This snippet — the "create a department" path from the controller you saw in Module 1 — shows the fix: after writing, throw the stale note away.
await using var dbContext = await dbFactory.CreateWriteAsync(orgId);
var department = new Department { DepartmentName = input.DepartmentName };
dbContext.Departments.Add(department);
await dbContext.SaveChangesAsync();
await listCache.RemoveAsync(ListCacheKey(orgId));
Open a writable connection pointed at THIS company's drawer (the right search_path is set for us).
Build the new department from what the user typed.
Stage it to be saved.
Save it into the company's drawer for real.
Now tear up the stale sticky note, so the next reader gets fresh data — this is the cleanup step.
Notice the note is filed under ListCacheKey(orgId) — a
cache key
that includes the company id. That way one company's notes can never answer another company's question. And tearing up the note after a change is called
cache invalidation.
If a sticky note is not torn up after a change, readers keep getting the old answer until the note expires. "I added it but it does not show up" is, nine times out of ten, a cache that was not invalidated.
Think it through
No definitions to recite — just put the pieces together.
A bug report lands: Dana adds a department, it saves fine, but the list does not refresh for 30 seconds. What is the likely cause?
You onboard a brand-new company. Beyond adding a registry row, what must happen before its users can store data?
Why does the Business API code never have to write "WHERE company = X" on its queries?
Is it safe to run the reconciler twice in a row?
Module 5's rulebook decided what Dana may do; this module showed where each company's records actually live and how they stay sealed apart. Next up, Module 7: how a developer boots this whole nine-program stack on a laptop with one command — and how a change safely ships to production.
Run It & Ship It
Nine programs sound impossible to run. One command boots the whole thing on a laptop — and a robot checks it still works before anything ships to real users.
From your laptop to live users
We have met nine programs, two databases, and an identity server. Here is the honest question: how does a single human run all of that at once just to build one feature?
Think of a film set. Before anyone shouts “action,” the assistant director calls every department to position in the right order. Then, before opening night, the cast runs a full dress rehearsal so nothing breaks in front of the audience. Those are the two halves of this module.
Run it locally
The orchestrator — specifically .NET Aspire — starts the entire stack on your machine with one command, so you can build features.
Ship it safely
A robot test (the dress rehearsal) plus a CI/CD pipeline that refuses to release anything that does not actually work.
You write code on your laptop
One command boots the whole stack
Robot rehearsal checks it works
It ships to real users
One command boots everything
The assistant director's script is a single file. It declares each container and database the system needs, then wires them together. First, the shared infrastructure — the filing cabinet and the sticky-note memory:
var postgres = builder.AddPostgres("postgres")
.WithDataVolume()
.WithPgAdmin(pgAdmin => pgAdmin.WithHostPort(5050));
var platformDb = postgres.AddDatabase("PlatformDb", "platform");
var redis = builder.AddRedis("redis");
Declare a Postgres database as a resource Aspire should start.
Keep its data on disk so it survives a restart.
Also start a little admin web page (on port 5050) for peeking inside the data.
Carve out one named database inside it called “platform.”
Declare a Redis cache too. Aspire will start it and remember its address.
Now the clever part. When a program needs the cache or needs to fetch tickets, you do not paste in addresses by hand. You just point one program at another, and Aspire fills in the real location automatically — that is service discovery:
.WithReference(redis, "redis").WaitFor(redis)
.WithEnvironment("VSPlatformAuth__Audience", "vs-platform-api")
.WithEnvironment("VSPlatformAuth__Broker", "Cts")
.WithEnvironment("VSPlatformAuth__Cts__BaseUrl", sts.GetEndpoint("http"));
Tell the Business API “your cache is over there” — and wait for Redis to be alive before starting.
Set an environment variable: tickets addressed to you say “vs-platform-api.”
Tell it where to get its tickets from: the CTS.
Fill in the CTS's actual address automatically — no copy-pasting connection strings.
The shared toolbox every program plugs into
Nine programs could each reinvent the same plumbing — or they could all plug into one shared toolbox. The platform chose the toolbox. A single setup call gives every program the same cross-cutting machinery:
builder.ConfigureOpenTelemetry();
builder.AddDefaultHealthChecks();
builder.Services.AddServiceDiscovery();
Turn on standardized tracing and monitoring so we can see what every program is doing.
Add a health check — an “are you alive?” signal anyone can ping.
Give the program the ability to find the others by name (service discovery).
Three lines, and every program suddenly behaves like a member of one well-run team instead of a lone wolf.
One standard way to see what every program is doing and how long it takes.
Every program answers a quick “are you alive?” ping the same way.
Programs find each other by name and retry calmly when a call hiccups — that calm retrying is called resilience.
Two ways to run: source mode vs container mode
Here is the trick that makes this one description do double duty. The exact same setup can run each program straight from its source code, or from a prebuilt image.
Source mode
Run each program straight from its code. Edits hot-reload instantly. Best while you are actively building a feature.
Container mode
Run prebuilt images — the exact artifacts that ship. Best for trusting “this is really what production will run.”
A small block of config controls the toggle. It names the registry to pull images from, and where the source code lives if you would rather run from source:
"Modules": {
"DefaultMode": "auto",
"Registry": "myregistry.azurecr.io",
"Api": { "Image": "vs-platform/vsplatform-api", "Tag": "master",
"SourcePath": "../../vs-plat-app/VSPlatform.Service.Api/VSPlatform.Service.Api.csproj" }
}
Settings for how to run each program.
“Auto”: decide for me — run from source if the code is here, otherwise pull the image.
The warehouse to pull prebuilt images from.
For the Business API: which image and which version (tag) to use…
…and where its source code lives, in case we run it from source instead.
DefaultMode: auto
If the code is checked out next door, run from source; otherwise pull the shipped image.
Registry
The warehouse (myregistry.azurecr.io) where finished images are stored.
Tag: master
Which version of the image to run — here, the latest from the main line of work.
One description, two ways to run. Same script, different cast members — live actors or recorded stand-ins.
The dress rehearsal: a robot opens your app
This is the safety net. Before any change reaches real users, a robot runs the full dress rehearsal. It boots the shipped images in container mode, then a real-but-invisible browser tries to open the app.
You open a PR (pull request) asking to merge your work.
The test repo starts the whole stack in container mode — the exact artifacts that will go live.
A headless browser, driven by Playwright, visits the running app.
If the app does not come back OK, this smoke test fails and the change cannot merge.
And here is the entire check — the moment of truth, in three short lines:
var response = await page.GotoAsync(
aspire.ClientBaseUrl, new() { WaitUntil = WaitUntilState.Commit });
Assert.NotNull(response);
Assert.True(response.Ok, $"Expected HTTP 200, got {response.Status}");
Tell the invisible browser to go visit the running app's home page…
…and wait until the page has actually started loading.
Make sure we got some response at all — not just silence.
Make sure that response was a healthy 200 OK. If not, fail the test and block the merge.
This gate lives in one place — the tests repo — not copy-pasted into all nine programs. One canonical rehearsal proves the whole stack can serve a page, instead of nine half-maintained copies drifting apart.
You have the whole map now
Let us trace Dana's one click all the way home, the way you can now read it. Dana opens the Departments page for Org Alpha, and a list of departments appears:
🦺 Keycloak proves who Dana is at login
🚪 Gateway checks her badge & reads the org from the URL
🎟️ CTS mints a capability ticket; 📖 the rulebook says what she may do
🗃️ The Business API reads Org Alpha's private schema — and the list appears
…and in this final module: how that whole machine is run on a laptop and shipped safely. The orchestrator boots it; the robot rehearsal guards it. That is the full map.
While building a feature, you want your code changes to appear instantly without rebuilding images. Which mode do you run the stack in?
The smoke gate boots container images rather than source. Why is that the right choice for a pre-merge safety net?
A teammate says “just copy the end-to-end test into every repo's pipeline.” Based on this module, what is the simpler design the platform actually uses?
Put these in order for Dana's click: rulebook lookup, gateway badge check, capability ticket minted, company schema read.
Seven modules ago, “nine programs, two databases, and an identity server” sounded like a wall of jargon. Now you can follow Dana's click from login to the door to the ticket to the rulebook to her company's private drawer — and explain how the whole thing is run on a laptop and shipped safely. That is reading the platform's map like a pro. Go build something.