diff --git a/protection/jwt-claims-dbquery/README.md b/protection/jwt-claims-dbquery/README.md new file mode 100644 index 0000000..0b11a95 --- /dev/null +++ b/protection/jwt-claims-dbquery/README.md @@ -0,0 +1,37 @@ +# Pulling field arguments from JWT claims + +Uses a SQL predicate to limit customer rows returned from a database +to those matching the regions defined in a JWT claim. + +# Try it Out + +Run the [sample operations](operations.graphql): + +JWT with `regions: IN`. + +``` +stepzen request -f operations.graphql --operation-name=Customers \ + --header "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1IiwicmVnaW9ucyI6WyJJTiJdfQ.hDi3-qaIOSFKzlFvaXwSh0trXC3vjiOehSKE0OxgOdE" +``` + +JWT with `regions: IN, UK`. + +``` +stepzen request -f operations.graphql --operation-name=Customers \ + --header "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1IiwicmVnaW9ucyI6WyJJTiIsIlVLIl19.CRD85IIMMwjaFebtQ_p3AjSoUM6KtH4gvjcfLQfdmjw" +``` + +JWT with `regions: US, UK`. + +``` +stepzen request -f operations.graphql --operation-name=Customers \ + --header "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1IiwicmVnaW9ucyI6WyJVUyIsIlVLIl19.pf0-A6TN_hT-ldCvsZyqYGv4Twjm9s6wO1aatCjK9Aw" +``` + +JWT with `regions: US, UK` and user supplied filter + +``` +stepzen request -f operations.graphql --operation-name=Customers \ + --var f='{"city": {"eq":"London"}}' \ + --header "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1IiwicmVnaW9ucyI6WyJVUyIsIlVLIl19.pf0-A6TN_hT-ldCvsZyqYGv4Twjm9s6wO1aatCjK9Aw" +``` diff --git a/protection/jwt-claims-dbquery/config.yaml b/protection/jwt-claims-dbquery/config.yaml new file mode 100644 index 0000000..0d28abe --- /dev/null +++ b/protection/jwt-claims-dbquery/config.yaml @@ -0,0 +1,21 @@ +deployment: + identity: + keys: + - algorithm: HS256 + key: development-only +access: + policies: + - type: Query + policyDefault: + condition: false + rules: + - name: "jwt-control" + fields: [customers] + condition: "?$jwt" + - name: "introspection" + fields: [__schema, __type] + condition: true +configurationset: + - configuration: + name: postgresql_config + uri: postgresql://postgresql.introspection.stepzen.net/introspection?user=testUserIntrospection&password=HurricaneStartingSample1934 diff --git a/protection/jwt-claims-dbquery/index.graphql b/protection/jwt-claims-dbquery/index.graphql new file mode 100644 index 0000000..538bc52 --- /dev/null +++ b/protection/jwt-claims-dbquery/index.graphql @@ -0,0 +1,14 @@ +schema + @sdl( + files: ["paging.graphql"] + # visibilty controls how fields included through files in this directive + # are visible outside the scope of this directive to GraphQL introspection + # and field references through @materializer etc. + # + # types and fields are regular expressions that match type and field names. + # Like field access rules if aat least one visibility pattern is present then by default + # root operation type (Query, Mutation, Subscription) fields are not exposed. + visibility: [{ expose: true, types: "Query", fields: ".*" }] + ) { + query: Query +} diff --git a/protection/jwt-claims-dbquery/operations.graphql b/protection/jwt-claims-dbquery/operations.graphql new file mode 100644 index 0000000..184f063 --- /dev/null +++ b/protection/jwt-claims-dbquery/operations.graphql @@ -0,0 +1,8 @@ +query Customers($f: CustomerFilter) { + customers(filter: $f) { + id + name + city + region + } +} diff --git a/protection/jwt-claims-dbquery/paging.graphql b/protection/jwt-claims-dbquery/paging.graphql new file mode 100644 index 0000000..2246a3c --- /dev/null +++ b/protection/jwt-claims-dbquery/paging.graphql @@ -0,0 +1,101 @@ +type Customer { + id: ID! + name: String + email: String + street: String + city: String + region: String +} + +""" +`CustomerConnection` is the connection type for `Customer` pagination. +""" +type CustomerConnection { + edges: [CustomerEdge] + pageInfo: PageInfo! +} + +""" +`CustomerEdge` provides access to the node and its cursor. +""" +type CustomerEdge { + node: Customer + cursor: String +} + +input StringFilter { + eq: String + ne: String +} + +input CustomerFilter { + name: StringFilter + email: StringFilter + city: StringFilter +} + +type _RegionsList { + regions: [String]! +} + +extend type Query { + # customers is the exposed field that limits the caller to regions + # based upon the regions claim in the request's JWT. + customers(first: Int! = 10, filter: CustomerFilter): [Customer] + @sequence( + steps: [ + { query: "_regions" } + { + query: "_customers_flatten" + arguments: [ + { name: "first", argument: "first" } + { name: "filter", argument: "filter" } + ] + } + ] + ) + + # extracts the regions visible to the request from the JWT. + _regions: _RegionsList + @value( + script: { + src: """ + {"regions": `$jwt`.regions } + """ + language: JSONATA + } + ) + + # this flattens the customer connection pagination structure + # into a simple list of Customer objects. + # This is needed as @sequence is not supported for connection types. + _customers_flatten( + first: Int! = 10 + filter: CustomerFilter + regions: [String]! + ): [Customer] @materializer(query: "_customers { edges { node }}") + + # Standard paginated field for a customers table in a database. + # Additional regions argument that is used to limit customer + # visibility based upon the 'regions' claim in a JWT. + # The regions allows a list of regions and uses SQL ANY to match rows. + _customers( + first: Int! = 10 + after: String! = "" + filter: CustomerFilter + regions: [String]! + ): CustomerConnection + @dbquery( + type: "postgresql" + schema: "public" + query: """ + SELECT C.id, C.name, C.email, A.street, A.city, A.countryregion AS region + FROM customer C, address A, customeraddress CA + WHERE + CA.customerid = C.id AND + CA.addressid = A.id AND + A.countryregion = ANY(CAST($1 AS VARCHAR ARRAY)) + """ + configuration: "postgresql_config" + ) +} diff --git a/protection/jwt-claims-dbquery/stepzen.config.json b/protection/jwt-claims-dbquery/stepzen.config.json new file mode 100644 index 0000000..af1c0ea --- /dev/null +++ b/protection/jwt-claims-dbquery/stepzen.config.json @@ -0,0 +1,3 @@ +{ + "endpoint": "api/miscellaneous" +} diff --git a/protection/jwt-claims-dbquery/tests/Test.js b/protection/jwt-claims-dbquery/tests/Test.js new file mode 100644 index 0000000..7bcf02b --- /dev/null +++ b/protection/jwt-claims-dbquery/tests/Test.js @@ -0,0 +1,171 @@ +const fs = require("fs"); +const path = require("node:path"); +const { + deployAndRun, + runtests, + GQLHeaders, + endpoint, + getTestDescription, +} = require("../../../tests/gqltest.js"); + +testDescription = getTestDescription("snippets", __dirname); + +const requestsFile = path.join(path.dirname(__dirname), "operations.graphql"); +const requests = fs.readFileSync(requestsFile, "utf8").toString(); + +describe(testDescription, function () { + // just deploy + deployAndRun(__dirname, [], undefined); + + // and then run with various JWTs + runtests( + "regions-in", + endpoint, + new GQLHeaders().withToken( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1IiwicmVnaW9ucyI6WyJJTiJdfQ.hDi3-qaIOSFKzlFvaXwSh0trXC3vjiOehSKE0OxgOdE" + ), + [ + { + label: "customers", + query: requests, + operationName: "Customers", + expected: { + customers: [ + { + id: "10", + name: "Salma Khan ", + city: "Delhi ", + region: "IN ", + }, + ], + }, + }, + ] + ); + runtests( + "regions-in-uk", + endpoint, + new GQLHeaders().withToken( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1IiwicmVnaW9ucyI6WyJJTiIsIlVLIl19.CRD85IIMMwjaFebtQ_p3AjSoUM6KtH4gvjcfLQfdmjw" + ), + [ + { + label: "customers", + query: requests, + operationName: "Customers", + expected: { + customers: [ + { + id: "3", + name: "Salim Ali ", + city: "London ", + region: "UK ", + }, + { + id: "4", + name: "Jane Xiu ", + city: "Edinburgh ", + region: "UK ", + }, + { + id: "10", + name: "Salma Khan ", + city: "Delhi ", + region: "IN ", + }, + ], + }, + }, + ] + ); + runtests( + "regions-us-uk", + endpoint, + new GQLHeaders().withToken( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1IiwicmVnaW9ucyI6WyJVUyIsIlVLIl19.pf0-A6TN_hT-ldCvsZyqYGv4Twjm9s6wO1aatCjK9Aw" + ), + [ + { + label: "customers", + query: requests, + operationName: "Customers", + expected: { + customers: [ + { + id: "1", + name: "Lucas Bill ", + city: "Boston ", + region: "US ", + }, + { + id: "2", + name: "Mandy Jones ", + city: "Round Rock ", + region: "US ", + }, + { + id: "3", + name: "Salim Ali ", + city: "London ", + region: "UK ", + }, + { + id: "4", + name: "Jane Xiu ", + city: "Edinburgh ", + region: "UK ", + }, + { + id: "5", + name: "John Doe ", + city: "Miami ", + region: "US ", + }, + { + id: "6", + name: "Jane Smith ", + city: "San Francisco ", + region: "US ", + }, + { + id: "7", + name: "Sandeep Bhushan ", + city: "New York ", + region: "US ", + }, + { + id: "8", + name: "George Han ", + city: "Seattle ", + region: "US ", + }, + { + id: "9", + name: "Asha Kumari ", + city: "Chicago ", + region: "US ", + }, + ], + }, + }, + { + label: "customers-filter", + query: requests, + operationName: "Customers", + variables: { + f: { city: { eq: "London" } }, + }, + expected: { + customers: [ + { + id: "3", + name: "Salim Ali ", + city: "London ", + region: "UK ", + }, + ], + }, + }, + ] + ); +}); diff --git a/tests/gqltest.js b/tests/gqltest.js index a9f5b98..0784cad 100644 --- a/tests/gqltest.js +++ b/tests/gqltest.js @@ -5,6 +5,7 @@ const path = require("node:path"); const { runtests, + GQLHeaders, } = require('gqltest/packages/gqltest/gqltest.js'); const stepzen = require("gqltest/packages/gqltest/stepzen.js"); @@ -59,5 +60,8 @@ function getTestDescription(testRoot, fullDirName) { exports.deployAndRun = deployAndRun; exports.getTestDescription = getTestDescription; +exports.endpoint = endpoint; +exports.GQLHeaders = GQLHeaders; +exports.runtests = runtests; exports.stepzen = stepzen;