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