StepZen is now part of IBM. For the most recent product information and updates go to
https://www.ibm.com/products/stepzen

Policies for Access Control of GraphQL Fields

Allowing fine grained control of access to GraphQL fields

Field policies provide a configuration based mechanism to mange access to your GraphQL API by providing policy for GraphQL types. The policies have rules that determine the conditions under which a field may be accessed. An operation must contain only fields whose policies permit access.

By creating a field policy for a GraphQL type, you can have fine grained access control when used in conjuction with JWT based authorization to manage access to your GraphQL API. They can also be used to declare portions of your API public. Given the nature of access control, the model for field policies leans towards asking for explict specification over implied specification. Policies are ignored for requests using an Admin or API key.

Field policies add conditions to fields to control access. If the condition is true, then access is permitted. Each field may have a single condition associated with it. Field policies are applied when the request is received against all field selections in the operation to be executed. An operation that includes any field selection denied by policy will not be evaluated by the GraphQL engine.

When field policies are in use, you will be opening access in a controlled fashion upon root operation type fields. For example, you might open all Query fields, but no Mutation fields. Access to non-root operation type fields will be left open by default (you'll only be able to see them through a root operation field, so you be protected), but you can control what fields are accessible even there. Of course, introspection will only return accessible types and fields.

Field policies are organized into policies for each type. Each policy has a list of rules that have a condition that permits access and a list of fields to which it applies. There is a policyDefault that is applied to all other fields in the type.

Field policies are specified as yaml in your config.yaml like:

access:
  policies:
    - type: Query
      rules: 
      - condition: PREDICATE
        name: name of rule
        fields: [ fieldname ... ]
      - condition: PREDICATE
        name: name of rule
        fields: [ fieldname ... ]
      policyDefault:
        condition: PREDICATE
    - type: Mutation
      rules:
      - condition: PREDICATE
        name: name of rule
        fields: [ fieldname ... ]
      policyDefault:
        condition: PREDICATE
    - type: MyType
      rules:
      - condition: PREDICATE
        name: name of rule
        fields: [ fieldname ... ]
      policyDefault:
        condition: PREDICATE
      

and apply to the GraphQL endpoint--named in your stepzen.config.json.

The condition clause takes predicates which are defined below but take upon the following forms:

  • true
  • false
  • $jwt.CUSTOMGROUP: String == "admin"

If a predicate evaluates to true, it means the associated field is allowed. So for the above, true would allow these fields, false would reject these fields, and the last would allow for "admin JWT's".

Unnamed fields use the catch-all policyDefault. Every policy has a policyDefault--if unspecified, it will be:

      policyDefault:
        condition: false  # Deny access

name is for your administrative use and is optional.

Field policies behavior can be summarized as follows:

Behavior for type
Access policies exist, but no policy for typeDeny access to all fields if type Query, Mutation, Subscription (root operation types); permit all fields otherwise
Access rule exists for type and no rule for fieldUse the policyDefault condition to permit or deny access
Access rule exists for type and rule for fieldUse the rule's condition to permit or deny access.

To illustrate the concept, the following policy would allow public access to the Query contents and pages fields and would deny other access.

access:
  policies:
    - type: Query
      rules:
      - condition: true
        name: public fields
        fields: [ "contents", "pages" ]

thereby allowing this operation query { contents { ... }} to anyone regardless of authorization.

So, if we had a schema like:

type Query {
     myQuery...
}
type Mutation {
     myMutation...
}

type PublicData {
     public...
     ...
}

type PrivateData {
     ...
}

and then the following field policy would allow access to Query.myQuery, PublicData.* but not PrivateData.* nor Mutation.*

access:
  policies:
    - type: Query
      rules:
      - condition: true
        name: public fields
        fields: [ "myQuery" ]
    - type: PrivateData
      policyDefault:
        condition: false  # default is false, but specify for clarity

Another way of saying this is that you can open access fields in type Query, Mutation, and Subscription and may close access to fields in other types.

Extending our first example so all allowed JWTs can access all fields in Query, just requires adding the policyDefault condition: "?$jwt".

access:
  policies:
    - type: Query
      rules:
      - condition: true
        name: public fields
        fields: [ "myQuery" ]
      policyDefault: 
        condition: "?$jwt"

The result is a policy would, using policyDefault, allow access to any Query field using an allowed JWT and using rules, provide public (e.g. unauthenticated) access to the Query myQuery field. Fields in type Mutation would not be accessible (except, as mentioned previously, by using API or Admin keys).

An arguable best practice is to disallow introspection of an endpoint's schema since it gives users insight into fields they should not be using. However, with field polices, as protected fields are not exposed via introspection, the risks are lowered. However, since field policies apply to __type, __schema and __typename Query fields used for introspection, you can enable introspection by adding this:

    - type: Query
      rules:
        - condition: true


          fields: [__type, __schema, __typename]

Or if you have opened all access to JWT, but want to deny introspection, you can do so like this:

    - type: Query
      rules:
        - condition: false


          name: introspection
          fields: [__type, __schema, __typename, _service]


      policyDefault: 
        condition: "?$jwt"

In addition, field policies allow you to apply straight-forward business logic. For example,

    - type: Mutation
      rules:
      - condition: "$jwt.CUSTOMGROUP : String == 'admin'"
        name: admin access
        fields: [ addUser ]

limits Mutation addUser to admin_ users.

Field policies won't always be the best course of action, sometimes it's better to defer the business logic to your backend especially when you already have complex business logic in place. You can do so by just adding a policyDefault of ?$jwt or skipping field policies. You could then forward the JWT token and make business logic decisions on the backend.

If you wish to make all GraphQL Query fields public, then you could express it as:

access:
  policies:
  - type: Query
    policyDefault: 
      condition: true

Examples of rules include:

  • if they have the "product-admin" role in their JWT token condition: $jwt.CUSTOMGROUP.role:String == "product-admin", fields: [ productAdminField, productAdminField2, ... ], name: product admin access
  • if they have the "admin" or "editor" role in their JWT token. condition: $jwt.CUSTOMGROUP.roles:String has "admin" || $jwt.CUSTOMGROUP.role:String has "editor" , fields: [ editorField, editorField2, ... ], name: editor access or any other rules that depend only upon claims in the JWT token.

The rules would look like this in a field policy:

access:
  policies:
    - type: Query
      rules:
      - condition: $jwt.CUSTOMGROUP.role:String == "product-admin"
        fields: [ productAdminField, productAdminField2, ... ]
        name: product admin access
      - condition: $jwt.CUSTOMGROUP.roles has "product-admin" || $jwt.CUSTOMGROUP.role:String has "editor"
        fields: [ editorField, editorField2, ... ]
        name: editor acess
      policyDefault:
        condition: ?$jwt # jwt access to all other `Query` fields

Fields listed in fields must be legal GraphQL identifiers.

name is limited to 99 characters.

Building a set of polices for your Query's or Mutation's is done by picking out the fields that need controlled or special access and associating conditions with them. The rest should be handled via the policyDefault clause. For the vast majority of usages, this should suffice.

Indirect references to fields are not affected by field based rules. Examples of indirect references are: @sequence and @materializer. This allows you to use @sequence, @materializer based fields to manage access to controlled fields.

Predicates

Predicates are built on a simple expression language. Given that a predicate is used in access control, the language is fairly strict and rigid to minimize ambiguity.

Types parallel the builtin GraphQL scalars including: Int, Boolean, Float, and String.

Builtin values are $jwt and $variables which parallel the JWT token in the Authorization header and the variables specified in the GraphQL HTTP Request. The values are expected to be in JSON form. The name/value pairs can be accessed using a dot notation. You may escape the name identifier as "" (e.g. http://YOURDOMAIN.com/claim`).

$jwt is only available if you have configured the identity config.yaml section for the endpoint (See JWT). The JWT will only be present if:

  • the token has been properly signed
  • the token reserved claims where present are valid
  • the token reserved claims where required in identity are present
  • the token reserved claims where specified in identity match This is what we have referred to as an allowed JWT. An allowed JWT will also, as mentioned, permit access to the GraphQL endpoint configured with identity.

There is no automatic typing of values. For example, you cannot just specify $jwt.`CUSTOM/userid` , you must specify $jwt.`CUSTOM/userid` : Int.

The core operators are <,<=,==, !=, >, >= and are allowed between any two operands of the same type. Additionally, there is a has operator that allows existence checks.

Processing will currently stop with an error if an allowed JWT token does not appear in a request and you refer to a JWT token. Typically, once you start using field predicates, you'll always have $jwt available and it would indeed be an error if it did not exist. But if you have a use case that requires the rule to be skipped, you should use the idiom "?$jwt && $jwt.field..." and you can write predicate such as:

  • ?$jwt, ?$jwt.`CUSTOM/bar` - check for existence
  • $jwt.`custom/anInt` :Int > 40 - check for integer value > 40

The has operator allows checking for existence of a scalar within a JSON value. For example, if you had the JWT with OpenID and custom claims as:

{
  "email": "stepzen@example.com",
  "email_verified": true,
  "iss": "https://IDP.com/",
  "sub": "1234567890",
  "aud": "my_client_id",
  "iat": 946713599,
  "exp": 946717199,
  "CUSTOM/groups": ["admin", "user", "moderator"],
}

then $jwt.`CUSTOM/groups`: String has "admin" or $jwt.`CUSTOM/groups`: String has "moderator" would be true if the user was in the admin or moderator groups.

Should your claim have a slightly more sophisticated structure such as:

"CUSTOM/roles": [ { "type": "user" }, { "type" : "admin" }, {"type": "moderator" }, {"withouttype" : ""}]

then you may specify $jwt.`CUSTOM/roles`.type: String has "admin" || $jwt.`CUSTOM/roles`.type: String has "moderator". The entry without type will be skipped.