Information Providers
This page covers using Dogwood’s information providers: values computed at
authorize time by a small piece of sandboxed code, then folded back into a policy as
if they had always been part of the request context. It explains how you call a
provider from an ordinary when { … } clause, the arguments a provider can take, how
its output composes with the rest of a condition, and how that output reaches you as
context.providers.<id>. Corpus cases named below are directories under
dogwood-language/tests/passing/provider_only/corpus/; each complete policy
shown below is also a runnable bundle under examples/.
This page takes the providers themselves as given. Declaring one — the
providers.json format, the Rhai implementation contract (the sandbox, host
functions, decimal support, the off-by-default net feature), and the advanced
features (output methods, no-implementation providers, and the guardrails { … }
sugar) — is the subject of the Advanced-topics page
The provider schema.
What an information provider is, and why
Cedar policies decide on the request they are given. Sometimes the fact you want to authorize on is not in the request — it has to be computed: does this document match a regex? Is this string on a denylist? What is the risk score of this content according to some classifier? An information provider lets you write that computation once, declare its shape, and then reference its result inside a policy as though it were an ordinary context attribute.
A provider is lowering-time sugar over Cedar. When you write a
provider invocation in a policy, Dogwood does not invent a new runtime evaluator.
Instead, at lowering time it hoists the invocation out of the policy
and rewrites it to a reference into context.providers.<id>. The Cedar that the
engine ultimately evaluates contains no provider call at all — just a plain attribute
access and comparison. Then, at authorize time, Dogwood runs the provider’s
declared implementation, and binds its output record into
context.providers.<id> before handing the request to Cedar.
Because providers are hoisted at lowering time, the hoisted field must be typed and
declared in the schema. A rule that calls a provider may use any action scope —
action == Ns::Action::"X", action in [list], action in Group, or a bare
unconstrained action. Dogwood declares all hoisted provider fields once in a
shared generated common type (normally DogwoodProviders; a numeric suffix is
used if that name is already occupied). Every action reached by at least one
provider-bearing rule references that type; actions untouched by all provider
rules omit providers. Provider evaluation itself remains unconditional (see
The provider contract below).
Calling a provider
You call a provider directly inside an ordinary when { … } clause, exactly where
you would write any other Cedar condition. There is no special marker: any
namespace-qualified name (Ns::Fn) is read as a provider invocation. You invoke it, reach into
its output, and compare — all as part of a normal Cedar expression:
permit ( principal, action == Doc::Action::"read", resource )
when {
Strings::Matches(context.input.document, "^[A-Z]+$").matched == true
};
That reads as: call the Strings::Matches provider with the document and a regex,
take the matched field of its output, and require it to be true.
Because the call sits in ordinary Cedar, only the call itself is special — the
projection (.matched) and comparison (== true) are plain Cedar. That means a
provider’s output composes with the full Cedar expression language: arithmetic,
if/then/else, any method, &&/||/!, comparisons against other context
fields — anything Cedar allows.
The shape of a call
A provider call has three parts, in order:
- Invocation —
Ns::Fn(args). The function name must be namespace-qualified: it needs at least two::-separated segments (likeStrings::Matches,Content::Risk,Lists::Blocked). A bare single-segment name is not a valid provider function. More segments are allowed (Ns::Sub::Fn). - Projection — zero or more accessors that reach into the provider’s output
record:
.field(field access) or["key"](index access). Because Cedar has no positional list indexing, an index must be a string key:record["k"]reads thekfield. The projection may be empty, in which case the output is compared directly. - Comparison — any Cedar comparison. Against a
bool/long/stringoutput you use the ordinary operators (==,!=,<,<=,>,>=). Adecimaloutput supports==and!=; to order one, use Cedar’s decimal-extension methods (lessThan,lessThanOrEqual,greaterThan,greaterThanOrEqual) with adecimal("…")literal.
A complete worked example
Here is a provider from the policy author’s side. It uses the Strings::Matches
provider (corpus case 0001_regex_matches_uppercase), which asks whether a document matches a regular
expression. The policy (policy_1.dw):
permit ( principal, action == Doc::Action::"read", resource )
when {
Strings::Matches(context.input.document, "^[A-Z]+$").matched == true
};
Runnable:
examples/provider_regex_matches_uppercase/—dogwood validateanddogwood replay.
Behind it, Strings::Matches is declared in a
providers.json (with argument types [string, string], an output record
{ matched: bool }, and a small Rhai script) — the declaration side is covered in
The provider schema.
What happens: at lowering time, Strings::Matches(context.input.document, "^[A-Z]+$")
is hoisted to a context.providers.<id> reference, and the policy Cedar becomes
context.providers.<id>.matched == true. At authorize time, Dogwood runs the
provider’s evaluate("...the document...", "^[A-Z]+$"), gets back the record
{ matched: … }, binds it into context.providers.<id>, and Cedar evaluates
.matched == true. So "ABC" gives true (permit), "abc" gives false, and
"AB12" gives false.
Combining providers with the rest of a condition
Because a provider call is just part of an ordinary Cedar condition, it combines with
&&, ||, !, parentheses, other provider calls, and plain Cedar terms — the full
expression language (see The policy language).
Two providers combined with && and ! (the Strings::Matches and Lists::Blocked
providers of case 0004_two_providers_and_not) — a fragment; the full rule is the
provider_matches_and_not_blocked bundle:
when {
Strings::Matches(context.input.document, "^[a-z]+$").matched == true
&& !(Lists::Blocked(context.input.document).blocked == true)
};
Disjunction and parentheses (the Lists::Allowed and Strings::Length providers of
case 0007_boolean_or_parens) — a fragment; the full rule is the
provider_allowed_or_short bundle:
when {
(Lists::Allowed(context.input.document).allowed == true
|| Strings::Length(context.input.document).length < 4)
};
Several calls to the same provider, each with plain-Cedar comparisons — case 0005_regex_operations
(a fragment; the full rule is the
provider_regex_analyze_fields bundle):
when {
Regex::Analyze(context.input.document, "^[A-Z]").is_match == true
&& Regex::Analyze(context.input.document, "[0-9]").count >= 3
&& Regex::Analyze(context.input.document, "[0-9]+").first_match == "42"
};
Because the output is plain Cedar, it can feed ordinary Cedar expressions — for
instance, an integer output used in arithmetic alongside an ordinary context
field — case 0010_unwrapped_mixed_with_cedar (a fragment; the full rule is the
provider_int_arithmetic_trusted bundle):
when {
context.input.trusted == true
&& Strings::DigitCount(context.input.document).count + 1 <= 3
};
The comparison, in two forms
The comparison against a provider’s output comes in two flavors, depending on the output’s type.
Operator form uses one of <=, >=, ==, !=, <, >. Verified examples
across the corpus include .matched == true (case 0001_regex_matches_uppercase), .length < 5
(case 0002_length_threshold), and .count >= 2 (case 0008_greater_than_family) — this fragment’s full rule is the
provider_digitcount_operator_ge bundle:
when {
Strings::DigitCount(context.input.document).count >= 2
};
Method form uses Cedar’s decimal comparison methods —
lessThan, lessThanOrEqual, greaterThan, greaterThanOrEqual — to compare a
decimal output against a decimal("…") literal (the Content::Risk provider of
case 0003_decimal_score_method) — a fragment; the full rule is the
provider_risk_decimal_method bundle:
when {
Content::Risk(context.input.document).severityScore.lessThan(decimal("0.5"))
};
Use the method form to order a decimal output; == and != work on a decimal
directly, and the operators cover bool / long / string outputs.
Projection: reaching into the output record
The projection is the path between the invocation and the comparison. Field access
(.field) and index access (["key"]) can be chained. Because Cedar has no
positional list indexing, an index must be a string key: record["k"] reads the
k field.
The Content::Filter provider of case 0006_set_arg_index_projection chains an index accessor and a field
accessor, then compares with the decimal method form (a fragment; the full rule is the
provider_filter_set_index_decimal bundle):
when {
Content::Filter(context.input.document, ["VIOLENCE", "HATE"])["VIOLENCE"].severityScore.lessThan(decimal("0.5"))
};
Here ["VIOLENCE"] selects the VIOLENCE sub-record from the output, .severityScore
reads its field, and .lessThan(decimal("0.5")) compares.
Providers work under permit and forbid
A provider call is independent of the rule’s effect: it works the same under permit
and forbid. Here the Strings::DigitCount provider of case 0008_greater_than_family gates a forbid
(with a catch-all permit alongside):
forbid ( principal, action == Doc::Action::"post", resource )
when {
Strings::DigitCount(context.input.document).count >= 2
};
Runnable:
examples/provider_digitcount_forbid/— theforbidplus a catch-allpermit;dogwood validateanddogwood replay.
A caution on names
A call is read as a provider invocation because of its shape — any
namespace-qualified name — not because it is declared. Declaredness is a separate
check, made at lowering: a namespace-qualified call that matches no declared provider
(and no macro and no Cedar built-in) is a hard error, “unresolved call to Ns::Fn
reached lowering — it is not a declared information provider, not a declared macro,
and not a Cedar built-in”. So a mistyped provider name is caught, not silently
ignored.
Provider arguments
A provider invocation passes arguments positionally. Because a provider is resolved before Cedar runs (it helps build the context Cedar evaluates against), an argument must be a value Dogwood can read off the request event directly. The argument kinds are:
- Attribute-path reference —
context.input.x,principal.id,resource.owner. It begins with one of the rootscontext/principal/resource, followed by one or more.identsegments. At authorize time Dogwood resolves the path against the decision event: acontextpath is looked up on the event’s input (leadingcontextskipped);principal/resourceresolve to the request scope entity, and a trailing.id/.typeprojects that entity’s id or type. A path that does not resolve is null at run time. Validation (see How invocations are validated against declarations) rejects a field-path argument undeclared on — or typed incompatibly with — an action the rule is scoped to; but because provider execution is unconditional (see The provider contract), the same provider still runs on events for other actions, where the argument may be absent and arrive as null — so scripts must tolerate null regardless. - String literal —
"…". - Integer literal — an optionally-signed integer (
i64). - Decimal literal —
decimal("0.5")(chiefly a method threshold, e.g.scoreAbove(decimal("0.5"))). - Bool literals —
true/false. - Set —
[ arg, … ], possibly empty, and possibly nesting other args (a set of strings, ints, bools, or nested sets/paths).
Arbitrary Cedar (arithmetic, if/then/else) is not a provider argument —
only the value forms above. This mirrors the temporal-logic argument restriction.
Field-and-string arguments — case 0001_regex_matches_uppercase:
Strings::Matches(context.input.document, "^[A-Z]+$")
A principal-rooted argument — case 0015_principal_id_arg (a fragment; the full rule is the
provider_principal_id_allowlist bundle):
Access::Allowed(principal.id)
A set argument — cases 0006_set_arg_index_projection / 0009_unwrapped_no_marker:
Content::Filter(context.input.document, ["VIOLENCE", "HATE"])
At authorize time, each argument is resolved to a runtime value (event fields and scope entities as above, literals as-is, sets recursively), and the values are passed positionally to the provider’s implementation, matched to its declared argument order.
Declaring a provider. The signature you invoke against — the argument types, the output record, and the implementation that computes it — is declared in a
providers.jsonfile, described in full in The provider schema. This page assumes those declarations already exist and focuses on calling them from a policy.
Enum-typed values
A provider can return a value from a fixed list, such as a severity of LOW, MEDIUM,
HIGH or CRITICAL, instead of a free-form string. The provider declares the list once,
as an enum in providers.json (Enum-typed values),
and policies compare against its values:
forbid ( principal, action == Drupe::Action::"Read", resource )
when {
Vendor::ThreatScan(context.input.document).severity
in [Vendor::Severity::"HIGH", Vendor::Severity::"CRITICAL"]
};
An enum value is written Enum::"VALUE", quoted, as in Cedar. Compare it with ==,
!= or in [..]. Compared with a plain string field, two things change:
-
Typos are caught when the policy is validated. With a string field,
.severity == "CRITCAL"validates and silently never matches, so the guardrail does nothing. With the enum it’s an error:error: for policy `policy_1`: entity `Vendor::Severity::"CRITCAL"` is of an enumerated entity type, but `"CRITCAL"` is not declared as a valid eidComparing the field with a plain string (
== "HIGH"), or ordering it (<), is also rejected. -
Unexpected values are denied. If the provider returns a value outside the list (a vendor adds
SEVERE), the request is denied, even if another policy in the set would permit it. Take a catch-allpermitnext to theforbidabove:permit ( principal, action == Drupe::Action::"Read", resource );severityreturnedforbidmatches?Decision LOWno Allow, by the permitHIGHyes Deny, by the forbidSEVEREno, it isn’t in [HIGH, CRITICAL]Deny: not a member of Vendor::SeverityWithout this check,
SEVEREwould match noforbid, thepermitwould allow it, and the most severe content would get through. The response says why:provider `Vendor::ThreatScan`.severity: `SEVERE` is not a member of `Vendor::Severity`. -
A provider enum never equals an enum from your Cedar schema. They are different types, even with the same values. Say the action schema declares its own
Severityfor a request field:namespace Drupe { entity Severity enum ["LOW", "MEDIUM", "HIGH", "CRITICAL"]; type ReadInput = { document: String, level: Severity }; … }This
forbidcompares the provider’sVendor::SeveritywithDrupe::Severity, so it never fires:forbid ( principal, action == Drupe::Action::"Read", resource ) when { Vendor::ThreatScan(context.input.document).severity == context.input.level };dogwood validateonly warns, and the policy passes:warning: for policy `policy_1`, policy is impossible: the policy expression evaluates to false for all valid requests OK: validation passed (1 warning(s)).Compare the provider field with the provider enum’s own values (
Vendor::Severity::"HIGH") instead. To use a Cedar-schema enum, pass it into the provider as an argument ("paramType": "entity", "entityType": "Drupe::Severity"; the script receives the bare id) and return abool:forbid ( principal, action == Drupe::Action::"Read", resource ) when { Vendor::IsBlocked(context.input.level).blocked };Validation checks that the argument has the declared enum type only for a direct
contextfield like this one, not for an enum inside a set or record argument (How invocations are validated).A provider can’t return a Cedar-schema enum: its output enums must be declared in
providers.json(Enum-typed values).
Changing an existing string field to an enum breaks policies that compare it with a
string (== "HIGH" becomes a type error). To migrate without breaking them, add the enum
as a new field.
In the exported Cedar. Dogwood adds no namespace to the schema. The enum is exported
into the action schema’s namespace under a generated name (Vendor::Severity becomes
Drupe::Vendor_Severity), and the lowered policy uses that name:
forbid(principal, action == Drupe::Action::"Read", resource) when {
(((context.providers).policy_1_p_0).severity) in
[Drupe::Vendor_Severity::"HIGH", Drupe::Vendor_Severity::"CRITICAL"] };
You keep writing Vendor::Severity::"HIGH", and error messages use that name too. If
the action schema has several namespaces, the enum is exported once and every namespace
uses it, so one unscoped guardrail covers all of them.
Runnable:
examples/provider_enum_severity_forbid/— a severity enum gating aforbid, with an out-of-list value denied; andexamples/provider_enum_two_namespaces/— one unscoped guardrail over actions in two namespaces.
How a provider binds to context.providers.<id>
Putting the pieces together, here is what happens to a provider invocation from the caller’s point of view.
Lowering time. Every provider invocation is hoisted: the call leaf is replaced
with a reference into context.providers.<id>. The surrounding projection and
comparison were already ordinary Cedar, so they lower natively — an index ["k"]
becomes .k, and the comparison stays as whatever Cedar op you wrote. (The
generated field names and the Cedar-schema augmentation this entails are covered in
The provider schema.) All provider fields are bundled in
one common type per touched namespace, and provider-relevant actions reference
that shared type rather than carrying duplicate inline records.
Authorize time. For each decision event Dogwood builds the Cedar request
context. It passes context.input through from the event, evaluates every
declared provider field (resolving each argument, then running the provider), and
collects the outputs into a single context.providers object keyed by
id. So context.providers.<id> holds that provider’s evaluated output record, and
Cedar evaluates the (already-lowered) comparison against it.
So you write the surface form Ns::Fn(args).field <cmp> literal, and
the engine evaluates context.providers.<id>.field <cmp> literal against the bound
output. For case 0001_regex_matches_uppercase, the engine runs the provider, binds { matched: … }, and
Cedar evaluates .matched == true — giving "ABC" → true, "abc" → false,
"AB12" → false. For case 0006_set_arg_index_projection, document="violent" scores VIOLENCE at 0.90 so
.lessThan(0.5) is false (deny), document="safe" scores 0.10 so it is true
(permit), and document="hateful" scores VIOLENCE at 0.10 (only HATE is 0.90) so it is
also true (permit).
The provider contract
Provider execution is unconditional. A provider invocation belongs to a rule,
but its evaluation is not gated by that rule — not by the rule’s action clause, its
principal/resource constraints, its when/unless conditions, or whether the rule
could fire at all. For every decision event, every provider invocation in the policy set
is evaluated and its output bound into context.providers; Cedar alone then decides which
policies fire, using its ordinary scope and condition semantics. (Deciding “could
this rule match this event?” before running its provider would mean re-implementing
Cedar’s scope semantics inside the provider machinery, so Dogwood does not.)
The exported schema omits providers from actions untouched by provider rules,
while Dogwood’s runtime context still contains the unconditional provider-output
superset. Integrations that perform schema-aware request-context validation must
account for that distinction, just as they must for action-selective temporal
fields.
Three consequences for provider authors:
-
Providers must be pure. A provider may run for events its rule has nothing to do with, and implementations are free to skip, cache, reorder, or repeat evaluations whose results cannot affect the verdicts. A provider must be a deterministic function of its arguments with no observable effects. (Replay —
dogwood replay— and checking one engine against another also assume this.) -
Any argument may be absent. On an event whose context or scope entities do not carry the fields a provider reads (a different action’s input shape, a resource type without the attribute), the argument arrives as Null — in a Rhai script, the unit value
(). Scripts must be defensive: detect unit arguments and return a sentinel that conforms to the declaredoutputTypeinstead of erroring:fn evaluate(text) { if type_of(text) == "()" { return #{ length: -1 }; } #{ length: text.len() } }Choose the sentinel deliberately, and mind the polarity trap: a sentinel that makes a guard false is the restrictive direction under
permit(the rule doesn’t fire) but the permissive direction underforbid(the denial doesn’t fire). Pick the sentinel that fails safe for the polarity of the rules reading it. -
An erroring provider is undefined behavior. If a provider evaluation errors (a script that throws, an external resolver that fails), the decision outcome carries no guarantees. This reference interpreter fails closed: it denies the request and reports the error in the response’s diagnostics. That is an implementation choice, not a contract — other implementations, or future versions of this one, may avoid the error entirely (and reach a different verdict) or handle it differently. A policy set whose safety depends on an erroring provider denying is incorrect on every implementation, including this one. Defensive scripts (point 2) are the only defense.
An output that doesn’t match its declared shape (a bare string where a record is declared, a missing required field) counts as a provider error only when the declared type contains an enum. Otherwise the value reaches Cedar as is. Cedar fails on the field, reports the error in the response’s diagnostics, and treats that policy as not satisfied for that request. For a
forbid, the request can then be allowed.
The same contract applies to external providers supplied through a
ProviderResolver: pure, tolerant of Null arguments, never relying on error
behavior.
How it composes with the rest of a policy
Providers slot into the same rule structure as everything else. A rule scopes a
principal, action, and resource in the usual
Cedar way (see The policy language), and the provider call
lives in an ordinary when { … } clause, freely combined with plain Cedar conditions
and with other providers.
The companion feature is temporal expressions, written with when temporal { … } —
see Temporal expressions. Where a provider computes a
value for the current request, a temporal expression reasons about the history of
requests. Both are lowered into ordinary Cedar the engine can evaluate.
Advanced features
Two provider features exist but most policies do not need them, and both are documented on the declaration-side page, The provider schema:
- The
guardrails { … }clause —when guardrails { E }is transparent sugar for a barewhen { E }; the tag carries no semantics and is retained for compatibility with existing policies. You can call a provider from an ordinarywhenjust as well. - Output methods and no-implementation providers — post-processing a provider’s
output with a declared method (
Provider::Fn(args).method(…)), and declaring a provider with no implementation so its value is supplied by your own code.
See The provider schema for both.
See also
- The provider schema — declaring a provider: the
providers.jsonformat, the Rhai implementation contract, and the advanced features above. - The policy language — rule structure and the Cedar condition language a provider call lives in.
- Temporal expressions — the companion feature, reasoning about request history.
- Calling macros — the other way to reuse logic across policies.
- The API and workflow — wiring provider declarations
into a
ServiceSchema(ServiceSchemaBuilder::providers) and the authorize-time flow that evaluates providers.