@requiresScopes

The @requiresScopes directive declares a GraphQL definition to require the agent (person, service, or device) to possess certain permissions. Lack of permissions will yield an authorization error.

Minimum requirements

PackageMinimum version

controlplane

router

wgc

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

OR Scopes

Consider the following @requiresScopes declared on Query.fieldOne:

type Query {
  fieldOne: String! @requiresScopes(scopes: [["read:field"], ["read:scalar"]])
}

If an agent wished to select Query.fieldOne, it would require EITHER the "read:field" permission OR the "read:scalar" permission.

AND Scopes

Consider the following @requiresScopes declared on Query.fieldTwo

type Query {
  fieldTwo: String! @requiresScopes(scopes: [["read:field", "read:scalar"]])
}

If an agent wished to select Query.fieldTwo, it would require BOTH the "read:field" permission and the "read:scalar" permission. Lack of one or both would return an authorization error.

Combining OR and AND Scopes

Consider the following @requiresScopes declared on Query.fieldThree

type Query {
  fieldThree: String! @requiresScopes(scopes: [
    ["read:field", "read:scalar"],
    ["read:query", "read:private"],
    ["read:all"]
  ])
}

If an agent wished to select Query.fieldThree, 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. However, there are some differences between declaration on leaf definitions and parent definitions.

Declaration on leaf definitions (enums and scalars)

When @requiresScopes is declared on a leaf definition, the @requiresScopes directive (including its scopes) will be applied to all field definitions whose named type name (the innermost response type name) is the respective leaf definition within that subgraph.

If the same leaf definition is defined in another subgraph without @requiresScopes, the corresponding field definitions unique to that that subgraph will be unaffected. But note that @requiresScopes could be applied to those field definitions through other means.

If at least one instance of a shared field is declared with @requiresScopes, that field definition will be declared with @requiresScopes in the federated graph (see Federation). Consider the following example:

# subgraph-a (raw)
enum Enum @requiresScopes(scopes: [["read:enum"]]) {
  VALUE
}

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

type Query {
  enumQuery: Enum!
  objectQuery: [Object!]!
  scalarQuery: Scalar!
}

type Object {
  enumField: Enum!
  scalarField: Scalar!
}

In subgraph-a, above, @requiresScopes has been declared on two leaf definitions:

  1. The enum "Enum"

  2. The scalar "Scalar"

And those leaf definitions are returned at the following field paths:

  1. Query.enumQuery (named type name is "Enum")

  2. Query.scalarQuery (named type name is "Scalar")

  3. Object.enumField (named type name is "Enum")

  4. Object.scalarField (named type name is "Scalar")

Consequently, @requiresScopes would be applied to all field definitions at the paths listed above. The normalized graph would look like so:

# subgraph-a (normalized)
enum Enum {
  VALUE
}

scalar Scalar

type Query {
  enumQuery: Enum! @requiresScopes(scopes: [["read:enum"]])
  objectsQuery: [Object!]!
  scalarQuery: Scalar! @requiresScopes(scopes: [["read:scalar"]])
}

type Object {
  enumField: Enum! @requiresScopes(scopes: [["read:enum"]])
  scalarField: Scalar! @requiresScopes(scopes: [["read:scalar"]])
}

Declaration on object definitions

When @requiresScopes is declared on an object definition, the @requiresScopes directive (including its scopes) will be applied to all field definitions defined on the object definition within that subgraph.

If the same object definition is defined in another subgraph without @requiresScopes, the corresponding field definitions unique to that that subgraph will be unaffected. But note that @requiresScopes could be applied to those field definitions through other means.

If at least one instance of a shared field is declared with @requiresScopes, that field definition will be declared with @requiresScopes in the federated graph (see Federation). Consider the following example:

# subgraph-b (raw)
type Query @requiresScopes(scopes: [["read:query"]]) {
  objectQuery: Object!
  objectsQuery: [Object!]!
}

type Object @requiresScopes(scopes: [["read:object"]]) {
  intField: Int!
  stringField: String!
}

In subgraph-b, above, @requiresScopes has been declared on two object definitions:

  1. The root object "Query"

  2. The object "Object"

And those object definitions define the following field definitions:

  1. Query.objectQuery

  2. Query.objectsQuery

  3. Object.intField

  4. Object.stringField

Consequently, the @requiresScopes directive (including its defined scopes) would be applied to all field definitions at the paths listed above. The normalized graph would look like so:

# subgraph-b (normalized)
type Query {
  objectQuery: Object! @requiresScopes(scopes: [["read:query"]]
  objectsQuery: [Object!]! @requiresScopes(scopes: [["read:query"]]
}

type Object {
  intField: Int! @requiresScopes(scopes: [["read:object"]])
  stringField: String! @requiresScopes(scopes: [["read:object"]])
}

Declaration on interface definitions

When @requiresScopes is declared on an interface definition, the @requiresScopes directive (including its scopes) will be applied to all field definitions defined on the interface definition within that subgraph.

If the same interface definition is defined in another subgraph without @authenticated, the corresponding field definitions unique to that subgraph be unaffected. But note that @requiresScopes could be applied to those field definitions through other means.

In addition, @requiresScopes will be applied to the corresponding field definitions defined on the objects that implement that interface within that subgraph.

If at least one instance of a shared field is declared with @requiresScopes, that field definition will be declared with @requiresScopes in the federated graph (see Federation). Consider the following example:

# subgraph-c (raw)
type Query {
  interfacesQuery: [Interface!]!
}

type Interface @requiresScopes([["read:field"]]) {
  intField: Int!
  stringField: String!
}

type Object implements Interface {
  intField: Int!
  stringField: String!
  objectOnlyField: Boolean!
}

type AnotherObject implements Interface {
  intField: Int!
  stringField: String!
  anotherObjectOnlyField: Float!
} 

In subgraph-c, above, @requiresScopes has been declared on the interface definition "Interface", which is implemented by two object definitions:

  1. Object

  2. AnotherObject

This interface defines the following field definitions:

  1. Interface.intField

  2. Interface.stringField

Consequently, the @requiresScopes directive (including its defined scopes) would be applied to all field definitions at the paths listed above, in addition to the same field definitions that are defined on the object definitions that implement that interface.

The normalized graph would look like so:

# subgraph-c (normalized)
type Query {
  interfacesQuery: [Interface!]!
}

type Interface {
  intField: Int! @requiresScopes(scopes: [["read:field"]])
  stringField: String! @requiresScopes(scopes: [["read:field"]])
}

type Object implements Interface {
  intField: Int! @requiresScopes(scopes: [["read:field"]])
  stringField: String! @requiresScopes(scopes: [["read:field"]])
  objectOnlyField: Boolean!
}

type AnotherObject implements Interface {
  intField: Int! @requiresScopes(scopes: [["read:field"]])
  stringField: String! @requiresScopes(scopes: [["read:field"]])
  anotherObjectOnlyField: Float!
}

When @requiresScopes is declared on an interface field definition directly, the corresponding field definitions on the object types that implement that interface within that subgraph will also declare @requiresScopes (including its defined scopes). For example:

# subgraph-d (raw)
type Interface {
  intField: Int!
  stringField: String! @requiresScopes(scopes: [["read:field"]])
}

type Object implements Interface {
  intField: Int!
  stringField: String!
  objectOnlyField: Boolean!
}

type AnotherObject implements Interface {
  intField: Int!
  stringField: String!
  anotherObjectOnlyField: Float!
}

The subgraph above, subgraph-d, would normalize into the following subgraph:

# subgraph-d (normalized)
type Interface {
  intField: Int!
  stringField: String! @requiresScopes(scopes: [["read:field"]])
}

type Object implements Interface {
  intField: Int!
  stringField: String! @requiresScopes(scopes: [["read:field"]])
  objectOnlyField: Boolean!
}

type AnotherObject implements Interface {
  intField: Int!
  stringField: String! @requiresScopes(scopes: [["read:field"]])
  anotherObjectOnlyField: Float!
}

Combining multiple scopes (matrix multiplication)

Sometimes, a field definition must combine multiple declarations of @requiresScopes.

Consider the following example:

# subgraph-e (raw)
type Query @requiresScopes(scopes: [["read:query"]]) {
  enumField: Enum! @requiresScopes(scopes: [["read:private"]])
}

enum Enum @requiresScopes(scopes: [["read:enum"]]) {
  VALUE
}

In this instance, the field definition Query.enumField is "inheriting" two other sets of scopes ("read:query" and "read:enum") in addition to its own set of scopes ("read:private").

These scopes are combined through the "AND" operator:

# subgraph-e (normalized)
type Query {
  enumField: Enum! @requiresScopes(scopes: [
    ["read:private", "read:query", "read:enum"]
  ])
}

enum Enum {
  VALUE
}

But sometimes, a field definition must combine more complicated declarations of @requiresScopes. These such cases are combined through matrix multiplication to ensure all contributed scopes are respected simultaneously.

Consider the following example:

# subgraph-f (raw)
type Query @requiresScopes(scopes: [["read:query"], ["read:root"]]) {
  enumField: Enum! @requiresScopes(scopes: [
    ["read:private", "read:field"],
    ["read:private", "read:object"]
  ])
}

enum Enum @requiresScopes(scopes: [["read:enum"]]) {
  VALUE
}

In this instance, the field definition Query.enumField is once again "inheriting" two more declarations of @requiresScopes. But this time, there are multiple sets of OR and AND scopes, and each set of OR scopes is required by each other set of OR scopes.

Consequently, the "read:query" AND scope declared on Query must be added to both sets of OR scopes declared on Query.enumField. And the same again should happen with the "read:root" AND scope.

Finally, the scopes defined on Enum must also be applied. In this case, it's just a single AND scope ("read:enum"), so it can be applied without creating any new sets. In the event that Enum defined several OR scopes, a new set of scopes would be created for each.

The results are four new sets representing AND scopes, and each set of AND scopes represents an entire OR scope:

  1. ["read:private", "read:field", "read:query", "read:enum"]
  2. ["read:private", "read:field", "read:root", "read:enum"]
  3. ["read:private", "read:object", "read:query", "read:enum"]
  4. ["read:private", "read:object", "read:root", "read:enum"]

Which would appear in the normalized subgraph like so:

# subgraph-f (normalized)
type Query {
  enumField: Enum! @requiresScopes(scopes: [
    ["read:private", "read:field", "read:query", "read:enum"],
    ["read:private", "read:field", "read:root", "read:enum"],
    ["read:private", "read:object", "read:query", "read:enum"],
    ["read:private", "read:object", "read:root", "read:enum"]
  ])
}

enum Enum {
  VALUE
}

Federation

The @requiresScopes directive will persist in the federated schema. Consequently, if @requiresScopes is declared on a field definition in one graph, and the same field definition (a shared field) is defined in another graph without @requiresScopes, then @requiresScopes will still be declared on the federated field. This also means selecting this field will always require the relevant permissions, regardless of whether it would be resolved from a subgraph that did not declare @requiresScopes. Consider the following two subgraphs and the resulting federated graph. The federated graph includes descriptions explaining how each @requiresScopes directive has persisted.

# subgraph-g (raw)
type Query {
  enumQuery: Enum!
  interfacesQuery: [Interface!]!
}

enum Enum @requiresScopes(scopes: [["read:enum"]]) {
  VALUE
}

type Interface {
  intField: Int!
  stringField: String! @requiresScopes(scopes: [["read:string"], ["read:scalar"]])
}

type Object implements Interface @key(fields: "id") {
  id: ID!
  intField: Int!
  objectOnlyEnumField: Enum!
  stringField: String! @shareable
}

type AnotherObject implements Interface @key(fields: "id") {
  id: ID!
  intField: Int!
  stringField: String! @shareable
}
# subgraph-h (raw)
type Query {
  scalarQuery: Scalar!
}

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

enum Enum {
  VALUE
}

type Interface @requiresScopes(scopes: [["read:interface", "read:object"], ["read:all"]] {
  booleanField: Boolean!
  enumField: Enum!
}

type Object implements Interface @key(fields: "id") {
  booleanField: Boolean!
  enumField: Enum!
  id: ID!
  objectOnlyBooleanField: Boolean!
  scalarField: Scalar!
  stringField: String! @shareable
}

type AnotherObject implements Interface @key(fields: "id") @requiresScopes(scopes: [["read:anotherobject"]]) {
  anotherObjectOnlyFloatField: Float!
  anotherObjectOnlyScalarField: Scalar!
  booleanField: Boolean!
  enumField: Enum!
  id: ID!
  intField: Int!
  stringField: String! @shareable
}
# federated graph
directive @requiresScopes(scopes: [[openfed__Scope!]!]!) on ENUM | FIELD_DEFINITION | INTERFACE | OBJECT | SCALAR

scalar openfed__Scope

type Query {
  "Query.enumQuery @requiresScopes: read:enum from subgraph-g Enum"
  enumQuery: Enum! @requiresScopes(scopes: [["read:enum"]])
  interfacesQuery: [Interface!]!
  """
    Scalar.enumQuery 
    @requiresScopes: read:scalar AND read:private from subgraph-h Scalar
  """
  scalarQuery: Scalar @requiresScopes(scopes: [["read:scalar", "read:private"]])
}

"""
  Scalar 
  @requiresScopes: (read:scalar AND read:private) initially added by subgraph-h Scalar
  @requiresScopes applied to relevant field definitions and removed here
"""
scalar Scalar

"""
  Enum @requiresScopes: read:enum initially added by subgraph-g Enum
  @requiresScopes applied to relevant field definitions and removed here
"""
enum Enum {
  VALUE
}

"""
  Interface 
  @requiresScopes: ((read:interface AND read:object) OR (read:all)) 
  initially added by subgraph-h Interface
  @requiresScopes applied to relevant field definitions and removed here
"""
type Interface {
  """
  Interface.booleanField @requiresScopes: 
  ((read:interface AND read:object) OR (read:all)) from subgraph-h Interface
  """
  booleanField: Boolean! @requiresScopes(scopes: [["read:interface", "read:object"],["read:all"]])
  """
    Interface.enumField @requiresScopes: 
    ((read:interface AND read:object) OR (read:all)) from subgraph-h Interface
  """
  enumField: Enum! @requiresScopes(scopes: [["read:interface", "read:object"],["read:all"]])
  intField: Int!
  """
    Interface.stringField @requiresScopes: 
    ((read:string) OR (read:scalar)) from subgraph-h Interface.stringField
  """
  stringField: String! @requiresScopes(scopes: [["read:string"], ["read:scalar"]])
}

type Object implements Interface {
   """
    Object.booleanField @requiresScopes: 
    ((read:interface AND read:object) OR (read:all)) from subgraph-h Interface
  """
  booleanField: @requiresScopes(scopes: [["read:interface", "read:object"],["read:all"]])
    """
    Object.enumField @requiresScopes: 
    ((read:interface AND read:object) OR (read:all)) from subgraph-h Interface
  """
  enumField: Enum! @requiresScopes(scopes: [["read:interface", "read:object"],["read:all"]])
  id: ID!
  intField: Int!
  objectOnlyBooleanField: Boolean!
  "Object.objectOnlyEnumField @requiresScopes: read:enum from subgraph-g Enum"
  objectOnlyEnumField: Enum! @requiresScopes(scopes: [["read:enum"]])
  """
    Object.scalarField 
    @requiresScopes: (read:scalar AND read:private) from subgraph-h Scalar
  """
  scalarField: Scalar! @requiresScopes(scopes: [["read:scalar", "read:private"]])
  """
    Object.stringField @requiresScopes: 
    ((read:string) OR (read:scalar)) from subgraph-h Interface.stringField
  """
  stringField: String! @requiresScopes(scopes: [["read:string"], ["read:scalar"]])
}

"""
  AnotherObject
  @requiresScopes: "read:anotherobject" initially added by subgraph-f AnotherObject
  @requiresScopes applied to relevant field definitions and removed here
"""
type AnotherObject implements Interface {
  """
    AnotherObject.anotherObjectOnlyFloatField @requiresScopes: 
    read:anotherobject from subgraph-h AnotherObject
  """
  anotherObjectOnlyFloatField: Float! @requiresScopes(scopes: [["read:anotherobject]])
  """
    AnotherObject.anotherObjectOnlyScalarField @requiresScopes: 
    read:anotherobject from subgraph-h AnotherObject
    (read:scalar AND read:private) from subgraph-h Scalar
  """
  anotherObjectOnlyScalarField: Scalar! @requiresScopes(scopes: [["read:anotherobject", "read:scalar", "read:private"]])
  """
    AnotherObject.booleanField @requiresScopes: 
    read:anotherobject from subgraph-h AnotherObject
    ((read:interface AND read:object) OR (read:all)) from subgraph-h Interface
  """
  booleanField: @requiresScopes(scopes: [["read:anotherobject", "read:interface", "read:object"], ["read:anotherobject", "read:all"]])
  """
    AnotherObject.enumField @requiresScopes: 
    read:anotherobject from subgraph-h AnotherObject
    ((read:interface AND read:object) OR (read:all)) from subgraph-h Interface
  """
  enumField: Enum! @requiresScopes(scopes: [["read:anotherobject", "read:interface", "read:object"], ["read:anotherobject", "read:all"]])
  "AnotherObject.id @requiresScopes: read:anotherobject from subgraph-h AnotherObject"
  id: ID! @requiresScopes(scopes: [["read:anotherobject]])
  """
    AnotherObject.intField 
    @requiresScopes: read:anotherobject from subgraph-h AnotherObject
  """
  intField: Int! @requiresScopes(scopes: [["read:anotherobject]])
  """
    AnotherObject.stringField @requiresScopes: 
    ((read:string) OR (read:scalar)) from subgraph-g Interface.stringField
    read:anotherobject from subgraph subgraph-h AnotherObject
  """
  stringField: String! @requiresScopes(scopes: [["read:anotherobject", "read:string"], ["read:anotherobject", "read:scalar"]])
}

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 @requiresScope(scopes: [["read:int"]])
  """Note that Query.floatField is non-nullable"""
  floatField: Float! @authenticated(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 authenticated data requested among unauthenticated data

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 Query {
  objectField: Object!
  stringField: String!
}

type Object {
  uauthenticatedObjectField: String!
  unauthenticatedNestedObject: NestedObject!
}

type NestedObject {
  """Note that NestedObject.authenticatedIntField is non-nullable"""
  authenticatedIntField: Int! @requiresScopes(scopes: [["read:int"]])
  unauthenticatedStringField: String!
}
query {
  stringField
  objectField {
    unauthenticatedObjectField
    unauthenticatedNestedObjectField {
      authenticatedNonNullableIntField # only this field requires permissions
      unauthenticatedStringField
    }
  }
}

An agent without the "read:int" permission sending the query above would receive something like the following:

{
  "errors":[{
    "message":"Unauthorized to load field 'Query.objectField.unauthenticatedNestedObjectField.authenticatedNonNullableIntField'. Reason: required scopes: 'read:int', actual scopes: <none>",
    "path":["objectField","unauthenticatedNestedObectField","authenticatedNonNullableIntField"]
  }],
  "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
}

Last updated