Project: Open Policy Agent (OPA) (Provisioning / Security & Compliance)
File: graphql-api-authorization.md

title: "GraphQL APIs" kind: tutorial weight: 1 GraphQL APIs have become a popular way to query a variety of datastores and microservices, and any application or service providing a GraphQL API generally needs to control which users can run queries, mutations, and so on. OPA makes it easy to write fine-grained, context-aware policies to implement GraphQL query authorization. In this tutorial, you'll use a simple GraphQL server that accepts any GraphQL request that you issue, and echoes the OPA decision back as text. OPA will fetch policy bundles from a simple bundle server. OPA, the bundle server, and the GraphQL server will all be run as containers. For this tutorial, our desired policy is: People can see their own salaries (`query user($id: <user>) { salary }` is permitted for `<user>`) A manager can see their direct reports' salaries (`query user($id: <user>) { salary }` is permitted for `<user>`'s manager) {{< danger >}} GraphQL API Authorization with OPA is currently experimental and the following tutorial is intended for demonstration purposes only. {{< /danger >}} This tutorial requires to run a demo web server along with OPA. Most modern GraphQL frameworks encourage starting with a schema, so we'll follow suit, and begin by defining the schema for this example. schema.gql: ```graphql type Employee { id: String! salary: Int! } schema { query: Query } type Query { employeeByID(id: String!): Employee } ``` Every GraphQL service has a `query` type, and may or may not have a `mutation` type. These types are special because they define the entry points of every GraphQL query for the API covered by that schema. For our example above, we've defined exactly one query entry point, the parameterized query `employeeByID(id: String!)`. GraphQL APIs allow surprising flexibility in how queries can be constructed, which makes writing policies for them a bit more challenging than for a REST API, which usually has a more fixed structure. To protect a particular endpoint or field, we need to see if they are referenced in the incoming GraphQL query. By using `graphql.parse`, we can extract an (AST) from the incoming query, and then walk down the tree to its leaves to see if our endpoint is the target of the query. We can then use separate rules to enforce conditions around the `salary` field, and who is allowed to access it. The policy below does all of the above in parts: Obtains the query AST (and validates it against our schema with `graphql.parse`). Recursive traversal with `walk()` to obtain chunks of the AST with queries of interest present. Selection of nodes of interest by name and structure. Salary field selected. Every query of interest found has to pass one of the `allowed_query` rules, or the entire query is rejected. Constant/variable cases for both employees and their managers.

```live:example:module:openable package graphqlapi.authz import rego.v1 subordinates := {"alice": [], "charlie": [], "bob": ["alice"], "betty": ["charlie"]} query_ast := graphql.parse(input.query, input.schema)[0] # If validation fails, the rules depending on this will be undefined. default allow := false allow if { employeeByIDQueries != {} every query in employeeByIDQueries { allowed_query(query) } } allowed_query(q) if { selected_salary(q) varname := variable_arg(q, "id") input.variables[varname] in subordinates[input.user] # Do value lookup from the 'variables' object. } allowed_query(q) if { selected_salary(q) username := constantstringarg(q, "id") username in subordinates[input.user] } allowed_query(q) if { selected_salary(q) varname := variable_arg(q, "id") input.user == input.variables[varname] # Do value lookup from the 'variables' object. } allowed_query(q) if { selected_salary(q) username := constantstringarg(q, "id") input.user == username } employeeByIDQueries contains value if { some value walk(queryast, [, value]) value.Name == "employeeByID" count(value.SelectionSet) > 0 # Ensure we latch onto an employeeByID query. } constantstringarg(value, argname) := arg.Value.Raw if { some arg in value.Arguments arg.Name == argname arg.Value.Kind == 3 } variable_arg(value, argname) := arg.Value.Raw if { some arg in value.Arguments arg.Name == argname arg.Value.Kind == 0 } selectedsalary(value) := value.SelectionSet[].Name == "salary" ``` Then, build a bundle. ```shell mkdir bundles opa build example.rego mv bundle.tar.gz ./bundles ``` You should now see a policy bundle (`bundle.tar.gz`) in your working directory. Next, create a `docker-compose.yml` file that runs OPA, a bundle server and the demo GraphQL server. docker-compose.yml: ```yaml services: opa: image: openpolicyagent/opa:{{< currentdockerversion >}} ports: "8181:8181" command: "run" "--server" "--log-format=json-pretty" "--set=decision_logs.console=true" "--set=services.nginx.url=http://bundle_server" "--set=bundles.nginx.service=nginx" "--set=bundles.nginx.resource=bundle.tar.gz" "--set=bundles.nginx.polling.mindelayseconds=10" "--set=bundles.nginx.polling.maxdelayseconds=30" depends_on: bundle_server api_server: image: openpolicyagent/demo-graphql-api:0.1 ports: "6000:5000" environment: OPA_ADDR=http://opa:8181 POLICY_PATH=/v1/data/graphqlapi/authz depends_on: opa bundle_server: image: nginx:1.20.0-alpine ports: 8888:80 volumes: ./bundles/:/usr/share/nginx/html/ ``` Then run `docker-compose` to pull and run the containers. {{< info >}} If running "Docker Desktop" (Mac or Windows) you may instead use the `docker compose` command. {{< /info >}} ```shell docker-compose -f docker-compose.yml up ``` Every time the demo GraphQL server receives an HTTP request, it asks OPA to decide whether an GraphQL query is authorized or not using a single RESTful API call. An example codebase is , but the crux of the (JavaScript, Apollo framework) code is shown below. ```javascript // we assume user is passed in as part of the request context. var user = req.user; // we feed in the query and schema strings, as well as the variables object. var input = { input: { schema: schema, // GraphQL schema text. query: query, // GraphQL query text. user: user, variables: variables // GraphQL variable bindings. } }; await axios // ask OPA for a policy decision. // (in reality OPA URL would be constructed from environment) .post("http://127.0.0.1:8181/v1/data/graphqlapi/authz", input) .then(res => { // GraphQL query allowed. }) .catch(error => { // GraphQL query

}); ``` We'll define a quick shell function to make the following examples cleaner on the command line: ```shell gql-query() { curl --user "$1" -H "Content-Type: application/json" "$2" --data-ascii "$3" } ``` The following command will succeed. ```shell gql-query alice:password "localhost:6000/" '{"query":"query { employeeByID(id: \"alice\") { salary }}"}' ``` The GraphQL server queries OPA to authorize the request. In the query, the server includes JSON data describing the incoming request. ```live:example:input { "schema": "type Employee {\n id: ...", "query": "query { employeeByID(id: \"alice\") { salary }}", "user": "alice", "variables": {} } ``` When the GraphQL server queries OPA it asks for a specific policy decision. In this case, the integration is hardcoded to ask for `/v1/data/graphqlapi/authz`. OPA translates this URL path into a query: ```live:example:query data.graphqlapi.authz ``` The answer returned by OPA for the input above is: ```live:example:output ``` ```shell gql-query bob:password "localhost:6000/" '{"query":"query { employeeByID(id: \"alice\") { salary }}"}' ``` `bob` is not `charlie`'s manager, so the following command will fail. ```shell gql-query bob:password "localhost:6000/" '{"query":"query { employeeByID(id: \"charlie\") { salary }}"}' ``` Suppose the organization now includes an HR department. The organization wants members of HR to be able to see any salary. Let's extend the policy to handle this. example-hr.rego: ```live:hrexample:module:readonly,openable package graphqlapi.authz import rego.v1 allowed_query(q) if { selected_salary(q) input.user == hr[_] } hr := ["david"] ``` Build a new bundle with the new policy included. ```shell opa build example.rego example-hr.rego mv bundle.tar.gz ./bundles ``` The updated bundle will automatically be served by the bundle server, but note that it might take up to the configured `maxdelayseconds` for the new bundle to be downloaded by OPA. If you plan to make frequent policy changes you might want to adjust this value in `docker-compose.yml` accordingly. For the sake of the tutorial we included `manager_of` and `hr` data directly inside the policies. In real-world scenarios that information would be imported from external data sources. Check that `david` can see anyone's salary. ```shell gql-query david:password "localhost:6000/" '{"query":"query { employeeByID(id: \"alice\") { salary }}"}' gql-query david:password "localhost:6000/" '{"query":"query { employeeByID(id: \"bob\") { salary }}"}' gql-query david:password "localhost:6000/" '{"query":"query { employeeByID(id: \"charlie\") { salary }}"}' gql-query david:password "localhost:6000/" '{"query":"query { employeeByID(id: \"david\") { salary }}"}' ``` OPA supports the parsing of JSON Web Tokens via the builtin function `io.jwt.decode`. To get a sense of one way the subordinate and HR data might be communicated in the real world, let's try a similar exercise utilizing the JWT utilities of OPA. example-jwt.rego: ```live:jwt_example:module:hidden package graphqlapi.authz import rego.v1 query_ast := graphql.parse(input.query, input.schema)[0] # If validation fails, the rules depending on this will be undefined. allowed_query(q) if { selected_salary(q) varname := variable_arg(q, "id") input.variables[varname] in token.payload.subordinates # Do value lookup from the 'variables' object. } allowed_query(q) if { selected_salary(q) username := constantstringarg(q, "id") username in token.payload.subordinates } allowed_query(q) if { selected_salary(q) varname := variable_arg(q, "id") token.payload.user == input.variables[varname] # Do value lookup from the 'variables'

} allowed_query(q) if { selected_salary(q) username := constantstringarg(q, "id") token.payload.user == username } allowed_query(q) if { selected_salary(q) token.payload.hr == true } employeeByIDQueries contains value if { some value walk(queryast, [, value]) value.Name == "employeeByID" count(value.SelectionSet) > 0 # Ensure we latch onto an employeeByID query. } constantstringarg(value, argname) := arg.Value.Raw if { some arg in value.Arguments arg.Name == argname arg.Value.Kind == 3 } variable_arg(value, argname) := arg.Value.Raw if { some arg in value.Arguments arg.Name == argname arg.Value.Kind == 0 } selectedsalary(value) := value.SelectionSet[].Name == "salary" ``` ```live:jwtexample/newrules:module:openable default allow := false allow if { employeeByIDQueries != {} userownstoken # Ensure we validate the JWT token. every query in employeeByIDQueries { allowed_query(query) } } userownstoken if input.user == token.payload.azp token := {"payload": payload} if { [, payload, ] := io.jwt.decode(input.token) } ``` ```live:jwt_example:input:hidden { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiYWxpY2UiLCJhenAiOiJhbGljZSIsInN1Ym9yZGluYXRlcyI6W10sImhyIjpmYWxzZX0.rz3jTY033z-NrKfwrK89_dcLF7TN4gwCMj-fVBDyLoM", "schema": "type Employee {\n id: ...", "query": "query { employeeByID(id: \"alice\") { salary }}", "user": "alice", "variables": {} } ``` Build a new bundle for the new policy. ```shell opa build example-jwt.rego example-hr.rego mv bundle.tar.gz ./bundles ``` For convenience, we'll want to store user tokens in environment variables (they're really long). ```shell export ALICETOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiYWxpY2UiLCJhenAiOiJhbGljZSIsInN1Ym9yZGluYXRlcyI6W10sImhyIjpmYWxzZX0.rz3jTY033z-NrKfwrK89dcLF7TN4gwCMj-fVBDyLoM" export BOBTOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiYm9iIiwiYXpwIjoiYm9iIiwic3Vib3JkaW5hdGVzIjpbImFsaWNlIl0sImhyIjpmYWxzZX0.nlXN4H8UXGA_fXTbgWRx8b40GXpAGQHWluiYVI9qf0" export CHARLIETOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiY2hhcmxpZSIsImF6cCI6ImNoYXJsaWUiLCJzdWJvcmRpbmF0ZXMiOltdLCJociI6ZmFsc2V9.EZdy_RHUnrCRMuauY7y5a1yiwdUHKRjm9xhVtjNALo" export BETTY_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiYmV0dHkiLCJhenAiOiJiZXR0eSIsInN1Ym9yZGluYXRlcyI6WyJjaGFybGllIl0sImhyIjpmYWxzZX0.TGCS6pTzjrs3nmALSOS7yiLO9Bh9fxzDXEDiq1LIYtE" export DAVID_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiZGF2aWQiLCJhenAiOiJkYXZpZCIsInN1Ym9yZGluYXRlcyI6W10sImhyIjp0cnVlfQ.Q6EiWzU1wx1g6sdWQ1r4bxT1JgSHUpVXpINMqMaUDMU" ``` These tokens encode the same information as the policies we did before (`bob` is `alice`'s manager, `betty` is `charlie`'s, `david` is the only HR member, etc). If you want to inspect their contents, start up the OPA REPL and execute `io.jwt.decode(<token here>, [header, payload, signature])` or open the example above in the Playground. Let's try a few queries (note: you may need to escape the `?` characters in the queries for your shell): Check that `charlie` can't see `bob`'s salary. ```shell gql-query charlie:password "localhost:5000/?token=$CHARLIE_TOKEN" '{"query":"query { employeeByID(id: \"bob\") { salary }}"}' ``` Check that `charlie` can't pretend to be `bob` to see `alice`'s salary. ```shell gql-query charlie:password "localhost:5000/?token=$BOB_TOKEN" '{"query":"query { employeeByID(id: \"alice\") { salary }}"}' ``` Check that `david` can see `betty`'s salary. ```shell gql-query david:password "localhost:5000/?token=$DAVID_TOKEN" '{"query":"query { employeeByID(id: \"betty\") { salary }}"}' ``` Check that `bob` can see `alice`'s salary. ```shell gql-query bob:password "localhost:5000/?token=$BOB_TOKEN" '{"query":"query { employeeByID(id: \"alice\") { salary }}"}' ``` Check that `alice` can see her own salary. ```shell gql-query alice:password "localhost:5000/?token=$ALICE_TOKEN" '{"query":"query { employeeByID(id: \"alice\") { salary }}"}' ``` Congratulations for finishing the tutorial! You learned a number of things about API authorization with OPA: OPA gives you fine-grained policy control over GraphQL APIs once you set up the server to ask OPA for authorization. You write allow/deny policies to control which endpoints and fields can be accessed by whom. You can import external data into OPA and write policies that depend on that data. You can use OPA data structures to define abstractions over your data. You can use a remote bundle server for distributing policy and data. The code for this tutorial can be found in the repository.