DynamicWhere.ex
DynamicWhere.exv3.2.0·docs

Admin API

dotnet add package DynamicWhere.ex.Policies.AspNetCore --version 3.2.0
app.MapDwPolicyAdmin(options =>
{
    options.RoutePrefix  = "/dw-policies";    // the default; mount it anywhere
    options.ReadPolicy   = "DwPolicyRead";    // both required, unless
    options.WritePolicy  = "DwPolicyWrite";   // AllowAnonymousAccess = true
});
It refuses to mount without an authorization policy
There is deliberately no default. POST /rules changes what every caller may see, so there is nothing safe to fall back to. ReadPolicy and WritePolicy are two separate names and both are required: leave either blank — or blank the route prefix — and the application fails at startup rather than on the first request, because for an endpoint nobody is supposed to call, the first call is exactly the one that must not be the discovery.

AllowAnonymousAccess exists for a deployment where something in front of the application authorizes. It is a deliberate choice, not a shortcut past registering a policy.

Seven endpoints

MethodRouteAuthPurpose
POST/schemaReadFields for a filter UI: labels, groups, order, allowed values, cost. Takes paths and depth.
GET/rules?subject=ReadList rules. The filter is Kind[:Key] — Role:auditor, not a bare key. Omitted, it lists every enabled broad rule; a user's rules need User:{key}.
POST/rulesWriteUpsert a rule. The body cannot carry a transform, operator lists, a forced predicate or facts — those go through IDwPolicyWritableStore.UpsertAsync.
DELETE/rules/{id}WriteDelete a rule.
POST/explainReadThe decision chain for one field, or for every field of the entity when none is named.
POST/simulateReadThe sanitized clause, without executing it.
GET/healthReadSnapshot version, age, degraded state, last error.

Asking for part of an entity

POST /dw-policies/schema takes a body rather than a query string, because the request carries a list of paths — and a list in a query string needs a separator. A comma is legal in a [DwAlias], so the separator would eventually split a name in half and resolve neither piece.

{ "entity": "employee" }                            // 59 fields, two levels
{ "entity": "employee", "depth": 1 }                // 13 fields, the entity alone
{ "entity": "employee", "depth": 99 }               // 99 fields, as deep as a query may reach
{ "entity": "employee", "paths": ["Manager"] }      // 59 fields, rooted at the manager
{ "entity": "employee", "paths": ["Manager", "Address"], "depth": 1 }

depth is an integer and nothing else. A value beyond the query cap is clamped rather than refused, and the response reports both the depth it used and the ceiling, so a caller wanting everything sends a large number and learns the limit from the reply.

The response is flat with a parent on every entry, which is a tree in adjacency form. nodes carries every navigation the walk touched, expanded or not, so a tree UI hangs each node and each field under its parent in one pass with no path parsing.

{
  "entity": "employee",
  "roots": ["Manager"], "depth": 2, "maxDepth": 4, "truncated": false,
  "fields": [ { "path": "Manager.FirstName", "parent": "Manager", ... } ],
  "nodes":  [ { "path": "Manager.Address", "parent": "Manager", "entity": "address",
                "depth": 3, "expanded": false, "remainingDepth": 0 } ]
}

remainingDepth says what asking for that path would return, so a node reporting zero has nothing to open. It accounts for SchemaCycleLimit as well as the query cap, and it is measured the way a request for that path would measure it — asking for a subtree resets the guard's count, which is what keeps drilling productive.

A full-depth request returns 99, not 335
The cycle guard lets a type appear twice on one path, so Manager.Email is described and Manager.Manager.Email is not. Both remain queryable, and the second remains reachable by asking for the Manager.Manager subtree. Raising SchemaCycleLimit restores the exhaustive listing exactly.

Schema, and the sealed-field rule

The schema is built from the entity rather than from a hand-maintained copy of it, so a field that becomes denied disappears from the UI without a front-end change. Expose the types you want reachable:

options.Entities.Expose<Employee>("Employee");

Sealed fields never appear — the schema omits them, and POST /rules rejects a rule aimed at one with a 400 whenever the body's entityType resolves through the exposed catalogue, so an operator is told at once rather than left to discover it. That check is the courtesy and not the guarantee: a rule naming a type nothing resolves is stored, and then loses at resolution time, where a sealed attribute outranks every dynamic level whatever any store did or did not check.

Sealed is decided per feature
A field whose mask is sealed but whose [DwDeny(Where)] is overridable is absent for the masked feature and still writable for the overridable one. A field sealed on every feature is absent entirely.

Explain

The whole chain, as JSON: an array of field entries, each carrying one record per feature saying what won, at which level, and what it overrode.

[
  {
    "field": "AccountNumber", "entityType": "MyApp.Models.Customer",
    "name": "AccountNumber", "isSealed": false,
    "features": [
      { "feature": "Select", "effect": "Mask",
        "decidedBy": "Rule a3f2 [Role:Finance]", "level": "DynamicRole",
        "attributionAmbiguous": false, "tiedWith": [],
        "overrode": ["DwMaskAttribute"] }
    ]
  }
]

features holds all six, so a feature nothing spoke about comes back as Allow with a null decidedBy. isSealed says an attribute decided at least one feature absolutely. A source renders as Rule {id} [Kind:Key], or as the attribute's type name with (sealed) appended.

Send field to explain one field; leave it out and every field of this caller's schema comes back, one entry each. There is no list of what was ignored.

When two sources tie on level, specificity, priority and effect alike, the decided effect is still deterministic — only which of the equal sources decidedBy names is arbitrary, and attributionAmbiguous with tiedWith is how the endpoint says so rather than crediting one of them. See Precedence.

Simulate

Send a clause, get back what it would become. Nothing executes and nothing is audited, so an operator checking a rule does not fill the audit trail with reads that never happened.

A simulation has no source, so it reads the type as a source it cannot see into. That shows in a clause that sends no Selects: every denial beneath a member counts, and the projection it shows keeps only the members that hold a value, a collection of values included. A guarded query keeps what its own source carries — over a projected row, the objects its initializer assigns; over an entity, its columns, owned and complex members, asking only about the denials whose value it loads; over rows in memory, values only. So the simulated clause can list fewer members than the query returns, and can show a projection an entity query does not need. PolicySimulator reads a type the same way. See A request that sends no Selects.

From a ClaimsPrincipal

var caller = await httpContext.GetPolicyContextAsync(claimsOptions);

// or explicitly
var caller = await DwClaimsAdapter.CreateContextAsync(User, claimsOptions, ct);

Claim types for user, role, tenant, custom subjects and context values are all configurable. AllowAnonymous is off by default: a coherent posture for a public read surface, and an accident everywhere else.

Audit middleware

app.UseDwPolicyAudit();

Drains whatever a request recorded against its context to an IDwAuditSink, once, at the end of the request. The sink is resolved from the request's own services, and the context it drains is the one stored in HttpContext.Features — a context built outside the pipeline records events nothing collects. Without the middleware the events are built and never written; with it and no sink registered, they are discarded with a warning.

It drains in a finally, so a request that threw still writes what it recorded. With DwPolicyOptions.AuditRefusals on, that includes the refusal itself: a refused guarded query records an event carrying its ErrorCode, drained in the same pass as the [DwAudit] events. The no-sink warning names both sources:

{Count} policy audit events were recorded and no IDwAuditSink is registered, so they were discarded. Register one, or stop recording them: remove [DwAudit] from the fields that produced them, or turn off DwPolicyOptions.AuditRefusals.