Multi-tenancy / Realms
Modgud uses a realm model for multi-tenancy. Each realm is a fully autonomous Identity Provider with its own database, users, roles, OAuth configuration, and login providers.
"Realm" vs. "tenant"
User-facing it's called realm everywhere (UI, docs). The code uses tenant in the infrastructure layer (TenantId, ITenantSessionFactory, MasterTableTenancy), because that's what Marten/Wolverine call it. TenantId = realm slug.
Domain-based routing
Realms are identified by the Host header, not by URL path. Each realm has one or more configured domains:
| Hostname | Realm |
|---|---|
auth.example.com | Control-Plane realm |
acme.example.com | Acme realm |
auth.acme.example.com | Acme realm (second domain) |
auth.localhost (dev) | Local development realm |
RealmMiddleware (src/dotnet/Modgud.Api/Middleware/RealmMiddleware.cs) runs as the very first middleware:
public async Task InvokeAsync(HttpContext context)
{
var path = context.Request.Path.Value;
if (SkipPaths.Any(p => path.StartsWith(p))) { await _next(context); return; }
var hostname = context.Request.Host.Host;
var resolution = await _realmCache.ResolveAsync(hostname);
if (resolution is null)
{
context.Response.StatusCode = 404;
return;
}
var tenantInfo = resolution.Tenant;
context.Items[TenantConstants.HttpContextTenantIdKey] = tenantInfo.Slug;
context.Items[TenantConstants.HttpContextTenantInfoKey] = tenantInfo;
// Set only when the host is an Application's own subdomain (see
// "Applications and domain routing" below).
if (resolution.ApplicationId is { } applicationId)
context.Items[TenantConstants.HttpContextApplicationIdKey] = applicationId;
// Ambient AsyncLocal so code without an HttpContext (background services,
// Wolverine handlers) can still see which realm is active; restored when
// the request scope unwinds.
using var _ = TenantContext.Enter(tenantInfo.Slug);
await _next(context);
}Skip paths: /health, /swagger, /openapi, /_framework — these run without realm context. /signalr is deliberately not skipped: SignalR connections still need a resolved realm so the auth cookie (encrypted with that realm's own keys) can be decrypted on /signalr/*/negotiate.
Single-tenant fallback in dev
If only one realm is active AND the host is a localhost variant (localhost, 127.0.0.1, ::1, 0.0.0.0), the cache returns that realm — even if it doesn't list the localhost domain. This way a single-realm dev boot works without a hosts-file entry.
Primary domain
While a realm may route from several domains, exactly one of them is its PrimaryDomain — the canonical public host. Any host in Domains resolves the realm for inbound requests, but the PrimaryDomain is what Modgud uses whenever it has to emit a host: magic-link and bootstrap-invite URLs, and the WebAuthn relying-party ID that binds passkeys. A realm always has a PrimaryDomain (it defaults to the first domain at creation) and it must be one of Domains. Re-point it from the admin UI's domain picker or via the Recovery CLI realm-set-primary-domain; because it is the passkey RP ID, changing it invalidates every passkey in the realm.
Applications and domain routing
A realm can also give one of its Applications its own subdomain (e.g. billing.acme.example.com). Resolving that host still lands on the realm — same tenant DB, same user pool, same OIDC issuer — but the middleware additionally pins which Application the request is for, so app-specific branding and login-experience settings apply. This is a routing refinement layered on top of the realm/domain mechanism above, not a second isolation boundary.
RealmCache
RealmCache (Modgud.Infrastructure/Realms/RealmCache.cs) holds a snapshot of the domain → realm mappings in memory:
private sealed record CacheSnapshot(
ConcurrentDictionary<string, TenantInfo> ByDomain,
ConcurrentDictionary<string, ApplicationDomainMatch> ByApplicationDomain,
TenantInfo? SingleActiveRealm,
DateTimeOffset LoadedAt);Loads all active realms from IGlobalStore (see below) at startup. Invalidated on realm CUD (Create/Update/Delete via the admin API), and also revalidated on a 60-second timer regardless — so a change made on another node of a multi-node deployment is picked up within that window even without a cross-node cache invalidation.
Database-per-tenant via Marten
Modgud uses Marten's MasterTableTenancy:
| Database | Contents |
|---|---|
<master-db> (master) | realms.mt_tenant_databases (tenant registry) + schema global (Realm documents) + Wolverine durability — pure control-plane infra, not a tenant |
<master-db>_<slug> | A dedicated physical DB for every realm, including the first |
The master DB holds no tenant data. Every realm has the same shape and lives in its own <master-db>_<slug> database. The Control-Plane flag can move between active realms; no database or slug is privileged by itself.
TenantedSessionFactory
A Marten ISessionFactory implementation (Modgud.Infrastructure/Persistence/Tenancy/TenantedSessionFactory.cs) that reads the TenantId from HttpContext.Items:
public IDocumentSession OpenSession()
=> _store.LightweightSession(ResolveTenantId(forWrite: true));
public IQuerySession OpenQuerySession()
=> _store.QuerySession(ResolveTenantId(forWrite: false));
private string ResolveTenantId(bool forWrite)
{
var explicitTenant = TenantContext.CurrentOrNull
?? _httpContextAccessor.HttpContext?
.Items[TenantConstants.HttpContextTenantIdKey] as string;
return explicitTenant ?? FallbackTenantId(forWrite);
}An ambient TenantContext.CurrentOrNull (set by RealmMiddleware, or explicitly via TenantContext.Enter(...) for a deliberate cross-realm operation) is checked before HttpContext.Items, which carries the same value on the common request path.
Wired up via:
builder.Services.AddMarten(...)
.BuildSessionsWith<TenantedSessionFactory>();This way every IDocumentSession/IQuerySession injection is realm-scoped. When neither signal resolves a tenant, reads and writes both fail closed. Deployment-wide work uses IGlobalStore; background realm work explicitly enters TenantContext.Enter(slug). Moving the Control Plane can therefore never redirect unrelated data into another realm.
IGlobalStore
The Realm document itself can't live in the tenant store — chicken-and-egg. It lives in a separate Marten store (IGlobalStore) against schema global of the master DB:
public sealed record TenantInfo(string Slug, bool IsControlPlane, bool IsActive, string? PrimaryDomain = null);
public class Realm
{
public Guid Id { get; set; }
public string Slug { get; set; } // = TenantId, immutable, reserved if "system"
public string DisplayName { get; set; }
public string? Description { get; set; }
public string[] Domains { get; set; } // ["acme.example.com", ...]
public string PrimaryDomain { get; set; } // must be one of Domains — see "Primary domain" above
public Dictionary<string, Guid> ApplicationDomains { get; set; } // subdomain -> Application id
// Stored and transferable. The first installed realm receives the
// flag; it can later be moved to any active realm.
public bool IsControlPlane { get; set; }
public bool IsActive { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? UpdatedAt { get; set; }
}RealmCache loads the realm list from IGlobalStore.
Bootstrap order
In Program.cs (before app.Run):
- Create the master DB (raw SQL)
- Apply primary and Global Store schemas
- Load and idempotently seed every existing active realm
- Warm RealmCache
- Check the recovery-CLI path or start Kestrel
A fresh boot stops here with zero realms. The first-installation flow creates the first tenant database and assigns its realm the Control-Plane flag only after the first realm:admin exists. Existing deployments keep their registered realms and persisted Control-Plane assignment.
Realm CRUD
Endpoints under /api/admin/realms — gated by realm:read / realm:write (catalog entries in the control-plane App, which is only seeded on the Control-Plane realm). Only reachable on the Control-Plane realm (the realm holding the control-plane flag). On any other host: 404 (the existence of the surface is hidden from tenant realms — see Concepts: Control Plane).
Create
POST creates a complete realm independently from administrator onboarding. InitialAdmin is optional for API clients that want creation and invitation in one atomic request; the admin UI uses the separate invitation action.
POST /api/admin/realms
{
"Slug": "acme",
"DisplayName": "Acme Corp",
"Domains": ["acme.example.com"]
}IsControlPlane is not in the request body — new realms are never the control plane. The role only moves via transfer.
Backend:
- Validates
slug(regex, reserved-words check). CREATE DATABASE <master-db>_acme(raw SQL).tenancy.AddDatabaseRecordAsync("acme", connStringForAcme).Storage.ApplyAllConfiguredChangesToDatabaseAsync().OAuthRealmSeederseeds 6 default scopes + the Internal login provider into the new tenant DB.AppRealmSeederseeds themodgudapp. Thecontrol-planeapp is only seeded when the new realm is itself the Control Plane.Realmdocument persisted inIGlobalStore.RealmCache.Invalidate().- Realm creation completes independently from administrator onboarding.
POST /api/admin/realms/{slug}/admin-invites issues a single-use, 24-hour realm-admin invitation. Issuing a new invitation revokes every previous open admin invitation in that realm.
The recipient consumes the invite at POST /api/account/bootstrap-admin on the realm's host, sets a password and gets auto-signed-in. Atomic with that consume, RealmAdminBootstrapper creates the user, seeds the default roles and adds the user to the Administrators group with realm:admin.
Update
PATCH /api/admin/realms/{slug}
{
"displayName": "Acme Corporation",
"domains": ["acme.example.com", "auth.acme.com"]
}Slug is immutable.
Soft-delete (deactivate)
PATCH /api/admin/realms/{slug}
{ "isActive": false }RealmCache filters on IsActive = true — all requests to the realm domain land on 404. Data is preserved.
Control-Plane realm
The realm currently holding IsControlPlane cannot be deactivated. Transfer the flag first.
Hard-delete
DELETE /api/admin/realms/{slug}?hard=trueEscalates from the reversible soft-delete above to a destructive delete that drops the realm's tenant database. Refused for the Control-Plane realm. Without ?hard=true, DELETE behaves the same as the soft-delete (isActive = false).
Declarative provisioning (import / apply / export)
Beyond the one-field-at-a-time Create/Update above, the same /api/admin/realms group also accepts a manifest — a single JSON document describing a realm's apps, OAuth clients/scopes/APIs, roles, users and groups:
POST /import— create a brand-new realm from a manifest.POST /{slug}/apply(optionally?prune=truefor a full sync that also removes anything absent from the manifest) — apply a manifest to an existing realm in place.GET /{slug}/export— export a realm's current shape as a manifest.GET /manifest-schema— the manifest's JSON Schema.
See Declarative Realm Provisioning for the full walkthrough.
Cookies and sessions in a multi-realm setup
Since each realm has its own domain, cookies are automatically realm-isolated by the browser's cookie-domain rule. A login on acme.example.com sets a cookie for exactly that domain — it isn't sent on finance.example.com. No path acrobatics required.
Sessions (UserSession documents) live per realm in the tenant store. A user logged in to two realms has two separate sessions, in two separate DBs.