Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.aspfox.com/llms.txt

Use this file to discover all available pages before exploring further.

What multi-tenancy means in AspFox

One deployed instance of AspFox serves multiple isolated workspaces — called tenants. A user can belong to multiple tenants and switch between them without re-authenticating. The currently active tenant is stored as a first-class JWT claim (tenant_id). Every database query automatically filters to that tenant’s data. Users are global (they have one account), but their membership, role, and permissions are per-tenant. A user can be an Owner in one workspace and a Member in another.

How tenant isolation is enforced

This is the most important thing to understand about the data model. Isolation is not implemented as an API-layer check (“does this user have access to this record?”). It is enforced by EF Core global query filters that automatically append WHERE tenant_id = @currentTenantId to every query against tenant-scoped tables.
// In ApplicationDbContext.OnModelCreating:
builder.Entity<Project>()
    .HasQueryFilter(p => p.TenantId == _tenantContext.CurrentTenantId);

builder.Entity<Invitation>()
    .HasQueryFilter(i => i.TenantId == _tenantContext.CurrentTenantId);

// Every tenant-scoped entity has this filter.
// It is applied automatically. No handler has to remember to add it.
TenantContext is a scoped service set once per request by TenantResolutionMiddleware:
// TenantResolutionMiddleware.cs
public async Task InvokeAsync(HttpContext context, TenantContext tenantContext)
{
    var tenantIdClaim = context.User.FindFirst("tenant_id")?.Value;
    if (Guid.TryParse(tenantIdClaim, out var tenantId))
    {
        tenantContext.CurrentTenantId = tenantId;
    }
    await _next(context);
}
The full request flow:
Incoming request (Authorization: Bearer <JWT>)


JWT validation middleware extracts claims


TenantResolutionMiddleware reads tenant_id claim
    │  sets TenantContext.CurrentTenantId = tenantId

Controller → MediatR → CommandHandler


EF Core query: dbContext.Projects.Where(p => p.Name.Contains(search))
    │  global filter automatically adds:
    │  AND tenant_id = @currentTenantId

SQL: SELECT * FROM projects WHERE name LIKE '%search%'
     AND tenant_id = 'abc123'


Only the current tenant's projects are returned.
Tenant B's projects are structurally unreachable.

What tenant isolation means architecturally

A user from Tenant A cannot access Tenant B’s data by any means short of modifying the source code. This is not a permission check that can be bypassed by a crafted request. It is an ORM filter that runs on every query. The only way to query across tenants is to call IgnoreQueryFilters() explicitly:
// This is only done in admin commands and background jobs.
// It requires deliberate code — it cannot happen accidentally.
var allTenants = await _context.Tenants
    .IgnoreQueryFilters()
    .ToListAsync();
IgnoreQueryFilters() appears in three places in the codebase: admin user management queries, admin tenant management queries, and background jobs (which process all tenants). Nowhere in the normal request path is it called.

The invitation flow

Inviting a user to a workspace:
POST /api/v1/tenants/current/invitations
    { "email": "newuser@example.com", "roleId": "<role-id>" }


InviteMemberCommandHandler
    │  checks caller has tenant.members.invite permission
    │  checks email not already a member
    │  generates cryptographically signed invitation token
    │  stores Invitation record (72-hour expiry)
    │  sends invitation email via Resend
    │  creates MemberInvited notification for tenant owner


Invited user clicks link in email
    │  link: https://app.yourdomain.com/invitations/accept?token=<token>


POST /api/v1/tenants/invitations/accept  { "token": "<token>" }


AcceptInvitationCommandHandler
    │  validates token (not expired, not already used)
    │  checks if user account exists for invited email

    ├─ Account EXISTS → add user to tenant with specified role
    │                 → issue new JWT with new tenant context
    │                 → return token pair

    └─ Account DOES NOT EXIST → return error code REGISTRATION_REQUIRED
                              → frontend redirects to register page
                              → after registration, accept flow retries
The REGISTRATION_REQUIRED case is handled on the frontend. When AcceptInvitationCommandHandler returns REGISTRATION_REQUIRED, the frontend stores the invitation token in sessionStorage and redirects to the registration page. After the user registers and verifies their email, the Accept Invitation page reads the stored token and retries the accept call automatically.

How to add a tenant-scoped entity

Every entity that belongs to a single tenant must implement ITenantScopedEntity and have the global query filter applied.
// 1. Domain entity
public class Project : BaseEntity, ITenantScopedEntity, IAuditableEntity
{
    public string Name { get; private set; } = string.Empty;
    public string Description { get; private set; } = string.Empty;
    public Guid TenantId { get; private set; }        // required by ITenantScopedEntity
    public Tenant Tenant { get; private set; } = null!;
    public DateTime CreatedAt { get; set; }            // required by IAuditableEntity
    public DateTime? UpdatedAt { get; set; }

    private Project() { }

    public static Project Create(string name, string description, Guid tenantId)
    {
        return new Project
        {
            Id = Guid.NewGuid(),
            Name = name,
            Description = description,
            TenantId = tenantId
        };
    }
}

// 2. EF Core configuration — include the global query filter
public class ProjectConfiguration : IEntityTypeConfiguration<Project>
{
    private readonly TenantContext _tenantContext;

    public ProjectConfiguration(TenantContext tenantContext)
    {
        _tenantContext = tenantContext;
    }

    public void Configure(EntityTypeBuilder<Project> builder)
    {
        builder.ToTable("projects");
        builder.HasKey(p => p.Id);
        builder.Property(p => p.Name).HasMaxLength(200).IsRequired();
        builder.Property(p => p.Description).HasMaxLength(2000);
        builder.HasIndex(p => p.TenantId);

        // This is the critical line. Without it, projects from all tenants are returned.
        builder.HasQueryFilter(p => p.TenantId == _tenantContext.CurrentTenantId);
    }
}
See the Adding a Feature guide for the complete walkthrough including migration, commands, and frontend.