This is a draft that could be altered, removed or replaced by other documents. It is not a recommendation approved by TO.
As well as sections marked as non-normative, all authoring guidelines, diagrams, examples, and notes in this specification are non-normative. Everything else in this specification is normative.
The key words MAY, MUST, MUST NOT, OPTIONAL, RECOMMENDED, REQUIRED, SHOULD, and SHOULD NOT in this document are to be interpreted as described in BCP 14 [RFC2119] [RFC8174] when, and only when, they appear in all capitals, as shown here.
This document defines a profile for authorizing GraphQL requests in the Federatieve Toegangsverlening (FTV) architecture. It specifies (a) a mapping function that turns the GraphQL body of an HTTP request into a list of selected fields, each with its parent type and its arguments, (b) the guarantees the enforcement chain gives about the request the PDP evaluated, and (c) the obligations of the policy engine: every field needs a rule or is denied, and the per-field results combine into one AuthZEN decision. A reference rule model is informative. The profile builds on the NLgov Profile for OpenID AuthZEN and is engine-neutral in its input: any Policy Decision Point (PDP) that accepts an AuthZEN request can implement it.
GraphQL [GRAPHQL] lets a data source publish one schema that many consumers query. Each consumer selects the fields it needs. This fits data minimization. It also creates a problem for access control: one HTTP request can ask for any subset of the schema, and that subset is only known at request time.
The NLgov AuthZEN profile [AUTHZEN-NLGOV] defines how a Policy Enforcement Point (PEP) asks a PDP for a decision. It does not say how a GraphQL request maps onto the AuthZEN information model. Without such a mapping, every policy author must parse GraphQL inside the policies. 3. Design Constraints explains why that does not work in practice.
This profile fills the gap with three parts:
The mapping function is type-aware and policy-blind. It knows the schema. It knows nothing about permissions. All decisions stay in the policies, which the Policy Administration Point (PAP) loads into the PDP. This keeps the design inside the EAM/PxP model of [FTV].
A deployment can authorize access to GraphQL data at different places:
| Approach | Where the PDP is asked | Precondition |
|---|---|---|
| In front of the endpoint | A standard PEP in front of the source asks the PDP for one decision about the whole request, before the source executes it. | No change to the implementation of the source. The configuration and the deployment pipeline of the source meet 10. Enforcement Guarantees. |
| In the resolvers | The resolvers of the source, or middleware in its GraphQL server, ask the PDP about objects or fields, at one or more levels of the query. | Control over the implementation of the source. |
| Schema stitching or federation | A gateway combines several GraphQL endpoints into one schema. The gateway, each endpoint, or both ask the PDP. | Control over the gateway. |
This profile specifies the first approach only: generic AuthZEN enforcement in front of a GraphQL endpoint. It does not need control over the implementation of the source, so it also applies when the deployer does not control that implementation. The other approaches are valid. They can give finer control, for example a decision on the data that a resolver loads. They are out of scope for this profile.
The approaches can be combined. A source can also enforce per resolver (2. Model). The source in front of which the PEP sits can itself be a gateway that combines other endpoints. This profile then treats the combined schema as the schema of the source.
| Document | Relation |
|---|---|
| OpenID AuthZEN Authorization API 1.0 [AUTHZEN] | Base standard. This profile does not change the API. |
| NLgov Profile for OpenID AuthZEN [AUTHZEN-NLGOV] | Base profile. This profile adds properties to action and resource and defines the content of the decision context. |
| AuthZEN COAZ Framework [COAZ] | Not used. COAZ describes how a PEP builds a request from a template. It cannot fan out over the N fields of a selection set (COAZ Section 2.6). See 3.3 Why not COAZ. |
| GraphQL specification [GRAPHQL] | The mapping function accepts any document that is valid under the GraphQL specification. |
| GraphQL over HTTP [GRAPHQL-HTTP] | Defines the request body the mapper reads. This profile restricts the transport to a subset (4.1 Transport subset). |
consumer ──POST /graphql──> PEP
│ AuthZEN request:
│ subject = caller (from mTLS), action = POST + body,
│ resource = request target, context = headers
▼
PDP
├─ 1. mapper ([[[#the-mapping-function]]])
│ parse + validate body ──> resource.properties.graphql
│ {profile, operation, schema, fields[], unverifiable}
└─ 2. policy engine ([[[#the-policy-model]]])
bind rules per field, AND ──> ONE decision
│
▼
PEP: allow → forward unchanged ([[[#enforcement-guarantees]]])
deny → 401 or 403 ([[[#decision-and-error-transport]]])
One HTTP request gives one AuthZEN evaluation and one decision. The
profile does not use the evaluations batch endpoint. Every field
record is self-contained so that a later version can fan out to one
evaluation per field without changing the record (15.6 Fan-out by the mapper).
The mapper MAY run inside the PDP (as a request pre-processing step before the policy engine) or as HTTP middleware in front of the PDP. Both placements conform. 6.4 Ownership and drift explains why the mapper belongs on the PDP side in practice.
A source MAY additionally enforce per resolver, with its own PDP calls. That is defence in depth and outside this profile.
The following constraints shape the profile. They are stated here so a reader can check every normative rule against them.
Only OPA (Rego) has GraphQL builtins (graphql.parse,
graphql.parse_query). Cedar, Cerbos, and OpenFGA have none. The Rego
AST carries no type information: a field node is {Alias, Name, Arguments, SelectionSet}. To find the parent type of a nested field,
a policy must fold the schema down the query path. Rego does not allow
recursion, so this becomes one rule per depth level. The result is a
large engine module, shipped in every policy bundle, that contains no
domain logic and runs on one engine only.
A GraphQL-aware PEP needs the schema. The schema the policies were written against lives in the policy bundle, which lands at the PDP. A PEP-side mapper needs a second distribution channel for the schema and must keep it in sync with the bundle. This is the coupling constraint 5 forbids. It also replaces an off-the-shelf PEP with a custom one.
COAZ [COAZ] standardizes how a PEP builds an AuthZEN request from a
declarative template. It forbids a mapping from fanning out per list
element (COAZ Section 2.6). A selection set is a list of N fields,
unknown until the request. Only a COAZ GraphQL binding could express
this, in the way [COAZ-MCP] binds MCP. No such binding exists as of
September 2026. What already
matches COAZ: the single evaluation envelope, fail-closed on mapping
error, and the trust-anchored subject.id set by the PEP.
[GRAPHQL-HTTP] allows GET with query parameters, several request media types, and lets servers accept batched or persisted documents. This profile restricts the transport so that the mapper sees exactly what the source executes.
The policy bundle names the GraphQL path (for example /graphql).
The PEP MUST send every request it receives for the source, on any
path and with any method, to the PDP. The mapper MUST accept only a
request that meets all of the following, and MUST fail closed with
subcode UNSUPPORTED_TRANSPORT (5.8 Fail-closed conditions) otherwise.
resource.id is byte-equal to the GraphQL path in the bundle. No
normalisation: /graphql/, /GraphQL, //graphql, and
percent-encoded variants do not match. A resource.id with a query
string (a bare ? included) does not match.action.name is POST.Content-Type header in context.headers has media type
application/json (compared case-insensitively per [RFC9110]) and
no parameter other than charset=utf-8 (parameter name and value
compared case-insensitively).Upgrade and no Content-Encoding header is present.action.properties.body is present and is a string.The mapper looks up header names case-insensitively. When more than
one name matches, or a value is not a string, the mapper fails closed
with UNSUPPORTED_TRANSPORT. A PEP MAY omit
the body for a media type it does not forward (for example every value
other than exactly application/json). The mapper then fails closed.
A deployment MUST configure the source to serve GraphQL on that one path only and to reject the same requests, so that a request the mapper never judged cannot execute (10. Enforcement Guarantees, H1).
The PEP MUST send an AuthZEN evaluation request as defined in [AUTHZEN-NLGOV] with the following content.
| Slot | Content | Requirement |
|---|---|---|
subject.type |
A type that [AUTHZEN-NLGOV] allows. This profile does not fix it. | MUST |
subject.id |
The identifier of the calling organisation (for example its OIN), taken from the mTLS client certificate, or from a token that the PEP has checked against that certificate | MUST. The PEP MUST set this value itself. It MUST NOT take it from a header or a claim that it has not verified. |
action.name |
The HTTP method as received | MUST |
action.properties.body |
The raw HTTP request body as a string that decodes to the same bytes | MUST when the body is valid UTF-8 without a byte order mark. Else the PEP omits the body, and the mapper fails closed. The PEP MUST NOT replace invalid bytes. |
resource.type |
"uri" |
MUST |
resource.id |
The request target (path and query string), byte-for-byte as received (for example /graphql) |
MUST |
context.headers |
The HTTP request headers as an object with string values. The PEP joins the values of a repeated header with ,. |
MUST. The mapper reads Content-Type, Upgrade, and Content-Encoding here. |
context.traceparent, context.tracestate |
W3C Trace Context as defined in the Context Properties of [AUTHZEN-NLGOV] | SHOULD, as in [AUTHZEN-NLGOV]. When absent, X-Request-ID is the correlation key. |
context.time |
The time the request was received, RFC 3339, as the Context Properties of [AUTHZEN-NLGOV] define | SHOULD. When absent, the PDP uses its own clock and records that in the log. |
The PEP MUST also send the X-Request-ID header on the API call as
[AUTHZEN-NLGOV] describes.
The PEP MUST NOT parse the GraphQL body. The PEP MUST NOT rewrite the request. The PEP forwards the request unchanged on allow (10. Enforcement Guarantees) and returns an error on deny (9. Decision and Error Transport).
The PEP calls the Access Evaluation API endpoint of the PDP as defined
by [AUTHZEN-NLGOV]. The PEP MUST treat every outcome other than a
well-formed response with "decision": true (timeout, transport
error, 5xx, malformed body) as deny. The PEP MUST NOT cache
decisions.
The mapper first checks the transport (4.1 Transport subset) and the body size
limit (5.7 Limits), before it parses anything. It then reads
action.properties.body. The body MUST be one JSON object with the
members defined by [GRAPHQL-HTTP]:
| Member | Type | Requirement |
|---|---|---|
query |
string | REQUIRED. The GraphQL document. |
operationName |
string or null | OPTIONAL. null is treated as absent. An empty string is invalid (GetOperation in [GRAPHQL] compares the string, so "" would be "present" and match nothing). REQUIRED when the document has more than one operation. |
variables |
object or null | OPTIONAL. null is treated as absent. |
extensions |
object | OPTIONAL. MUST be absent or an empty object unless the bundle lists allowed member names. |
The mapper MUST fail closed with subcode INVALID_BODY when:
operationName is the
empty string;documentId or id, which select a persisted document);extensions has a member the bundle does not list; persistedQuery
MUST NOT be listed;The mapper adds one object at resource.properties.graphql. The
example uses the fictional schema of Appendix A and this request:
{
"query": "query Inkomen($bsn: BSN!, $jaar: Int = 2024) { persoon(bsn: $bsn) { inkomens(jaren: [$jaar]) { jaar ... on LoonInkomen { bedrag { waarde valuta } } } } }",
"variables": { "bsn": "999990011" }
}
{
"profile": "ftv-graphql/0.1",
"operation": { "type": "query", "name": "Inkomen" },
"schema": { "digest": "sha256:9f2c…" },
"fields": [
{ "path": ["persoon"], "parentType": "Query", "field": "persoon", "leaf": false,
"args": { "bsn": { "value": "999990011", "origin": "variable:bsn", "variables": ["bsn"] } } },
{ "path": ["persoon", "inkomens"], "parentType": "Persoon", "field": "inkomens", "leaf": false,
"args": { "jaren": { "value": [2024], "origin": "mixed", "variables": ["jaar"] } } },
{ "path": ["persoon", "inkomens", "jaar"], "parentType": "Inkomen", "field": "jaar", "leaf": true },
{ "path": ["persoon", "inkomens", "bedrag"], "parentType": "LoonInkomen", "on": "LoonInkomen", "field": "bedrag", "leaf": false },
{ "path": ["persoon", "inkomens", "bedrag", "waarde"], "parentType": "Bedrag", "field": "waarde", "leaf": true },
{ "path": ["persoon", "inkomens", "bedrag", "valuta"], "parentType": "Bedrag", "field": "valuta", "leaf": true }
],
"unverifiable": null
}
Top-level properties:
| Property | Type | Meaning |
|---|---|---|
profile |
string | "ftv-graphql/0.1". Identifies this representation version. |
operation.type |
string | "query", "mutation", or "subscription". |
operation.name |
string or null | The operation name, null for an anonymous operation. |
schema |
object or null | { "digest": ... } (6.3 Schema digest). null when no schema could be loaded (SCHEMA_UNAVAILABLE). |
fields[] |
array of Field | Every selected field, in document order, after fragment expansion. Empty when unverifiable is not null. |
unverifiable |
object or null | null when the field list is complete and correct. Else {code, subcode, message} (5.8 Fail-closed conditions). |
Field record:
| Property | Type | Meaning |
|---|---|---|
path |
array of string | Response keys from the operation root to this field (5.6 Path and field id). |
alias |
string | Present only when the selection uses an alias. |
parentType |
string | Name of the type on which the field is selected. |
on |
string | Present only when the selection set that directly contains this field belongs to a fragment (inline or named) with a type condition. Equal to that type condition. Not propagated to descendants. |
field |
string | Schema field name. Never the alias. |
leaf |
boolean | true when the selection has no sub-selection. |
args |
object | Present only when at least one argument entry remains after 5.5 Arguments. Keyed by argument name. |
The mapper MAY also add resource.properties.declared (7.2 Resource).
The mapper MUST NOT remove or change any other property of the AuthZEN
request. The raw body stays in action.properties.body. The enriched
request MUST remain a valid AuthZEN request.
The mapper MUST select the operation as follows.
PARSE_ERROR.NO_OPERATION. This check runs before validation,
because a fragment-only document also fails validation (Fragments
Must Be Used) and the more specific code is the useful one.operationName is present and no operation has that name,
fail closed with subcode OPERATION_NOT_FOUND. This follows
GetOperation in [GRAPHQL].operationName is absent and the document has exactly one
operation, select it. When operationName is absent and the
document has more than one operation, fail closed with subcode
OPERATION_AMBIGUOUS.mutation or subscription,
fail closed with code OPERATION_NOT_SUPPORTED. This profile
version covers queries only.The mapper MUST perform validation and the walk as follows.
INVALID_QUERY. Because the bundled SDL
defines no executable directives (6.1 The schema is part of the policy bundle), the Directives Are
Defined rule rejects every executable directive other than the
built-in @skip and @include. The set of directives the
validator accepts at executable locations MUST be exactly @skip
and @include, whatever the validator library knows by default
(for example @defer).f on parent type P, the
mapper emits a Field with parentType = P, field = f, path = parent path + [response key], alias when present, and on when
the selection set that directly contains the field belongs to a
fragment with a type condition. When the selection has a
sub-selection, leaf = false and the walk continues with the
unwrapped return type of P.f as the new parent type (Section
7.2). Otherwise leaf = true.... on T { ... }, the mapper walks the
sub-selection with parent type T. The fragment adds no Field and
no path segment. Its direct children get on = "T".on....F, the mapper walks the selection set of
fragment F with parent type equal to the type condition of F.
The spread adds no Field and no path segment. Its direct children
get on equal to the type condition of F. A fragment spread
more than once produces its fields more than once, once per
position.args.@include and @skip: the mapper MUST emit the field
regardless of the directive. A field that may be skipped still
counts. This is the conservative choice. See 15.3 @skip and @include.__typename, __schema, __type): the
mapper emits them like any other field. The policy engine drops
__typename and the descendants of __schema and __type
(8.2 Data fields).For every selection, args holds one entry per argument that has a
value after the resolution below, keyed by the argument name. Each
entry has value and origin. When no entry remains, args is
absent.
Variable coercion. Before the walk, the mapper MUST coerce the
supplied variables against the variable definitions of the selected
operation as CoerceVariableValues in [GRAPHQL] describes: built-in
scalars, enums, lists, input objects, and non-null wrappers are
coerced; a coercion failure, a null for a non-null variable, or a
missing non-null variable without default fails closed with subcode
VARIABLE_ERROR. Values of custom scalars (for example BSN) are
passed through as the JSON received; the mapper cannot know the
source's coercion for them (13. Security Considerations).
Value. value is the argument value as the source executes it:
the result of input coercion of the argument literal against the
argument's type as CoerceArgumentValues and Input Coercion in
[GRAPHQL] define, after variable resolution. This includes: enum
values as strings; a single value coerced to a one-item list where the
type is a list; input-object fields that are absent and have a
declared default filled in with that default; null as null.
Variable resolution. The table applies to the coerced variable values. Rows 3 to 5 concern nullable variables without default; a non-null variable without default that is not supplied already failed coercion.
| Case | Resolved value |
|---|---|
Supplied in variables (including an explicit null) |
The coerced supplied value. |
| Not supplied, declared with a default | The declared default. |
| Not supplied, no default, used directly as an argument value | The argument is not provided. Argument-definition default applies (below). |
| Not supplied, no default, used as an input-object field value | The input field's declared default when it has one, else the field is omitted from the object. |
| Not supplied, no default, used as a list item | The item is null. (A non-null item type in this position fails validation: All Variable Usages Are Allowed.) |
Argument-definition defaults. When an argument is not provided
(not written, or resolved to "not provided" above) and the schema
declares a default for it, the entry is added with the schema default
and origin: "schema-default". This is what the source executes
(CoerceArgumentValues in [GRAPHQL]). When an argument is not provided
and has no schema default, there is no entry.
Origin. Every entry has origin. Entries whose value references
one or more variables also have variables: the list of referenced
variable names. Entries whose value contains input-object fields that
input coercion filled from a schema default also have
schemaDefaults: the list of paths to those fields inside value.
Each path is an array, for example ["minimum"], or [0, "minimum"]
for an input object inside a list.
origin |
Meaning |
|---|---|
"literal" |
The value contains no variables. |
"variable:<name>" |
The value is exactly the variable <name>, supplied in variables. |
"default:<name>" |
The value is exactly the variable <name>, resolved to its declared default. |
"schema-default" |
The argument was not provided; the value is the argument's default from the schema. |
"mixed" |
A literal that contains one or more variables (for example [$jaar]); variables lists them. |
There is no global argument map. Two selections of the same field
with different arguments produce two records with different args.
path is the list of response keys from the operation root to the
field. The response key is the alias when present, else the field
name ([GRAPHQL] Response Key). Fragments add no segment. Example:
["a", "naam"] for a: persoon(bsn: "1") { naam }.
The field id is path joined with .. It is a display form for
messages and logs. It is not unique: two records can share a path when
they sit under different concrete types of one abstract parent
(5.4 Validation and the walk.7) or when a fragment is spread twice into one selection
set. Where a record must be identified exactly, the profile uses its
index in fields[].
The mapper MUST enforce the following limits at the stage named.
Exceeding any of them MUST fail closed with subcode LIMIT_EXCEEDED.
The values are bundle settings; the defaults apply when the bundle
does not set a value.
| Limit | Default | Stage and count |
|---|---|---|
| Body size in bytes | 1 048 576 | Before parsing; length of action.properties.body |
| JSON and GraphQL nesting depth | 64 | During parsing; nesting of JSON values, and of GraphQL selection sets and value literals |
| Fragment definitions | 100 | After parsing; definitions in the document |
| Validation time | 1 s | During validation; elapsed time (Overlapping Fields Can Be Merged is super-linear on adversarial documents) |
| Selection depth | 64 | During the walk; nesting of field selection sets, fragments do not add depth |
| Number of Field records | 1 000 | During the walk; records emitted so far, checked at each emission |
| Number of aliased selections | 100 | During the walk; records with alias, checked at each emission |
A deployment SHOULD set these below the corresponding limits of the source, so that the PDP rejects before the source does.
When any condition below holds, the mapper MUST set unverifiable to
an object with code, subcode (when listed), and message, and
MUST leave fields empty.
| Condition | code |
subcode |
|---|---|---|
| Transport outside 4.1 Transport subset | COVERAGE_UNVERIFIABLE |
UNSUPPORTED_TRANSPORT |
| Body fails 5.1 Input | COVERAGE_UNVERIFIABLE |
INVALID_BODY |
query does not parse |
COVERAGE_UNVERIFIABLE |
PARSE_ERROR |
| Validation fails, or a disallowed executable directive (5.4 Validation and the walk.1) | COVERAGE_UNVERIFIABLE |
INVALID_QUERY |
| Document has no operation | COVERAGE_UNVERIFIABLE |
NO_OPERATION |
operationName names no operation |
COVERAGE_UNVERIFIABLE |
OPERATION_NOT_FOUND |
More than one operation and no operationName |
COVERAGE_UNVERIFIABLE |
OPERATION_AMBIGUOUS |
| Variable coercion fails (5.5 Arguments) | COVERAGE_UNVERIFIABLE |
VARIABLE_ERROR |
| A limit of 5.7 Limits is exceeded | COVERAGE_UNVERIFIABLE |
LIMIT_EXCEEDED |
| Operation is a mutation or subscription | OPERATION_NOT_SUPPORTED |
- |
| Schema is missing, unreadable, or does not parse | CONFIG_ERROR |
SCHEMA_UNAVAILABLE |
message is for the decision log. For INVALID_QUERY it MUST contain
only the name of the failed validation rule and the source location;
it MUST NOT contain schema-derived suggestions such as "Did you mean".
The conditions are checked in the order of 5.1 Input and 6.3 to
6.7: transport, body size, body shape, parse, no operation,
validation, operation selection, variables, walk limits. The schema
is needed from validation onward, so SCHEMA_UNAVAILABLE is reported
only for a request that passed the earlier checks.
The policy engine MUST deny when unverifiable is not null, before it
evaluates any rule (8.5 Aggregation).
The policy bundle MUST contain the GraphQL SDL the rules were written against. The SDL MUST NOT define executable directives (Section 6.4.1). The SDL MAY be a subset of the source's schema: types and fields the policies never cover can be left out, except as 6.2 Type resolution requires. A query that selects a field absent from the bundled SDL fails validation and is denied.
The mapper MUST load the SDL from the policy bundle or from a location the bundle points to. The mapper MUST NOT fetch the schema from the source at runtime and MUST NOT use GraphQL introspection. One evaluation MUST use one bundle version for the SDL, the rules, and the bundle settings. When the mapper runs outside the policy engine or loads the SDL from a location outside the bundle, the pinned digest of 6.3 Schema digest is REQUIRED.
The mapper resolves parent types from the SDL. For a field P.f with
a sub-selection, the next parent type is the named type of P.f with
list and non-null wrappers removed: [Bedrag!]! gives Bedrag.
For a field selected on an interface without a type condition, the
parent type is the interface. On a union, only __typename can be
selected without a type condition ([GRAPHQL] Field Selections), so
no data field ever has a union as parent type. For a field inside
... on T, the parent type is T.
A binding on an interface field key (Inkomen.jaar) covers that field on
every implementing type the source has, whenever the consumer selects
the field without a type condition. The bundled SDL MUST contain
every implementing type of every interface it contains, and every
member of every union it contains. H3 (10. Enforcement Guarantees) requires the
source to have exactly those types, so a new implementing type at the
source fails the H3 check instead of opening access. A concrete-type
key never
restricts what an interface key allows: the consumer chooses the
parent type by writing or omitting ... on T. A bundle SHOULD NOT
combine an unconditional interface key with a conditional
concrete-type key for the same field, and a bundle check SHOULD
reject it. Rules for sensitive fields SHOULD use concrete-type keys
only (13. Security Considerations).
The mapper MUST compute schema.digest as "sha256:" followed by
the lowercase hexadecimal SHA-256 of the UTF-8 bytes of the SDL text
it loaded, and MUST put it in the output (5.2 Output). The digest
identifies the schema artifact in the bundle. It does not identify
the source's schema: the bundled SDL may be a subset and byte-level
differences (whitespace, comments, order) change the digest.
The policy bundle MAY pin an expected digest. When it does, the policy
engine MUST deny with CONFIG_ERROR / SCHEMA_MISMATCH when
schema.digest differs from the pinned value. The digest appears in
the decision log, so every decision records the schema artifact it
was made against.
The policy author owns the schema in the bundle, together with the rules. When the source adds or renames a field, the new field is absent from the bundled schema. A query that selects it fails validation and is denied. Access reopens only when the policy author updates the schema and the rules in one bundle. Drift is safe, not invisible.
Four kinds of drift are not visible from the query:
Guarantee H3 (10. Enforcement Guarantees) covers all four. It runs in two places: in the bundle pipeline before a bundle is deployed, and in the source pipeline before a schema change is deployed. The second check closes the time window between a source deployment and the next bundle check. In addition, a rule never allows a checked argument value that comes from a schema default (8.4 Per-field evaluation).
Because the schema travels with the bundle, the mapper belongs where the bundle lands: on the PDP side. A PEP-side mapper would need a second distribution channel for the schema.
This section lists what the policy engine sees after the mapper and any PDP-internal attribute retrieval. It follows the structure of the information model chapter of [AUTHZEN-NLGOV].
Unchanged from the PEP. subject.id is the trust anchor: the PEP sets
it from mTLS. Policies MUST base the caller identity on subject.id
and MUST NOT base it on a header.
| Property | Set by | Meaning |
|---|---|---|
resource.type |
PEP | "uri" |
resource.id |
PEP | Request target as received |
resource.properties.graphql |
mapper | 5.2 Output |
resource.properties.declared |
mapper, OPTIONAL | Attributes the caller declares in request headers (for example a purpose). Untrusted. Policies MUST validate them. |
Unchanged from the PEP. action.properties.body keeps the raw body.
| Property | Set by | Meaning |
|---|---|---|
context.headers |
PEP | Request headers |
context.traceparent, context.tracestate |
PEP | W3C Trace Context |
context.time |
PEP, else PDP | Request time, RFC 3339, as [AUTHZEN-NLGOV] defines (4.2 Request content) |
The policy engine evaluates the enriched request. 8.1 Rules and binding to 9.5 state what every policy engine MUST do, whatever its rule language and whatever notation the bundle uses for the binding. 8.6 Reference policy model (informative) is informative: it gives a reference rule model that meets these requirements. A bundle MAY use another model.
A rule is one unit of policy with conditions. A binding connects a data field to zero or more rules. The binding MUST be part of the policy bundle and MUST be deployed through the PAP together with the rules. It MUST NOT depend on the schema the source serves at runtime.
This profile does not prescribe how the binding is written. Section 9.6 shows rule data that names field keys.
The engine MUST take resource.properties.graphql.fields[] and drop:
field is __typename;parentType starts with __ (the children of
__schema and __type).The rest are the data fields. Root fields other than __typename are
data fields. The root introspection fields __schema and __type are data fields with keys
Query.__schema and Query.__type: introspection executes and
returns the source's full schema, so it needs a rule like any other
root edge.
When the set of data fields is empty, the decision is deny with code
NO_DATA_FIELDS.
When resource.properties.graphql is absent, or its profile is not
"ftv-graphql/0.1", the engine MUST deny with CONFIG_ERROR /
MAPPER_OUTPUT_MISSING. This covers a mapper deployed as middleware
that was bypassed.
Whatever the notation, the binding MUST meet these requirements.
NO_APPLICABLE_RULE).parentType.field. The choice MAY also use leaf
and whether args is present. It MUST NOT use alias, path, or
on: the consumer chooses those.args MUST be allowed only by a rule
bound to its field key specifically. A rule bound through the
parent type, or through any other group of fields, does not see
the arguments.Query.__schema and Query.__type
included, MUST be allowed only by a rule bound to its field key
specifically. A rule bound through the root type would cover every
entry point, also entry points added to the bundled SDL later.args. Two records with the same field key are two
decisions.For every data field, the engine evaluates each bound rule against the evaluation context:
| Name | Value |
|---|---|
subject |
input.subject |
field |
The Field record under evaluation |
fields |
All Field records of the request, including the records 8.2 Data fields drops |
resource |
input.resource without properties.graphql |
time |
context.time |
A field decides allow when at least one bound rule evaluates to allow. A field decides deny when no bound rule evaluates to allow. Each deny carries a per-field code (9.2 Reason codes). When several bound rules deny with different codes, a server-class code wins; else the code of the first denying rule in bundle order.
A condition MUST read the arguments it checks from field.args, not
from another record in fields. A condition MUST NOT grant access
because of the presence or absence of another record: records can
share a path (5.6 Path and field id), and a field under @skip is present in
fields but may not execute. fields exists for cross-record
constraints that only deny (for example: deny when two persoon
selections carry different bsn values).
A rule condition that reads an argument the rule needs (for example
field.args.jaren) and finds no entry MUST evaluate to deny
(reference code CONDITION_FAILED), never to undefined. An undefined
result is never an allow.
A rule condition that checks an argument value MUST evaluate to deny
(reference code CONDITION_FAILED) when the value comes from a schema
default, in whole (origin is schema-default) or in part
(schemaDefaults is present). The source executes its own default,
which can differ from the bundled one. A consumer that needs the
value writes it in the query.
A rule that needs an attribute from a PIP and gets none evaluates to
deny with PIP_UNAVAILABLE.
The engine MUST aggregate as follows.
if graphql output absent or wrong profile: decision = false, code = CONFIG_ERROR, subcode = MAPPER_OUTPUT_MISSING
else if pin set and schema not null and
digest differs from pin: decision = false, code = CONFIG_ERROR, subcode = SCHEMA_MISMATCH
else if unverifiable is not null: decision = false, code = unverifiable.code, subcode = unverifiable.subcode
else if data_fields is empty: decision = false, code = NO_DATA_FIELDS
else if any field denied with a
server-class per-field code: decision = false, code = that code
else if all data fields allow: decision = true
else: decision = false, code = FIELD_NOT_PERMITTED
The decision context lists every denied field with its per-field code (9.1 AuthZEN response).
Aggregation is AND. A query is allowed only when every data field is allowed. There are no partial results in this profile version. See 15.2 Partial results.
This section is informative. It shows one rule model that meets 8.1 Rules and binding to 9.5. The test vectors of Appendix A use it for their reference decisions.
Rule shape. A rule is data with four parts:
{
"rule_id": "r-0001",
"covers_types": ["Persoon", "Inkomen", "LoonInkomen", "Bedrag"],
"covers_fields": ["Query.persoon",
"Persoon.inkomens",
"Inkomen.bedrag",
"LoonInkomen.bedrag"],
"conditions": { "...": "engine-specific" }
}
| Part | Meaning |
|---|---|
rule_id |
Unique id. Appears in the decision log. |
covers_types |
Set of type names. Leaves of these types can inherit this rule. |
covers_fields |
Set of field keys ParentType.field. These fields bind to this rule. |
conditions |
Predicates over the per-field evaluation context (8.4 Per-field evaluation). Engine-specific. |
In this model the rule is the catalog: there is no separate
field-to-rule table. Interface fields and concrete-type fields are
separate keys. A rule that must cover jaar on the interface and on
the concrete type lists both Inkomen.jaar and LoonInkomen.jaar.
Root fields are covered like any other edge or leaf: Query.persoon
is a field key. The rule that covers a root field is the natural place
for conditions on its arguments, for example that the subject holds
consent for the person bsn identifies.
Binding. For every data field with key k = parentType + "." + field:
k in covers_fields, those rules bind. Stop.leaf = true), the record has no
args, and parentType is not a root operation type, the rules
with parentType in covers_types bind. Stop.NO_APPLICABLE_RULE.Consequences:
NO_APPLICABLE_RULE. 8.3 Binding requirements does not
require this; it is a design choice of the reference model, so that
every reachable object type is an explicit decision of the policy
author.args.On allow:
{ "decision": true }
On deny, the PDP MUST return the decision context below. It follows
the free-form context of [AUTHZEN-NLGOV]: reason_admin and
reason_user carry human-readable text keyed by language tag, and
graphql carries the structured detail this profile defines.
{
"decision": false,
"context": {
"reason_admin": { "en": "2 fields denied: persoon.inkomens.werkgever (LoonInkomen.werkgever): NO_APPLICABLE_RULE; persoon.inkomens.werkgever.naam (Werkgever.naam): NO_APPLICABLE_RULE" },
"reason_user": { "en": "Field not permitted: persoon.inkomens.werkgever" },
"graphql": {
"client": {
"code": "FIELD_NOT_PERMITTED",
"field_id": "persoon.inkomens.werkgever"
},
"admin": {
"code": "FIELD_NOT_PERMITTED",
"schema_digest": "sha256:9f2c…",
"denied_fields": [
{ "index": 6,
"path": ["persoon", "inkomens", "werkgever"],
"key": "LoonInkomen.werkgever",
"code": "NO_APPLICABLE_RULE",
"evaluated": [] },
{ "index": 7,
"path": ["persoon", "inkomens", "werkgever", "naam"],
"key": "Werkgever.naam",
"code": "NO_APPLICABLE_RULE",
"evaluated": [] }
]
}
}
}
}
| Property | Meaning |
|---|---|
graphql.client |
What the PEP MAY return to the consumer. Content per the class column of 9.2 Reason codes: code, and for client-class codes also subcode and field_id when applicable. |
graphql.admin.code |
The top-level code from 8.5 Aggregation. |
graphql.admin.subcode, graphql.admin.message |
From unverifiable when present, or the subcode 8.5 Aggregation sets. |
graphql.admin.schema_digest |
schema.digest of the request. Absent when no schema was loaded. |
graphql.admin.denied_fields[] |
One entry per denied data field: index in fields[], path, key, per-field code, and evaluated (the identifiers of the rules that bound, in the notation of the bundle). Absent when unverifiable is not null. |
reason_user.en MUST NOT contain more than graphql.client reveals.
Some PEPs return reason_admin to the consumer as well. For that
reason reason_admin.en MUST NOT contain server-class codes or
subcodes, rule ids, schema digests, or PIP detail, and MUST apply the
same hiding settings as graphql.client (13. Security Considerations). That detail is
in graphql.admin and in the decision log (9.4 Decision log).
| Code | Subcodes | Class | Meaning | graphql.client |
|---|---|---|---|---|
COVERAGE_UNVERIFIABLE |
UNSUPPORTED_TRANSPORT, INVALID_BODY, PARSE_ERROR, INVALID_QUERY, NO_OPERATION, OPERATION_NOT_FOUND, OPERATION_AMBIGUOUS, VARIABLE_ERROR, LIMIT_EXCEEDED |
client | The mapper could not produce a complete field list (5.8 Fail-closed conditions) | code, subcode (a bundle setting MAY report INVALID_QUERY as FIELD_NOT_PERMITTED, 13. Security Considerations) |
OPERATION_NOT_SUPPORTED |
- | client | Mutation or subscription | code |
NO_DATA_FIELDS |
- | client | The query selects only __typename |
code |
FIELD_NOT_PERMITTED |
- | client | One or more data fields have no rule, or their rules deny | code, field_id of the denied field with the lowest index (a bundle setting MAY omit field_id, 13. Security Considerations) |
NO_APPLICABLE_RULE |
- | per field, client | Reference code: no rule binds to the field. Aggregates to FIELD_NOT_PERMITTED. |
via FIELD_NOT_PERMITTED |
CONDITION_FAILED |
- | per field, client | Reference code: a bound rule exists and denies. Aggregates to FIELD_NOT_PERMITTED. |
via FIELD_NOT_PERMITTED |
PIP_UNAVAILABLE |
- | per field, server | A rule needed an attribute from a PIP and the PIP did not answer. Escalates to the top level (8.5 Aggregation). | code = ACCESS_DENIED |
CONFIG_ERROR |
SCHEMA_UNAVAILABLE, SCHEMA_MISMATCH, MAPPER_OUTPUT_MISSING |
server | The PDP is misconfigured | code = ACCESS_DENIED |
ACCESS_DENIED |
- | client-facing only | Replaces every server-class code in graphql.client |
itself |
A bundle MAY use other client-class per-field codes than the
reference codes. They aggregate to FIELD_NOT_PERMITTED. The
top-level codes and the classes are normative: the PEP and the
consumer depend on them.
Server-class codes MUST NOT appear in graphql.client or
reason_user. The consumer gets ACCESS_DENIED and the trace id
(traceparent, else X-Request-ID).
On deny, the PEP MUST NOT forward the request to the source. It MUST
return HTTP 403 or 401; 403 is RECOMMENDED, because the caller is
authenticated and the request is refused ([RFC9110]). The PEP SHOULD
return a body shaped like a GraphQL error response, built from
graphql.client and reason_user. A PEP with its own error format
MAY use that format instead:
{
"errors": [
{
"message": "Field not permitted: persoon.inkomens.werkgever",
"extensions": {
"code": "FIELD_NOT_PERMITTED",
"field_id": "persoon.inkomens.werkgever",
"traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}
}
]
}
When the PEP returns this GraphQL-shaped body, the response
Content-Type MUST be application/graphql-response+json when the
request Accept header allows it, else application/json.
Under [GRAPHQL-HTTP] a server that receives a well-formed request
returns a GraphQL result; a 403 without a data entry is the shape of
a request error result. The PEP is a proxy that never executes the
document, so it returns a request error result with the status that
[RFC9110] defines for a refused request. The consumer can distinguish
"denied" (401 or 403, no data) from "executed with errors" (200,
data present).
The PEP MUST NOT copy graphql.admin to the consumer. It SHOULD NOT
copy reason_admin; 9.1 AuthZEN response limits its content for a PEP that
does.
The PDP MUST log, per evaluation: traceparent when present, else
X-Request-ID, subject.id, the
enriched request, the decision, the full decision context of Section
10.1, and the bundle version. The log entry is a valid AuthZEN request
plus response, so it can be replayed against a policy bundle.
The mapping function is only sound when the request the PDP evaluated is the request the source executes. The chain MUST give the following guarantees. A deployment that cannot give them does not conform to this profile.
| # | Guarantee | Closes |
|---|---|---|
| H1 | The request the source receives has the same method, request target, Content-Type, and byte-for-byte body as the request the PDP evaluated (action.name, resource.id, the Content-Type header, action.properties.body). No component between PEP and source normalises, re-serialises, or modifies them. The source is configured to accept only the transport subset of 4.1 Transport subset. |
A component that changes the request after the decision; a server that reads the query from the URL or accepts GET, batching, or persisted documents. |
| H2 | The source selects the operation to execute by the same operationName the mapper used (5.3 Operation selection). This holds for any conformant GraphQL server. |
Two operations in one document: PDP judges one, source runs the other. |
| H3 | For every type, field, argument, argument default, input field, input-field default, and return type in the bundled SDL, the source's running schema has the same definition. A field has the same arguments, and an input object type the same input fields, no more and no fewer. Every interface has the same implementing types and every union the same members. H3 compares these elements only. The check MUST run (a) in the bundle pipeline, before a bundle is deployed, against the source's published schema, and (b) in the source pipeline, before a schema change is deployed, against the bundled SDL of the live bundle. A failed check MUST block that deployment. | Drift that validation cannot see (6.4 Ownership and drift). |
How the PDP obtains attributes that are not in the request is out of scope for this profile. Examples: consent status from a consent register, pseudonym resolution, subject attributes from a registry. The PDP retrieves them in the way its policy language and the FTV methodology define (for example a PIP call from the policy engine).
Two rules keep the profile independent of that mechanism:
resource.properties.graphql.When a rule needs an attribute from a PIP and the PIP does not answer,
the field decides deny with PIP_UNAVAILABLE (8.4 Per-field evaluation, 10.2).
An implementation conforms to this profile when it meets every MUST in this document. The following checklist summarizes them by component.
Mapper:
UNSUPPORTED_TRANSPORT (5.1, 6.8)extensions members, duplicate JSON names, inexact numbers (6.1)PARSE_ERROR otherwise (4 constraint 1, 6.3.1)NO_OPERATION before validation; validates the whole document before operation selection (6.3.2, 6.3.3, 6.4.1)OPERATION_NOT_FOUND, OPERATION_AMBIGUOUS as in 6.3@skip and @include (6.4.1)on on direct children of fragments with a type condition (6.4.3, 6.4.4, 6.4.6)@skip and @include (6.4.8)VARIABLE_ERROR on failure (6.5)origin; lists variables and schemaDefaults (6.5)path uses response keys (6.6)unverifiable with code, subcode, message; no suggestions in INVALID_QUERY messages (6.8)profile, operation, schema.digest (6.2, 7.3)Policy engine:
MAPPER_OUTPUT_MISSING when the mapper output is absent (9.2, 9.5)unverifiable, before rule evaluation (9.5)__typename and __-typed records; keeps Query.__schema / Query.__type as data fields (9.2)NO_DATA_FIELDS when nothing remains (9.2, 9.5)fields only to deny (9.4)reason_admin, reason_user, graphql.client, graphql.admin per class; no server-class detail in reason_admin (10.1, 10.2)The reference model of 8.6 Reference policy model (informative) is not part of this checklist.
PEP and chain:
subject.id from mTLS (5.2)resource.id, headers, raw body, X-Request-ID; never replaces invalid UTF-8 (5.2)graphql.admin (10.3)Trust anchor. subject.id set by the PEP from mTLS is the only
trusted identity. All other declared attributes (headers, declared,
variables) are untrusted input that policies validate.
Fail closed. Every path where the mapper is unsure ends in deny. This includes a missing schema, which turns a configuration error into a denial of service for that endpoint, never into open access.
Transport. A GraphQL server that reads query from the URL, or
accepts GET, batching, persisted documents, or WebSocket upgrades,
executes requests the mapper never saw. 4.1 Transport subset and H1 close this
on both sides: the PEP sends everything to the PDP, the mapper accepts
one shape, and the source is configured to accept the same shape.
Duplicate JSON names. With last-wins and first-wins parsers in the
field, a body with two query members is two different requests.
5.1 Input rejects it.
Root fields. A root field with no data-field descendant (for
example b: persoon(bsn: "x") { __typename }) still executes a
resolver with its argument and reveals existence. Root fields are
data fields and bind by key (8.3 Binding requirements), so a rule on Query.persoon
sees every bsn argument, one record per selection.
Aliases. Two selections of one field under two aliases are two requests for data. A representation that keeps one argument map per name loses one of them. This profile keeps one record per selection with its own arguments (5.4 Validation and the walk.7, 6.5).
Defaults. A variable default or a schema argument default is executed by the source whether or not the consumer sent a value. A mapper that omits them lets a rule of the form "if present, then check" pass a value the rule never saw. This profile resolves both (5.5 Arguments).
Custom scalars. The mapper passes custom-scalar values through as received. A lenient source-side scalar can trim, case-fold, or stringify a value that a deny-list rule did not match. Rules on custom-scalar arguments SHOULD be allow-list rules against exact values, and a deployment SHOULD document the canonical form of each custom scalar.
Multiple operations. Selecting the wrong operation would authorize
one operation and execute another. 5.3 Operation selection ties the mapper to the
same operationName the source uses (H2).
Schema drift. A field added at the source is denied until the bundle is updated. A changed return type, a new implementing type or union member, a new argument or input field, or a changed default is not detectable from the query. Guarantee H3 blocks these changes in both deployment pipelines. When the source pipeline does not run the H3 check, such a change opens access until the next bundle check; that deployment does not conform to this profile. Rules for sensitive fields SHOULD use concrete-type keys even so.
__typename. Under a permitted edge, __typename reveals the
concrete type of each item. A rule that permits Persoon.inkomens
accepts that consequence.
Introspection. __schema and __type return the source's full
schema, which is a superset of the bundled SDL. They are root data
fields (8.2 Data fields) and execute only under a rule that covers
Query.__schema or Query.__type. A permitted data field in the same
query does not carry them along.
Directives. Ignoring @skip and @include is conservative: a
field that might be skipped still needs a rule. Every other executable
directive fails validation, because a source-defined directive could
change what executes.
Limits. The limits of 5.7 Limits bound the mapper's work and the size of the decision log entry at every stage: body size before parsing, nesting during parsing, validation time, and fan-out during the walk. Fragment fan-out can grow exponentially, so the walk limits are checked at each emission, not after.
Field enumeration. field_id in the consumer-facing error
confirms that a field exists in the bundled schema, and the pair
INVALID_QUERY versus FIELD_NOT_PERMITTED tells whether a field is
in the bundled subset. Validation messages never reach the consumer.
A deployment that treats the bundled subset as sensitive MAY omit
field_id and MAY report INVALID_QUERY as FIELD_NOT_PERMITTED in
graphql.client.
The mapper copies argument values, including personal identifiers such
as a BSN, into resource.properties.graphql.fields[].args. The
decision log of 9.4 Decision log then contains them. How the decision
log protects personal data is outside this profile (Open Issue
16.5).
The AND aggregation supports data minimization: a query that asks for one field too many returns nothing. The consumer must shape the query to its permission. No data leaves the source for a denied query.
This draft puts the field list in
resource.properties.graphql (the fields are the thing accessed). The
alternative is context.graphql (the list is derived by the PDP and
never supplied by the caller). The workgroup decides.
All-or-nothing is a feature for data
minimization and fits the single-decision hook. A future version could
return the permitted fields and list the denied ones in GraphQL
errors[]. That needs a response filter after the source, and touches
H1.
Conservative today. Honoring them moves directive-argument evaluation into the mapper.
Denied today. A mutation asks a different question (which change with which input), and its root fields execute in sequence with side effects. A future version needs its own walk rules and rule model.
Outside this profile. The workgroup decides whether and where to specify it.
In this draft, one AuthZEN evaluation carries the whole field list, and the policy engine loops over it (8. The Policy Model). Engines that cannot loop over a list, such as Cedar and OpenFGA, need engine-specific code in the PDP. That code splits the list into one check per field and combines the results.
A future version can move that split into the mapper. The PEP still sends one evaluation and gets one decision. The mapper then:
evaluations endpoint of [AUTHZEN] on
the PDP, with the evaluations_semantic option deny_on_first_deny
or execute_all;A batch that the PEP builds is not an alternative. The PEP then needs a GraphQL parser and the schema (3.2 Why not solve it in the PEP), and the PEP combines the results. With a fan-out by the mapper, the field list comes from the component that validated the query against the bundled schema. The aggregation stays on the PDP side, and the PEP stays a standard component.
Points to decide:
resource.type, resource.id (the field key), and the context.deny_on_first_deny, the decision log does not list every
denied field (9.1 AuthZEN response). execute_all gives the full list, at a
higher cost.The record shape of 5.2 Output is self-contained, so a fan-out needs no change to the records.
Rules list both the interface key and each concrete-type key when they want both. A future version could let the engine expand interface keys from the schema, with the drift consequence of 6.2 Type resolution made explicit.
Query.__schema and Query.__type need a
rule like any root field; a query with only __typename denies with
NO_DATA_FIELDS. A bundle-level switch for tooling access could
replace the rule.
A level without a schema (rules bind on path only, no validation, no types) would let sources without a published schema start. This draft requires the schema.
The bundle could declare a pattern per custom scalar that the mapper enforces, closing the lenient-coercion gap of 13. Security Considerations.
Who maintains the reference mapper and the conformance vectors.
All examples in this document use the following fictional schema. It exists only to show the mechanics: an interface with two concrete types, a union, a list argument with a schema default, an input object with a field default, an argument without default, a variable with a default, a root leaf, and a nested object type. It is not a schema of any real source.
scalar BSN
type Query {
persoon(bsn: BSN!): Persoon
zoek(term: String!): [Zoekresultaat!]
aantalPersonen: Int
}
type Persoon {
bsn: BSN!
naam: String
adres(peildatum: String): Adres
inkomens(jaren: [Int!] = [2024], filter: InkomenFilter): [Inkomen!]
}
input InkomenFilter { minimum: Int = 0, valuta: String }
type Adres { straat: String, plaats: String }
interface Inkomen {
jaar: Int!
bedrag: Bedrag
}
type LoonInkomen implements Inkomen {
jaar: Int!
bedrag: Bedrag
werkgever: Werkgever
}
type WinstInkomen implements Inkomen {
jaar: Int!
bedrag: Bedrag
}
union Zoekresultaat = Persoon | Werkgever
type Werkgever { naam: String }
type Bedrag { waarde: Float!, valuta: String! }
type Mutation {
wijzigAdres(bsn: BSN!, adres: AdresInput!): Persoon
}
input AdresInput { straat: String, plaats: String }
Each vector gives the body, the expected mapper output (abbreviated:
profile and schema omitted), and the expected decision under rule
r-0001 of 8.6 Reference policy model (informative) with these conditions:
Query.persoon: field.args.bsn.value is in the set of persons
the subject holds consent for. The subject holds consent for
999990011 only.Persoon.inkomens: field.args.jaren is present and every value
is at most 2024.Persoon.adres: field.args.peildatum is present.For these vectors covers_fields of r-0001 also contains
Persoon.adres, and covers_types also contains Adres. Query.zoek, Query.aantalPersonen, Query.__schema,
and Werkgever are not covered by any rule.
The parts of a vector have different force:
| Part | Force | Vectors |
|---|---|---|
| Mapper output | Normative for every mapper | All |
| Decision | Normative for every policy engine. The decision follows from 5.8 Fail-closed conditions and 9.2 to 9.5 alone, for any bundle that binds no rule to the fields the vector names as uncovered. For A.2, A.4, A.36, and A.37 only decision: false is normative: the code, field_id, and denied_fields also depend on how the bundle decides the other fields. |
A.2 to A.4, A.10, A.12 to A.26, A.28, A.29, A.32 to A.34, A.36, A.37, A.40 |
| Decision | Informative. It depends on the conditions of r-0001 and on the reference model of 8.6 Reference policy model (informative). |
A.1, A.5 to A.9, A.11, A.27, A.30, A.31, A.35, A.38, A.39 |
Per-field codes and denied_fields use the reference codes of
8.6 Reference policy model (informative) in every vector.
Body:
{
"query": "query Inkomen($bsn: BSN!, $jaar: Int = 2024) { persoon(bsn: $bsn) { inkomens(jaren: [$jaar]) { jaar ... on LoonInkomen { bedrag { waarde valuta } } } } }",
"variables": { "bsn": "999990011" }
}
Mapper output: as in 5.2 Output. jaren resolved to [2024] from
the variable default, origin: "mixed".
Binding:
| index | key | binds via | result |
|---|---|---|---|
| 0 | Query.persoon |
covers_fields; bsn consented |
allow |
| 1 | Persoon.inkomens |
covers_fields; jaren = [2024] |
allow |
| 2 | Inkomen.jaar |
leaf, covers_types |
allow |
| 3 | LoonInkomen.bedrag |
covers_fields |
allow |
| 4 | Bedrag.waarde |
leaf, covers_types |
allow |
| 5 | Bedrag.valuta |
leaf, covers_types |
allow |
Decision: true.
Same body with werkgever { naam } appended after bedrag { waarde valuta } inside ... on LoonInkomen.
LoonInkomen.werkgever is an edge and is not in covers_fields.
Records: index 6 LoonInkomen.werkgever (edge, no key) and index 7
Werkgever.naam (leaf, Werkgever not in covers_types). Both get
NO_APPLICABLE_RULE.
Decision: false, graphql.client = { "code": "FIELD_NOT_PERMITTED", "field_id": "persoon.inkomens.werkgever" }, denied_fields has two
entries (indices 6 and 7), as in 9.1 AuthZEN response.
{ "query": "{ persoon(bsn: \"999990011\") { nieuwVeld { x } } }" }
Persoon.nieuwVeld is not in the bundled schema. Validation fails.
unverifiable = { "code": "COVERAGE_UNVERIFIABLE", "subcode": "INVALID_QUERY", "message": "Field Selections at 1:31" }. Rule names in
message are the section titles of the Validation chapter of
[GRAPHQL].
Decision: false. graphql.client = { "code": "COVERAGE_UNVERIFIABLE", "subcode": "INVALID_QUERY" }.
... on LoonInkomen { w: werkgever { naam } }. The mapper emits
field = "werkgever", alias = "w", path = ["persoon", "inkomens", "w"], on = "LoonInkomen". Decision as in A.2 with field_id = persoon.inkomens.w and two denied entries.
{ "query": "{ a: persoon(bsn: \"999990011\") { naam } b: persoon(bsn: \"999990022\") { naam } }" }
Mapper output fields:
[
{ "path": ["a"], "alias": "a", "parentType": "Query", "field": "persoon", "leaf": false,
"args": { "bsn": { "value": "999990011", "origin": "literal" } } },
{ "path": ["a", "naam"], "parentType": "Persoon", "field": "naam", "leaf": true },
{ "path": ["b"], "alias": "b", "parentType": "Query", "field": "persoon", "leaf": false,
"args": { "bsn": { "value": "999990022", "origin": "literal" } } },
{ "path": ["b", "naam"], "parentType": "Persoon", "field": "naam", "leaf": true }
]
Binding: index 0 (Query.persoon, bsn 999990011) allow; index 1
(Persoon.naam, leaf, covers_types) allow; index 2 (Query.persoon,
bsn 999990022) CONDITION_FAILED; index 3 allow.
Decision: false, FIELD_NOT_PERMITTED, field_id = "b",
denied_fields = [ { "index": 2, "path": ["b"], "key": "Query.persoon", "code": "CONDITION_FAILED", "evaluated": ["r-0001"] } ].
A mapper with a single argument map would have kept only one of the
two bsn values, and a rule reading it would have seen one person
while the source returns two.
{ "query": "{ a: persoon(bsn: \"999990011\") { naam } b: persoon(bsn: \"999990022\") { __typename } }" }
Index 2 is Query.persoon with bsn 999990022; index 3 is
__typename and is dropped. Index 2 is a data field and binds to
r-0001, which fails. Decision: false, FIELD_NOT_PERMITTED,
field_id = "b".
Without root-field binding, b would have no data-field descendant
and the source would reveal whether the person exists.
{ "query": "query ($bsn: BSN!, $jaar: Int = 2019) { persoon(bsn: $bsn) { inkomens(jaren: [$jaar]) { jaar } } }",
"variables": { "bsn": "999990011" } }
Index 1 args.jaren = { "value": [2019], "origin": "mixed", "variables": ["jaar"] }. The
condition "every value at most 2024" holds. Decision: true. With
$jaar: Int = 2025 instead: index 1 CONDITION_FAILED, decision
false.
A mapper that dropped the unsupplied variable would have shown no
jaren and the rule would have denied for the wrong reason, or, with
an "if present" rule, allowed 2025.
{ "query": "query ($bsn: BSN!, $jaren: [Int!] = [2024]) { persoon(bsn: $bsn) { inkomens(jaren: $jaren) { jaar } } }",
"variables": { "bsn": "999990011" } }
Index 1 args.jaren = { "value": [2024], "origin": "default:jaren", "variables": ["jaren"] }.
Decision: true.
{ "query": "{ persoon(bsn: \"999990011\") { inkomens { jaar } } }" }
inkomens is written without jaren. The schema declares
jaren: [Int!] = [2024]. Index 1 args.jaren = { "value": [2024], "origin": "schema-default" }. The rule checks jaren and the value
comes from a schema default (8.4 Per-field evaluation). Decision: false,
FIELD_NOT_PERMITTED, per-field CONDITION_FAILED on index 1.
The source executes its own default for jaren, which the bundle
cannot see. A mapper that omitted schema defaults would show no
jaren, and an "if present" rule would allow whatever that default
is.
{ "query": "query A { persoon(bsn: \"999990011\") { naam } } query B { persoon(bsn: \"999990022\") { naam } }" }
subcode = OPERATION_AMBIGUOUS. Decision: false.
Same document with "operationName": "B". The mapper walks B only:
index 0 path = ["persoon"], args.bsn.value = "999990022". The
operation name is not a path segment. Decision: false,
FIELD_NOT_PERMITTED, per-field CONDITION_FAILED on index 0.
Single-operation document with "operationName": "Other".
subcode = OPERATION_NOT_FOUND. Decision: false.
{ "query": "mutation { wijzigAdres(bsn: \"999990011\", adres: {plaats: \"X\"}) { naam } }" }
unverifiable.code = OPERATION_NOT_SUPPORTED. Decision: false.
{ "query": "{ persoon(bsn: \"999990011\") { ...Missing } }" }
subcode = INVALID_QUERY (Fragment Spread Target Defined). Decision:
false.
{ "query": "{ persoon(bsn: \"999990011\") { ...A } } fragment A on Persoon { ...B } fragment B on Persoon { ...A }" }
subcode = INVALID_QUERY (Fragment Spreads Must Not Form Cycles).
Decision: false.
{ "query": "{ persoon(bsn: \"999990011\" { naam }" }
subcode = PARSE_ERROR. Decision: false.
{ "query": "fragment N on Persoon { naam }" }
The document parses and has no operation definition. 5.3 Operation selection.2
fires before validation: subcode = NO_OPERATION. Decision: false.
With the selection depth limit configured to 2:
{ "query": "{ persoon(bsn: \"999990011\") { adres(peildatum: \"2026-01-01\") { straat } } }" }
straat is at depth 3. subcode = LIMIT_EXCEEDED. Decision: false.
{ "hello": "world" }
subcode = INVALID_BODY. Decision: false.
[ { "query": "{ persoon(bsn: \"999990011\") { naam } }" } ]
subcode = INVALID_BODY. Decision: false.
{ "query": "{ persoon(bsn: \"999990011\") { naam } }", "query": "{ persoon(bsn: \"999990022\") { naam adres { straat } } }" }
subcode = INVALID_BODY. Decision: false.
{ "query": "{ persoon(bsn: \"999990011\") { naam } }", "documentId": "abc" }
subcode = INVALID_BODY. Decision: false. Same for
extensions.persistedQuery.
POST /graphql?query=%7B%20persoon(bsn%3A%22999990022%22)%7Bnaam%7D%7D
with a valid body. subcode = UNSUPPORTED_TRANSPORT. Decision:
false. Same for GET /graphql?query=…, Content-Type: application/graphql, and a request with an Upgrade header.
A body that passes transport, size, shape, and parse checks, with no
SDL loaded. unverifiable.code = CONFIG_ERROR,
subcode = SCHEMA_UNAVAILABLE, no schema.digest. Decision: false.
graphql.client = { "code": "ACCESS_DENIED" },
graphql.admin.schema_digest absent.
Bundle pins sha256:aaaa…; mapper loaded an SDL with digest
sha256:9f2c…. Decision: false, CONFIG_ERROR / SCHEMA_MISMATCH,
graphql.client = { "code": "ACCESS_DENIED" }.
{ "query": "{ __schema { types { name } } }" }
Mapper emits three records: index 0 Query.__schema, index 1
__Schema.types, index 2 __Type.name. The engine drops indices 1
and 2 (parentType prefix __). Index 0 is a root data field with no
rule: NO_APPLICABLE_RULE. Decision: false, FIELD_NOT_PERMITTED,
field_id = "__schema". See 15.8 Introspection.
With { __typename } alone, the only record is dropped and the
decision is false, NO_DATA_FIELDS.
{ "query": "{ persoon(bsn: \"999990011\") { adres { straat } } }" }
adres is written without peildatum, and peildatum has no schema
default, so args is absent on index 1. The condition on
Persoon.adres finds no peildatum and evaluates to deny. Decision:
false, FIELD_NOT_PERMITTED, per-field CONDITION_FAILED on index
1.
{ "query": "query ($bsn: BSN!) { persoon(bsn: $bsn) { naam } }", "variables": {} }
$bsn is non-null, not supplied, no default. subcode = VARIABLE_ERROR. Decision: false.
{ "query": "query ($bsn: BSN!, $jaar: Int = 2024) { persoon(bsn: $bsn) { inkomens(jaren: [$jaar]) { jaar } } }",
"variables": { "bsn": "999990011", "jaar": "2024" } }
"2024" is a string for an Int variable. subcode = VARIABLE_ERROR. Decision: false.
{ "query": "{ a: persoon(bsn: \"999990011\") { ...N } b: persoon(bsn: \"999990011\") { ...N } } fragment N on Persoon { naam }" }
Four records: index 0 a; index 1 ["a","naam"] with on: "Persoon"; index 2 b; index 3 ["b","naam"] with on: "Persoon".
Both Query.persoon records carry the consented bsn. Decision:
true.
{ "query": "{ persoon(bsn: \"999990011\") { inkomens(jaren: [2024]) { ... on LoonInkomen { bedrag { waarde } } ... on WinstInkomen { bedrag { valuta } } } } }" }
Two records have path = ["persoon", "inkomens", "bedrag"]: index 2
with parentType = "LoonInkomen" and index 4 with parentType = "WinstInkomen". WinstInkomen.bedrag is not in covers_fields
(only Inkomen.bedrag and LoonInkomen.bedrag are). Index 4 gets
NO_APPLICABLE_RULE. Decision: false, field_id = "persoon.inkomens.bedrag", denied_fields[0].index = 4. The index
disambiguates what the field id cannot.
Note the consequence of 6.2 Type resolution: the same consumer can write
inkomens(jaren: [2024]) { bedrag { valuta } } without a type
condition. That binds
Inkomen.bedrag, which r-0001 covers, and returns bedrag for
WinstInkomen items too. A concrete-type key never restricts an
interface key.
{ "query": "{ zoek(term: \"*\") { __typename } }" }
Index 0 Query.zoek is a root edge, not in covers_fields;
__typename is dropped. Decision: false, FIELD_NOT_PERMITTED,
field_id = "zoek".
{ "query": "{ aantalPersonen }" }
Index 0 Query.aantalPersonen is a root leaf with no field key. A
root field never inherits from a rule bound through Query (Section
9.3, item 4). NO_APPLICABLE_RULE. Decision: false.
{ "query": "{ persoon(bsn: \"999990011\") { naam @export(as: \"n\") } }" }
@export is not defined in the bundled SDL (7.1), so validation
(Directives Are Defined) fails. subcode = INVALID_QUERY. Decision:
false. The same holds for @defer and @stream.
{ "query": "{ persoon(bsn: \"999990011\") { inkomens(jaren: [2024], filter: {valuta: \"EUR\"}) { jaar } } }" }
Index 1 args: jaren = { "value": [2024], "origin": "literal" }
and filter = { "value": { "minimum": 0, "valuta": "EUR" }, "origin": "literal", "schemaDefaults": [["minimum"]] }. The input field
minimum was not written; input coercion filled its default. The rule
does not check filter. Decision: true. A rule that checks
filter denies (8.4 Per-field evaluation).
{ "query": "{ persoon(bsn: \"999990011\") { naam } __schema { types { name } } }" }
Indices 0 and 1 allow. Index 2 Query.__schema is a root data field
with no rule: NO_APPLICABLE_RULE. Decision: false,
FIELD_NOT_PERMITTED, field_id = "__schema". The permitted persoon
does not carry introspection along.
Schema variant for this vector only: Persoon.naam(formaat: String).
{ "query": "{ persoon(bsn: \"999990011\") { naam(formaat: \"kort\") } }" }
Index 1 is a leaf with args. r-0001 covers the type Persoon,
but a rule bound through the parent type does not see formaat
(8.3 Binding requirements, item 3). Persoon.naam is not in covers_fields.
NO_APPLICABLE_RULE. Decision: false.
{ "query": "{ persoon(bsn: \"999990011\") { inkomens(jaren: [2024]) { jaar bedrag { waarde } } } }" }
Index 2 Inkomen.jaar (leaf, covers_types), index 3 Inkomen.bedrag
(covers_fields), index 4 Bedrag.waarde (leaf, covers_types).
Decision: true. The source returns bedrag for every implementing
type. H3 guarantees that these are exactly LoonInkomen and
WinstInkomen (6.2 Type resolution).
The engine receives a request without resource.properties.graphql.
Decision: false, CONFIG_ERROR / MAPPER_OUTPUT_MISSING,
graphql.client = { "code": "ACCESS_DENIED" }.
| Area | draft-00 | draft-01 |
|---|---|---|
| Scope | Implicit | Enforcement in front of the endpoint only; enforcement in the resolvers and schema stitching or federation are out of scope (1.1 Scope) |
| Arguments | One global flat map with dotted keys; later selections overwrote earlier ones | Per field, {value, origin}; no global map (5.5 Arguments) |
| Aliases | Dropped; two aliased selections collapsed to the same id | One record per selection; path uses response keys; alias recorded (5.4 Validation and the walk.7, 6.6) |
| Variable, argument, and input-field defaults | Unsupplied variable dropped; schema defaults ignored | Resolved by input coercion, with origin default:<name> / schema-default; variables listed per entry (5.5 Arguments) |
| Variable coercion | None | Per [GRAPHQL]; VARIABLE_ERROR on failure (5.5 Arguments) |
| Validation | Field-map lookup only; unknown field gave parent "?" |
Full validation against the bundled SDL; unknown field fails closed (5.4 Validation and the walk.1) |
| Root fields | Dropped from binding | Data fields; bind by key (8.2 Data fields, 9.3) |
| Introspection | Dropped by field prefix | __typename and __-typed records dropped; Query.__schema / Query.__type are data fields; NO_DATA_FIELDS (8.2 Data fields) |
| Transport | POST body only, implicit | Explicit transport subset with byte-exact path, UNSUPPORTED_TRANSPORT; duplicate JSON names, unlisted extensions, persisted-document members, inexact numbers rejected; PEP fail-closed and no caching (4.1 Transport subset, 5.2, 6.1) |
| Field-map | Separate generated artifact | Derived from the SDL inside the mapper (6.2 Type resolution) |
| Schema digest | None | schema.digest identifies the bundle artifact; optional pin (6.3 Schema digest) |
| Enforcement guarantees | Implied | H1 to H3 (10. Enforcement Guarantees) |
| Fail-closed representation | coverage_unverifiable boolean + error_code |
unverifiable object with code, subcode, message (5.8 Fail-closed conditions) |
| Reason codes | INVALID_REQUEST, UNSUPPORTED_OPERATION |
COVERAGE_UNVERIFIABLE + subcodes, OPERATION_NOT_SUPPORTED, NO_DATA_FIELDS; server-class escalation (8.5 Aggregation, 10.2) |
| Decision context | reason_admin / reason_user as structured objects |
NLgov-style reason_*.en text plus graphql.client / graphql.admin (9.1 AuthZEN response) |
| Trace | context.trace |
context.traceparent / tracestate and X-Request-ID per [AUTHZEN-NLGOV] (4.2 Request content) |
| Limits | Depth only | Body size before parse; nesting, fragment count, validation time; depth, record count, alias count during the walk (5.7 Limits) |
| Record property names | parent, name, scalar, known, id |
parentType, field, leaf, path, alias, on; id derived from path, not unique |
| Evaluation context | subject, field, args, resource, time |
subject, field, fields, resource, time; leaves with arguments never inherit (8.3 Binding requirements, 9.4) |
| Policy model | Rule shape and binding algorithm normative | Engine obligations normative (8.1 Rules and binding to 9.5); rule shape and binding algorithm are an informative reference model (8.6 Reference policy model (informative)); root fields bind by field key only |
| Drift | "Drift MUST never open access", not met for invisible drift | H3 in the bundle and source pipelines; same arguments and input fields; same implementing types and union members; a checked argument value from a schema default never allows, marked by origin or schemaDefaults (3. Design Constraints, 6.5, 7.4, 9.4, 11) |
| PEP request shape | resource.type: "http_path", path only |
resource.type: "uri", resource.id = raw request target; content type from context.headers; traceparent SHOULD as in [AUTHZEN-NLGOV]; deny with 401 or 403; reason_admin limited (4. Requirements on the PEP, 10) |
| Engine guards | None | MAPPER_OUTPUT_MISSING; digest pin before unverifiable (8.2 Data fields, 9.5) |
| Term | Meaning |
|---|---|
| PEP | Policy Enforcement Point. The component that intercepts the HTTP request and asks the PDP. For example an FSC Inway [FSC]. |
| PDP | Policy Decision Point. The component that answers the AuthZEN request. |
| Policy engine | The part of the PDP that evaluates rules against the enriched request. |
| PAP | Policy Administration Point. Delivers the policy bundle to the PDP. |
| PIP | Policy Information Point. Delivers extra attributes at decision time. |
| Source | The GraphQL server that executes the query after an allow. |
| Mapper | The component that implements the mapping function of 5. The Mapping Function. |
| Policy bundle | The set of artifacts the PAP loads into the PDP: rules, the schema, and bundle settings. Bundle settings defined by this profile: the GraphQL path (4.1 Transport subset), the pinned schema digest (6.3 Schema digest), the allowed extensions members (5.1 Input), and the limits (5.7 Limits). |
| Schema | The GraphQL SDL the policies were written against. Part of the policy bundle. |
| Selected field | One field selection in the query, after fragment expansion. Two selections of the same schema field under different aliases are two selected fields. |
| Field record | The JSON object the mapper emits for one selected field (5.2 Output). |
| Response key | The alias of a field selection when present, else the field name. This is the key the source uses in the response. |
| Path | The list of response keys from the root of the operation to the selected field. |
| Parent type | The GraphQL object, interface, or union type on which a field is selected. |
| Field key | ParentType.field. The key a rule uses to cover a field. |
| Field id | The path joined with .. Used in messages and logs. Not unique (5.6 Path and field id). |
| Leaf | A selected field without a sub-selection. |
| Edge | A selected field with a sub-selection (an object, interface, union, or a list of these). |
| Root field | A selected field whose parent type is the root operation type of the operation. |
| Data field | A Field record that 8.2 Data fields does not drop. Root fields other than __typename are data fields, Query.__schema and Query.__type included. |
| Rule | One unit of policy, with conditions. A binding connects it to data fields (8.1 Rules and binding). |
| Coverage | The property that every data field has at least one rule. |
| Fail closed | When the mapper cannot produce a complete and correct field list, the decision is deny. |