Skip to content

18-data-ownership

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

LevelAttributeWhat it doesUse case
L1[HasOwner]”I see only my records”Personal data, user-created resources
L2[HasAccessScopes]”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]
[HasOwner]
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>]
[HasOwner]
public partial class Document
{
public string OwnerId { get; private set; } = ""; // Manual — SG skips this
}

[Entity<Guid>]
[HasAccessScopes]
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

ScopeInterceptor, at SaveChanges, on every write path — the same place OwnershipInterceptor stamps the owner and AuditingInterceptor stamps attribution, and for the same reason: an entity created by an action through a repository has to be attributed too, not only one created by a mutation.

An inserted row with an empty AccessScopesgets the caller’s own scope, user:{id}
An inserted row that already carries scopeskeeps them — an import attributing rows to the department that owned them is not re-attributed to whoever ran the import
An updated rowis never re-stamped: the last person to touch a row does not acquire it
No current user — a job, a message off a bus, a seednothing is stamped, and the row is the system’s

⚠️ Until this existed, nothing wrote AccessScopes at all: the attribute generated the column, the grant/revoke pair and the filter, and the filter was therefore false for every row and every caller. A scoped entity was invisible to everyone, including the person who had just created it, and only the view-all bypass could see it.

⚠️ The stamped string has to be the one IUserScopeResolver produces for the same principal. Both sides read it from ScopeIdentifiers for that reason: a divergence is not an error anywhere, it is a row nobody can see.

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

⚠️ Both are generated internal. Only the assembly that owns the entity can grant or revoke a scope, so these calls belong in that module’s own code — a mutation, a domain action, a lifecycle hook — and not in the host or another boundary.

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>]
[HasOwner]
[HasAccessScopes]
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 and update, at SaveChangesFast (indexed)Stale if the rule changes
ComputedAt query timeSlowerAlways current
HybridBothFast + verifiedBest

Nothing to call: ScopeInterceptor runs the registered rules over the rows being inserted and updated, and the generated query-filter registration wires one typed step per scoped entity. Saving an invoice whose Currency is "EUR" leaves scope:billing-eu on it.

Updates matter, and are the difference from the creator’s stamp beside it. A materialised scope is a function of the row’s data, so an invoice moving from EUR to USD loses scope:billing-eu and gains scope:billing-usd — which is why the materializer removes as well as adds.

⚠️ Order: the stamp first, then the rules, fixed inside ScopeInterceptor. The stamp writes the creator’s scope only when the list is empty, so materialising first would fill the list, “empty” would never be true again, and no row would ever carry its creator’s scope. The two do not otherwise interfere: the stamp writes user:, the rules add and remove only their own scope:{name}.

⚠️ Until this was wired, IScopeMaterializer was reachable only by calling it yourself, and no production path did — so a rule could be declared, registered, accepted, and never once evaluated against a row.

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)
Temporal200Singleton (stateless)
Ownership200Scoped (ICurrentUser)
Tenant200Scoped (ITenantContext)
DataAccess (combined)200Scoped (ICurrentUser + IUserScopeResolver)
ScopedData250Scoped (IUserScopeResolver)
ComputedScope260Scoped (IUserScopeResolver + rules)
Custom300+Your choice — IQueryFilter.Priority defaults to 0, which runs before all of these

Four of them share 200, and nothing orders a tie: they are ANDed, so the order between them does not change the result.


CreatedBy (from [Auditable]) tracks who created the record for audit purposes. OwnerId (from [HasOwner]) controls who can see the record. They are set to the same value at creation but OwnerId can be reassigned; CreatedBy cannot.

[HasOwner] and [HasAccessScopes] require [Entity] 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 [HasOwner] alone. Adding [HasAccessScopes] without actually using scopes adds unnecessary complexity (JSON column, scope resolver injection).