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.

BaseDbContext.cs (EF Core Global Filters)C#
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)
    );
}
Multi-Tenant Data Isolation & Context Resolution
Incoming Request
X-Tenant-Context Header?
Header Present
Verify tenants.drill_down Permission
Extract TenantId from JWT Claims
AES Decrypt & Overwrite CurrentTenantId
EF Core Global Query Filter
SELECT * WHERE TenantId = @CurrentTenantId OR TenantId IS NULL

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.

CurrentUserService.cs (Admin Drill-Down Scoping)C#
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;
TenantContextMiddleware.cs (Middleware Firewall)C#
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 LevelImplementationUse Case
Row-Level (Current)EF Core Global Query Filters on TenantIdSingle database, all tenants share tables
Schema-Level (Planned)Separate schema per tenantHigher isolation, same database
Database-Level (Planned)Separate database per tenantMaximum 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.

Tenant.cs Hierarchy FieldsC#
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.

TenantHierarchyService.cs (Constant-Time Queries)C#
// 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 Hierarchy Example
ACME Corp (Level 0)
ParentTenantId
Cairo Branch (Level 1)
Dubai Branch (Level 1)
HR Department (Level 2)
Finance Dept (Level 2)

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.

C#
// 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.

CreateTenantCommandHandler.cs (Auto-Role Scaffolding)C#
// 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 RolePropertiesPermissions
{CODE}_SUPER_ADMINIsTenantSuperAdmin=true, IsPermissionLocked=true, IsDeletable=false, Priority=0All permissions granted to the tenant via plan edition
{CODE}_DEFAULTIsDefaultRole=true, IsDeletable=false, Priority=100Basic 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.

Cascade Delete Dependency Check Flow
Delete Tenant Request
Has Child Tenants?
Has Children
CascadeChildren Parameter == true?
No
Block Deletion (Validation Error)
Verify Plan Gate: Identity.CascadeDelete.Enabled
Verify tenants.cascade_delete Permission
Execute Bottom-Up Cascade Deletion
Bulk Soft Delete Users & Roles
Clean Permissions & Domains
Log Audit Event & Reconcile Quotas
DeleteTenantCommandHandler.cs (Cascade Gates & Bottom-Up Deletion)C#
// 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.

Permission Inheritance Flow
Parent Tenant (100 permissions)
Admin grants 60 permissions to child
Child Tenant (max 60 permissions)
Child grants 30 to grandchild
Grandchild (max 30 permissions)

Tenant API Endpoints

CRUD Endpoints

MethodEndpointDescriptionAuth
GET/api/v1/tenantsPaginated list with search, sorting🔒 Required
GET/api/v1/tenants/{id}Full tenant detail with settings🔒 Required
POST/api/v1/tenantsCreate 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}/logoUpload tenant branding logo🔒 Required

Hierarchy Endpoints

MethodEndpointDescriptionAuth
GET/api/v1/tenants/hierarchyFull tree structure with levels🔒 Required
GET/api/v1/tenants/my-childrenDirect children of current user's tenant🔒 Required
GET/api/v1/tenants/my-tenant-and-childrenCurrent tenant + children (for admin transfer dialog)🔒 Required
GET/api/v1/tenants/{id}/childrenSub-tenants tab drill-down🔒 Required
GET/api/v1/tenants/{id}/descendant-countCascade delete warning count🔒 Required

Settings Endpoints

MethodEndpointDescriptionAuth
GET/api/v1/tenants/my-settingsGet current user's tenant settings🔒 Required
PUT/api/v1/tenants/my-settingsUpdate own tenant settings🔒 Required
GET/api/v1/tenants/{id}/settingsAdmin view of any tenant's settings🔒 Required
PUT/api/v1/tenants/{id}/settingsAdmin update of any tenant's settings🔒 Required
GET/api/v1/tenants/{id}/statsDashboard KPI statistics per tenant🔒 Required

Permission Endpoints

MethodEndpointDescriptionAuth
GET/api/v1/tenants/creation-permissionsAvailable permissions for new tenant (filtered by parent)🔒 Required
GET/api/v1/tenants/{id}/permissionsPermission pool for role assignment🔒 Required
PUT/api/v1/tenants/{id}/permissionsUpdate tenant's permission pool🔒 Required

Drill-Down Endpoints

MethodEndpointDescriptionAuth
GET/api/v1/tenants/{id}/adminsList admins in this tenant🔒 Required
GET/api/v1/tenants/{id}/rolesList 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

TypeCreated ByExampleDeletableAuto-Verified
autoSystem (on tenant creation){code}.{PlatformDomain}NoYes
customAdmin (via API/UI)app.acme.comYesNo — 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.

Domain Resolution Flow
Incoming Request
Host Sniffer (useTenantResolution Hook)
Is Localhost or Platform Domain?
Yes
Load Platform Branding
ResolveTenantByDomain Query
Query TenantDomain (IsVerified == true)
Found
Deep-Merge LoginBranding (Safe Mode Checked)
Dev Fallback (?code= / ?_tenant=)
useTenantResolution.ts (Client Domain Sniffer)TypeScript
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);
}
ResolveTenantByDomainQueryHandler.cs (Server Resolution)C#
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.

Custom Domain Verification Flow
Admin Adds Custom Domain (RFC 1123 & Quota Checked)
System Generates scr_ Verification Token
Admin Configures DNS Records
CNAME: domain.com -> app.scripe.com
TXT: _scr-verify.domain.com = scr_{token}
Click Verify -> DnsClient.NET Query TXT
Token Found in DNS TXT Records?
Yes
Set IsVerified = true & Activate Domain
Show Verification Error
VerifyTenantDomainCommandHandler.cs (DNS lookup validation)C#
// 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.

appsettings.json — Tenancy SectionJSON
{
  "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...
  }
}
TenancySettings.cs — Configuration POCOC#
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_";
}
SettingPurposeDefaultExample Override
PlatformDomainBase domain for auto-generated tenant subdomainsscripe.commyapp.io
CnameTargetTarget shown in DNS CNAME instructionsapp.scripe.comapp.myapp.io
VerificationPrefixTXT record hostname prefix for domain ownership verification_scr-verify_custom-verify
TokenPrefixPrefix for verification token stringsscr_verify_

Domain API Endpoints

MethodEndpointDescriptionAuth
GET/api/v1/tenants/{id}/domainsList all domains + cnameTarget + verificationPrefix🔒 Required
POST/api/v1/tenants/{id}/domainsAdd 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}/verifyTrigger DNS verification for a custom domain🔒 Required
PUT/api/v1/tenants/{id}/domains/{domainId}/set-primarySet a domain as the tenant's primary domain🔒 Required