Skip to content

Data Ownership & Scoped Visibility

Three levels of data access control that compose naturally with entity traits.

LevelAttributeWhat it doesUse case
L1[OwnedEntity]”I see only my records”Personal data, user-created resources
L2[ScopedEntity]”My team sees our records”Team/department/region visibility
L3DataScopeRule<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.


[Entity<Guid>]
[Auditable]
[OwnedEntity]
public partial class Reservation { ... }
FileContent
Reservation.Ownership.g.csOwnerId property, SetOwnerId() method, IOwnedEntity interface
Reservation.OwnershipFilter.g.csNested OwnershipFilter : IPermissionBasedFilter<Reservation>
  1. On create: MutationInvoker.CreateEntity() calls entity.SetOwnerId(_currentUser?.Id ?? "") automatically
  2. On query: OwnershipFilter applies e => e.OwnerId == currentUser.Id
  3. Admin bypass: users with booking.reservation.view-all permission skip the filter
  4. No user context: background jobs and seed data skip the filter (no ICurrentUser)
{boundary}.{entity-kebab-case}.view-all

Examples: booking.reservation.view-all, catalog.room-type.view-all

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
}

[Entity<Guid>]
[ScopedEntity]
public partial class Invoice { ... }
FileContent
Invoice.Scoping.g.csAccessScopes property, GrantScope()/RevokeScope() methods, IScopedEntity
Invoice.ScopedDataFilter.g.csNested ScopedDataFilter : IPermissionBasedFilter<Invoice>

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))

DefaultUserScopeResolver produces:

  • user:{userId} — always present
  • role:{roleName} — for each assigned role
  • scope:{value} — for each data-scope claim
invoice.GrantScope("scope:billing-eu"); // EU team can now see this invoice
invoice.GrantScope("user:bob"); // Bob can now see this invoice
invoice.RevokeScope("scope:billing-eu"); // EU team loses access

Register named scopes and assign them to groups:

// In AuthorizationBuilder
authz.MapDataScope("billing-eu", "EUR invoices");
authz.MapGroup("eu-team", g => g
.WithRoles("billing-agent")
.WithDataScopes("billing-eu"));

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.


Define rules that automatically assign entities to scopes based on expressions.

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";
}
services.AddDataScopeRule<EurInvoiceScopeRule, Invoice>();
StrategyWhen evaluatedPerformanceAccuracy
MaterializedOn create/update (explicit)Fast (indexed)Stale if rule changes
ComputedAt query timeSlowerAlways current
HybridBothFast + verifiedBest
// Explicit materialization — evaluates all Materialized/Hybrid rules
scopeMaterializer.Materialize(invoice);
// invoice.AccessScopes now contains "scope:billing-eu" if Currency == "EUR"

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.


FilterPriorityType
SoftDelete100Singleton (stateless)
Temporal150Singleton (stateless)
Ownership200Scoped (ICurrentUser)
Tenant200Scoped (ITenantContext)
DataAccess (combined)200Scoped (ICurrentUser + IUserScopeResolver)
ScopedData250Scoped (IUserScopeResolver)
ComputedScope260Scoped (IUserScopeResolver + rules)
Custom300+Your choice

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.

[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).