Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. Invocation — Ns::Fn(args). The function name must be namespace-qualified: it needs at least two ::-separated segments (like Strings::Matches, Content::Risk, Lists::Blocked). A bare single-segment name is not a valid provider function. More segments are allowed (Ns::Sub::Fn).
  2. 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 the k field. The projection may be empty, in which case the output is compared directly.
  3. Comparison — any Cedar comparison. Against a bool/long/string output you use the ordinary operators (==, !=, <, <=, >, >=). A decimal output supports == and !=; to order one, use Cedar’s decimal-extension methods (lessThan, lessThanOrEqual, greaterThan, greaterThanOrEqual) with a decimal("…") 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 validate and dogwood 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/ — the forbid plus a catch-all permit; dogwood validate and dogwood 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 roots context / principal / resource, followed by one or more .ident segments. At authorize time Dogwood resolves the path against the decision event: a context path is looked up on the event’s input (leading context skipped); principal / resource resolve to the request scope entity, and a trailing .id / .type projects 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.json file, 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 eid
    

    Comparing 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-all permit next to the forbid above:

    permit ( principal, action == Drupe::Action::"Read", resource );
    
    severity returnedforbid matches?Decision
    LOWnoAllow, by the permit
    HIGHyesDeny, by the forbid
    SEVEREno, it isn’t in [HIGH, CRITICAL]Deny: not a member of Vendor::Severity

    Without this check, SEVERE would match no forbid, the permit would 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 Severity for a request field:

    namespace Drupe {
      entity Severity enum ["LOW", "MEDIUM", "HIGH", "CRITICAL"];
      type ReadInput = { document: String, level: Severity };
      …
    }
    

    This forbid compares the provider’s Vendor::Severity with Drupe::Severity, so it never fires:

    forbid ( principal, action == Drupe::Action::"Read", resource )
    when { Vendor::ThreatScan(context.input.document).severity == context.input.level };
    

    dogwood validate only 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 a bool:

    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 context field 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 a forbid, with an out-of-list value denied; and examples/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:

  1. 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.)

  2. 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 declared outputType instead 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 under forbid (the denial doesn’t fire). Pick the sentinel that fails safe for the polarity of the rules reading it.

  3. 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 bare when { E }; the tag carries no semantics and is retained for compatibility with existing policies. You can call a provider from an ordinary when just 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.json format, 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.