Policies for Access Control of GraphQL Fields
Polices allow 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 in the Predicates section but take upon the following forms:
truefalse$jwt.CUSTOMGROUP: String == "admin"
If a predicate evaluates to true, it means the associated field is allowed. So in the preceding section, 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 type | Deny 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 field | Use the policyDefault condition to permit or deny access |
| Access rule exists for type and rule for field | Use 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 accessor 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
identityare present - the token reserved claims where specified in
identitymatch 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 withidentity.
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.