Data Ownership & Scoped Visibility
Three levels of data access control that compose naturally with entity traits.
Overview
Section titled “Overview”| Level | Attribute | What it does | Use case |
|---|---|---|---|
| L1 | [OwnedEntity] | ”I see only my records” | Personal data, user-created resources |
| L2 | [ScopedEntity] | ”My team sees our records” | Team/department/region visibility |
| L3 | DataScopeRule<T> | ”Records matching X belong to scope Y” | Rule-based assignment, dynamic groups |
All three levels integrate with IPermissionBasedFilter<T> — admin users with the bypass permission see everything.
L1: Creator Ownership
Section titled “L1: Creator Ownership”[Entity<Guid>][Auditable][OwnedEntity]public partial class Reservation { ... }What gets generated
Section titled “What gets generated”| File | Content |
|---|---|
Reservation.Ownership.g.cs | OwnerId property, SetOwnerId() method, IOwnedEntity interface |
Reservation.OwnershipFilter.g.cs | Nested OwnershipFilter : IPermissionBasedFilter<Reservation> |
How it works
Section titled “How it works”- On create:
MutationInvoker.CreateEntity()callsentity.SetOwnerId(_currentUser?.Id ?? "")automatically - On query:
OwnershipFilterappliese => e.OwnerId == currentUser.Id - Admin bypass: users with
booking.reservation.view-allpermission skip the filter - No user context: background jobs and seed data skip the filter (no
ICurrentUser)
Bypass permission format
Section titled “Bypass permission format”{boundary}.{entity-kebab-case}.view-allExamples: booking.reservation.view-all, catalog.room-type.view-all
Manual override
Section titled “Manual override”If you declare OwnerId yourself, the SG skips property generation but still generates the filter:
[Entity<Guid>][OwnedEntity]public partial class Document{ public string OwnerId { get; private set; } = ""; // Manual — SG skips this}L2: Scope-Based Access
Section titled “L2: Scope-Based Access”[Entity<Guid>][ScopedEntity]public partial class Invoice { ... }What gets generated
Section titled “What gets generated”| File | Content |
|---|---|
Invoice.Scoping.g.cs | AccessScopes property, GrantScope()/RevokeScope() methods, IScopedEntity |
Invoice.ScopedDataFilter.g.cs | Nested ScopedDataFilter : IPermissionBasedFilter<Invoice> |
How scopes work
Section titled “How scopes work”AccessScopes is a List<string> stored as a JSON column. Each entry is a scope identifier:
["user:alice", "role:billing-agent", "scope:billing-eu"]At query time, IUserScopeResolver expands the current user’s identity into scope identifiers. The filter checks for overlap:
entity => entity.AccessScopes.Any(s => userScopes.Contains(s))Default scope expansion
Section titled “Default scope expansion”DefaultUserScopeResolver produces:
user:{userId}— always presentrole:{roleName}— for each assigned rolescope:{value}— for eachdata-scopeclaim
Granting/revoking access
Section titled “Granting/revoking access”invoice.GrantScope("scope:billing-eu"); // EU team can now see this invoiceinvoice.GrantScope("user:bob"); // Bob can now see this invoiceinvoice.RevokeScope("scope:billing-eu"); // EU team loses accessAuthorization integration
Section titled “Authorization integration”Register named scopes and assign them to groups:
// In AuthorizationBuilderauthz.MapDataScope("billing-eu", "EUR invoices");authz.MapGroup("eu-team", g => g .WithRoles("billing-agent") .WithDataScopes("billing-eu"));L1 + L2 Combined
Section titled “L1 + L2 Combined”When an entity has both attributes, a single DataAccessFilter replaces separate filters:
[Entity<Guid>][OwnedEntity][ScopedEntity]public partial class Guest { ... }Generated: Guest.DataAccessFilter.g.cs with OR logic:
entity => entity.OwnerId == userId || entity.AccessScopes.Any(s => userScopes.Contains(s))The user sees the record if they own it OR if their scopes overlap.
L3: Specification Scopes
Section titled “L3: Specification Scopes”Define rules that automatically assign entities to scopes based on expressions.
Define a rule
Section titled “Define a rule”public sealed class EurInvoiceScopeRule : DataScopeRule<Invoice>{ public override string ScopeName => "billing-eu"; public override ScopeStrategy Strategy => ScopeStrategy.Materialized;
public override Expression<Func<Invoice, bool>> ToExpression() => invoice => invoice.Currency == "EUR";}Register
Section titled “Register”services.AddDataScopeRule<EurInvoiceScopeRule, Invoice>();Strategies
Section titled “Strategies”| Strategy | When evaluated | Performance | Accuracy |
|---|---|---|---|
Materialized | On create/update (explicit) | Fast (indexed) | Stale if rule changes |
Computed | At query time | Slower | Always current |
Hybrid | Both | Fast + verified | Best |
Materialize
Section titled “Materialize”// Explicit materialization — evaluates all Materialized/Hybrid rulesscopeMaterializer.Materialize(invoice);// invoice.AccessScopes now contains "scope:billing-eu" if Currency == "EUR"Computed filter
Section titled “Computed filter”For Computed and Hybrid strategies, ComputedScopeFilter<T> evaluates rule expressions at query time, OR-composing only the rules whose scopes the user has access to. Registered automatically by AddDataScopeRule.
Priority Reference
Section titled “Priority Reference”| Filter | Priority | Type |
|---|---|---|
| SoftDelete | 100 | Singleton (stateless) |
| Temporal | 150 | Singleton (stateless) |
| Ownership | 200 | Scoped (ICurrentUser) |
| Tenant | 200 | Scoped (ITenantContext) |
| DataAccess (combined) | 200 | Scoped (ICurrentUser + IUserScopeResolver) |
| ScopedData | 250 | Scoped (IUserScopeResolver) |
| ComputedScope | 260 | Scoped (IUserScopeResolver + rules) |
| Custom | 300+ | Your choice |
Common Mistakes
Section titled “Common Mistakes”Using CreatedBy instead of OwnerId
Section titled “Using CreatedBy instead of OwnerId”CreatedBy (from [Auditable]) tracks who created the record for audit purposes. OwnerId (from [OwnedEntity]) controls who can see the record. They are set to the same value at creation but OwnerId can be reassigned; CreatedBy cannot.
Forgetting [Entity<T>]
Section titled “Forgetting [Entity<T>]”[OwnedEntity] and [ScopedEntity] require [Entity<T>] on the class. Without it, the SG won’t detect the entity and no filter will be generated.
Using both attributes when only one is needed
Section titled “Using both attributes when only one is needed”If you only need “see my own records”, use [OwnedEntity] alone. Adding [ScopedEntity] without actually using scopes adds unnecessary complexity (JSON column, scope resolver injection).