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.
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.
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
| Zone | Holds | Lifetime |
|---|---|---|
| Broad | Global, tenant, role and custom rules — everything but a user rule | Cached and shared across requests |
| Narrow | Per-user rules | Loaded 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));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.StoreFailure | When the store is unreachable |
|---|---|
LastKnownGood (default) | Serve the last snapshot that loaded, bounded by MaxSnapshotAge. |
FailClosed | Refuse the query with StoreUnavailable. |
StaticOnly | Fall 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.