Scoped Lenses¶
Verified by tests
ScopedLensFactoryTests, ScopedLensFactoryImplTests, ScopedLensQueryTests, ScopeFilterTests, ScopeFilterBuilderTests, ScopeDefinitionTests, FilterModeTests, EFCoreFilterableLensQueryTests — library CI run #37346231411 (2026-10-05)
Scoped Lenses combine the query capabilities of Lenses with the data isolation features of the Scoping System. They automatically apply tenant, user, organization, and principal-based filters to your queries without manual WHERE clauses.
Overview¶
Scoped lenses solve a common problem in multi-tenant applications: ensuring queries only return data the caller is authorized to see. Instead of manually adding WHERE TenantId = @CurrentTenant to every query, scoped lenses apply these filters automatically based on configuration and runtime context.
Key Components:
| Component | Purpose |
|---|---|
IScopedLensFactory |
Factory for creating lenses with scope filters applied |
ScopedLensFactory |
Default implementation that resolves lenses from DI |
IScopedLensQuery<T> |
Auto-scoping query for singleton services |
ScopedLensQuery<T> |
Implementation that creates fresh scopes per operation |
ScopeDefinition |
Defines a named scope configuration |
LensOptions |
Configuration container for scope definitions |
FilterMode |
How filters are applied (Equals vs In) |
IScopedLensFactory¶
The IScopedLensFactory is the primary entry point for obtaining scoped lenses. It resolves lens instances from DI and automatically applies scope filters based on the current context.
Primary API: Composable Filters¶
Primary API: Composable Filters
public interface IScopedLensFactory {
// Get lens with composable scope filters
TLens GetLens<TLens>(ScopeFilters filters) where TLens : ILensQuery;
// Get lens with filters + permission check
TLens GetLens<TLens>(ScopeFilters filters, Permission requiredPermission) where TLens : ILensQuery;
// Get lens with filters + any-of permissions
TLens GetLens<TLens>(ScopeFilters filters, params Permission[] anyOfPermissions) where TLens : ILensQuery;
}
Convenience Methods¶
For common patterns, use the convenience methods:
Convenience Methods
// No filtering (admin access)
factory.GetGlobalLens<TLens>(); // ScopeFilters.None
// Tenant isolation
factory.GetTenantLens<TLens>(); // ScopeFilters.Tenant
// Tenant + User isolation
factory.GetUserLens<TLens>(); // ScopeFilters.Tenant | ScopeFilters.User
// Tenant + Organization
factory.GetOrganizationLens<TLens>(); // ScopeFilters.Tenant | ScopeFilters.Organization
// Tenant + Customer
factory.GetCustomerLens<TLens>(); // ScopeFilters.Tenant | ScopeFilters.Customer
// Tenant + Principal membership
factory.GetPrincipalLens<TLens>(); // ScopeFilters.Tenant | ScopeFilters.Principal
// "My records OR shared with me"
factory.GetMyOrSharedLens<TLens>(); // ScopeFilters.Tenant | ScopeFilters.User | ScopeFilters.Principal
Usage Examples¶
Usage Examples
public class OrderController : ControllerBase {
private readonly IScopedLensFactory _lensFactory;
public OrderController(IScopedLensFactory lensFactory) {
_lensFactory = lensFactory;
}
[HttpGet]
public async Task<IActionResult> GetOrders() {
// Only returns orders for current tenant
var lens = _lensFactory.GetTenantLens<IOrderLens>();
var orders = await lens.GetAllAsync();
return Ok(orders);
}
[HttpGet("my")]
public async Task<IActionResult> GetMyOrders() {
// Only returns orders owned by current user (within tenant)
var lens = _lensFactory.GetUserLens<IOrderLens>();
var orders = await lens.GetAllAsync();
return Ok(orders);
}
[HttpGet("shared")]
public async Task<IActionResult> GetMyOrSharedOrders() {
// Returns user's orders OR orders shared with their groups
var lens = _lensFactory.GetMyOrSharedLens<IOrderLens>();
var orders = await lens.GetAllAsync();
return Ok(orders);
}
}
Permission Checks¶
Combine scope filtering with permission verification:
Permission Checks
// Throws AccessDeniedException if caller lacks permission
var lens = _lensFactory.GetLens<IOrderLens>(
ScopeFilters.Tenant,
Permission.Read("orders"));
// Caller must have at least one of these permissions
var lens = _lensFactory.GetLens<IReportLens>(
ScopeFilters.Tenant | ScopeFilters.Principal,
Permission.Read("reports"),
Permission.Read("analytics"));
When permission checks fail:
1. An AccessDenied system event is emitted for audit logging
2. AccessDeniedException is thrown with details about the required permission
ScopedLensQuery¶
IScopedLensQuery<TModel> provides auto-scoping queries for use in singleton services, background workers, or test fixtures where you cannot inject scoped services directly.
The Problem¶
When injecting ILensQuery<T> into a singleton service, you get a stale DbContext:
The Problem
// BAD: DbContext becomes stale in singleton
public class OrderProcessor : BackgroundService {
private readonly ILensQuery<Order> _lens; // Injected once, never refreshed
protected override async Task ExecuteAsync(CancellationToken ct) {
while (!ct.IsCancellationRequested) {
// This query uses a stale DbContext!
var orders = await _lens.DefaultScope.Query.ToListAsync();
}
}
}
The Solution¶
Use IScopedLensQuery<T> which creates a fresh service scope for each operation:
The Solution
// GOOD: Fresh scope per query
public class OrderProcessor : BackgroundService {
private readonly IScopedLensQuery<Order> _scopedLens;
public OrderProcessor(IScopedLensQuery<Order> scopedLens) {
_scopedLens = scopedLens;
}
protected override async Task ExecuteAsync(CancellationToken ct) {
while (!ct.IsCancellationRequested) {
// Each query creates a fresh scope with new DbContext
var orders = await _scopedLens.ExecuteAsync(
async (lens, token) => await lens.DefaultScope.Query
.Where(o => o.Data.Status == "pending")
.ToListAsync(token),
ct);
await ProcessOrdersAsync(orders);
await Task.Delay(TimeSpan.FromSeconds(30), ct);
}
}
}
IScopedLensQuery Methods¶
IScopedLensQuery Methods
public interface IScopedLensQuery<TModel> where TModel : class {
// Stream results with auto-scoping
IAsyncEnumerable<PerspectiveRow<TModel>> QueryAsync(
Func<ILensQuery<TModel>, IQueryable<PerspectiveRow<TModel>>> queryBuilder,
CancellationToken cancellationToken = default);
// Projection queries
IAsyncEnumerable<TResult> QueryAsync<TResult>(
Func<ILensQuery<TModel>, IQueryable<TResult>> queryBuilder,
CancellationToken cancellationToken = default);
// Fast ID lookup
Task<TModel?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
// Materialized queries (ToListAsync, FirstOrDefaultAsync, etc.)
Task<TResult> ExecuteAsync<TResult>(
Func<ILensQuery<TModel>, CancellationToken, Task<TResult>> queryExecutor,
CancellationToken cancellationToken = default);
}
Usage Examples¶
Usage Examples - ReportGenerator
public class ReportGenerator {
private readonly IScopedLensQuery<OrderSummary> _orderLens;
public ReportGenerator(IScopedLensQuery<OrderSummary> orderLens) {
_orderLens = orderLens;
}
public async Task<decimal> CalculateTotalRevenueAsync(
DateOnly startDate,
DateOnly endDate,
CancellationToken ct) {
// ExecuteAsync for aggregations
return await _orderLens.ExecuteAsync(
async (lens, token) => await lens.DefaultScope.Query
.Where(o => o.Data.OrderDate >= startDate && o.Data.OrderDate <= endDate)
.SumAsync(o => o.Data.Total, token),
ct);
}
public async Task<OrderSummary?> GetOrderAsync(Guid orderId, CancellationToken ct) {
// GetByIdAsync for single-item lookup
return await _orderLens.GetByIdAsync(orderId, ct);
}
public async IAsyncEnumerable<OrderSummary> GetRecentOrdersAsync(
[EnumeratorCancellation] CancellationToken ct) {
// QueryAsync for streaming results
await foreach (var row in _orderLens.QueryAsync(
lens => lens.DefaultScope.Query
.OrderByDescending(o => o.Data.OrderDate)
.Take(100),
ct)) {
yield return row.Data;
}
}
}
Scope Definition¶
ScopeDefinition defines a named scope configuration for the legacy string-based API. While the composable ScopeFilters flags are preferred, scope definitions provide a way to define reusable, named scopes.
Properties¶
Properties
public sealed class ScopeDefinition {
// Unique name for this scope (e.g., "Tenant", "User", "Global")
public string Name { get; }
// Property name to filter by (e.g., "TenantId", "UserId")
public string? FilterPropertyName { get; set; }
// Key to retrieve filter value from context
public string? ContextKey { get; set; }
// Filter comparison mode (Equals or In)
public FilterMode FilterMode { get; set; } = FilterMode.Equals;
// When true, no filter is applied (admin/global access)
public bool NoFilter { get; set; }
// Optional interface type for filtered models
public Type? FilterInterfaceType { get; set; }
}
Defining Named Scopes¶
Defining Named Scopes
services.AddWhizbang(options => {
// Tenant-only scope
options.Lenses.DefineScope("Tenant", scope => {
scope.FilterPropertyName = "TenantId";
scope.ContextKey = "TenantId";
scope.FilterInterfaceType = typeof(ITenantScoped);
});
// User scope (within tenant)
options.Lenses.DefineScope("User", scope => {
scope.FilterPropertyName = "UserId";
scope.ContextKey = "UserId";
});
// Global/admin scope (no filtering)
options.Lenses.DefineScope("Global", scope => {
scope.NoFilter = true;
});
// Hierarchical scope (IN clause)
options.Lenses.DefineScope("TenantHierarchy", scope => {
scope.FilterPropertyName = "TenantId";
scope.ContextKey = "TenantHierarchy";
scope.FilterMode = FilterMode.In;
});
});
Using Named Scopes (Legacy API)¶
Using Named Scopes (Legacy API)
// Get lens using named scope
var lens = factory.GetLens<IOrderLens>("Tenant");
var globalLens = factory.GetLens<IOrderLens>("Global");
Note: Prefer the composable ScopeFilters flags over named scopes for new code.
Configuration¶
LensOptions is the configuration container for lens scoping. It holds scope definitions and is configured at service registration time.
LensOptions API¶
LensOptions API
public sealed class LensOptions {
// Get all defined scopes
public IReadOnlyList<ScopeDefinition> Scopes { get; }
// Define a named scope
public LensOptions DefineScope(string name, Action<ScopeDefinition> configure);
// Get scope by name (case-insensitive)
public ScopeDefinition? GetScope(string name);
}
Configuration Example¶
Configuration Example
services.AddWhizbang(options => {
// Chain multiple scope definitions
options.Lenses
.DefineScope("Tenant", scope => {
scope.FilterPropertyName = "TenantId";
scope.ContextKey = "TenantId";
scope.FilterInterfaceType = typeof(ITenantScoped);
})
.DefineScope("User", scope => {
scope.FilterPropertyName = "UserId";
scope.ContextKey = "UserId";
})
.DefineScope("Global", scope => {
scope.NoFilter = true;
});
});
Accessing Configuration at Runtime¶
Accessing Configuration at Runtime
public class CustomLensFactory {
private readonly LensOptions _options;
public CustomLensFactory(LensOptions options) {
_options = options;
}
public void ListScopes() {
foreach (var scope in _options.Scopes) {
Console.WriteLine($"Scope: {scope.Name}, Filter: {scope.FilterPropertyName}");
}
}
public bool IsScopeDefined(string name) {
return _options.GetScope(name) is not null;
}
}
Filter Modes¶
FilterMode specifies how scope filters are applied to queries. This affects the SQL WHERE clause generation.
FilterMode Enum¶
FilterMode Enum
public enum FilterMode {
// Filter using equality (WHERE property = @value)
Equals = 0,
// Filter using IN clause (WHERE property IN @values)
In = 1
}
Equals Mode (Default)¶
Use for single-value filtering:
Equals Mode (Default)
// Configuration
scope.FilterPropertyName = "TenantId";
scope.FilterMode = FilterMode.Equals;
// Generated SQL
// WHERE scope->>'TenantId' = 'tenant-123'
In Mode¶
Use for hierarchical or multi-value filtering:
In Mode
// Configuration
scope.FilterPropertyName = "TenantId";
scope.ContextKey = "TenantHierarchy"; // Returns multiple values
scope.FilterMode = FilterMode.In;
// Generated SQL
// WHERE scope->>'TenantId' IN ('parent-tenant', 'child-1', 'child-2')
Use Cases for IN Mode¶
Tenant Hierarchies: A parent tenant can see data from all child tenants:
Use Cases for IN Mode
// Context provides hierarchy
scopeContext.Set("TenantHierarchy", new[] { "parent", "child-1", "child-2" });
// Scope definition
options.Lenses.DefineScope("TenantHierarchy", scope => {
scope.FilterPropertyName = "TenantId";
scope.ContextKey = "TenantHierarchy";
scope.FilterMode = FilterMode.In;
});
Region-based Access: Access data from multiple regions:
Use Cases for IN Mode (2)
scopeContext.Set("AllowedRegions", new[] { "us-west", "us-east" });
options.Lenses.DefineScope("Region", scope => {
scope.FilterPropertyName = "Region";
scope.ContextKey = "AllowedRegions";
scope.FilterMode = FilterMode.In;
});
EF Core Implementation¶
EFCoreFilterableLensQuery<TModel> is the EF Core implementation that applies scope filters to queries. It implements IFilterableLens to receive filter information from IScopedLensFactory.
How It Works¶
IScopedLensFactoryresolves the lens from DI- If the lens implements
IFilterableLens, the factory callsApplyFilter() - The lens stores the filter info and applies it when
Queryis accessed - EF Core translates the LINQ expressions to PostgreSQL JSONB queries
Filter Composition¶
Filter Composition
public class EFCoreFilterableLensQuery<TModel> : ILensQuery<TModel>, IFilterableLens {
private ScopeFilterInfo _filterInfo;
public void ApplyFilter(ScopeFilterInfo filterInfo) {
_filterInfo = filterInfo;
}
public IQueryable<PerspectiveRow<TModel>> Query {
get {
if (_filterInfo.IsEmpty) {
// No explicit filter applied - fall back to the configured default scope
return DefaultScope.Query;
}
var query = _context.Set<PerspectiveRow<TModel>>().AsNoTracking();
// Tenant filter (always AND'd first)
if (_filterInfo.Filters.HasFlag(ScopeFilters.Tenant) && _filterInfo.TenantId is not null) {
query = query.Where(r => r.Scope.TenantId == _filterInfo.TenantId);
}
// Organization filter
if (_filterInfo.Filters.HasFlag(ScopeFilters.Organization) && _filterInfo.OrganizationId is not null) {
query = query.Where(r => r.Scope.OrganizationId == _filterInfo.OrganizationId);
}
// Customer filter
if (_filterInfo.Filters.HasFlag(ScopeFilters.Customer) && _filterInfo.CustomerId is not null) {
query = query.Where(r => r.Scope.CustomerId == _filterInfo.CustomerId);
}
// User + Principal with special OR logic
var hasUserFilter = _filterInfo.Filters.HasFlag(ScopeFilters.User) && _filterInfo.UserId is not null;
var hasPrincipalFilter = _filterInfo.Filters.HasFlag(ScopeFilters.Principal)
&& _filterInfo.SecurityPrincipals.Count > 0;
if (_filterInfo.UseOrLogicForUserAndPrincipal && hasUserFilter && hasPrincipalFilter) {
// "My records OR shared with me"
query = query.FilterByUserOrPrincipals(_filterInfo.UserId, _filterInfo.SecurityPrincipals);
} else {
if (hasUserFilter) {
query = query.Where(r => r.Scope.UserId == _filterInfo.UserId);
}
if (hasPrincipalFilter) {
query = query.FilterByPrincipals(_filterInfo.SecurityPrincipals);
}
}
return query;
}
}
}
Generated SQL Examples¶
| Filter Combination | Generated WHERE Clause |
|---|---|
Tenant |
WHERE scope->>'TenantId' = 'tenant-123' |
Tenant \| User |
WHERE scope->>'TenantId' = ? AND scope->>'UserId' = ? |
Tenant \| Organization |
WHERE scope->>'TenantId' = ? AND scope->>'OrganizationId' = ? |
Tenant \| Principal |
WHERE scope->>'TenantId' = ? AND scope->'AllowedPrincipals' ?| [...] |
Tenant \| User \| Principal |
WHERE scope->>'TenantId' = ? AND (scope->>'UserId' = ? OR scope->'AllowedPrincipals' ?| [...]) |
IFilterableLens Interface¶
Implement this interface on custom lens implementations to support scope filtering:
IFilterableLens Interface
public interface IFilterableLens {
void ApplyFilter(ScopeFilterInfo filterInfo);
}
// Custom implementation
public class CustomLens<TModel> : ILensQuery<TModel>, IFilterableLens {
private ScopeFilterInfo _filterInfo;
public void ApplyFilter(ScopeFilterInfo filterInfo) {
_filterInfo = filterInfo;
}
public IQueryable<PerspectiveRow<TModel>> Query {
get {
// Apply _filterInfo to your query implementation
}
}
}
Best Practices¶
DO¶
- Use composable
ScopeFiltersflags for new code - Prefer convenience methods (
GetTenantLens,GetUserLens) for common patterns - Combine scope filtering with permission checks for defense in depth
- Use
IScopedLensQuery<T>in singleton services and background workers - Apply the most restrictive scope appropriate for each use case
DON'T¶
- Don't bypass scoped lenses with raw SQL that ignores filters
- Don't cache lenses across requests (they contain request-specific filters)
- Don't use
GetGlobalLenswithout strong justification (admin only) - Don't mix legacy named scopes with composable flags unnecessarily
Related Documentation¶
- Lenses - Base lens concepts and query patterns
- Scoping - Scope filters, PerspectiveScope, and ScopeFilterBuilder
- Security - Permissions, roles, and access control
- System Events - AccessDenied and other security events
Version 1.0.0 - Foundation Release