DynamicWhere.ex
DynamicWhere.exv3.0.0·docs

Admin API

dotnet add package DynamicWhere.ex.Policies.AspNetCore --version 3.0.0
app.MapDwPolicyAdmin(options =>
{
    options.RoutePrefix  = "/dw-policies";
    options.ReadPolicy   = "DwPolicyRead";    // both required
    options.WritePolicy  = "DwPolicyWrite";
});
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. Omit either name 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.
POST/rulesWriteUpsert a rule.
DELETE/rules/{id}WriteDelete a rule.
POST/explainReadThe decision chain for one field.
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 them, so an operator cannot even attempt to grant one. Enforced at configuration time and again at resolution time.

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: what won, what it overrode, what was ignored and why.

Salary
  CanSelect : true (masked)
  Mask      : Partial(keepEnd 4)
  Decided by: Rule a3f2 - Role=Manager, Priority 10
  Overrode  : [DwMask(Full)] attribute (Overridable = true)
  Ignored   : Rule b21c - Global, lower precedence

When two sources tie on level, specificity, priority and effect alike, the decided effect is still deterministic — only which of the equal sources is named here is arbitrary. 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.

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 the configured IDwAuditSink, once, at the end of the request. Without it the events are built and never written.