How CreatedBy/CreatedOn/UpdatedBy/UpdatedOn get populated, and why a caller can never forge
them. Full API surface for the two packages behind this: DKNet.EfCore.Abstractions
(docs/EfCore/DKNet.EfCore.Abstractions.md)
and DKNet.EfCore.DataAuthorization
(docs/EfCore/DKNet.EfCore.DataAuthorization.md).
Every aggregate in this template ultimately derives from AuditedEntity<TKey>
(DKNet.EfCore.Abstractions), which exposes IAuditedProperties:
| Field | Meaning |
|---|---|
CreatedBy / CreatedOn |
Who created the row, and when |
UpdatedBy / UpdatedOn |
Who last modified the row, and when |
Minimal.Domains/Share/DomainEntity.cs extends AuditedEntity<Guid> and calls SetCreatedBy from
its constructor. Minimal.Domains/Share/AggregateRoot.cs extends DomainEntity and is the base
every feature aggregate (PurchaseOrder, Product) ultimately uses.
Invariant: audit and ownership values come from the authenticated principal at save time, never from a request property. A generated create request deliberately carries no acting-user parameter.
Two mechanisms enforce this, depending on which sample you’re looking at:
Product (automated) |
PurchaseOrder (manual) |
|
|---|---|---|
| Mechanism | DKNet.EfCore.DataAuthorization’s DataOwnerHook, stamping on SaveChanges |
[FromClaim] populates a request property; the aggregate stamps itself |
| Where the acting user is read | IDataOwnerProvider.GetOwnershipKey() |
ClaimTypes.Name, via the pipeline’s contextual request population |
| Use it when | The entity is plain CRUD and an audit stamp is all you need | A domain method needs the acting user’s identity as domain data, not just an audit stamp |
Product) — the hook stamps itMinimal.Domains/Features/AutomatedSample/Entities/Product.cs’s [CrudCreate] constructor takes
only name and price. There is no createdBy/byUser parameter to spoof, because the generated
create request’s shape comes straight from that constructor’s parameter list.
CreatedBy/UpdatedBy are stamped instead by DKNet.EfCore.DataAuthorization’s DataOwnerHook,
wired via:
Minimal.AppServices/Share/IPrincipalProvider.cs — extends IDataOwnerProvider, adding
ProfileId/Email/UserName read from the bearer token’s claims.Minimal.Api/Configs/Handlers/PrincipalProvider.cs — the implementation. GetOwnershipKey()
returns the caller’s subject claim, never their name: the first non-empty of
http://schemas.microsoft.com/identity/claims/objectidentifier, oid,
ClaimTypes.NameIdentifier, sub. When the caller is not authenticated it returns
SharedConsts.SystemAccount instead. (ProfileId on the same class exposes that key parsed as a
Guid, or Guid.Empty when it does not parse — it is a convenience for domain code, not what
the hook stamps.)Minimal.Api/Configs/ServiceConfigs.cs: .AddDataOwnerProvider<CoreDbContext, PrincipalProvider>()
registers the provider and wires DataOwnerHook onto CoreDbContext.On SaveChanges, DataOwnerHook:
CreatedBy/ownership on every newly-added entity, from IDataOwnerProvider.GetOwnershipKey().UpdatedBy/UpdatedOn from the same ownership key. The hook first
checks whether a domain method already called SetUpdatedBy explicitly for this change set, by
comparing the property’s current value against its EF Core OriginalValue. If a domain method
already set it, the hook leaves both fields untouched rather than overwriting them.IOwnedBy.OwnedBy on a modified entity against reassignment to a key the current context
doesn’t hold, preventing cross-tenant transfer.Because the payload has no acting-user field at all, there is nothing for a caller to smuggle in.
This is proven by Minimal.App.Tests/Integration/AutomatedSample/V1/ProductSecurityTests.cs:
Create_ShouldStampCreatedByFromAuthenticatedCallersOwnershipKey,
Create_ShouldIgnoreAnyExtraActingUserFieldInThePayload, and
Update_ShouldStampUpdatedByFromAuthenticatedCallersOwnershipKey all assert the stamped value
matches the authenticated caller, never an attacker-supplied one.
PurchaseOrder) — [FromClaim] populates it, the aggregate stamps it itselfPurchaseOrder is hand-written end to end — no declarative attribute raises its event or stamps its
audit fields. Instead, Minimal.AppServices/ManualSample/V1/Actions/Create.cs declares:
[FromClaim(ClaimTypes.Name)]
public string? ByUser { get; set; }
The endpoint pipeline’s contextual request population (DKNet.AspCore.Extensions, see
api-pipeline.md) overwrites ByUser from the caller’s ClaimTypes.Name claim
before validation and before the handler runs. Any value the caller sent in the body or query
string is discarded unconditionally, never trusted. The handler then passes request.ByUser into
the aggregate’s constructor (PurchaseOrder(customerName, amount, byUser) → base(byUser) →
SetCreatedBy), and PurchaseOrder.ChangeAmount calls SetUpdatedBy(userId) itself on update.
Use this pattern instead of relying on DataOwnerHook when a domain method needs the acting user’s
identity as domain data, not just an audit stamp. For example, to pass it into a further business
rule, or when the entity’s own methods (not just SaveChanges) need to record who called them.
This is pinned by Minimal.App.Tests/Integration/ManualSample/V1/PurchaseOrderSecurityTests.cs:
Create_ShouldAttributeCreatedByToAuthenticatedCaller_IgnoringPayloadByUser and
Update_ShouldAttributeUpdatedByToAuthenticatedCaller_IgnoringPayloadByUser both send
"byUser": "someone-else" in the payload and assert the stored value is the authenticated caller’s
name, never the spoofed one.
Minimal.Infra/Contexts/CoreDbContext.cs implements IDataOwnerDbContext directly, exposing
AccessibleKeys from the same IDataOwnerProvider used for stamping. DKNet.EfCore.DataAuthorization
uses this to apply a global query filter on any entity implementing IOwnedBy, so a caller only
ever sees rows whose ownership key matches their own. Filtering happens at the query level, not in
application code.
DataOwnerHook runs as part of EF Core’s SaveChanges/SaveChangesAsync pipeline. It’s a
BeforeSaveAsync hook, registered via .AddDataOwnerProvider<CoreDbContext, PrincipalProvider>().
It runs after change tracking has determined which entities are added or modified, and before the
UPDATE/INSERT statements are sent — so the stamped values are always part of the same
transaction as the data change itself.
CoreDbContext overrides every SaveChanges/SaveChangesAsync entry point with
EnsureOwnershipResolvable. Before EF Core writes anything, it checks whether the ownership key is
empty while the change set contains a newly-added IAuditedProperties entity whose CreatedBy
column is non-nullable and still unset. If so it throws OwnershipRequiredException — a fail-closed
refusal rather than a row attributed to nobody.
Minimal.Api/Configs/GlobalExceptions/GlobalExceptionHandler.cs maps that exception to
403 Forbidden with Title "Request refused.", deliberately separate from the generic 500 path
so no EF Core column or entity name leaks into the response.