Skip to content

Commit

Permalink
Merge pull request #125 from Bugs5382/filtering-output
Browse files Browse the repository at this point in the history
  • Loading branch information
jonnydgreen authored Nov 18, 2023
2 parents 8dd8ce5 + 35cb45f commit b02fd01
Show file tree
Hide file tree
Showing 13 changed files with 946 additions and 9 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
node-version: [14.x, 16.x, 18.x]
node-version: [18.x, 20.x]
os: [ubuntu-latest, windows-latest, macOS-latest]
steps:
- uses: actions/checkout@v3
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Features:
- [Apply Policy](docs/apply-policy.md)
- [Auth Directive](docs/auth-directive.md)
- [Schema filtering](docs/schema-filtering.md)
- [Schema replacement](docs/schema-replacement.md)
- [External Policy](docs/external-policy.md)
- [Errors](docs/errors.md)
- [Federation](docs/federation.md)
Expand Down
145 changes: 145 additions & 0 deletions docs/schema-replacement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# Schema Replacement

The auth directive can be used as an identifier
to protect a GraphQL String Scalar field within the GraphQL schema
to do a replacement of the output if the policy allows it.

This is designed to potentially obfuscate your database output to the end user without having to do this on the frontend. Useful to prevent people who may or may not have a role or matching the required "profile" to not see what is being sent from the server.

Example could be to:
* Social Security Numbers
* HIPPA
* PCI Information

It will take the string and replace it with something that you designate. It doesn't change the database backend, which is helpful during obfuscation.

```js
const messages = [
{
title: 'one',
message: 'acme one',
notes: 'acme one',
password: 'acme-one'
},
{
title: 'two',
message: 'acme two',
notes: '123-45-6789',
password: 'acme-two'
}
]
```
A sample message object.

```js
'use strict'

const Fastify = require('fastify')
const mercurius = require('mercurius')
const mercuriusAuth = require('mercurius-auth')

const app = Fastify()

const schema = `
directive @filterData (disallow: String!) on FIELD_DEFINITION
type Message {
message: String!
notes: String @filterData (disallow: "no-read-notes")
}
type Query {
publicMessages: [Message!]
}
`

const resolvers = {
Query: {
publicMessages: async (parent, args, context, info) => {
return messages
}
}
}

app.register(mercurius, {
schema,
resolvers
})

app.register(mercuriusAuth, {
authContext: function hasPermissionContext (context) {
const headerValue = context.reply.request.headers['x-permission']
return { permission: headerValue ? headerValue.split(',') : [] }
},
applyPolicy: async function hasFilterPolicy (authDirectiveAST, parent, args, context, info) {
const notNeeded = authDirectiveAST.arguments.find(arg => arg.name.value === 'disallow').value.value
return !context.auth.permission.includes(notNeeded)
},
outputPolicyErrors: {
enabled: false
},
authDirective: 'filterData'
})

app.listen({ port: 3000 })
```
The `applyPolicy` must return `false` in order to do the replacement. Returning `true` will output as normal.

This by default will make `notes` equal `null` when sent back to the client and not error back as long as your schema accepts `null` values, otherwise trigger and error.

### String Return

Within the `app.register` block, you can need to efficacy a function or a common string that will do your replacement. No matter what, the return ***must*** be a string.

```js
app.register(mercuriusAuth, {
authContext: function hasPermissionContext (context) {
const headerValue = context.reply.request.headers['x-permission']
return { permission: headerValue ? headerValue.split(',') : [] }
},
applyPolicy: async function hasFilterPolicy (authDirectiveAST, parent, args, context, info) {
const notNeeded = authDirectiveAST.arguments.find(arg => arg.name.value === 'disallow').value.value
return !context.auth.permission.includes(notNeeded)
},
outputPolicyErrors: {
enabled: false,
valueOverride: 'foo'
},
authDirective: 'filterData'
})
```

This will replace the `notes` schema, no matter what it is from the database, with the string `foo`.

_or_

```js
app.register(mercuriusAuth, {
authContext: function hasPermissionContext (context) {
const headerValue = context.reply.request.headers['x-permission']
return { permission: headerValue ? headerValue.split(',') : [] }
},
applyPolicy: async function hasFilterPolicy (authDirectiveAST, parent, args, context, info) {
const notNeeded = authDirectiveAST.arguments.find(arg => arg.name.value === 'disallow').value.value
return !context.auth.permission.includes(notNeeded)
},
outputPolicyErrors: {
enabled: false,
valueOverride: (value) => {
// replace the first 7 numbers of your social security number with *
return value.replace(/\d(?=.{5,})/g, "*");
}
},
authDirective: 'filterData'
})
```

This will replace the `notes` schema if it matches the regex for removing the first seven (7) characters with asterisks. Since this would not apply to `notes: 'acme one'` this would come over as it is.

## Invalid Scalar Types

You can only do the replacement on String Scalar types.

Please note that you create a custom Scalar type, however,
it still must be registered as a String otherwise it will fail.

67 changes: 67 additions & 0 deletions examples/result-filter-replace.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
'use strict'

const Fastify = require('fastify')
const mercurius = require('mercurius')
const mercuriusAuth = require('..')

const app = Fastify()

const schema = `
directive @filterData (disallow: String!) on FIELD_DEFINITION
type Message {
message: String!
notes: String @filterData (disallow: "no-read-notes")
}
type Query {
publicMessages: [Message!]
}
`

const messages = [
{
title: 'one',
message: 'acme one',
notes: 'acme one',
password: 'acme-one'
},
{
title: 'two',
message: 'acme two',
notes: 'acme two',
password: 'acme-two'
}
]

const resolvers = {
Query: {
publicMessages: async (parent, args, context, info) => {
return messages
}
}
}

app.register(mercurius, {
schema,
resolvers
})

app.register(mercuriusAuth, {
authContext (context) {
const headerValue = context.reply.request.headers['x-permission']
return { permission: headerValue ? headerValue.split(',') : [] }
},
async applyPolicy (authDirectiveAST, parent, args, context, info) {
const notNeeded = authDirectiveAST.arguments.find(arg => arg.name.value === 'disallow').value.value

return !context.auth.permission.includes(notNeeded)
},
outputPolicyErrors: {
enabled: false
},
filterSchema: true,
authDirective: 'filterData'
})

app.listen({ port: 3000 })
9 changes: 9 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ export interface MercuriusAuthDirectiveOptions<TParent=any, TArgs=any, TContext=
* When set to true, the plugin will automatically filter the output Schema during Introspection queries if the applyPolicy function is not satisfated.
*/
filterSchema?: boolean;
/**
* Output Policy Errors
*/
outputPolicyErrors?: {
/**
* If this is set any 'string' type would be replaced with the returned string value either static or from a function.
*/
valueOverride?: ((input: string) => string) | string;
}
}

export interface MercuriusAuthExternalPolicyOptions<TParent=any, TArgs=any, TContext=MercuriusContext, TPolicy=any> extends MercuriusAuthBaseOptions<TParent, TArgs, TContext, TPolicy> {
Expand Down
4 changes: 2 additions & 2 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@ const plugin = fp(
const authSchema = auth.getPolicy(app.graphql.schema)

// Wrap resolvers with auth handlers
auth.registerAuthHandlers(app.graphql.schema, authSchema)
auth.registerAuthHandlers(app.graphql.schema, authSchema, opts.outputPolicyErrors)

// Add hook to regenerate the resolvers when the schema is refreshed
if (app.graphqlGateway) {
app.graphqlGateway.addHook('onGatewayReplaceSchema', async (instance, schema) => {
const authSchema = auth.getPolicy(schema)
auth.registerAuthHandlers(schema, authSchema)
auth.registerAuthHandlers(schema, authSchema, opts.outputPolicyErrors)
if (opts.filterSchema === true) {
filterSchema.updatePolicy(app, authSchema, opts)
}
Expand Down
52 changes: 48 additions & 4 deletions lib/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@ const {
kAuthDirective,
kGetAuthDirectiveAST,
kMakeProtectedResolver,
kMakeProtectedResolverReplace,
kMode,
kPolicy,
kBuildPolicy,
kSetTypePolicy,
kSetFieldPolicy,
kWrapFieldResolver
kWrapFieldResolver,
kWrapFieldResolverReplace
} = require('./symbols')
const { MER_AUTH_ERR_FAILED_POLICY_CHECK } = require('./errors')
const { MER_AUTH_ERR_FAILED_POLICY_CHECK, MER_AUTH_ERR_USAGE_ERROR } = require('./errors')

class Auth {
constructor ({ applyPolicy, authContext, authDirective, mode, policy }) {
Expand Down Expand Up @@ -48,6 +50,27 @@ class Auth {
}
}

[kMakeProtectedResolverReplace] (policy, resolverFn, valueOverride) {
return async (parent, args, context, info) => {
// Adding support for returned errors to match graphql-js resolver handling
const result = await this[kApplyPolicy](policy, parent, args, context, info)
if (result instanceof Error) {
throw result
}
if (!result) {
const replacementValue = typeof valueOverride === 'function' ? valueOverride(parent[info.fieldName]) : valueOverride
if (typeof replacementValue !== 'string' && replacementValue !== null) {
throw new MER_AUTH_ERR_FAILED_POLICY_CHECK('Replacement must be a valid string.')
}
parent = {
...parent,
[info.fieldName]: replacementValue
}
}
return resolverFn(parent, args, context, info)
}
}

[kSetTypePolicy] (policy, typeName, typePolicy) {
// This is never going to be defined because it is always the first check for a type
policy[typeName] = { __typePolicy: typePolicy }
Expand Down Expand Up @@ -77,6 +100,16 @@ class Auth {
}
}

[kWrapFieldResolverReplace] (schemaTypeField, fieldPolicy, fieldReplace) {
// Overwrite field resolver
const fieldName = schemaTypeField.name
if (typeof schemaTypeField.resolve === 'function') {
throw new MER_AUTH_ERR_USAGE_ERROR('Replacement can not happen on a resolver. Only a field.')
} else {
schemaTypeField.resolve = this[kMakeProtectedResolverReplace](fieldPolicy, (parent) => parent[fieldName], fieldReplace)
}
}

[kBuildPolicy] (graphQLSchema) {
const policy = {}
const schemaTypeMap = graphQLSchema.getTypeMap()
Expand Down Expand Up @@ -110,7 +143,7 @@ class Auth {
return this[kBuildPolicy](graphQLSchema)
}

registerAuthHandlers (graphQLSchema, policy) {
registerAuthHandlers (graphQLSchema, policy, opts) {
for (const [typeName, typePolicy] of Object.entries(policy)) {
const schemaType = graphQLSchema.getType(typeName)
if (typeof schemaType !== 'undefined' && typeof schemaType.getFields === 'function') {
Expand All @@ -135,7 +168,18 @@ class Auth {
} else {
const schemaTypeField = schemaType.getFields()[fieldName]
if (typeof schemaTypeField !== 'undefined') {
this[kWrapFieldResolver](schemaTypeField, fieldPolicy)
if (typeof opts !== 'undefined' && !opts.enabled) {
const { valueOverride = null } = opts
// Need to open a PR for GraphQL to check for a string type.
// It does not exist which is very odd.
// -- Shane
if ((schemaTypeField.type.name !== 'String' && schemaTypeField.type.ofType.name !== 'String') && valueOverride !== null) {
throw new MER_AUTH_ERR_FAILED_POLICY_CHECK('You can not do a replacement on a GraphQL scalar type that is not a String')
}
this[kWrapFieldResolverReplace](schemaTypeField, fieldPolicy, valueOverride)
} else {
this[kWrapFieldResolver](schemaTypeField, fieldPolicy)
}
}
}
}
Expand Down
7 changes: 7 additions & 0 deletions lib/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ const errors = {
MER_AUTH_ERR_FAILED_POLICY_CHECK: createError(
'MER_AUTH_ERR_FAILED_POLICY_CHECK',
'Failed auth policy check on %s'
),
/**
* Usage errors
*/
MER_AUTH_ERR_USAGE_ERROR: createError(
'MER_AUTH_ERR_USAGE_ERROR',
'Usage error: %s'
)
}

Expand Down
2 changes: 1 addition & 1 deletion lib/prune-schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const {
* - Otherwise, leave field unchanged
*/
function filterField (filterDirectiveMap, type, field) {
// If field is explicitly not allowed by a policy, omit
// If a policy explicitly not allows field, omit
if (filterDirectiveMap[type.name] && filterDirectiveMap[type.name][field.name] === false) {
return false
}
Expand Down
4 changes: 3 additions & 1 deletion lib/symbols.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ module.exports = {
kAuthDirective: Symbol('auth directive'),
kGetAuthDirectiveAST: Symbol('get auth directive ast'),
kMakeProtectedResolver: Symbol('make protected resolver'),
kMakeProtectedResolverReplace: Symbol('make protected resolver replace'),
kBuildPolicy: Symbol('build policy'),
kSetTypePolicy: Symbol('set type policy'),
kSetFieldPolicy: Symbol('set field policy'),
kWrapFieldResolver: Symbol('wrap field resolver')
kWrapFieldResolver: Symbol('wrap field resolver'),
kWrapFieldResolverReplace: Symbol('wrap field resolver replace')
}
Loading

0 comments on commit b02fd01

Please sign in to comment.