Multi-Tenancy
Row-level data isolation, hierarchical tenants, per-tenant settings, branding, and scoping architecture.
SCRIPE supports full multi-tenancy with row-level data isolation using EF Core's global query filters. Every entity with a TenantId column is automatically filtered based on the current user's tenant, ensuring complete data separation between tenants.
Architecture
Row-level data isolation is achieved dynamically via EF Core Global Query Filters. The base DB context dynamically builds query filters that restrict access to the current tenant (`CurrentTenantId`) or platform-wide records (`TenantId == null`). Rather than capturing a static value during compilation, EF Core dynamically evaluates the active tenant's context on each database query execution.
protected void ApplyTenantFilters(ModelBuilder modelBuilder)
{
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
var tenantIdProperty = entityType.FindProperty("TenantId");
var tenantIdClrType = tenantIdProperty?.ClrType;
if (tenantIdClrType == typeof(Guid) || tenantIdClrType == typeof(Guid?))
{
var method = typeof(BaseDbContext)
.GetMethod(
nameof(ApplyTenantFilterForEntity),
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance
)!
.MakeGenericMethod(entityType.ClrType);
method.Invoke(this, new object[] { modelBuilder, tenantIdClrType });
}
}
}
private void ApplyTenantFilterForEntity<TEntity>(ModelBuilder modelBuilder, Type tenantIdClrType)
where TEntity : class
{
var parameter = Expression.Parameter(typeof(TEntity), "e");
var tenantId = CreateTenantIdExpression(parameter, tenantIdClrType);
var currentTenantId = Expression.Property(
Expression.Constant(this),
typeof(BaseDbContext).GetProperty(
nameof(CurrentTenantId),
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic
)!
);
// e.TenantId == null || e.TenantId == this.CurrentTenantId
var tenantFilterBody = Expression.OrElse(
Expression.Equal(tenantId, Expression.Constant(null, typeof(Guid?))),
Expression.Equal(tenantId, currentTenantId)
);
var entityBuilder = modelBuilder.Entity<TEntity>();
foreach (var existingFilter in entityBuilder.Metadata.GetDeclaredQueryFilters())
{
var existingExpression = existingFilter.Expression;
if (existingExpression is null) continue;
var existingBody = new ParameterReplaceVisitor(
existingExpression.Parameters[0],
parameter
).Visit(existingExpression.Body)!;
tenantFilterBody = Expression.AndAlso(existingBody, tenantFilterBody);
}
entityBuilder.HasQueryFilter(
Expression.Lambda<Func<TEntity, bool>>(tenantFilterBody, parameter)
);
}System administrators do not bypass tenant data boundaries implicitly. Bypassing requires a drill-down action where the client app attaches the encrypted tenant ID in the `X-Tenant-Context` header. The middleware firewall intercepts the request, checks for the `tenants.drill_down` permission, decrypts the header using AES, and overrides the active tenant ID context for the duration of the request.
public Guid? ContextTenantId
{
get
{
if (!HasPermission("tenants.drill_down")) return null;
var header = _httpContextAccessor.HttpContext?.Request.Headers["X-Tenant-Context"];
var contextTenantStr = header?.ToString();
if (string.IsNullOrEmpty(contextTenantStr)) return null;
var decryptedId = _idEncryptionService.TryDecrypt(contextTenantStr) ?? Guid.Empty;
return decryptedId != Guid.Empty ? decryptedId : null;
}
}
public Guid? TenantId => ContextTenantId.HasValue ? ContextTenantId.Value : _claimTenantId;var tenantContextHeader = context.Request.Headers["X-Tenant-Context"].FirstOrDefault();
if (!string.IsNullOrEmpty(tenantContextHeader))
{
if (!currentUser.HasPermission("tenants.drill_down"))
{
context.Response.StatusCode = 403;
await context.Response.WriteAsJsonAsync(new {
error = "TENANT_CONTEXT_FORBIDDEN",
message = "You do not have permission to switch tenant context. Missing: tenants.drill_down"
});
return;
}
}| Isolation Level | Implementation | Use Case |
|---|---|---|
| Row-Level (Current) | EF Core Global Query Filters on TenantId | Single database, all tenants share tables |
| Schema-Level (Planned) | Separate schema per tenant | Higher isolation, same database |
| Database-Level (Planned) | Separate database per tenant | Maximum isolation, regulatory compliance |
Tenant Hierarchy
Tenants form a tree structure via ParentTenantId. Each tenant has a HierarchyLevel (depth counter) and HierarchyPath (materialized path like /root/company/branch/). This enables organizational structures with parent companies, branches, and departments.
public class Tenant : AuditableEntity<Guid>
{
[Required] [MaxLength(200)]
public string Name { get; set; } = null!;
[Required] [MaxLength(50)]
public string Code { get; set; } = null!;
public Guid? ParentTenantId { get; set; } // Self-ref FK -> tree
public int HierarchyLevel { get; set; } // 0 = root, 1 = child, 2 = grandchild...
[MaxLength(500)]
public string HierarchyPath { get; set; } = "/"; // Materialized path: "/root-id/child-id/"
// Navigation
public virtual Tenant? ParentTenant { get; set; }
public virtual ICollection<Tenant> ChildTenants { get; set; } = [];
public virtual TenantSettings? Settings { get; set; }
}Instead of relying on database-specific recursive CTE queries, SCRIPE scaffolds tenant hierarchy on child tenant creation by concatenating materialized paths (`HierarchyPath`) like `/{grandparent-id}/{parent-id}/`. Ancestry checks and descendants sub-tree queries are executed in constant time using indexed starts-with/contains string checks, which translate into highly performant SQL `LIKE` queries.
// Descendant Verification (StartsWith path matching utilizes indexes)
public async Task<bool> IsDescendantOrSameAsync(Guid parentTenantId, Guid targetTenantId, CancellationToken ct = default)
{
if (parentTenantId == targetTenantId) return true;
var targetPath = await GetHierarchyPathAsync(targetTenantId, ct);
if (string.IsNullOrEmpty(targetPath)) return false;
return targetPath.Contains($"/{parentTenantId}/");
}
// Sub-tree Retrieval using StartsWith (LIKE 'path%')
public async Task<IReadOnlyList<Guid>> GetDescendantIdsAsync(Guid parentTenantId, CancellationToken ct = default)
{
var parentPath = await GetHierarchyPathAsync(parentTenantId, ct);
if (string.IsNullOrEmpty(parentPath)) return Array.Empty<Guid>();
var descendants = await _tenantRepository.GetDescendantsAsync(parentPath, ct);
return descendants.Select(t => t.Id).ToList();
}Tenant Features
Data Isolation
Row-level isolation via EF Core global query filters. Each query automatically includes WHERE TenantId = @CurrentTenant.
Per-Tenant Settings
Each tenant has independent configuration: quotas (MaxAdmins/MaxRoles/MaxSubTenants), security policies, audit settings, and branding.
Custom Branding
Upload tenant logos, set primary color, and customize the company name for each tenant's UI.
User Scoping
Users belong to a single tenant. Tenant admins can only see and manage their own users.
Role Scoping
Roles are scoped to tenants. Each tenant can create custom roles with different permission sets.
Data Scoping
All business data is automatically scoped to the tenant. No risk of cross-tenant data leaks.
Tenant Settings (Per-Tenant Configuration)
Each tenant has a 1:1 TenantSettings entity with 4 configuration groups. Values of -1 mean unlimited.
// TenantSettings.cs Quota Group
public int MaxAdmins { get; set; } = -1; // -1 = unlimited
public int MaxRoles { get; set; } = -1;
public int MaxSubTenants { get; set; } = -1;Auto-Role Creation
Creating a tenant dynamically provisions roles and an administrator account within an atomic transaction. Roles created are `{CODE}_SUPER_ADMIN` and `{CODE}_DEFAULT`. The Super Admin role follows a lock state transition: during scaffolding, it is unlocked (`IsPermissionLocked = false`) to configure initial permissions via plan edition assignment before transition to a locked state (`IsPermissionLocked = true`) preventing further modifications.
// Provisioning Super Admin & Default Roles within an atomic database transaction
var superAdminRole = Role.Create("${tenant.Code}_SUPER_ADMIN", "Super Administrator", tenant.Id);
superAdminRole.IsSystem = true;
superAdminRole.IsDeletable = false;
superAdminRole.IsTenantSuperAdmin = true;
superAdminRole.IsPermissionLocked = false; // Unlocked for scaffolding
var defaultRole = Role.Create("${tenant.Code}_DEFAULT", "Default User Role", tenant.Id);
defaultRole.IsSystem = true;
defaultRole.IsDeletable = false;
defaultRole.IsDefaultRole = true;
await _roleRepository.AddAsync(superAdminRole, ct);
await _roleRepository.AddAsync(defaultRole, ct);
// Lock transition phase:
// 1. AssignEditionCommand executes post-creation, raising SubscriptionChangedEvent.
// 2. SyncSuperAdminRolePermissionsAsync copies edition permissions to SUPER_ADMIN.
// 3. SUPER_ADMIN is set to IsPermissionLocked = true, freezing permissions.| Auto-Created Role | Properties | Permissions |
|---|---|---|
| {CODE}_SUPER_ADMIN | IsTenantSuperAdmin=true, IsPermissionLocked=true, IsDeletable=false, Priority=0 | All permissions granted to the tenant via plan edition |
| {CODE}_DEFAULT | IsDefaultRole=true, IsDeletable=false, Priority=100 | Basic read-only permissions |
Cascade Delete Protection
Tenant deletion enforces strict security check gates. If descendants exist, deletion is blocked unless the request sets `CascadeChildren` to `true`. Execution enforces the `Identity.CascadeDelete.Enabled` plan gate, validation of `tenants.cascade_delete` RBAC permissions, and performs a bottom-up reverse path deletion (deepest children first) along with bulk soft deletes and immediate cleanup of junction table records (domains, direct permissions) to avoid hostage domains, followed by quota counter reconciliation.
// 1. Descendant check gate
if (tenant.ChildTenants?.Count > 0 && !request.CascadeChildren)
{
var childCount = await _tenantRepository.GetDescendantCountAsync(tenant.Id, ct);
return Error.Validation(
ErrorCodes.ValidationFailed,
_l["tenant.cannotDeleteWithDescendants", new { count = childCount }]
);
}
// 2. Plan feature check & permissions validation
var cascadeEnabled = await _featureChecker.IsEnabledAsync(_currentUser.TenantId.Value, "Identity.CascadeDelete.Enabled", ct);
if (!cascadeEnabled) return Error.Forbidden(ErrorCodes.Forbidden, _l["feature.notAvailable"]);
if (!_currentUser.HasPermission("tenants.cascade_delete")) return Error.Forbidden(ErrorCodes.Forbidden, _l["permission.missing"]);
// 3. Bottom-up deletion ordering (deepest children soft-deleted first)
var descendantIds = await _tenantRepository.GetDescendantIdsAsync(tenant.Id, ct);
foreach (var descendantId in descendantIds.Reverse())
{
var descendant = await _tenantRepository.GetByIdAsync(descendantId, ct);
if (descendant != null && !descendant.IsDeleted)
{
_tenantRepository.Delete(descendant);
}
}
// 4. Bulk soft deletes and junction cleanups
await _adminRepository.BulkSoftDeleteByTenantIdsAsync(targetTenantIds, ct);
await _roleRepository.BulkSoftDeleteByTenantIdsAsync(targetTenantIds, ct);
foreach (var tenantId in targetTenantIds)
{
await _tenantPermissionRepository.RemoveAllDirectAsync(tenantId, ct);
await _tenantDomainRepository.SoftDeleteAllForTenantAsync(tenantId, ct);
}
// 5. Auditing & Quota Reconciliation
await _auditService.LogEntityChangeAsync(
targetTenantIds.Count > 1 ? AuditEventTypes.BulkTenantCascadeDelete : AuditEventTypes.Delete,
"Tenant", tenant.Id.ToString(), auditDetails, null, null, ct
);
if (tenant.ParentTenantId.HasValue)
await _quotaService.OnResourceDeletedAsync(tenant.ParentTenantId.Value, "subtenant", ct);Permission Inheritance
When creating a child tenant, the parent can only grant permissions that it already has. This creates a cascading security model a child tenant can never have more permissions than its parent. The GET /tenants/creation-permissions endpoint returns the available permission pool filtered by the current user's tenant.
Tenant API Endpoints
CRUD Endpoints
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| GET | /api/v1/tenants | Paginated list with search, sorting | 🔒 Required |
| GET | /api/v1/tenants/{id} | Full tenant detail with settings | 🔒 Required |
| POST | /api/v1/tenants | Create tenant (auto-creates 2 roles) | 🔒 Required |
| PUT | /api/v1/tenants/{id} | Update name, code, description | 🔒 Required |
| DELETE | /api/v1/tenants/{id} | Soft-delete (cascade requires tenants.cascade_delete) | 🔒 Required |
| PUT | /api/v1/tenants/{id}/logo | Upload tenant branding logo | 🔒 Required |
Hierarchy Endpoints
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| GET | /api/v1/tenants/hierarchy | Full tree structure with levels | 🔒 Required |
| GET | /api/v1/tenants/my-children | Direct children of current user's tenant | 🔒 Required |
| GET | /api/v1/tenants/my-tenant-and-children | Current tenant + children (for admin transfer dialog) | 🔒 Required |
| GET | /api/v1/tenants/{id}/children | Sub-tenants tab drill-down | 🔒 Required |
| GET | /api/v1/tenants/{id}/descendant-count | Cascade delete warning count | 🔒 Required |
Settings Endpoints
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| GET | /api/v1/tenants/my-settings | Get current user's tenant settings | 🔒 Required |
| PUT | /api/v1/tenants/my-settings | Update own tenant settings | 🔒 Required |
| GET | /api/v1/tenants/{id}/settings | Admin view of any tenant's settings | 🔒 Required |
| PUT | /api/v1/tenants/{id}/settings | Admin update of any tenant's settings | 🔒 Required |
| GET | /api/v1/tenants/{id}/stats | Dashboard KPI statistics per tenant | 🔒 Required |
Permission Endpoints
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| GET | /api/v1/tenants/creation-permissions | Available permissions for new tenant (filtered by parent) | 🔒 Required |
| GET | /api/v1/tenants/{id}/permissions | Permission pool for role assignment | 🔒 Required |
| PUT | /api/v1/tenants/{id}/permissions | Update tenant's permission pool | 🔒 Required |
Drill-Down Endpoints
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| GET | /api/v1/tenants/{id}/admins | List admins in this tenant | 🔒 Required |
| GET | /api/v1/tenants/{id}/roles | List roles in this tenant | 🔒 Required |
Domain Management
Each tenant can have multiple domains — one auto-generated subdomain created at tenant creation, plus optional custom domains added by administrators. The system supports DNS-based domain verification to prove ownership of custom domains before they become active. All domain-related configuration is fully externalized to appsettings.json, enabling seamless rebranding and multi-deployment setups.
Domain Types
| Type | Created By | Example | Deletable | Auto-Verified |
|---|---|---|---|---|
| auto | System (on tenant creation) | {code}.{PlatformDomain} | No | Yes |
| custom | Admin (via API/UI) | app.acme.com | Yes | No — requires DNS verification |
Domain Resolution Architecture
Incoming requests resolve the tenant context via the client-side `useTenantResolution` hook and server-side `ResolveTenantByDomainQueryHandler`. The client checks if the hostname is a local/platform domain, otherwise query the API. The server looks up the TenantDomain table, verifies `IsVerified == true`, deep-merges tenant-specific login branding overrides, or falls back to `?code=` query parameters in developer mode.
const hostname = window.location.hostname;
const isPlatform = hostname === "localhost" || hostname === process.env.NEXT_PUBLIC_APP_URL;
if (isPlatform) {
loadPlatformBranding();
} else {
const resolved = await resolveTenantApi(hostname);
loadTenantBranding(resolved);
}public async Task<Result<TenantResolutionResponse>> Handle(ResolveTenantByDomainQuery request, CancellationToken ct)
{
// Query verified domain from TenantDomain table
var domain = await _tenantDomainRepository.GetByDomainAsync(request.Host, ct);
if (domain == null || !domain.IsVerified)
{
// Dev Mode Fallback: query by ?code= parameter
return await ResolveByCodeAsync(request.Code, ct);
}
var tenant = await _tenantRepository.GetByIdAsync(domain.TenantId, ct);
var settings = tenant.Settings;
// Deep-merge login branding override on top of default platform settings
var branding = settings?.IsSafeMode == true ? null
: (settings?.LoginBrandingJson ?? sys?.LoginBrandingJson);
}DNS Verification Flow
Custom domains must be validated against RFC 1123, reserved subdomains list, and the tenant's `Tenancy.MaxCustomDomains` quota limit. Ownership is proved by generating a verification token (`scr_` prefix) and checking the presence of a CNAME target mapping and a matching TXT record (`_scr-verify.{domain}`) utilizing DNS lookup queries.
// Verify custom domain using DnsClient.NET TXT record checking
var domain = await _tenantDomainRepository.GetByIdAsync(request.DomainId, ct);
var expectedHost = $"\{_tenancySettings.VerificationPrefix\}.\{domain.Domain\}";
var result = await lookup.QueryAsync(expectedHost, QueryType.TXT, ct);
// Checks if TXT record includes the verification token
var verified = result.Answers.TxtRecords().Any(r => r.Text.Contains(domain.VerificationToken));
if (verified)
{
domain.IsVerified = true;
await _unitOfWork.SaveChangesAsync(ct);
}Configurable Platform Domain
Every domain-related value is configurable via the Tenancy section in appsettings.json. This means you can rebrand the entire platform — changing the base domain, CNAME target, verification prefix, and token prefix — by editing a single configuration block. Zero code changes required. The backend injects TenancySettings via IOptions<T>, and the frontend receives the CNAME target and verification prefix from the GET /domains API response.
{
"Tenancy": {
"PlatformDomain": "scripe.com", // Auto-subdomains: {code}.scripe.com
"CnameTarget": "app.scripe.com", // DNS instruction: CNAME -> this
"VerificationPrefix": "_scr-verify", // TXT record: _scr-verify.{domain}
"TokenPrefix": "scr_" // Token format: scr_base64...
}
}public sealed class TenancySettings
{
public const string SectionName = "Tenancy";
public string PlatformDomain { get; init; } = "scripe.com";
public string CnameTarget { get; init; } = "app.scripe.com";
public string VerificationPrefix { get; init; } = "_scr-verify";
public string TokenPrefix { get; init; } = "scr_";
}| Setting | Purpose | Default | Example Override |
|---|---|---|---|
| PlatformDomain | Base domain for auto-generated tenant subdomains | scripe.com | myapp.io |
| CnameTarget | Target shown in DNS CNAME instructions | app.scripe.com | app.myapp.io |
| VerificationPrefix | TXT record hostname prefix for domain ownership verification | _scr-verify | _custom-verify |
| TokenPrefix | Prefix for verification token strings | scr_ | verify_ |
Domain API Endpoints
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| GET | /api/v1/tenants/{id}/domains | List all domains + cnameTarget + verificationPrefix | 🔒 Required |
| POST | /api/v1/tenants/{id}/domains | Add custom domain (requires feature: Tenancy.CustomDomain.Enabled) | 🔒 Required |
| DELETE | /api/v1/tenants/{id}/domains/{domainId} | Remove custom domain (auto domains cannot be removed) | 🔒 Required |
| POST | /api/v1/tenants/{id}/domains/{domainId}/verify | Trigger DNS verification for a custom domain | 🔒 Required |
| PUT | /api/v1/tenants/{id}/domains/{domainId}/set-primary | Set a domain as the tenant's primary domain | 🔒 Required |