DynamicWhere.ex
DynamicWhere.exv3.0.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
Validation lives in the PolicyRule constructor itself, so every store and the admin API get the same refusals. A rule naming a sealed field is rejected on write, not ignored on read.

Two zones

ZoneHoldsLifetime
BroadGlobal, tenant and role rulesCached 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
Any store provider that sees one throws PolicyContextNotPrepared. 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.

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.

All three are bounded by the ceiling. 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);   // serving last known good
    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.