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 | 32 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 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.
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.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.