Skip to content

Host vs Tenant — Schema Separation

In a multi-tenant application, some data is host-level (shared infrastructure) and some is tenant-level (business data isolated per tenant). Granit provides GranitDbDefaults to configure this separation declaratively — one line in appsettings.json puts all host tables in a dedicated schema.

Without a global schema mechanism, every application must override each module’s *DbProperties.DbSchema individually. The showcase app had 15+ manual overrides and still missed OpenIddict and BFF — their tables landed in PostgreSQL’s public schema while everything else was in showcase.

A static class with two properties that serve as global fallbacks for all module *DbProperties.DbSchema values:

public static class GranitDbDefaults
{
public static string? DbSchema { get; set; } // All modules
public static string? HostDbSchema { get; set; } // Host modules only
}

Each module’s DbSchema property uses one of two fallback chains, determined at compile-time by the module author:

Module typeFallback chainExample modules
Hostexplicit → HostDbSchema → DbSchema → nullMultiTenancy, OpenIddict, Auditing, BackgroundJobs, Features, Settings, BFF
Tenantexplicit → DbSchema → nullAuthorization, BlobStorage, Notifications, Timeline, Webhooks, Workflow
{
"TenantIsolation": {
"Strategy": "SchemaPerTenant",
"HostSchema": "host"
}
}

The framework reads TenantIsolation:HostSchema and sets GranitDbDefaults.HostDbSchema automatically. No manual overrides needed.

Any module can still override its schema individually — the explicit value always wins:

// Override a specific host module to use a different schema
GranitAuditingDbProperties.DbSchema = "compliance";
// Opt a module out of the global schema (back to provider default)
GranitBffDbProperties.DbSchema = null; // explicit null overrides HostDbSchema

These modules use AddGranitDbContext (standard registration). Their tables always live in the host schema, regardless of the active tenant. TenantId (when present) is a data filter, not a schema router.

ModuleIMultiTenantJustification
MultiTenancy (Tenant)NoTenant registry = host by definition
OpenIddict (Users)Yes (nullable)Identity centralized at host level
OpenIddict (Roles)NoGlobal RBAC shared cross-tenant
OpenIddict (Applications)Yes (nullable)OIDC apps: global or per-tenant
OpenIddict (SigningKeys)NoCryptographic infrastructure
BFF (Sessions)NoAuth sessions coupled to identity
AuditingYesCentralized audit trail (ISO 27001)
SettingsNoProvider key (G/T/U), not IMultiTenant
BackgroundJobsNoGlobal job queue
FeaturesNoFeature flags = global configuration

These modules expose Configure*Module() extensions on ModelBuilder. The host application calls them in its tenant-isolated DbContext.OnModelCreating. Tables live in tenant-specific schemas (or are filtered by TenantId in shared database mode).

ModuleConfigure methodJustification
AuthorizationConfigureAuthorizationModule()Permission grants per tenant
ApiKeysConfigureApiKeysModule()API keys per tenant
NotificationsConfigureNotificationsModule()Notifications per tenant
BlobStorageConfigureBlobStorageModule()Blobs per tenant
BlobStorage DBConfigureBlobStorageDatabaseModule()Blob content per tenant
TimelineConfigureTimelineModule()Activity stream per tenant
WebhooksConfigureWebhooksModule()Subscriptions per tenant
WorkflowConfigureWorkflowModule()State transitions per tenant
DataExchangeConfigureDataExchangeModule()Import/export mappings per tenant
TemplatingConfigureTemplatingModule()Templates per tenant
QueryEngineConfigureQueryEngineModule()Saved views per tenant
PrivacyConfigurePrivacyModule()Legal documents per tenant

Some host modules contain data for both host and tenants, differentiated by TenantId:

  • OpenIddict UsersTenantId = null = host admin, TenantId = {guid} = tenant user
  • OpenIddict AppsTenantId = null = global app, TenantId = {guid} = tenant-restricted
  • AuditingTenantId = null = host audit entries, TenantId = {guid} = tenant audit. Centralized in the host schema for ISO 27001 unified cross-tenant audit trail.

Identity tables (users, roles, applications, tokens) stay in the host schema only — never duplicated in tenant schemas or databases:

  • OpenIddictDbContext uses AddGranitDbContext (standard, never isolated)
  • Authentication always goes through the host (BFF → OpenIddict → host DB)
  • The user ID comes from the JWT token, not from a direct table query
  • No DbContext split needed

When operating in a tenant context and needing to access host-level data, use the semantic ChangeToHost() extension:

public class TenantInfoService(
ICurrentTenant currentTenant,
ITenantReader tenantReader)
{
public async Task<TenantData?> GetTenantInfoAsync(
Guid tenantId, CancellationToken ct)
{
// Currently in tenant context — switch to host to read tenant registry
using (currentTenant.ChangeToHost())
{
return await tenantReader.FindByIdAsync(tenantId, ct);
}
// Previous tenant context is restored
}
}

ChangeToHost() is a semantic wrapper around currentTenant.Change(null) — it communicates the intent clearly (“I’m accessing host data”) rather than using a magic null value.

With TenantIsolation:HostSchema = "host" and SchemaPerTenant:

Schema: host
├── tenants_tenants
├── openiddict_users, openiddict_roles, openiddict_applications, ...
├── bff_sessions
├── audit_log_entries, audit_log_entity_changes
├── background_jobs_*
├── features_*
└── settings_*
Schema: tenant_3fa85f6456954b5ab7d9c4f11f0b7f5e
├── authorization_permission_grants
├── notifications_*
├── blob_storage_descriptors
├── timeline_entries
├── webhooks_subscriptions
├── workflow_transition_records
└── (application-specific tables)

By default, host context (no X-Tenant-Id header) results in the MultiTenant query filter matching TenantId IS NULL — returning only host-level entities. This is a safe default, but host administrators need to see data across all tenants.

Granit provides a 3-layer defense-in-depth architecture for this:

  1. Endpoint layer.AllowHostAccess() marks endpoints that support host admin. Verifies the caller is authenticated.
  2. Authorization layer.RequireAuthorization(permission) checks permissions using the perm:global:{role}:{permission} cache key in host context.
  3. Data layerEfStoreBase.Query(db) bypasses the MultiTenant named query filter per-query via IgnoreQueryFilters, without affecting other filters.
// Endpoint: opt-in to host admin access
group.MapGet("/{id:guid}", GetByIdAsync)
.RequireAuthorization(InvoicingPermissions.Invoices.Read)
.AllowHostAccess();
// Store: pass ICurrentTenant to EfStoreBase for automatic bypass
internal sealed class EfInvoiceStore(
IDbContextFactory<InvoicingDbContext> contextFactory,
ICurrentTenant currentTenant)
: EfStoreBase<Invoice, InvoicingDbContext>(contextFactory, currentTenant),
IInvoiceReader, IInvoiceWriter
{ }

For detailed API and security scenarios, see Authorization — Host access and Query Filters — Host admin bypass.