DynamicWhere.ex
DynamicWhere.exv3.0.0·docs

Transforms & Masking

Transformation happens in memory, after materialization— never in SQL. Filtering and sorting still run against the real values in the database; what reaches the caller is changed on the way out.

Why that matters more than it sounds
Because the real value is what the database sorts and aggregates, a masked field that is still sortable or aggregatable leaks. Both have controls, and both are on Security.

Detached, then transformed

Entities returned from a guarded query are detached before anything is changed. If they were tracked, the masked value would be a pending modification and the next unrelated SaveChanges on the same context would write asterisks over the real data — silently, with no exception and no way back without a restore.

A transform on a query the caller materializes itself is refused with TransformRequiresMaterialization rather than skipped, so a composable method cannot hand back an IQueryable that quietly never masks anything.

The nine mask strategies

StrategyResult
FullEvery character replaced.
PartialKeeps KeepStart and KeepEnd characters.
EmailMasks the local part and the domain, keeps the shape.
PhoneKeeps the last group of digits.
RegexPattern and Replacement.
FixedA constant string from Text.
HashHMAC-SHA256 keyed by options.HashSalt. A query is refused without one, and a salt under 16 characters is refused where it is written.
NullRemoves the value. Refused at startup on a non-nullable value type.
TokenizeA random token from options.TokenVault. A query is refused without one.
[DwMask(MaskStrategy.Partial, KeepEnd = 4)]
public string CardNumber { get; set; }        // ************4242

[DwMask(MaskStrategy.Email)]
public string Email { get; set; }             // s*************@c******.com

[DwMask(MaskStrategy.Hash)]
public string NationalId { get; set; }        // stable per salt, useful for joins

[DwMask(MaskStrategy.Tokenize)]
public string PassportNumber { get; set; }    // stable per vault, useful for joins

Hashing against tokenizing

Both keep a column groupable and joinable while hiding what is in it, and both do it by mapping one value to one output. The difference is where the secret lives, and it decides what an attacker has to reach to undo the mask.

HashTokenize
Output32 hex characters32 hex characters
Derived from the valueyesno
Reversed byholding the saltreading the vault
A weak secretbrute-forced offlinedoes not exist
Survives a restartalwaysonly with a durable vault
Discloses equalityyesyes

A hash is computed, so whoever holds the salt can recompute every digest the deployment has ever emitted, and a guessable salt is recovered offline. A token is drawn at random the first time a value is seen and written into a vault, so the only way back is to read that vault — a store you can lock, move and revoke separately from the data.

new DwPolicyOptions
{
    HashSalt   = secret,                      // 16 characters or more
    TokenVault = new RedisTokenVault(redis)   // or EfTokenVault, or InMemoryTokenVault
}

Three vaults ship and all three pass one conformance suite. InMemoryTokenVault lives and dies with the process, which is right for a test and wrong for any column compared across restarts. RedisTokenVault and EfTokenVault keep the mapping outside the process and cache every mapping they resolve, which they can do safely because a token is written once and never rewritten.

Tokens are namespaced by the field's own path, so two columns holding the same value get different tokens. Name a shared TokenScope on both when you want them to match — at the cost of telling a caller the two rows concern the same subject.

Neither one hides equality
The same value maps to the same output under both, which is what makes the column usable and is also a disclosure no setting removes. Anyone who can write a chosen value and read the column back masked learns that value's stand-in and can recognise it in every other row. A field that cannot accept that wants Fixed, Null, or a denial.

The other five

AttributeWhat it does
[DwGeneralize(mode)]Round (to Step), Bucket (a band label), DatePart (Year, Quarter, Month, Day), Truncate (drop decimals).
[DwMutate(typeof(T))]Your own IValueTransformer. Resolved from options.Services; the type is checked by the startup scan rather than on the first query.
[DwDefault]The type default, or a constant that must be readable as the member type.
[DwTruncate(n)]Shorten text, with an optional Ellipsis.
[DwFormat("fmt")]A standard or custom .NET format string.
Generalization is the half masking cannot reach
Arithmetic runs in decimal and converts back to the member own type, so rounding an int yields an int. Bucket is the one mode that emits text, because a band is a label rather than a number — so it is valid only on a string member, and the startup scan enforces that.

What can be applied to what

Masking, truncation and formatting all emit text, so they cannot be assigned to a decimal or a DateTime. Startup validation reports this as an error and names the fix.

Employee.Salary: [DwMask] emits text, which cannot be assigned to Decimal.
Use [DwGeneralize] to reduce a number or a date while keeping its type, or
project into a type whose member is a string.

Chaining

Several transforms on one member compose into a chain, elected one stage at a time. A runtime rule can add a stage on top of a sealed one and cannot replace it.

[DwGeneralize(GeneralizeMode.Round, Step = 1000)]
[DwFormat("C0")]
public string SalaryBand { get; set; } = string.Empty;

The member is a string because [DwFormat] ends the chain in text, and startup validation refuses a chain that emits text into a member that cannot hold it. Reduce a number while keeping its type with [DwGeneralize] alone.

Through the graph

The walk descends through reference navigations, collections, arrays, interfaces, structs and jagged collections, transforming every element it reaches. A field one navigation deeper than the walk reaches is a field that is quietly not protected, which is why the depth is a configured cap rather than a guess.