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 |
|---|---|
system.example.com | System realm |
acme.example.com | Acme realm |
auth.acme.example.com | Acme realm (second domain) |
localhost (dev, single-realm) | System realm (single-tenant fallback) |
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 per realm, including the bootstrap system realm (<master-db>_system) |
The master DB holds no tenant data. Every realm — including the bootstrap system realm — lives in its own <master-db>_<slug> database, so the system realm is an equal, deletable peer and the control plane can be transferred off it.
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 automatically realm-scoped. When neither signal resolves a tenant, the fallback splits by intent:
- No
HttpContextat all (background service, hosted service, CLI, test) — falls back to the system tenant. This is the intended, load-bearing path for infrastructure jobs and single-realm boots. - An in-flight HTTP request with no resolved realm — this can only happen on a realm-agnostic skip-path (
/health,/openapi, …), since every routed request is resolved or 404'd byRealmMiddleware. A write in that state throws instead of silently landing in the system tenant's database; a read falls back to the system tenant with a warning logged.
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, transferable: exactly one realm carries the flag. The
// bootstrap "system" realm is stamped at first boot, but the role
// can 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 and
<master-db>_system(raw SQL) - Apply Marten storage →
realms.mt_tenant_databasesis created - Register the system tenant in the tenancy table (
tenancy.AddDatabaseRecordAsync("system", systemCs)— pointing at<master-db>_system, not the master DB) - Apply Marten storage again → the system tenant gets per-tenant tables in its own DB
- Seed system realm document (
EnsureSystemRealmExistsAsync) - OAuthRealmSeeder seeds 6 default scopes (
openid,email,profile,roles,offline_access,permissions) + internal LoginProvider into the system tenant - Warm up RealmCache
- Check the recovery-CLI path or start Kestrel
Upgrading across the system-DB split
Older deployments ran the system realm inside the master DB. This version moves it to its own <master-db>_system database. On a fresh install — or when you can recreate data — nothing is needed; the boot block provisions the new layout. For an existing deployment with data you must relocate the system realm's data before first boot:
- Stop the app and terminate open connections, then
CREATE DATABASE "<master-db>_system" TEMPLATE "<master-db>"; - Repoint the registry before the first boot (a boot-time self-correction is not single-boot-safe — writes in the stale window strand in the master DB):
UPDATE realms.mt_tenant_databases SET connection_string = replace(connection_string, 'Database=<master-db>;', 'Database=<master-db>_system;') WHERE tenant_id = 'system'; - Deploy the new code; verify the system realm resolves and data is intact.
- Optionally clean the now-duplicated
mt_*tables out of the master DB (table-precise — do notDROP SCHEMA public, it is shared with Wolverine infra).
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 requires an InitialAdmin payload — a realm with no admin path would be unreachable.
POST /api/admin/realms
{
"Slug": "acme",
"DisplayName": "Acme Corp",
"Domains": ["acme.example.com"],
"InitialAdmin": {
"UserName": "max",
"Email": "max@acme.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().- Bootstrap-invite issued atomically: a
PendingAdminInviteis written into the new tenant DB, the magic-link email is sent, and the URL is returned in the response (InitialAdminInvite.MagicLinkUrl).
The recipient consumes the invite at POST /api/account/bootstrap-admin on the new realm's host (anonymous, rate-limited under bootstrap), sets a password, gets auto-signed-in. Atomic with that consume, RealmAdminBootstrapper creates the user, seeds the three default PermissionRoles (System Admin / User Manager / Viewer) and adds the user to the Administrators group with realm:admin.
If the invite link gets lost or expires before it's consumed, POST /api/admin/realms/{slug}/resend-bootstrap-invite revokes the previous invite and issues a fresh one for the same recipient, with a new 7-day expiry.
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.
System realm
The system realm cannot be deactivated — the endpoint blocks that.
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.