FTV GraphQL Profile for the NLgov AuthZEN Authorization API

Draft

Editor:
Peter-Jan Karens (ICTU)
Author:
Peter-Jan Karens (ICTU)

Status of This Document

This is a draft that could be altered, removed or replaced by other documents. It is not a recommendation approved by TO.

Conformance

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.

Abstract

Abstract

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.

This document is a profile on the NLgov Profile for OpenID AuthZEN Authorization API. It is a working draft of the FTV workgroup and has no official standing.

1. Introduction

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].

1.1 Scope

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.

1.2 Relation to other documents

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

2. Model

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.

3. Design Constraints

The following constraints shape the profile. They are stated here so a reader can check every normative rule against them.

  1. GraphQL compliance. The mapper MUST parse any document that is valid under [GRAPHQL]. Where the mapper cannot decide, it MUST deny, not guess.
  2. Policies decide. The schema the source serves MUST NOT carry access rules. The binding between fields and rules is part of the policy bundle and is deployed through the PAP together with the rules.
  3. One decision. One query is allowed or denied as a whole.
  4. Deny by default. GraphQL APIs have no versions. A source can add a field at any time. A data field without a rule MUST be denied.
  5. Independent deployment. The source deploys its API on its own schedule. The PAP deploys the policy bundle on its own schedule. A field the source adds or renames MUST NOT open access: it fails validation until the bundle is updated. A change to a definition that the bundle already contains is not visible in the query. Such a change MUST NOT open access either: guarantee H3 blocks it in the deployment pipelines of the source and of the bundle (6.4 Ownership and drift). Drift MAY close access.
  6. What is judged is what runs. The request the PDP evaluated and the request the source executes MUST be the same request (Section 11).

3.1 Why not solve it in the policies

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.

3.2 Why not solve it in the PEP

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.

3.3 Why not COAZ

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.

4. Requirements on the PEP

4.1 Transport subset

[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.

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

4.2 Request content

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.

5. The Mapping Function

5.1 Input

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:

5.2 Output

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.

5.3 Operation selection

The mapper MUST select the operation as follows.

  1. Parse the full document. A parse error fails closed with subcode PARSE_ERROR.
  2. When the document contains no operation definition, fail closed with subcode 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.
  3. Validate the full document (5.4 Validation and the walk.1) before selecting an operation. This catches an anonymous operation next to a named one ([GRAPHQL] Lone Anonymous Operation).
  4. When operationName is present and no operation has that name, fail closed with subcode OPERATION_NOT_FOUND. This follows GetOperation in [GRAPHQL].
  5. When 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.
  6. When the selected operation is a mutation or subscription, fail closed with code OPERATION_NOT_SUPPORTED. This profile version covers queries only.

5.4 Validation and the walk

The mapper MUST perform validation and the walk as follows.

  1. Validate the whole document against the bundled schema according to the Validation section of [GRAPHQL]. A validation error fails closed with subcode 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).
  2. The walk starts at the root operation type of the selected operation, with an empty path.
  3. For every field selection with name 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.
  4. For an inline fragment ... 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".
  5. For an inline fragment without a type condition, the mapper walks the sub-selection with the current parent type. Its children get no on.
  6. For a fragment spread ...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.
  7. The mapper emits one Field per selection. It MUST NOT merge selections that share a response key. After validation, two selections with the same response key in the same merged selection set have identical field names and arguments, except when their parent types are different object types ([GRAPHQL] Fields In Set Can Merge). Only that case yields two records with the same path and different args.
  8. Directives @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.
  9. Introspection fields (__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).
  10. Limits (5.7 Limits) are checked during the walk. The walk MUST stop at the first exceedance.

5.5 Arguments

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.

5.6 Path and field id

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[].

5.7 Limits

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.

5.8 Fail-closed conditions

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

6. The Schema

6.1 The schema is part of the policy bundle

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.

6.2 Type resolution

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

6.3 Schema digest

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.

6.4 Ownership and drift

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.

7. Information Model of the Enriched Request

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].

7.1 Subject

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.

7.2 Resource

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.

7.3 Action

Unchanged from the PEP. action.properties.body keeps the raw body.

7.4 Context

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)

7.5 Decision

See 9. Decision and Error Transport.

8. The Policy Model

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.

8.1 Rules and binding

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.

8.2 Data fields

The engine MUST take resource.properties.graphql.fields[] and drop:

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.

8.3 Binding requirements

Whatever the notation, the binding MUST meet these requirements.

  1. Closed world. A data field that no rule binds decides deny, with a client-class per-field code (reference code NO_APPLICABLE_RULE).
  2. Field key. The rules that bind to a record MUST be chosen by its field key 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.
  3. Arguments. A record with 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.
  4. Root fields. A root field, 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.
  5. Each selection. Every record is bound and evaluated on its own, with its own args. Two records with the same field key are two decisions.

8.4 Per-field evaluation

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.

8.5 Aggregation

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.

8.6 Reference policy model (informative)

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:

  1. If any rule has k in covers_fields, those rules bind. Stop.
  2. Else, if the field is a leaf (leaf = true), the record has no args, and parentType is not a root operation type, the rules with parentType in covers_types bind. Stop.
  3. Else no rule binds. The field decides deny with per-field code NO_APPLICABLE_RULE.

Consequences:

9. Decision and Error Transport

9.1 AuthZEN response

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

9.2 Reason codes

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

9.3 HTTP transport by the PEP

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.

9.4 Decision log

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.

10. Enforcement Guarantees

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

11. Policy Information Points

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:

  1. The mapper MUST NOT depend on any attribute obtained from a PIP.
  2. The PDP MUST NOT let anything it obtains from a PIP change 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).

12. Conformance checklist

An implementation conforms to this profile when it meets every MUST in this document. The following checklist summarizes them by component.

Mapper:

Policy engine:

The reference model of 8.6 Reference policy model (informative) is not part of this checklist.

PEP and chain:

13. Security Considerations

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.

14. Privacy Considerations

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.

15. Open Issues

15.1 Field list placement

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.

15.2 Partial results

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.

15.3 @skip and @include

Conservative today. Honoring them moves directive-argument evaluation into the mapper.

15.4 Mutations and subscriptions

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.

15.5 Identifying arguments in the log

Outside this profile. The workgroup decides whether and where to specify it.

15.6 Fan-out by the mapper

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:

  1. makes one evaluation per data field, with the field record in the context;
  2. sends the evaluations to the evaluations endpoint of [AUTHZEN] on the PDP, with the evaluations_semantic option deny_on_first_deny or execute_all;
  3. combines the results as 8.5 Aggregation describes. A missing or malformed result is a deny.

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:

The record shape of 5.2 Output is self-contained, so a fan-out needs no change to the records.

15.7 Interface duplication

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.

15.8 Introspection

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.

15.9 Conformance levels

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.

15.10 Custom scalar canonical forms

The bundle could declare a pattern per custom scalar that the mapper enforces, closing the lenient-coercion gap of 13. Security Considerations.

15.11 Governance

Who maintains the reference mapper and the conformance vectors.

A. Test 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:

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.

A.1 Worked example: allow

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.

A.2 One field too many: deny

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.

A.3 Unknown field: fail closed

{ "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" }.

A.4 Alias of an uncovered field: deny

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

A.5 Two aliases, two persons: deny

{ "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.

A.6 Existence oracle through a root field: deny

{ "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.

A.7 Variable default: resolved

{ "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.

A.8 Variable used directly, resolved to its default

{ "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.

A.9 Schema argument default: resolved

{ "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.

A.10 Two operations without operationName: fail closed

{ "query": "query A { persoon(bsn: \"999990011\") { naam } } query B { persoon(bsn: \"999990022\") { naam } }" }

subcode = OPERATION_AMBIGUOUS. Decision: false.

A.11 Two operations with operationName: the named one

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.

A.12 operationName names no operation: fail closed

Single-operation document with "operationName": "Other". subcode = OPERATION_NOT_FOUND. Decision: false.

A.13 Mutation: fail closed

{ "query": "mutation { wijzigAdres(bsn: \"999990011\", adres: {plaats: \"X\"}) { naam } }" }

unverifiable.code = OPERATION_NOT_SUPPORTED. Decision: false.

A.14 Unknown fragment: fail closed

{ "query": "{ persoon(bsn: \"999990011\") { ...Missing } }" }

subcode = INVALID_QUERY (Fragment Spread Target Defined). Decision: false.

A.15 Fragment cycle: fail closed

{ "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.

A.16 Parse error: fail closed

{ "query": "{ persoon(bsn: \"999990011\" { naam }" }

subcode = PARSE_ERROR. Decision: false.

A.17 No operation: fail closed

{ "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.

A.18 Depth over limit: fail closed

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.

A.19 Not a GraphQL body: fail closed

{ "hello": "world" }

subcode = INVALID_BODY. Decision: false.

A.20 Batched body: fail closed

[ { "query": "{ persoon(bsn: \"999990011\") { naam } }" } ]

subcode = INVALID_BODY. Decision: false.

A.21 Duplicate JSON member: fail closed

{ "query": "{ persoon(bsn: \"999990011\") { naam } }", "query": "{ persoon(bsn: \"999990022\") { naam adres { straat } } }" }

subcode = INVALID_BODY. Decision: false.

A.22 Persisted document member: fail closed

{ "query": "{ persoon(bsn: \"999990011\") { naam } }", "documentId": "abc" }

subcode = INVALID_BODY. Decision: false. Same for extensions.persistedQuery.

A.23 Query string on the request: fail closed

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.24 Schema missing: fail closed

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.

A.25 Pinned digest mismatch: deny

Bundle pins sha256:aaaa…; mapper loaded an SDL with digest sha256:9f2c…. Decision: false, CONFIG_ERROR / SCHEMA_MISMATCH, graphql.client = { "code": "ACCESS_DENIED" }.

A.26 Introspection only: deny

{ "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.

A.27 Argument the rule needs is absent: deny

{ "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.

A.28 Missing non-null variable: fail closed

{ "query": "query ($bsn: BSN!) { persoon(bsn: $bsn) { naam } }", "variables": {} }

$bsn is non-null, not supplied, no default. subcode = VARIABLE_ERROR. Decision: false.

A.29 Variable coercion failure: fail closed

{ "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.

A.30 Named fragment spread twice, on set

{ "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.

A.31 Same path under two concrete types

{ "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.

A.32 Union with __typename only: deny

{ "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".

A.33 Root leaf: deny

{ "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.

A.34 Disallowed executable directive: fail closed

{ "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.

A.35 Input-object field default: resolved

{ "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).

A.36 Introspection next to a permitted field: deny

{ "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.

A.37 Leaf with arguments does not inherit: deny

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.

A.38 PIP unavailable: server class

A.1 with the consent register unreachable. Index 0 (Query.persoon) decides deny with PIP_UNAVAILABLE. 8.5 Aggregation escalates: decision false, graphql.admin.code = PIP_UNAVAILABLE, graphql.client = { "code": "ACCESS_DENIED" }. No field_id reaches the consumer.

A.39 Interface key without type condition: allow

{ "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).

A.40 Mapper output missing: server class

The engine receives a request without resource.properties.graphql. Decision: false, CONFIG_ERROR / MAPPER_OUTPUT_MISSING, graphql.client = { "code": "ACCESS_DENIED" }.

B. Changes from draft-00

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)

C. Terminology

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.

D. Index

D.1 Terms defined by this specification

D.2 Terms defined by reference

E. References

E.1 Normative references

[AUTHZEN]
OpenID AuthZEN Authorization API 1.0. OpenID Foundation. URL: https://openid.net/specs/authorization-api-1_0.html
[AUTHZEN-NLGOV]
NLgov Profile for OpenID AuthZEN Authorization API. Logius. 2026-06-25. URL: https://logius-standaarden.github.io/authzen-nlgov/
[COAZ]
AuthZEN COAZ Framework 1.0 (Draft 1). OpenID AuthZEN WG. February 2026. URL: https://openid.github.io/authzen/authzen-coaz-framework-1_0.html
[COAZ-MCP]
AuthZEN COAZ MCP Binding 1.0 (Draft 1). OpenID AuthZEN WG. February 2026. URL: https://openid.github.io/authzen/authzen-coaz-mcp-binding-1_0.html
[FSC]
Federated Service Connectivity (FSC) Core. Logius. URL: https://gitdocumentatie.logius.nl/publicatie/fsc/core/
[FTV]
Federatieve Toegangsverlening. VNG Realisatie. URL: https://vng-realisatie.github.io/ftv/
[GRAPHQL]
GraphQL Specification. GraphQL Foundation. URL: https://spec.graphql.org/
[GRAPHQL-HTTP]
GraphQL over HTTP. GraphQL Foundation. URL: https://graphql.github.io/graphql-over-http/
[RFC2119]
Key words for use in RFCs to Indicate Requirement Levels. S. Bradner. IETF. March 1997. Best Current Practice. URL: https://www.rfc-editor.org/info/rfc2119/
[RFC8174]
Ambiguity of Uppercase vs Lowercase in RFC 2119 Key Words. B. Leiba. IETF. May 2017. Best Current Practice. URL: https://www.rfc-editor.org/info/rfc8174/
[RFC8259]
The JavaScript Object Notation (JSON) Data Interchange Format. IETF. December 2017. URL: https://www.rfc-editor.org/rfc/rfc8259
[RFC9110]
HTTP Semantics. IETF. June 2022. URL: https://www.rfc-editor.org/rfc/rfc9110