Workspace Isolation Middleware — Plan
Automatic query filtering and entity stamping at the workspace level, sitting one layer below ABP multi-tenancy (Tenant = Business, Workspace = sub-entity of that business).
Overview
Adds per-workspace request scoping to a multi-workspace ABP application — automatic query filtering and entity stamping at the workspace level, sitting one layer below ABP’s multi-tenancy (Tenant = Business, Workspace = sub-entity of that business).
Decision: Workspace Scoping is BackOffice-only
The EF Core
IWorkspaceglobal query filter andSaveChangesstamping live inBackOfficeDbContextONLY.PublicPortalDbContextdoes NOT apply them.
Rationale:
- PublicPortal’s dominant read path is cross-workspace (and cross-tenant). A marketplace listing every workspace across every business cannot use an ambient single-workspace filter — it would have to be disabled on nearly every marketplace query.
SaveChangesstamping is dead weight / a corruption risk on a projection. PublicPortal does no business writes; its read models are updated by distributed event handlers running in a background consumer context with no HTTP request / no subdomain, so the correctWorkspaceIdcomes from the event payload, not ambient request context. Auto-stamping from ambient context would either no-op or write the wrong workspace.- Workspace-site scoping is better explicit. On
{slug}.{domain}, read models are denormalized and keyed byWorkspaceIdfor a single indexed lookup —WHERE WorkspaceId = @id, with the id supplied by the subdomain resolver viaICurrentWorkspace.Id. - Isolation is already handled a level up by ABP multi-tenancy (cross-business). A customer seeing another workspace’s public availability is wrong data, not the security breach that a staff member seeing another workspace’s bookings is.
| Piece | BackOffice | PublicPortal |
|---|---|---|
EF query filter (ShouldFilterEntity / CreateFilterExpression) | ✅ keep | ❌ removed |
SaveChanges stamping (ApplyCurrentWorkspaceId) | ✅ keep | ❌ removed |
Subdomain resolver → ICurrentWorkspace | n/a | ✅ keep (workspace sites need to know which workspace) |
CurrentWorkspace property on DbContext | ✅ | ✅ keep (for explicit read-side scoping) |
Read models implement IWorkspace marker | ✅ scoped entities do | ❌ plain WorkspaceId property, set from ETO |
| Middleware registered in host | ✅ | ✅ (no subdomain on marketplace → no-op) |
PublicPortal keeps the workspace context (populated from the subdomain) but drops the automatic filtering/stamping: workspace-site read queries scope explicitly with CurrentWorkspace.Id; marketplace queries run unscoped.
Why Workspace-Level Scoping?
Tenant (Business)
└── Workspace A ← staff can be scoped to one workspace
└── Workspace B
└── Workspace C
- A business owner may manage multiple workspaces.
- BackOffice staff (Manager, Receptionist) are assigned to ONE workspace; their API calls must only see data for that workspace without every query needing explicit
WHERE WorkspaceId = ?. - PublicPortal serves each workspace’s public site at
{slug}.{domain}— the current workspace must be resolved from the subdomain for every request.
Architecture
HTTP Request
│
▼
[ABP Multi-Tenancy Middleware] ← resolves Tenant (Business) first
│
▼
[WorkspaceResolutionMiddleware] ← lives in YourApp.Core
│ runs pluggable IWorkspaceResolveContributor chain
│ cache → DB fallback, no explicit TTL (uses ABP global cache settings)
│
▼
ICurrentWorkspace.Id set in AsyncLocal (CurrentWorkspace singleton in YourApp.Core)
│
▼
[EF Core DbContext] ← BackOfficeDbContext ONLY has the workspace filter
│ global query filter: WHERE WorkspaceId = CurrentWorkspace.Id
│ SaveChanges: stamps WorkspaceId on new IWorkspace entities
│ (PublicPortalDbContext does NOT filter/stamp — see "Decision" above)
▼
AppService (BackOfficeAppService / PublicPortalAppService)
│ protected ICurrentWorkspace CurrentWorkspace — available for explicit checks
▼
Handler returns data scoped to the workspace
YourApp.Core Module
YourApp.Core is the right home for all cross-cutting workspace infrastructure — a shared module that neither BackOffice nor PublicPortal depends on directly, keeping the dependency direction clean.
What goes in Core
modules/Core/
├── YourYourApp.Core.csproj
└── MultiWorkspace/
├── IWorkspace.cs
├── ICurrentWorkspace.cs
├── CurrentWorkspace.cs
├── WorkspaceDto.cs
├── IWorkspaceResolveContributor.cs
├── IWorkspaceResolveContext.cs
├── WorkspaceResolveContext.cs
├── WorkspaceResolveOptions.cs
└── WorkspaceResolutionMiddleware.cs
WorkspaceCoreModule.cs
What stays in each module
| Concern | Location |
|---|---|
WorkspaceIdHeaderResolveContributor | BackOffice.HttpApi — knows the Workspace repository |
WorkspaceSubdomainResolveContributor | PublicPortal.HttpApi — serves *.{domain} |
WorkspaceRouteResolveContributor | BackOffice.HttpApi — admin route param |
| EF Core workspace query filter + stamping | Each module’s EntityFrameworkCore project |
BackOfficeAppService.CurrentWorkspace | BackOffice.Application |
PublicPortalAppService.CurrentWorkspace | PublicPortal.Application |
Dependency Graph Changes
YourApp.Core
↑
BackOffice.Domain.Shared ← add project ref + DependsOn(WorkspaceCoreModule)
PublicPortal.Domain.Shared ← add project ref + DependsOn(WorkspaceCoreModule)
↑
BackOffice.Domain / PublicPortal.Domain (already depend on their Domain.Shared — no change)
↑
BackOffice.HttpApi ← add ref to YourApp.Core (for contributor base types)
PublicPortal.HttpApi ← add ref to YourApp.Core
↑
AppHttpApiHostModule ← register middleware + configure WorkspaceResolveOptions
Layer 1 — YourApp.Core: Interfaces + Context
IWorkspace.cs
namespace YourYourApp.Core.MultiWorkspace;
public interface IWorkspace
{
Guid? WorkspaceId { get; set; }
}
ICurrentWorkspace.cs
namespace YourYourApp.Core.MultiWorkspace;
public interface ICurrentWorkspace
{
Guid? Id { get; }
string Name { get; }
string Slug { get; } // subdomain slug — used by PublicPortal
bool IsAvailable { get; }
IDisposable Change(Guid? id);
IDisposable Change(Guid? id, string name);
IDisposable Change(Guid? id, string name, string slug);
}
CurrentWorkspace.cs
using Volo.Abp.DependencyInjection;
namespace YourYourApp.Core.MultiWorkspace;
public class CurrentWorkspace : ICurrentWorkspace, ISingletonDependency
{
private readonly AsyncLocal<WorkspaceCacheItem?> _current = new();
public Guid? Id => _current.Value?.WorkspaceId;
public string Name => _current.Value?.Name!;
public string Slug => _current.Value?.Slug!;
public bool IsAvailable => Id.HasValue;
public IDisposable Change(Guid? id) => Change(id, null, null);
public IDisposable Change(Guid? id, string? name) => Change(id, name, null);
public IDisposable Change(Guid? id, string? name, string? slug)
{
var prev = _current.Value;
if (id == prev?.WorkspaceId && name == prev?.Name && slug == prev?.Slug)
return NullRestore.Instance;
_current.Value = new WorkspaceCacheItem(id, name, slug);
return new WorkspaceRestore(this, prev?.WorkspaceId, prev?.Name, prev?.Slug);
}
private sealed record WorkspaceCacheItem(Guid? WorkspaceId, string? Name, string? Slug);
private sealed class WorkspaceRestore(
CurrentWorkspace owner, Guid? id, string? name, string? slug) : IDisposable
{
public void Dispose() =>
owner._current.Value = id.HasValue ? new WorkspaceCacheItem(id, name, slug) : null;
}
private sealed class NullRestore : IDisposable
{
public static readonly NullRestore Instance = new();
private NullRestore() { }
public void Dispose() { }
}
}
WorkspaceDto.cs (cache payload — keep small)
namespace YourYourApp.Core.MultiWorkspace;
public class WorkspaceDto
{
public Guid Id { get; set; }
public string Name { get; set; } = default!;
public string Slug { get; set; } = default!;
public Guid? TenantId { get; set; }
}
Layer 2 — YourApp.Core: Resolver Infrastructure + Middleware
Resolver contracts
// IWorkspaceResolveContext.cs
public interface IWorkspaceResolveContext
{
Guid? WorkspaceId { get; set; }
string WorkspaceSlug { get; set; }
HttpContext GetHttpContext();
}
// IWorkspaceResolveContributor.cs
public interface IWorkspaceResolveContributor
{
string Name { get; }
Task ResolveAsync(IWorkspaceResolveContext context);
}
// WorkspaceResolveOptions.cs
public class WorkspaceResolveOptions
{
public List<IWorkspaceResolveContributor> WorkspaceResolvers { get; } = [];
}
WorkspaceResolutionMiddleware.cs
No explicit TTL on SetAsync — defers to ABP’s global distributed cache options configured in appsettings.json.
public class WorkspaceResolutionMiddleware(
IOptions<WorkspaceResolveOptions> options,
ILogger<WorkspaceResolutionMiddleware> logger) : IMiddleware, ITransientDependency
{
private readonly WorkspaceResolveOptions _options = options.Value;
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
var resolveCtx = new WorkspaceResolveContext(context);
foreach (var resolver in _options.WorkspaceResolvers)
{
await resolver.ResolveAsync(resolveCtx);
if (resolveCtx.WorkspaceId.HasValue)
{
logger.LogDebug("Workspace resolved by {Resolver}: {WorkspaceId}",
resolver.Name, resolveCtx.WorkspaceId.Value);
break;
}
}
if (!resolveCtx.WorkspaceId.HasValue)
{
await next(context);
return;
}
await SetCurrentWorkspaceAndContinueAsync(context, next, resolveCtx);
}
private async Task SetCurrentWorkspaceAndContinueAsync(
HttpContext context, RequestDelegate next, WorkspaceResolveContext resolveCtx)
{
var workspaceId = resolveCtx.WorkspaceId!.Value;
var services = context.RequestServices;
var cache = services.GetRequiredService<IDistributedCache<WorkspaceDto, Guid>>();
var currentTenant = services.GetRequiredService<ICurrentTenant>();
var currentWorkspace = services.GetRequiredService<ICurrentWorkspace>();
var workspaceDto = await cache.GetAsync(workspaceId);
if (workspaceDto is null)
{
logger.LogDebug("Workspace {WorkspaceId} not in cache, querying database.", workspaceId);
using (currentTenant.Change(currentTenant.Id))
{
// IWorkspaceRepository is the Core contract; the EF implementation lives in
// BackOffice.EntityFrameworkCore. Keeps dependency direction Core ← BackOffice.
var repo = services.GetRequiredService<IWorkspaceRepository>();
workspaceDto = await repo.FindByIdAsync(workspaceId);
if (workspaceDto is not null)
{
// No explicit TTL — respects global AbpDistributedCacheOptions
await cache.SetAsync(workspaceDto.Id, workspaceDto);
logger.LogDebug("Workspace {WorkspaceId} cached.", workspaceId);
}
}
}
if (workspaceDto is null)
{
logger.LogWarning("Workspace {WorkspaceId} not found in tenant.", workspaceId);
await next(context);
return;
}
using (currentWorkspace.Change(workspaceDto.Id, workspaceDto.Name, workspaceDto.Slug))
{
await next(context);
}
}
}
WorkspaceCoreModule.cs
[DependsOn(
typeof(AbpDddDomainModule),
typeof(AbpCachingModule),
typeof(AbpAspNetCoreMvcModule)
)]
public class WorkspaceCoreModule : AbpModule
{
// CurrentWorkspace is registered automatically via ISingletonDependency convention.
}
Layer 3 — BackOffice.Domain.Shared: Depend on Core
Edit BackOfficeDomainSharedModule.cs:
[DependsOn(
typeof(AbpDddDomainSharedModule),
typeof(WorkspaceCoreModule) // ← add
)]
public class BackOfficeDomainSharedModule : AbpModule { }
Edit BackOffice.Domain.Shared.csproj:
<ProjectReference Include="..\..\..\..\modules\Core\YourYourApp.Core.csproj" />
Layer 4 — PublicPortal.Domain.Shared: Depend on Core
Edit PublicPortalDomainSharedModule.cs:
[DependsOn(
typeof(AbpDddDomainSharedModule),
typeof(WorkspaceCoreModule) // ← add
)]
public class PublicPortalDomainSharedModule : AbpModule { }
Both modules now have IWorkspace, ICurrentWorkspace, and CurrentWorkspace available without any cross-module reference between BackOffice ↔ PublicPortal.
Layer 5 — AppService Base Classes (both modules)
Edit BackOffice.Application/BackOfficeAppService.cs:
public abstract class BackOfficeAppService : ApplicationService
{
protected ICurrentWorkspace CurrentWorkspace =>
LazyServiceProvider.LazyGetRequiredService<ICurrentWorkspace>();
protected BackOfficeAppService()
{
LocalizationResource = typeof(BackOfficeResource);
ObjectMapperContext = typeof(BackOfficeApplicationModule);
}
}
Edit PublicPortal.Application/PublicPortalAppService.cs:
public abstract class PublicPortalAppService : ApplicationService
{
protected ICurrentWorkspace CurrentWorkspace =>
LazyServiceProvider.LazyGetRequiredService<ICurrentWorkspace>();
protected PublicPortalAppService()
{
LocalizationResource = typeof(PublicPortalResource);
ObjectMapperContext = typeof(PublicPortalApplicationModule);
}
}
Layer 6 — EF Core: Workspace Query Filter (BackOfficeDbContext ONLY)
Applied to
BackOfficeDbContextonly.PublicPortalDbContextkeeps just theCurrentWorkspaceproperty (for explicit read-side scoping) and omitsIsMultiWorkspaceFilterEnabled, theSaveChangesoverrides,ApplyCurrentWorkspaceId,ShouldFilterEntity, andCreateFilterExpression. See the “Decision: Workspace Scoping is BackOffice-only” section.
ABP 10.4.1 note:
CreateFilterExpression<TEntity>gained a second parameter vs. 9.x. Override is nowCreateFilterExpression<TEntity>(ModelBuilder modelBuilder, EntityTypeBuilder<TEntity> entityTypeBuilder)and must forward both args tobase.CreateFilterExpression(...).
// In BackOfficeDbContext
protected ICurrentWorkspace CurrentWorkspace =>
LazyServiceProvider.LazyGetRequiredService<ICurrentWorkspace>();
protected bool IsMultiWorkspaceFilterEnabled =>
DataFilter?.IsEnabled<IWorkspace>() ?? false;
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
ApplyCurrentWorkspaceId();
return base.SaveChanges(acceptAllChangesOnSuccess);
}
public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess,
CancellationToken ct = default)
{
ApplyCurrentWorkspaceId();
return base.SaveChangesAsync(acceptAllChangesOnSuccess, ct);
}
private void ApplyCurrentWorkspaceId()
{
if (CurrentWorkspace?.Id is null) return;
var id = CurrentWorkspace.Id.Value;
foreach (var entry in ChangeTracker.Entries()
.Where(e => e.Entity is IWorkspace &&
(e.State == EntityState.Added || e.State == EntityState.Modified)))
{
entry.Property(nameof(IWorkspace.WorkspaceId)).CurrentValue = id;
if (entry.State == EntityState.Modified)
entry.Property(nameof(IWorkspace.WorkspaceId)).IsModified = false; // prevent drift
}
}
protected override bool ShouldFilterEntity<TEntity>(IMutableEntityType entityType)
{
if (typeof(IWorkspace).IsAssignableFrom(typeof(TEntity))) return true;
return base.ShouldFilterEntity<TEntity>(entityType);
}
protected override Expression<Func<TEntity, bool>> CreateFilterExpression<TEntity>(
ModelBuilder modelBuilder,
EntityTypeBuilder<TEntity> entityTypeBuilder)
{
var baseExpr = base.CreateFilterExpression<TEntity>(modelBuilder, entityTypeBuilder);
if (!typeof(IWorkspace).IsAssignableFrom(typeof(TEntity))) return baseExpr;
Expression<Func<TEntity, bool>> workspaceFilter = e =>
!IsMultiWorkspaceFilterEnabled
|| CurrentWorkspace.Id == null
|| EF.Property<Guid?>(e, nameof(IWorkspace.WorkspaceId)) == CurrentWorkspace.Id;
return baseExpr is null
? workspaceFilter
: QueryFilterExpressionHelper.CombineExpressions(baseExpr, workspaceFilter);
}
Cross-workspace admin queries: using (DataFilter.Disable<IWorkspace>()) { ... }
Layer 7 — Resolver Implementations
A — WorkspaceIdHeaderResolveContributor (BackOffice.HttpApi)
Two employee caches — fast access-check list + full DTO list.
public class WorkspaceIdHeaderResolveContributor : IWorkspaceResolveContributor, ITransientDependency
{
public const string HeaderName = "X-Workspace-Id";
public const string ContributorName = "WorkspaceIdHeader";
public string Name => ContributorName;
private readonly IDistributedCache<List<Guid>, Guid> _employeeWorkspaceIdCache;
private readonly IDistributedCache<List<WorkspaceEmployeeDto>, Guid> _employeeWorkspaceDtoCache;
private readonly ICurrentUser _currentUser;
private readonly ILogger<WorkspaceIdHeaderResolveContributor> _logger;
public async Task ResolveAsync(IWorkspaceResolveContext context)
{
var httpContext = context.GetHttpContext();
var headerVal = httpContext.Request.Headers[HeaderName].FirstOrDefault();
if (string.IsNullOrWhiteSpace(headerVal)) return;
if (!Guid.TryParse(headerVal, out var workspaceId)) return;
var employeeId = _currentUser.Id.GetValueOrDefault();
var accessibleSpaces = await _employeeWorkspaceIdCache.GetAsync(employeeId);
if (accessibleSpaces?.Any() == true && !accessibleSpaces.Contains(workspaceId))
{
_logger.LogWarning(
"User {UserId} attempted to access unauthorized workspace {WorkspaceId}",
employeeId, workspaceId);
return;
}
_logger.LogDebug("Workspace {WorkspaceId} resolved from header.", workspaceId);
context.WorkspaceId = workspaceId;
}
}
WorkspaceEmployeeDto: { Guid WorkspaceId; string WorkspaceName; } — small DTO. Cache keyed by UserId. No explicit TTL.
B — WorkspaceSubdomainResolveContributor (PublicPortal.HttpApi)
Two caches (slug → Guid first, then the main WorkspaceDto cache). No explicit TTL.
public class WorkspaceSubdomainResolveContributor : IWorkspaceResolveContributor, ITransientDependency
{
public const string ContributorName = "WorkspaceSubdomain";
public string Name => ContributorName;
private readonly IDistributedCache<WorkspaceDto, string> _cacheBySlug;
private readonly IDistributedCache<WorkspaceDto, Guid> _cacheByGuid;
private readonly IRepository<Workspace, Guid> _workspaceRepo;
private readonly IConfiguration _config;
public async Task ResolveAsync(IWorkspaceResolveContext context)
{
var host = context.GetHttpContext().Request.Host.Host;
var slug = ExtractSlug(host);
if (slug is null) return;
var dto = await _cacheBySlug.GetAsync(slug);
if (dto is not null)
{
context.WorkspaceId = dto.Id;
context.WorkspaceSlug = dto.Slug;
return;
}
var workspace = await _workspaceRepo.FindBySlugAsync(slug);
if (workspace is null) return;
dto = new WorkspaceDto { Id = workspace.Id, Name = workspace.Name, Slug = workspace.Slug, TenantId = workspace.TenantId };
await _cacheBySlug.SetAsync(slug, dto);
await _cacheByGuid.SetAsync(workspace.Id, dto);
context.WorkspaceId = workspace.Id;
context.WorkspaceSlug = workspace.Slug;
}
private string? ExtractSlug(string host)
{
var rootDomain = _config["RootDomain"] ?? "example.com";
if (!host.EndsWith($".{rootDomain}", StringComparison.OrdinalIgnoreCase)) return null;
var sub = host[..^(rootDomain.Length + 1)];
return string.IsNullOrEmpty(sub) || sub == "www" ? null : sub;
}
}
C — WorkspaceRouteResolveContributor (BackOffice.HttpApi, optional)
// Reads workspaceId from route values or query string.
// Only runs if no workspace was resolved by header.
// Gate behind BackOffice.Workspaces.CrossWorkspaceAccess permission at call site.
Caching Strategy
No explicit TTL anywhere. TTL is configured globally via ABP:
// appsettings.json
"DistributedCache": {
"KeyPrefix": "YourApp:",
"GlobalCacheEntryOptions": {
"AbsoluteExpirationRelativeToNow": "00:10:00"
}
}
| Cache | Key type | Owned by | Purpose |
|---|---|---|---|
IDistributedCache<WorkspaceDto, Guid> | WorkspaceId | Middleware + subdomain contributor | Main workspace metadata lookup |
IDistributedCache<WorkspaceDto, string> | Slug | Subdomain contributor | Slug → WorkspaceDto before Guid is known |
IDistributedCache<List<Guid>, Guid> | UserId | Header contributor | Fast access-check (ID list only) |
IDistributedCache<List<WorkspaceEmployeeDto>, Guid> | UserId | Header contributor | Full employee-workspace DTO list |
Cache invalidation on WorkspaceUpdatedEto:
await _cacheByGuid.RemoveAsync(eto.Id);
await _cacheBySlug.RemoveAsync(eto.OldSlug);
if (eto.Slug != eto.OldSlug)
await _cacheBySlug.RemoveAsync(eto.Slug);
On employee-workspace assignment change: evict both employee caches keyed by affected UserId.
Module Registration
BackOfficeHttpApiModule — header + route contributors
Configure<WorkspaceResolveOptions>(options =>
{
options.WorkspaceResolvers.Add(context.Services
.GetRequiredService<WorkspaceIdHeaderResolveContributor>());
options.WorkspaceResolvers.Add(context.Services
.GetRequiredService<WorkspaceRouteResolveContributor>());
});
PublicPortalHttpApiModule — subdomain contributor
Configure<WorkspaceResolveOptions>(options =>
{
options.WorkspaceResolvers.Add(context.Services
.GetRequiredService<WorkspaceSubdomainResolveContributor>());
});
AppHttpApiHostModule — register middleware
// OnApplicationInitialization:
app.UseMultiTenancy();
app.UseMiddleware<WorkspaceResolutionMiddleware>(); // ← after tenancy, before UoW
app.UseUnitOfWork();
Entity Usage
The Workspace aggregate (BackOffice.Domain/Workspaces/Workspace.cs)
// Workspace itself does NOT implement IWorkspace — it IS the workspace, not an entity owned by one.
public class Workspace : FullAuditedAggregateRoot<Guid>, IMultiTenant
{
public Guid? TenantId { get; private set; }
public string Name { get; private set; } // max 128
public string Slug { get; private set; } // max 64, unique per tenant
public string Description { get; private set; } // max 1024, nullable
public bool IsActive { get; private set; }
private Workspace() { } // for ORM
public Workspace(Guid id, Guid? tenantId, string name, string slug, string description = null);
public Workspace SetName(string name);
public Workspace SetSlug(string slug);
public Workspace SetDescription(string description);
public void Activate();
public void Deactivate();
}
- Consts in
BackOffice.Domain.Shared/Workspaces/WorkspaceConsts.cs. - Mapped with unique index
(TenantId, Slug),IsActivedefaulttrue.
Workspace-scoped entities
// Any entity scoped to a workspace — implements IWorkspace from YourApp.Core
public class Ground : FullAuditedAggregateRoot<Guid>, IMultiTenant, IWorkspace
{
public Guid? TenantId { get; set; }
public Guid? WorkspaceId { get; set; } // ← IWorkspace (BackOffice filter scopes on this)
// ...
}
public class Booking : FullAuditedAggregateRoot<Guid>, IMultiTenant, IWorkspace
{
public Guid? TenantId { get; set; }
public Guid? WorkspaceId { get; set; }
// ...
}
PublicPortal read models carry a plain Guid WorkspaceId property (NOT the IWorkspace marker) set explicitly from event payloads — they are not auto-filtered.
Security / Validation
| Resolver | Validation |
|---|---|
| Header | _employeeWorkspaceIdCache checked; unauthorized access logged + silently dropped |
| Subdomain | Slug must resolve to a workspace in the current tenant (repo.FindBySlugAsync scoped by tenancy) |
| Route | Permission gate: BackOffice.Workspaces.CrossWorkspaceAccess (owner/admin only) |
Angular BackOffice Changes
Existing Angular Infrastructure
| File | What it does |
|---|---|
theme/services/workspace-context.service.ts | Angular signal-based workspace selection; localStorage('app.selectedWorkspace'); auto-selects single workspace |
theme/sidebar/workspace-switcher/workspace-switcher.component.ts | Sidebar dropdown for switching workspaces |
shared/models/workspace.model.ts | Workspace interface, PagedResult<T> |
shared/services/workspace.service.ts | Abstract WorkspaceService DI token |
WorkspaceSelection type:
export type WorkspaceSelection = { mode: 'all' } | { mode: 'single'; workspaceId: string };
mode: 'all' → no header sent → backend cross-workspace view (owner)mode: 'single' → X-Workspace-Id header sent → backend scoped view (staff)
Missing — Angular Additions
A — HTTP Interceptor
New: src/app/shared/interceptors/workspace-header.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { WorkspaceContextService } from '../../theme/services/workspace-context.service';
import { environment } from '../../../environments/environment';
export const workspaceHeaderInterceptor: HttpInterceptorFn = (req, next) => {
const apiBase = environment.apis['default'].url;
if (!req.url.startsWith(apiBase)) return next(req); // skip auth/external calls
const sel = inject(WorkspaceContextService).selection();
if (sel.mode === 'single') {
req = req.clone({ setHeaders: { 'X-Workspace-Id': sel.workspaceId } });
}
return next(req);
};
Edit app.config.ts:
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { workspaceHeaderInterceptor } from './shared/interceptors/workspace-header.interceptor';
// Add to providers:
provideHttpClient(withInterceptors([workspaceHeaderInterceptor])),
ABP note: Verify whether
provideAbpCoreinternally callsprovideHttpClient. If it does, useHTTP_INTERCEPTORSmulti-token instead ofwithInterceptors.
B — Workspace-Required Route Guard
New: src/app/shared/guards/workspace-required.guard.ts
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { WorkspaceContextService } from '../../theme/services/workspace-context.service';
export const workspaceRequiredGuard: CanActivateFn = () => {
const ctx = inject(WorkspaceContextService);
const router = inject(Router);
if (ctx.selection().mode === 'single') return true;
if (ctx.isSingleWorkspace()) return true; // auto-selected — guard passes
return router.createUrlTree(['/workspaces'], { queryParams: { selectFirst: true } });
};
Apply to workspace-specific pages:
// app.routes.ts
{ path: 'schedule', canActivate: [workspaceRequiredGuard], loadComponent: () => ... },
{ path: 'workspaces/:id/items', canActivate: [workspaceRequiredGuard], loadComponent: () => ... },
End-to-End Flow (Angular BackOffice → Backend)
Staff selects workspace in WorkspaceSwitcherComponent
→ WorkspaceContextService.select({ mode: 'single', workspaceId })
→ persisted to localStorage
Staff opens Schedule page
→ workspaceRequiredGuard: mode='single' → pass
→ ScheduleComponent calls BookingService.getSchedule()
→ workspaceHeaderInterceptor: appends X-Workspace-Id header
Backend:
→ WorkspaceResolutionMiddleware: reads X-Workspace-Id
→ WorkspaceIdHeaderResolveContributor: validates employee access via cache
→ cache hit: WorkspaceDto loaded without DB
→ ICurrentWorkspace.Change(id, name, slug) — AsyncLocal set
→ EF Core query filter: WHERE WorkspaceId = <id>
→ Only this workspace's data returned
Angular renders schedule grid scoped to the selected workspace.
Implementation Checklist
- Create
YourApp.Coreproject withMultiWorkspace/folder (all interfaces + middleware) - Register
YourApp.Corein solution + addWorkspaceCoreModule[DependsOn] - Wire
BackOffice.Domain.Shared+PublicPortal.Domain.Shared→WorkspaceCoreModule - Add
protected ICurrentWorkspace CurrentWorkspaceto both AppService base classes - Add EF Core filter + SaveChanges stamping to
BackOfficeDbContextONLY - Implement
WorkspaceIdHeaderResolveContributorinBackOffice.HttpApi - Implement
WorkspaceSubdomainResolveContributorinPublicPortal.HttpApi - Implement optional
WorkspaceRouteResolveContributorinBackOffice.HttpApi - Register contributors in both HttpApi modules via
WorkspaceResolveOptions - Register
app.UseMiddleware<WorkspaceResolutionMiddleware>()in host (between tenancy and UoW) - Add
DistributedCache.GlobalCacheEntryOptions(10 min TTL) to hostappsettings.json - Create
Workspaceaggregate + consts + EF mapping + migration - Implement
WorkspaceRepository : IWorkspaceRepositoryviaIDbContextProvider<BackOfficeDbContext> - Create Angular
workspace-header.interceptor.ts+ register inapp.config.ts - Create Angular
workspace-required.guard.ts+ apply to workspace-specific routes - Add
IWorkspaceto real scoped entities when they’re created - Integration test:
X-Workspace-Idheader → verify BackOffice EF filter scopes results
Key Design Decisions
| Concern | Decision | Why |
|---|---|---|
| Resolution strategies | Header + Subdomain + Route | Different callers need different resolution paths |
| Slug cache | Separate slug → Guid cache | Subdomain resolution knows slug before Guid; avoids extra DB round-trip |
| Extra context field | Slug on ICurrentWorkspace | PublicPortal needs the slug, not just the id |
| Caching | No explicit TTL anywhere | Defer to global AbpDistributedCacheOptions in appsettings.json |
| Employee caches | Two: List<Guid> (fast check) + List<EmployeeWorkspaceDto> (full DTO) | Access check uses only IDs; full DTO only needed when richer context is required |
| Filter scope | BackOffice only | PublicPortal is a projection — auto-filtering would require disabling on nearly every read |
| Repository coupling | IWorkspaceRepository contract in Core, EF impl in BackOffice | Keeps dependency direction Core ← BackOffice; avoids cycle |
| ABP version | 10.4.1 | CreateFilterExpression gained EntityTypeBuilder<TEntity> second param vs. earlier versions |