Skip to main content

Minimum requirements

PackageMinimum version
controlplane0.58.0
router0.60.0
wgc0.39.0
Make sure you have correctly set up Authentication & Authorization.

Definition

directive @requiresScopes(
    scopes: [[openfed__Scope!]!]!
) on ENUM | FIELD_DEFINITION | INTERFACE | OBJECT | SCALAR

scalar openfed__Scope

Arguments

Argument NameArgument Type
scopes[[openfed__Scope!]!]!
The “scopes” argument requires an array (GraphQL List) of nested arrays. The outermost array represents a set of OR scopes. Each nested array sibling represents a set of AND scopes. Each element in the AND scopes array should be an openfed__Scope Scalar, which is an instance of permissions as defined in your authentication token. For example, "read:field".

Declaration

OR Scopes

Consider the following @requiresScopes declared on Query.a:
type Query {
  a: String! @requiresScopes(scopes: [["read:field"], ["read:scalar"]])
}
If an agent wished to select Query.a, it would require EITHER the "read:field" permission OR the "read:scalar" permission.

AND Scopes

Consider the following @requiresScopes declared on Query.b:
type Query {
  b: String! @requiresScopes(scopes: [["read:field", "read:scalar"]])
}
If an agent wished to select Query.b, it would require BOTH the "read:field" permission and the "read:scalar" permission. Lack of one or both would return an authorization error.

Multiple OR scopes with multiple AND scopes

Consider the following @requiresScopes declared on Query.c:
type Query {
  c: String! @requiresScopes(scopes: [
    ["read:field", "read:scalar"],
    ["read:query", "read:private"],
    ["read:all"]
  ])
}
If an agent wished to select Query.c, it would require at least one of the following sets of scopes: (("read:field" AND "read:scalar") OR ("read:query" AND "read:private") OR ("read:all"))

Declaration

The @requiresScopes directive can be declared on Enums, field definitions, Interfaces, Objects, and Scalars.

Declaration on field definitions (Interface and Object fields)

When @requiresScopes is declared on an Object field definition, that specific field will be protected (require the specified scopes). For example, given the following federated schema:
type Query {
  ids: [ID!]! @requiresScopes(scopes: [["read:id"]])
  names: [String!]!
}
The field Query.ids would be protected in the following operation:
query {
  ids # requires scopes "read:id"
  names # does not require any scopes
}
The behavior is similar for Interfaces. For example, given the following federated schema:
interface Interface {
  id: ID! @requiresScopes(scopes: [["read:id"]])
  name: String!
}

type Object implements Interface {
  id: ID!
  name: String!
}

type Query {
  interfaces: [Interface!]!
  objects: [Object!]!
}
The field Interface.id would be protected in the following operation (but note that @requiresScopes declared on an Interface field does not protect the fields of its implementations):
query {
  interface {
    id # this field requires scopes "read:id"
    name # this field would not require any scopes
  }
  objects {
    id # this field would not require any scopes
    name # this field would not require any scopes
  }
}

Declaration on the “type level” (Enums, Interfaces, Objects, and Scalars)

When @requiresScopes is declared on the “type level”, all field definitions with that named type (the innermost response type name) will require those scopes to access. For example, consider the following federated schema:
enum Enum @requiresScopes(scopes: [["read:enum"]]) {
  A
}

interface Interface @requiresScopes(scopes: [["read:interface"]]) {
  id: ID
}

type ObjectA implements Interface {
  enum: Enum!
  id: ID
  scalar: Scalar!
}

type ObjectB @@requiresScopes(scopes: [["read:object"]]) {
  id: ID
  name: String!
}

scalar Scalar @requiresScopes(scopes: [["read:scalar"]])

type Query {
  enums: [Enum!]!
  interfaces: [Interface!]!
  objectAs: [ObjectA!]!
  objectBs: [ObjectB!]!
  scalars:[Scalar!]!
}
Above, @requiresScopes has been declared on:
  1. The Enum “Enum”
  2. The Interface “Interface”
  3. The Object “ObjectB”
  4. The Scalar “Scalar”
Consider now the following operation:
query {
  enums # requires scopes "read:enum"
  interfaces { # requires scopes "read:interface"
    id # does not require any scopes
  }
  objectAs {
    enum # requires scopes "read:enum"
    id # does not require any scopes
    scalar # requires scopes "read:scalar"
  }
  objectBs { # requires scopes "read:object"
    id # does not require any scopes
    name # does not require any scopes
  }
  scalars # requires scopes "read:scalar"
}
  1. Query.enums requires scopes because it returns type “Enum”, which requires the scopes "read:enum".
  2. Query.interfaces requires scopes because it returns type “Interface”, which requires the scopes "read:interface".
  3. Query.objectAs.enum requires scopes because it returns type “Enum”, which requires the scopes "read:enum".
  4. Query.objectAs.scalar requires scopes because it returns type “Scalar”, which requires the scopes "read:scalar".
  5. Query.objectBs requires scopes because it returns type “ObjectB”, which requires the scopes "read:object".
  6. Query.scalars requires scopes because it returns type “Scalar”, which requires the scopes "read:scalar".

Federation

The @requiresScopes directive will always persist in the federated schema.

Shared fields

If @requiresScopes is declared on a field definition in one subgraph, and another instance of the same field definition (a shared field) is defined in another subgraph without @requiresScopes, then @requiresScopes will still be declared on the federated field. This also means that selecting this field will always require the defined scopes, regardless of whether it would be resolved from a subgraph that did not declare @requiresScopes. This is shown in the example below:
# subgraph-a
type Query @shareable {
  ids: [ID!]! @requiresScopes(scopes: [["read:id"]])
}
# subgraph-b
type Query @shareable {
  ids: [ID!]!
}
# federated graph
type Query {
  ids: [ID!]! @requiresScopes(scopes: [["read:id"]]) # @requiresScopes is persisted from subgraph-a
}

Combining scopes in the same subgraph (matrix multiplication)

Disparate sets of OR scopes affecting a single field will always combine through a multiplication matrix. This is to ensure data cannot be accessed without all appropriate permissions.
The maximum total number of scopes that can apply to a single field both directly and indirectly is 16.
If one set of AND scopes are declared on a field definition and another set of AND scopes are defined on the “type level”, the scopes will be multiplied together resulting in both sets of AND scopes being required. For instance, consider the following federated graph:
# federated graph
type Query {
  scalars: [Scalar!]! @requiresScopes(scopes: [["read:query"]]) # scopes defined in the field level
}

scalar Scalar @requiresScopes(scopes: [["read:scalar"]]) # scopes defined on the type level
Selecting the field Query.scalars would require the permissions "read:query" AND "read:scalar". This is effectively the same as a single set of AND scopes defined as [["read:query", "read:scalar"]]. If there are multiple sets of AND scopes (multiple OR scopes), each set of AND scopes on the “field level” are multiplied against each set of AND scopes on the “type level”. Consider the following federated graph:
# federated graph
type Query {
  scalars: [Scalar!]! @requiresScopes(scopes: [["read:query"], ["read:private"]])
}

scalar Scalar @requiresScopes(scopes: [["read:scalar"]])
Selecting the field Query.scalars would require the permissions ("read:query" AND "read:scalar") OR ("read:scalar" AND "read:private"). This is effectively the same as a single set of OR scopes defined as [["read:query", "read:scalar"], ["read:private", "read:scalar"]]. Each set of AND scopes on the “field level” will be added to each set of AND scopes on the “type level”. Typically, the number of resultant scopes will be <number of AND scopes on field level> * <number of AND scopes on type level>. Consider the following federated graph:
# federated graph
type Query {
  scalars: [Scalar!]! @requiresScopes(scopes: [["read:query", "read:field"], ["read:private"], ["read:list"]])
}

scalar Scalar @requiresScopes(scopes: [["read:scalar", "read:custom"], ["read:sensitive"]])
In the example above, each set of AND scopes defined at the “field level” would be ANDed together with each set of AND scopes defined at the “type level”. To break this down:
  1. ["read:query", "read:field"] from the “field level” would be added to ["read:scalar", "read:custom"] from the “type level”, producing ["read:query", "read:field", "read:scalar", "read:custom"].
  2. ["read:query", "read:field"] from the “field level” would be added to ["read:sensitive"] from the “type level”, producing ["read:query", "read:field", "read:sensitive"].
  3. ["read:private"] from the “field level” would be added to ["read:scalar", "read:custom"] from the “type level”, producing ["read:private", "read:scalar", "read:custom"].
  4. ["read:private"] from the “field level” would be added to ["read:sensitive"] from the “type level”, producing ["read:private", "read:sensitive"].
  5. ["read:list"] from the “field level” would be added to ["read:scalar", "read:custom"] from the “type level”, producing ["read:list", "read:scalar", "read:custom"].
  6. ["read:list"] from the “field level” would be added to ["read:sensitive"] from the “type level”, producing ["read:list", "read:sensitive"].
This is effectively the same as a single set of scopes defined as:
[
  ["read:query", "read:field", "read:scalar", "read:custom"],
  ["read:query", "read:field", "read:sensitive"],
  ["read:private", "read:scalar", "read:custom"],
  ["read:private", "read:sensitive"],
  ["read:list", "read:scalar", "read:custom"],
  ["read:list", "read:sensitive"]
]

Combining scopes across subgraphs (matrix multiplication)

If multiple instances of a field (shared field) or type define scopes, those scopes will also be multiplied together. Consider the following subgraphs:
# subgraph-a
type Query @shareable {
  ids: [ID!]! @requiresScopes(scopes: [["read:id"], ["read:private"]])
  objects: [Object!]!
}

type Object @shareable @requiresScopes(scopes: [["read:object"]]]) {
  id: ID!
}
# subgraph-b
type Query @shareable {
  ids: [ID!]! @requiresScopes(scopes: [["read:field"], ["read:sensitive"]])
}

type Object @shareable @requiresScopes(scopes: [["read:type"], ["read:private"]]) {
  id: ID!
}
The scopes defined by each instance of the field will be multiplied together, and the resulting product will be shown in the federated graph:
type Query {
  ids: [ID!]! @requiresScopes(scopes: [
    ["read:id", "read:field"],
    ["read:id", "read:sensitive"],
    ["read:private", "read:field"],
    ["read:private", "read:sensitive"],
  ])
  objects: [Object!]!
}

type Object @requiresScopes(scopes: [
    ["read:object", "read:type"],
    ["read:object", "read:private"],
  ]) {
  id: ID!
}
Note that the federated graph will show the combined result of scopes for a specific field or type. The effective scopes, i.e., the “type level” scopes affecting a field, will not be reflected in the federated graph.

Combining scopes (superset scope reduction)

In the event that combining scopes would produce superfluous scopes, they will be removed. Consider the following subgraphs:
# subgraph-a
type Query @shareable {
  ids: [ID!]! @requiresScopes(scopes: [["read:id"], ["read:field"], ["read:private"]])
}
# subgraph-b
type Query @shareable {
  ids: [ID!]! @requiresScopes(scopes: [["read:id"], ["read:field"]])
}
One might assume the scopes would merge thus:
# federated graph
type Query {
  ids: [ID!]! @requiresScopes(scopes: [
    ["read:id"],
    ["read:id", "read:field"],
    ["read:field", "read:id"],
    ["read:field"],
    ["read:private", "read:id"],
    ["read:private", "read:field"]
  ])
}
However, upon closer inspection, it becomes apparent that many of these scopes are either repeated or supersets of other scopes. Consequently, the merge result remove any scopes that are supersets of other scopes. For example, the scope "read:private" now only exists with either "read:id" or "read:field". This makes "read:private" redundant because if one has either the "read:id" or "read:field" permissions, this is sufficient to access the field. This the same case for the combined scopes of "read:id" and "read:field"; just one is sufficient. And so, the resulting federated graph is actually shown below (note that "read:private" is removed):
# federated graph
type Query {
  ids: [ID!]! @requiresScopes(scopes: [
    ["read:id"],
    ["read:field"],
  ])
}

Errors

In the event that an agent without relevant permissions selects a non-nullable field that is declared with @requiresScopes, an authorization error will be returned, and the entire data will be null (see Non-nullable authenticated data requested among unauthenticated data).
{
"errors":[{
  "message":"Unauthorized to load field 'Query.enumField'. Reason: required scopes: ('read:enum' AND 'read:field') OR ('read:all'), actual scopes: <none>",
  "path":["enumField"]
}],
  "data":null
}
In the event that an agent without relevant permissions selects a nullable field that is declared with @requiresScopes, an authorization error will be returned, and the specific field will be null (see Partial data):
{
"errors":[{
  "message":"Unauthorized to load field 'Query.enumField'. Reason: required scopes: ('read:enum' AND 'read:field') OR ('read:all'), actual scopes: <none>",
  "path":["enumField"]
}],
  "data":{
    "enumField":null
  }
}

Partial data (nullable data requiring permissions)

Imagine an agent without relevant permissions selects a field that is declared with @requiresScopes and the response type of that field is nullable. However, the agent also selects a field that is not declared @requiresScopes (nor are any potential nested fields). In this event, an authorization error will still be returned, but the specific data that requires authentication will be null, while the data not requiring authentication will be returned. Consider the following federated graph and corresponding query:
# federated graph
type Query {
  """Note that Query.intField is nullable"""
  intField: Int @requiresScopes(scopes: [["read:int"]])
  """Note that Query.floatField is non-nullable"""
  floatField: Float! @requiresScopes(scopes: [["read:float"]])
  stringField: String! # note that this field does not require any permissions
}
query {
  intField
  stringField
}
An agent without any permissions sending the query above would receive something like the following:
{
  "errors":[{
    "message":"Unauthorized to load field 'Query.intField'. Reason: required scopes: 'read:int', actual scopes: <none>",
    "path":["intField"]
  }],
  "data":{
    "intField":null,
    "stringField":"I'm a string!"
  }
}

Non-nullable data requiring scopes requested among data not requiring scopes

In the event an agent without relevant permissions selects any non-nullable fields that declare @requiresScopes (and therefore require one or more permissions), an authorization error will be returned, and the entire data will return null. This is true even if one or more field selections did not require permissions or are nullable. Consider the following federated graph and corresponding query:
# federated graph
type NestedObject {
  """Note that NestedObject.scopedInt is non-nullable"""
  scopedInt: Int! @requiresScopes(scopes: [["read:int"]])
  unscopedId: Id!
}

type Object {
  unscopedString: String!
  unscopedNestedObject: NestedObject!
}

type Query {
  objects: [Object!]!
  strings: [String!]!
}
query {
  strings
  objects {
    unscopedString
    unscopedNestedObject {
      scopedInt # only this field requires permissions
      unscopedId
    }
  }
}
An agent without the "read:int" permission sending the query above would receive something like the following:
{
  "errors":[{
    "message":"Unauthorized to load field 'Query.objects.unscopedNestedObject.scopedInt'. Reason: required scopes: 'read:int', actual scopes: <none>",
    "path":["objects","unscopedNestedObject","scopedInt"]
  }],
  "data":null
}

Partial permissions

An agent must have all relevant permissions within at least one entire set of AND scopes among the OR scopes declared through @requiresScopes for a selected field to return data. In the event that the agent has some but not all permissions, the error message will be transparent:
{
  "errors":[{
    "message":"Unauthorized to load field 'Query.employeeField'. Reason: required scopes: ('read:employee' AND 'read:private') OR ('read:all'), actual scopes: read:employee",
    "path":["employeeField"]
  }],
  "data":null
}