Skip to content

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:

HostnameRealm
system.example.comSystem realm
acme.example.comAcme realm
auth.acme.example.comAcme 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:

csharp
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:

csharp
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:

DatabaseContents
<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:

csharp
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:

csharp
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 HttpContext at 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 by RealmMiddleware. 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:

csharp
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):

  1. Create the master DB and <master-db>_system (raw SQL)
  2. Apply Marten storagerealms.mt_tenant_databases is created
  3. Register the system tenant in the tenancy table (tenancy.AddDatabaseRecordAsync("system", systemCs) — pointing at <master-db>_system, not the master DB)
  4. Apply Marten storage again → the system tenant gets per-tenant tables in its own DB
  5. Seed system realm document (EnsureSystemRealmExistsAsync)
  6. OAuthRealmSeeder seeds 6 default scopes (openid, email, profile, roles, offline_access, permissions) + internal LoginProvider into the system tenant
  7. Warm up RealmCache
  8. 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:

  1. Stop the app and terminate open connections, then CREATE DATABASE "<master-db>_system" TEMPLATE "<master-db>";
  2. 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';
  3. Deploy the new code; verify the system realm resolves and data is intact.
  4. Optionally clean the now-duplicated mt_* tables out of the master DB (table-precise — do not DROP 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.

http
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:

  1. Validates slug (regex, reserved-words check).
  2. CREATE DATABASE <master-db>_acme (raw SQL).
  3. tenancy.AddDatabaseRecordAsync("acme", connStringForAcme).
  4. Storage.ApplyAllConfiguredChangesToDatabaseAsync().
  5. OAuthRealmSeeder seeds 6 default scopes + the Internal login provider into the new tenant DB.
  6. AppRealmSeeder seeds the modgud app. The control-plane app is only seeded when the new realm is itself the Control Plane.
  7. Realm document persisted in IGlobalStore.
  8. RealmCache.Invalidate().
  9. Bootstrap-invite issued atomically: a PendingAdminInvite is 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

http
PATCH /api/admin/realms/{slug}
{
  "displayName": "Acme Corporation",
  "domains": ["acme.example.com", "auth.acme.com"]
}

Slug is immutable.

Soft-delete (deactivate)

http
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

http
DELETE /api/admin/realms/{slug}?hard=true

Escalates 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=true for 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.

Released under the Apache-2.0 License.