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.
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
| Strategy | Result |
|---|---|
Full | Every character replaced. |
Partial | Keeps KeepStart and KeepEnd characters. |
Email | Masks the local part and the domain, keeps the shape. |
Phone | Keeps the last group of digits. |
Regex | Pattern and Replacement. |
Fixed | A constant string from Text. |
Hash | HMAC-SHA256 keyed by options.HashSalt. A query is refused without one, and a salt under 16 characters is refused where it is written. |
Null | Removes the value. Refused at startup on a non-nullable value type. |
Tokenize | A 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 joinsHashing 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.
Hash | Tokenize | |
|---|---|---|
| Output | 64 hex characters | 32 hex characters |
| Derived from the value | yes | no |
| Reversed by | holding the salt | reading the vault |
| A weak secret | brute-forced offline | does not exist |
| Survives a restart | always | only with a durable vault |
| Discloses equality | yes | yes |
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 TokenScope, and where none is given the namespace falls back to the field's path relative to the entity being queried — with no type name in it. Two columns reached by different paths therefore get different tokens for the same value, and two entities whose member path is spelled identically share them, whether or not anybody decided they should. Name the scope explicitly on both sides when you want the match — at the cost of telling a caller the two rows concern the same subject — and name a distinct one on each when you do not.
Fixed, Null, or a denial.The other five
| Attribute | What 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. |
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.Bucket, Step = 5000)] // 45000-49999
[DwTruncate(5, Ellipsis = "+")] // 45000+
public string SalaryBand { get; set; } = string.Empty;The member is a string because both stages emit 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.
[DwDefault], [DwMutate], [DwGeneralize], [DwFormat], [DwMask], [DwTruncate] — and not in the order you wrote them. [DwFormat] applies its format string only to an IFormattable, and Round converts back to the value's own runtime type. So [DwGeneralize(Round)] with [DwFormat("C0")] on a string member rounds a string back into a string and leaves the format nothing to act on: it is dropped in silence, with no error at startup and none at query time.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.