DynamicWhere.ex
DynamicWhere.exv3.0.0·docs

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);
Frozen at startup, and refused on a second call
The posture is read by every request thread without synchronization. A tier that can change while requests are in flight is one that can be relaxed by a code path nobody expected to be security-relevant, so mutation after 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

TierA denied fieldAlso
Convenience (default)Is dropped from the projection, sort or groupinggetQueryString allowed
StrictThrowsgetQueryString 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

CapDefaultMeaning
MaxPageSize1000Largest page a caller may request.
MaxConditions50Conditions in one filter.
MaxOrderFields10Order fields in one query.
MaxNavigationDepth4How deep a field path may reach.
MaxQueryCost1000Budget consumed by [DwCost] weights.
DefaultFieldCost1Charged for an unweighted field.
MaxAuditEvents10000Audit buffer before draining.
SchemaDepth2Levels a schema request walks when it names no depth.
SchemaCycleLimit2Times one type may appear on one path.
MaxSchemaFields2000Fields one schema response may carry before it truncates.
MinGroupSize5k-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.

A key nothing answers to refuses to start
The binder's own default is to ignore an unmatched key, which would let 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.

A salt in appsettings.json is not a salt
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 rowsTimeAllocated
Unguarded685 µs210 KB
Guarded, nothing denied or transformed683 µs (1.00×)220 KB (1.05×)
Guarded, one field deny-select785 µs (1.15×)409 KB (1.95×)
Guarded, two fields transformed every row1,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.

No database in those numbers
These are in-memory LINQ, so the policy layer share looks as large as it ever can. Against a real query the I/O dominates and the relative overhead is much smaller.
dotnet run -c Release --project DynamicWhere.Benchmarks \
  -- --filter "*PolicyBenchmarks*" --job medium

Error codes

PolicyException.ErrorCode, values 1 to 22:

CodeRaised when
FieldDeniedForWhereFieldDeniedForSegment (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.