Releases: Sajadh92/DynamicWhere.ex
Release list
DynamicWhere.ex 3.3.0
DynamicWhere.ex 3.3 answers the third change request DCMP raised, and closes what eight rounds of review found on top of it: a path or a member the policy could not name had no policy at all. Most of that was already in 3.2.0. If you guard queries with ApplyPolicy, upgrade.
dotnet add package DynamicWhere.ex --version 3.3.0Packages
| Package | Version |
|---|---|
| DynamicWhere.ex | 3.3.0 |
| DynamicWhere.ex.Policies.EntityFrameworkCore | 3.3.0 |
| DynamicWhere.ex.Policies.Redis | 3.3.0 |
| DynamicWhere.ex.Policies.AspNetCore | 3.3.0 |
Why to upgrade
- A path beneath a framework-typed member had no policy at all. No attribute can be placed on
Salary.Valueof adecimal?,Secret.Length,Born.Year,Bag.CountorLines.Count, and no rule named such a path, so it resolved as allowed: a[DwDenied] decimal?was filtered on, sorted by, grouped by with its values as the group keys, aggregated and handed back, underStrict. A masked member gave its stored value the same way, an audited one was read unrecorded, a weighted one cost the default. Default configuration, both tiers, present in 3.2.0 and earlier. Such a path now takes the policy of the member it reads. - A masked member no policy path reaches came back as stored: five segments down an included or in-memory graph, on a subtype of the row's type, on an object a dictionary holds, on the far side of a cycle. Default configuration, under
Strict. The rows are walked by run-time type as well now. - Declaration order could decide whether a tenant scope applied. A type first met at the attribute walk's fourth segment read as a cycle wherever it was met again, which dropped
[DwForceWhere],[DwRequireWhere]and[DwAlias]from a shorter path to it. [DwAudit]could be evaded. A request that sent noSelectsreceived the whole row with nothing written down, and so did a member no policy path names. A use is what the request reads now, not only what it spells out.- A caller who hung up cancelled the record of what they read. The ASP.NET Core audit middleware drained with the request's own abort token. The drain has a thirty-second budget of its own now.
- Four refusals, the audit cap and
MaxNavigationDepthtold a real field from a made-up name underStrict. Each answers as an unknown name does now. - With
MaxNavigationDepthraised above 4, a denied member past four segments was not policed. Default configuration was never exposed to this one. - The four methods that hand back a query for the caller to run were not refused on a type whose only transformed member is one no path names.
- On the EF Core 6 floor, all four packages resolved
Microsoft.Extensions.Caching.Memory6.0.1, which is open to CVE-2024-43483. 6.0.2 is named directly.
Read before upgrading
Sixteen behaviours changed. The full list is breaking changes, points 30 to 45. The ones most likely to reach you:
- A path the query cannot compute is refused under
Strictrather than thrown at by the provider (30). [DwAudit]records more: every audited member a query hands back, named or not (36, 43). A deployment already running the control sees more events and can reachMaxAuditEventswhere it did not.- Results that carried stored values now carry transformed ones where a masked member sat off the named paths (38), and a transformed member there with no setter fails the query, as one along a named path always has.
- A query that ran unscoped may now be scoped, and a required filter may now be demanded, on a model that reaches one type both at four segments and nearer (39).
- A
Numbervalue is read the way the expression parser reads it (44): invariant culture, no thousands separator, no leading plus, noNaN, and checked against the member's type. What was the parser'sParseExceptionisLogicExceptionInvalidFormat. Nothing that ran before is refused. - A
nullentry in a request's list is aLogicExceptionnaming the list, where it was aNullReferenceExceptionor anArgumentNullException(45). A null or blankSelectsentry isConditionMustHasValidFieldName. - A page number whose offset passes
Int32is an empty page (41), where it was a server error on SQL Server and PostgreSQL and the first page again on SQLite. - Code that switched on
AmbiguousFieldNameorCapExceededunderStrict, relied on a secondConfigurethrowing, or counted audit events, sees the change.
What's new
- DW-16:
DwPolicy.Configuretakes the same posture twice. A second host asking for the posture already in force starts; a different posture still throws. The comparison runs inside the lock that configures, so no caller needs one of its own. - DW-17: a path no database can compute is refused rather than run, under
Strict, with the unknown-name refusal, so a caller cannot tell it from a field they may not use. Clone()is public onFilter,SegmentandSummary: every node new, a condition's values the caller's own objects in a new list.- A token vault can hold a key.
RedisTokenVaultandEfTokenVaulttake one in a new constructor and store an HMAC-SHA256 of the value rather than a plain SHA-256, so a backup, a replica or a dump of the store no longer gives the values back. Every token already issued is kept, and unkeyed mappings are retired only when told to.InMemoryTokenVaultkeys itself. No schema change. - A reflection-cache lookup takes no lock and allocates nothing on a hit. One million lookups of one cached member went from 152 ms to 35 ms on one thread and from 2,697 ms to 108 ms on eight.
Also fixed
- Two Redis writers moving one rule could leave a stale copy behind. The commit is conditional on the rule's owner entry, and the writer that loses is told to write again.
- A guarded
SummarywhoseHavingcarried"conditions": nullfailed with aNullReferenceExceptionwherever the group floor is on, which is the default. - Two over-blocks against 3.2.0: a provider wrapping EF Core's (LinqKit, DelegateDecompiler) was held to EF Core's model, and a member a subquery builds was read against the entity's model.
- A blank
GroupBy.Fieldsentry, and an alias spelled like another member of the same type.
Known limits, documented
- A member typed
object, or a collection that is not generic, says nothing about what it holds and is not read into. Type the member as what it holds. - A second host configured with the same posture runs with the first host's vault, container and rule stores.
- A vault given a key while instances without it still run can give a value first met in that window two tokens. Give every instance the key before traffic, and turn
retireUnkeyedon only after that.
Verification
- 3,256 tests on EF Core 8 and 2,427 on the declared EF Core 6.0.22 floor. All four projects build with zero warnings.
- Every fix was mutation-checked: removed on purpose, and a test turned red. More than a hundred mutations in all.
- Eight rounds of independent review: security, over-blocking, correctness, and the docs against the code. The last was a pass over the whole codebase and found eleven defects, each reproduced with a probe before it was fixed.
- DCMP's own shape on PostgreSQL 17, EF Core 9 and Npgsql 9 gives the expected answers.
- A behavioural sweep of the demo API against 3.2.0, and again against each later head: no answer differs.
- EF Core binary compatibility checked against EF Core 6, 7, 8, 9 and 10.
Compatibility
Targets net6.0, runs on .NET 6 through 10, with EF Core 6.0.22 or later. A project with no policy attributes and no DwPolicy.Configure call is affected only by points 41, 44 and 45 above.
DynamicWhere.ex 3.2.0
DynamicWhere.ex 3.2 answers the second change request DCMP raised on 3.1, and closes the ways a [DwDenied] value could still reach a result. Most of those were already in 3.1.0. If you guard queries with ApplyPolicy, upgrade.
dotnet add package DynamicWhere.ex --version 3.2.0Packages
| Package | Version |
|---|---|
| DynamicWhere.ex | 3.2.0 |
| DynamicWhere.ex.Policies.EntityFrameworkCore | 3.2.0 |
| DynamicWhere.ex.Policies.Redis | 3.2.0 |
| DynamicWhere.ex.Policies.AspNetCore | 3.2.0 |
Why to upgrade
- A denial beneath a member, with no
Selects, was never enforced, in both tiers. A denied field came back through a projected list, a row in memory, and an entity's included, auto-included, lazily loaded or owned members. What a query loads is now read from the EF Core model, including includes named from another root, re-rooted bySelect,SelectManyorJoin, projections hidden behind anotherSelect, and an object a reshaping lambda gets from an application method or captures. - A query through a provider that wraps EF Core's — LinqKit's
AsExpandable, DelegateDecompiler'sDecompile— ran with tracking, because EF Core'sAsNoTrackinghands such a query back unchanged. The context filled in navigations it already held, denied ones included, and a masked value became a pending change the nextSaveChangeswould write.AsNoTrackingnow goes into the query itself. - Denials on subtypes and other declarations were invisible. A denied field on a derived entity, a subclass, an open generic subtype or a subclass of a framework class did not apply through the base type, and neither did a
[DwDenied]on another declaration of the same member: an override of either accessor, a member a subtype hides withnew, or an interface member or its implementation — explicit, inherited, declared by an open generic class, or reached through a variant instantiation. - A
"*"deny with exact allows treated every path the walk never asked about as allowed: past four segments, around a cycle, with no setter, or on a subtype. Each such path is asked of the policy now. Selectsnaming a member carried denials the gate could not see: a navigation whose key is denied, a member typedIReadOnlyList<T>, a denial past four segments, in a framework generic, or in an owned chain or converted column, andMain.Leadnot gatingMain.Id.- A field denied at the top of
Tholding no simple value — a blob, list, owned or JSON member — synthesized nothing. - An application's own collection class hid its own denied members, and two members sharing a name (one hidden with
newunder another type, or spelled in another case) hid the second one's. - The attribute walker read any namespace starting with "System" as the framework's.
Read before upgrading
Five behaviours changed. The full list is breaking changes, points 25 to 29. The ones most likely to reach you:
- Entity navigations are left out of a synthesized projection. Once a denial needs a projection, navigations EF Core does not own are left out, included ones too, as 3.1.0 already did for a top-level denial. The trace records each one.
- Hierarchy roots come back as
T. A root whose derived type declares a denied field returns root-type rows. Over an abstract root the typed terminal fails withSelectTypeMustHaveParameterlessConstructor; the dynamic terminal works. - A
[DwDenied]on an override, a member hidden withnew, or an implementation denies the base path for every row, in every clause. Anewmember counts because whether it reads the member it hides cannot be told from outside. - Rows in memory are projected whenever a loaded subtype declares a denial, since the policy does not inspect rows.
- A narrowed null reference comes back as an empty object, as for dotted
Selects. ToListAsync(filter, default)no longer compiles.defaultis ambiguous betweenboolandCancellationToken. Name the argument or passCancellationToken.None.
What's new
- DW-12: a synthesized projection carries what an unguarded call would return, less what the policy withholds. An object member is kept whole when nothing it can hold is denied, and narrowed when something is — on a row a projection builds, a row in memory, and an entity's columns, owned and complex members.
- DW-13:
[DwEntity(DefaultOrder)]applies to a projected source when its outermostSelectbuilds the type in an initializer that assigns every default field a column. A computed value never takes the default, so a default can never make a query fail that ran unguarded. - DW-15: every async terminal, guarded and unguarded, has
CancellationTokenoverloads. They are overloads rather than optional parameters, so 3.1 binaries still bind. On EF Core the dynamic and summary reads and the summary count go through EF Core's async operators, so the token reaches the database.
Precision: what does not change
The guard projects only where a denied value can reach the result. Ordinary queries read as they did in 3.1.0:
- A denial beneath a navigation nothing loads asks for nothing.
- A member that can hold
object— a geometry, JSON bag orBitArraycolumn — asks for no projection. - Specifications, repository queries,
FromSql,Setthrough a context interface and query functions are read as EF Core reads them; query-syntax joins, lets, left joins and composite keys build nothing; a value that only feeds a predicate or a key, such asEF.Functions.Likeornew DateTime(...), does not count. BitArrayand the framework's string collections hold values, not objects.- A projected member is read as the type its initializer constructs.
- Under a
"*"deny, members whose every path is named are kept.
Known limits, documented
- A member typed
object, a framework interface or a non-generic collection is opaque: it never asks for a projection. With nothing else denied, a converter returning an application type through anobjectcolumn, or an unmappedobjectgetter over a private navigation, comes back as loaded. - A denial on an override or a
newmember counts for every loaded subtype, including a class EF Core does not map, such as a view model deriving from an entity. - Types from an unloadable
AssemblyLoadContextstay referenced by the policy caches. - A repository or specification method in a reshaping lambda runs once more per guarded read, and one that returns a different query each call is enforced as it answered the guard.
- A wrapping provider that hides the EF Core root behind its own expression runs with tracking; LinqKit and DelegateDecompiler show it.
- On EF Core 6, a reshaped query whose projection EF Core 6 cannot translate fails guarded, where it ran unguarded. It fails closed.
Verified
- 2,522 tests on EF Core 8 and 1,818 on the declared EF Core 6.0.22 floor.
- Every fix was mutation-checked: removed on purpose, and a test turned red. Every review round's probes ship as the
Review*Testsfiles. - Seven rounds of independent review — security, over-blocking, correctness, and the docs against the code — plus a line-by-line pass.
- A DCMP-shaped probe on EF Core 9, Npgsql 9 and PostgreSQL 17 gives the expected answers, including a canceled token stopping the typed, dynamic and summary reads.
- A 264-call behavioural sweep of the demo API against 3.1.0: no answer differs.
- EF Core binary compatibility checked against EF Core 6, 7, 8, 9 and 10.
Compatibility
Targets net6.0, runs on .NET 6 through 10, with EF Core 6.0.22 or later. A project with no policy attributes and no DwPolicy.Configure call is affected only by the behaviour changes above.
v3.1.0 — DCMP's change request, and two fixes every user needs
DynamicWhere.ex 3.1 answers the change request DCMP raised after moving onto 3.0, and closes two defects in the core that affect every user, guarded or not. If you are on 3.0.0, upgrade.
dotnet add package DynamicWhere.ex --version 3.1.0Packages
| Package | Version |
|---|---|
| DynamicWhere.ex | 3.1.0 |
| DynamicWhere.ex.Policies.EntityFrameworkCore | 3.1.0 |
| DynamicWhere.ex.Policies.Redis | 3.1.0 |
| DynamicWhere.ex.Policies.AspNetCore | 3.1.0 |
Why to upgrade
- A member named
Root,ItorParentbypassed field policies. System.Linq.Dynamic.Core read those names as its ownroot/it/parentkeywords, soRoot.Nameaddressed the row's ownName. UnderApplyPolicy, a dynamic projection ofRoot.Namereturned a[DwDenied]column, and a[DwForceWhere]scope reached through such a navigation filtered the wrong column. Expressions are now parsed with those keywords off, andParsingConfig.Defaultis no longer read. - One
InorNotIncondition with about 700 values ended the process. The values were joined as one flat chain, one level of nesting per value, and EF Core and the expression compiler walk that tree recursively. The request thread's stack overflowed, and nocatchcan stop that. A list longer than 32 values is now nested as a balanced tree; a list of 32 or fewer is written exactly as before. This affected guarded and unguarded queries alike. - Invented field names grew the process without limit. The reflection cache tracked a path before validating it, so every failed name stayed forever. Only valid paths are tracked now.
- The strict tier's refusals leaked which columns exist. A denied field and a missing name gave different answers, padding a name with dots changed the refusal, a cost-weighted hidden field broke the budget first, and
MissingContextValuenamed the scope column and the key it reads. Every one of these now answers alike. [DwForceWhere(AllowNull = true)]onIsNullorIsNotNullscoped nothing, rendering(field IS NOT NULL OR field IS NULL). It is refused now, at construction and when a stored rule is read.- Refusal audit events hold caller text, which could forge a log line. It is cut to 256 characters, and control, format, line separator and paragraph separator characters are escaped.
Read before upgrading
Eleven behaviours changed. The full list is breaking changes, points 14 to 24. The ones most likely to reach you:
- Dates read the member's type.
DateTimeOffsetmembers, nullable dates andDateOnlymembers now filter correctly, where 3.0.0 threw. - A date value is ISO 8601, year-first, or a format your deployment declares.
01/09/2026is refused as ambiguous until you callDwDates.Configure. - Segment set operations run in the database. They were combined in memory by object reference, so
Intersectreturned nothing andExceptremoved nothing whenever the query was untracked, projected or guarded. - A field path may not start with one of the parser's own words —
new,iif,np,isnull,is,as,cast,true,false,null. Seven of them used to throw from the parser, andNullreturned no rows and no error. The library refuses them by name now, in every clause. PageCounton an unpaged result is1.ApplyPolicy(ctx)refuses a context that never went throughPrepareAsync, with or without a store.- Four new caps refuse guarded requests 3.0.0 ran:
MaxConditionDepthandMaxConditionSets(10),MaxConditionValues(1000) andMaxAggregates(50). - Under
DwTier.Strictthe policy trace stays off results unless you ask for it;LastTracestill holds it.
What's new
[DwEntity(DefaultOrder = "CreatedAt desc, Id")]— the order a guarded query takes when its caller sends none.DwCaps.DefaultPageSize— a page for a guarded query that sends none.[DwForceWhere(..., AllowNull = true)]— the scope for a record that belongs to one tenant or to none.DwPolicyOptions.AuditRefusals— every refused guarded query in the audit, with the real field.DwAuditEventgainsErrorCode.DwDates.Configure— declare the date formats your deployment accepts, once, at startup.DwPolicyContext.IsPreparedandLogicException.Subjectare public.
The public API only gains members. Nothing was removed or changed.
Also fixed
- An idle rule store refused every guarded query fifteen minutes after its last write.
- The composable
GroupandSummaryreturned groups the k-anonymity floor suppresses. - A forced null check built from a context key failed every guarded query on its type.
- A
DateTimewhoseKindisLocal, compared against aDateTimeOffsetmember, filtered hours away from the moment it held. DwDates.Configurerefuses a format ISO 8601 already reads, which read a value one way on a server in UTC and another way outside it.
Verified
- 2,067 tests on EF Core 8 and 1,382 on the declared EF Core 6.0.22 floor. Release builds with zero warnings.
- Every fix was mutation-checked: broken on purpose, and a test turned red.
- Four review passes over the branch — security, QA, dates, and the docs against the code.
- 232 of 232 demo API endpoints against a seeded PostgreSQL 16.
- The docs reference names all 156 public types and 931 members, checked mechanically.
Compatibility
Targets net6.0, runs on .NET 6 through 10, with EF Core 6.0.22 or later. A project with no policy attributes and no DwPolicy.Configure call is affected only by the behaviour changes above.
v3.0.0 — Field-level policies
DynamicWhere.ex 3.0 adds field-level policies: a layer that decides, per caller, what may be filtered, sorted, selected, grouped, aggregated and seen. Attributes carry the rules you ship; an optional store carries the rules you change at runtime.
It is entirely opt-in. A project with no policy attributes and no DwPolicy.Configure call behaves exactly as 2.1.5.
dotnet add package DynamicWhere.ex --version 3.0.0Packages
| Package | What it is |
|---|---|
| DynamicWhere.ex | The library, now with the policy layer, an in-memory rule store and an in-memory token vault |
| DynamicWhere.ex.Policies.EntityFrameworkCore | New. Rules and tokens in your own database, through any EF Core provider |
| DynamicWhere.ex.Policies.Redis | New. Rules and tokens in Redis, with changes reaching every instance through pub/sub |
| DynamicWhere.ex.Policies.AspNetCore | New. Admin API, schema discovery, explain, simulate, health and audit |
What's new
- Access control.
[DwDeny]and six named attributes across Where, Select, Order, Group, Aggregate and Segment;[DwOperators];[DwEntity(RequirePolicy = true)]. - Injection.
[DwAlias],[DwForceWhere],[DwRequireWhere]. - Transformation.
[DwMask]with nine strategies — among themHash, keyed HMAC-SHA256, andTokenize, random tokens kept in a vault — plus[DwMutate],[DwDefault],[DwGeneralize],[DwTruncate]and[DwFormat]. Filtering and sorting still run against the real value in SQL; the transformation is applied to the result. - Precedence. Six levels, with attributes sealed by default.
- Dynamic rules. An optional store with broad and narrow zones and three failure modes. The in-memory, Redis and EF Core stores all pass one conformance suite.
- Admin surface. Mounts only with a named authorization policy — there is deliberately no default.
- Configuration. The whole posture binds from
IConfiguration, and an unrecognised key refuses to start. - Inference defences. The seven inference channels in the design's threat model are closed, including a k-anonymity floor on grouped summaries.
Before you turn it on
- The group floor ships on, at 5. A guarded grouped summary suppresses groups of fewer than five rows. It cannot change an existing query, because only guarded queries are affected and those are new.
MinGroupSize = 1turns it off. - A mask does not stop sorting or filtering. Both run on the real value. Pair a mask with
[DwNoOrder], and with[DwOperators]where comparisons could narrow the value down. - Neither
HashnorTokenizehides equality. Both keep a column groupable and joinable, so equal values stay equal. A field that cannot accept that needsFixed,Nullor a denial. - Transforming values costs something. Gating is free, but rebuilding every value of a large result costs about 1.6× in time and 7× in allocations.
Compatibility
No breaking changes to the 2.x API. FilterResult<T> and SummaryResult each gain one nullable Policy property, null when a query was not guarded. Targets net6.0 and runs on .NET 6 through 10, with EF Core 6.0.22 or later.
Verified
The release workflow ran 1,660 tests on EF Core 8 and 1,184 on the EF Core 6.0.22 floor before it packed. The store and token-vault conformance suites run against real Redis and PostgreSQL.
Documentation: Policies · Use cases · Security · For AI agents
Pull request: #2