Configuration
DwPolicy.Configure(new DwPolicyOptions
{
Tier = DwTier.Convenience,
DryRun = false,
HashSalt = secret, // 16 characters or more
TokenVault = tokenVault, // needed only by MaskStrategy.Tokenize
Services = serviceProvider, // resolves IValueTransformer
StoreFailure = StoreFailureMode.LastKnownGood,
MaxSnapshotAge = TimeSpan.FromMinutes(15),
RefreshInterval = TimeSpan.FromSeconds(30),
}, providers);Configure throws.AttributePolicyProvider is added whether or not you pass it. Attributes are the sealed level, and a configuration that omitted them would let a store grant what the source refuses.
Tiers
| Tier | A denied field | Also |
|---|---|---|
Convenience (default) | Is dropped from the projection, sort or grouping | getQueryString allowed |
Strict | Throws | getQueryString throws; deny-select implies deny-where inside a Segment |
A dropped field leaves nothing behind in the data, so FilterResult<T>.Policy is the only way a caller can tell a policy drop from a null value.
Caps
| Cap | Default | Meaning |
|---|---|---|
MaxPageSize | 1000 | Largest page a caller may request. |
MaxConditions | 50 | Conditions in one filter. |
MaxOrderFields | 10 | Order fields in one query. |
MaxNavigationDepth | 4 | How deep a field path may reach. |
MaxQueryCost | 1000 | Budget consumed by [DwCost] weights. |
DefaultFieldCost | 1 | Charged for an unweighted field. |
MaxAuditEvents | 10000 | Audit buffer before draining. |
SchemaDepth | 2 | Levels a schema request walks when it names no depth. |
SchemaCycleLimit | 2 | Times one type may appear on one path. |
MaxSchemaFields | 2000 | Fields one schema response may carry before it truncates. |
MinGroupSize | 5 | k-anonymity group floor. Set 1 to switch it off. See Security. |
Every cap is frozen at startup, refuses a value below one, and reports through the trace with its own error code.
MinGroupSize is the one that starts unset rather than at its default value, so that MinGroupSize = 1 can mean "no floor, and I mean it" rather than being indistinguishable from a deployment that never configured anything. IsMinGroupSizeSet reports which of the two happened.
Dry run
DryRun traces every decision without enforcing any of them, so a policy can be rolled out and watched before it starts refusing anything. It is also per-context, not only global, so a single canary role can run in dry run while everyone else is enforced — an all-or-nothing rollout is the thing nobody does.
var canary = new DwPolicyContext { DryRun = true }
.WithSubject(DwSubjectKind.User, userId);Startup validation
PolicyModelReport report = DwPolicy.ValidateModel(typeof(Employee), typeof(Customer));
foreach (var warning in report.Warnings) logger.LogWarning("{W}", warning);
if (report.Errors.Count > 0) throw new InvalidOperationException("Policy model invalid.");Reported as a list rather than thrown one at a time, so a model is fixed in one pass instead of one exception per restart. Errors cover contradictions that cannot work — a text-emitting transform on a numeric member, a [DwMutate] type that is not an IValueTransformer, a default that cannot be read as the member type. Warnings cover things that work but probably should not, chiefly a transformed field that is still orderable.
Configuration from a file
Every value on the posture binds from IConfiguration. Three things cannot, because they are objects rather than values: the entity catalogue, the token vault and the service provider. Those stay in code, which is what the callback is for.
builder.Services.AddDwPolicies(
builder.Configuration.GetSection("DynamicWhere:Policies"),
options =>
{
options.Entities.Expose<Employee>("Employee");
options.TokenVault = new RedisTokenVault(redis);
});{
"DynamicWhere": {
"Policies": {
"Tier": "Strict",
"StoreFailure": "LastKnownGood",
"MaxSnapshotAge": "00:15:00",
"Caps": {
"MinGroupSize": 5,
"SchemaDepth": 2,
"MaxSchemaFields": 2000
}
}
}
}Configuration binds first and the callback runs second, so a line somebody wrote deliberately is never overwritten by a file. Every key is optional, and an absent one leaves its default in place.
MinGropSize sit in a file doing nothing while the deployment believed it had set a floor. Binding runs with ErrorOnUnknownConfiguration, so a typo fails at startup rather than silently switching a control off.Every setter's own validation still applies. A cap below one, a snapshot age that is not a positive interval and a salt shorter than sixteen characters are all refused exactly as they are in code. The group floor's opt-out survives unchanged, because it lives in the setter: saying nothing leaves it unset, writing 1 records a deliberate choice.
HashSalt binds like anything else, and configuration is the right channel for it — through user secrets, an environment variable or a vault. Committing it to a file in the repository is the thing the attribute refuses to allow, and nothing here can tell the difference.Performance
There are two budgets, because there are two costs. Gating is paid once per query. Transformation is paid per row per transformed field, so no single percentage describes it — the same guard is 1.16× over a hundred rows and 1.62× over ten thousand, on identical code.
Measured with BenchmarkDotNet over 10,000 in-memory rows:
| 10,000 rows | Time | Allocated |
|---|---|---|
| Unguarded | 685 µs | 210 KB |
| Guarded, nothing denied or transformed | 683 µs (1.00×) | 220 KB (1.05×) |
| Guarded, one field deny-select | 785 µs (1.15×) | 409 KB (1.95×) |
| Guarded, two fields transformed every row | 1,111 µs (1.62×) | 1,488 KB (7.1×) |
Gating costs nothing measurable. Resolving every field, sanitizing the filter and injecting forced predicates lands inside the noise of the unguarded query. A cached field resolve is 232–234 ns and sanitizing a five-condition filter is 2.8 µs.
Deny-select costs 1.15×, because denying a field means the query projects instead of returning entities. That belongs to the feature rather than to the guard.
Transformation costs about 21 ns and 65 bytes per value, against a design budget of 100 ns. It builds a new value for each one, because the change happens after materialization rather than in SQL.
dotnet run -c Release --project DynamicWhere.Benchmarks \
-- --filter "*PolicyBenchmarks*" --job mediumError codes
PolicyException.ErrorCode, values 1 to 22:
| Code | Raised when |
|---|---|
FieldDeniedForWhere … FieldDeniedForSegment (1–6) | A field is refused for that feature, in the Strict tier. |
AllSelectsDenied (7) | Every requested field was denied. |
OperatorNotAllowed (8) | An operator outside the permitted set. |
CapExceeded (9) | A cap above was exceeded. |
PolicyRequired (10) | An unguarded query on a RequirePolicy type. |
RequiredFilterMissing (11) | A [DwRequireWhere] field was not filtered on. |
MissingContextValue (12) | A forced predicate needed a context value that was absent. |
AmbiguousFieldName (13) | A name could mean more than one field. |
QueryStringDenied (14) | getQueryString in the Strict tier. |
AmbiguousGroupKey (15) | A group key could mean more than one field. |
TransformRequiresMaterialization (16) | A transform on a query the caller materializes itself. |
StoreUnavailable (17) | The store failed under FailClosed. |
PolicyContextNotPrepared (18) | PrepareAsync was never called. |
QueryCostExceeded (19) | The query cost budget was exceeded. |
GroupTooSmall (20) | A summary already uses the alias the group floor reserves. |
MissingHashSalt (21) | A field masks to a hash and no salt was configured. |
MissingTokenVault (22) | A field masks to a token and no vault was configured. |