DynamicWhere.ex
DynamicWhere.exv3.2.0·docs

Dynamic Policy Store

Attributes are the compile-time half. A store supplies the other half at runtime, so an operator can grant or revoke access without a redeploy — and can never grant what the source code seals.

A rule

var rule = new PolicyRule(
    subjectKind: DwSubjectKind.Role,
    subjectKey:  "Support",
    entityType:  typeof(Employee).FullName!,
    fieldPath:   "Position",          // or "*" for every field of the type
    features:    PolicyFeature.Select | PolicyFeature.Order,
    effect:      PolicyEffect.Deny,
    priority:    10,
    validFrom:   DateTimeOffset.UtcNow,
    validTo:     DateTimeOffset.UtcNow.AddDays(30));

A rule also carries a transform, an operator restriction, an alias, a forced predicate, a required-operator list and the discovery facts, plus audit columns. ValidFrom and ValidTo make a grant expire on its own — expiry that depends on someone remembering is expiry that does not happen.

Bad rules are refused at the boundary
A malformed rule — an unknown subject kind, a validity window that closes before it opens, an effect with no feature to apply it to — is refused by the PolicyRule constructor itself, so every store and the admin API get the same refusals. The sealed-field refusal is separate: SealedFields.Refuse, called by each store's UpsertAsync and by POST /rules. It needs a way to turn the rule's entity name into a Type, so a store built without that resolver accepts the rule instead of rejecting it — which costs nothing, because a sealed attribute outranks it at resolution time regardless. Hand the store a resolver and the operator is told on write rather than left with a rule that quietly never applies.

A forced predicate on a rule

A rule carries the runtime form of [DwForceWhere] as a ForcedPredicate, built by one of three factories: FromConstant, FromContext and FromNullCheck. FromConstant and FromContext each have an overload taking a fifth argument, bool allowNull, which ForcedPredicate.AllowNull reports; the four-argument overloads mean allowNull: false, and FromNullCheck is unchanged. Either factory throws ArgumentException for allowNull: true with IsNull or IsNotNull, which compare against nothing: a widened IsNotNull would inject (field IS NOT NULL OR field IS NULL), a scope that scopes nothing. Without the flag, a constant handed to FromConstant with a null check is ignored, as before. A context key is not: FromContext, both overloads, throws ArgumentException for IsNull and IsNotNull, which read no context value. Build a null check with FromNullCheck.

// Every caller sees their own institution's roles, and the roles no institution owns.
var scope = new PolicyRule(
    subjectKind: DwSubjectKind.Global,
    subjectKey:  null,
    entityType:  typeof(Role).FullName!,
    fieldPath:   "InstitutionId",
    features:    PolicyFeature.None,      // carries a predicate and decides nothing
    effect:      PolicyEffect.Allow,
    forced:      ForcedPredicate.FromContext(
        "InstitutionId", Operator.Equal, DataType.Number, "TenantId", allowNull: true));

The injected term, the refusal of a context that does not supply the value, and the trace all follow the attribute. One thing differs: a rule is written without the entity type to hand, so it is not refused on a member that can never be null. On such a member it injects the comparison alone, which is the same predicate, and the trace records forced predicate (Equal) without or null. How a stored rule writes the flag is on Store providers.

Fixed in 3.1.0: a null check built from a context key never worked
FromContext used to accept IsNull and IsNotNull. The key was still required, so a caller without it was refused with MissingContextValue. A caller with it had the value added to the null check, which validation refuses with ConditionWithOperator[IsNull-IsNotNull]MustHasNoValues, so every guarded query on the type failed. The factory now refuses the predicate where it is built, as [DwForceWhere] already refused a ContextValue on a null check.

Two zones

ZoneHoldsLifetime
BroadGlobal, tenant, role and custom rules — everything but a user ruleCached and shared across requests
NarrowPer-user rulesLoaded for the identities on one context

Each zone refuses the other rules at construction rather than filtering them out, so a store that returns user rules from the broad load fails loudly instead of quietly caching one user grants for everyone.

Prepare once per request

var caller = await DwPolicy.PrepareAsync(
    new DwPolicyContext()
        .WithSubject(DwSubjectKind.User, userId)
        .WithSubject(DwSubjectKind.Tenant, tenantId));
An unprepared context is refused, not tolerated
ApplyPolicy(ctx) throws PolicyContextNotPrepared before a store is consulted at all, and does so whether or not one is configured. Falling back to attributes alone would look exactly like a working policy with the dynamic half missing, which is the worst possible failure for this feature — and the deployment where the missing call was silently tolerated is the one that starts refusing in production the day it gains a store.

That check sits in front of the store's own two rather than replacing them. A provider still refuses a context it attached no snapshot to, and still refuses one that gained a User subject after it was prepared. DwPolicyContext.IsPrepared reports which state a context is in, preparation is recorded even when no store pinned anything to it, and a context copied to simulate a prepared caller is prepared. The overload taking an explicit DwPolicyOptions and PolicyResolver does not check: a host composing its own configuration owns preparation, and a store it hands in refuses an unprepared context by itself.

A context carries the snapshot it was served, and the staleness ceiling measures how old that snapshot is — not how fresh the provider is now. A context pinned longer than MaxSnapshotAge is refused even after the provider has refreshed, which is exactly why it is built once per request.

Three failure modes

options.StoreFailureWhen the store is unreachable
LastKnownGood (default)Serve the last snapshot that loaded, bounded by MaxSnapshotAge.
FailClosedRefuse the query with StoreUnavailable.
StaticOnlyFall back to attributes alone.

The ceiling is checked after the mode, so it binds LastKnownGood and a healthy provider alike: a snapshot older than MaxSnapshotAge refuses the query even when nothing has failed. StaticOnly is the one exception, and only once the provider is already degraded — it has fallen back to attributes by then and never reaches the check. A startup load failure always throws, whatever the mode: an application that has never loaded a policy has no last known good to serve.

Refresh

The provider polls on options.RefreshInterval and, where the store supports it, also watches for change notifications. Refresh happens on a background timer and never on the query path. A query that starts on version 41 finishes on version 41 — the swap is atomic.

foreach (var provider in DwPolicy.StoreProviders)
{
    Console.WriteLine(provider.Version);      // snapshot version
    Console.WriteLine(provider.Age);          // how old it is
    Console.WriteLine(provider.IsDegraded);   // the last refresh or poll failed
    Console.WriteLine(provider.LastError);    // why
}

Choosing a store

InMemoryPolicyStore ships in the core package and is enough for a single process. For anything else see Store providers. All three pass one shared conformance suite, so they behave alike or the build fails.