Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add schema rules support using nexus-shield #8

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
291 changes: 272 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,40 +1,58 @@
# nexus-plugin-shield

Unlock the power of [graphql-shield](https://github.com/maticzav/graphql-shield) in your nexus app
Nexus plugin Shield helps you create a permission layer for your nexus application.

If your project only rely on `@nexus/schema`, you should directly use [nexus-shield](https://github.com/Sytten/nexus-shield)

## Table of contents

- [Installation](#installation)
- [Setup](#setup)
- [Plugin options](#shieldrules-rules-options-options)
- [Rules](#rules)
- [Definition](#definition)
- [Operators](#operators)
- [caching](#caching)
- [Schema](#schema)
- [Shield Parameter](#shield-parameter)
- [Type safety](#type-safety)
- [Generic rules](#generic-rules)

## Installation

```
npm install nexus-plugin-shield
```

## Known limitations

- fragments not supported

## Example Usage

### Setup

```typescript
// app.ts

import { use } from 'nexus'
import { shield, rule, deny, not, and, or } from 'nexus-plugin-shield'
import {
shield,
ShieldCache,
rule,
deny,
not,
and,
or,
} from 'nexus-plugin-shield'

const isAuthenticated = rule({ cache: 'contextual' })(
const isAuthenticated = rule({ cache: ShieldCache.CONTEXTUAL })(
async (parent, args, ctx: NexusContext, info) => {
return ctx.user !== null
}
)

const isAdmin = rule({ cache: 'contextual' })(
const isAdmin = rule({ cache: ShieldCache.CONTEXTUAL })(
async (parent, args, ctx: NexusContext, info) => {
return ctx.user.role === 'admin'
}
)

const isEditor = rule({ cache: 'contextual' })(
const isEditor = rule({ cache: ShieldCache.CONTEXTUAL })(
async (parent, args, ctx: NexusContext, info) => {
return ctx.user.role === 'editor'
}
Expand All @@ -54,7 +72,7 @@ const permissions = shield({
Customer: isAdmin,
},
options: {
fallbackRule: deny,
defaultRule: deny,
},
})

Expand All @@ -66,19 +84,254 @@ use(permissions)
#### `rules`

A rule map must match your schema definition.

[graphql-shield documentation](https://github.com/maticzav/graphql-shield#shieldrules-options)
You can define global, per Type, or per Field rules.

#### `options`

[graphql-shield documentation](https://github.com/maticzav/graphql-shield#options)
| Property | Required | Default | Description |
| ------------------- | -------- | ---------------------------------------------------- | ------------------------------------------------------- |
| allowExternalErrors | false | false | Toggle catching internal errors. |
| debug | false | false | Toggle debug mode. |
| defaultRule | false | allow | Rule that is used if none is specified for a field |
| defaultError | false | Error('Not Authorised!') | Error Permission system fallbacks to. |
| hashFunction | false | [object-hash](https://github.com/puleos/object-hash) | Function used to hash the input to provide caching keys |

### Per Type default Rule

There is an option to specify a rule that will be applied to all fields of a type (`Query`, `Mutation`, ...) that do not specify a rule.
It is similar to the `options.defaultRule` but allows you to specify a `defaultRule` per type.

```ts
const permissions = shield({
Query: {
"*": deny
query1: allow,
query2: allow,
},
Mutation: {
"*": deny
},
}, {
fallbackRule: allow
})
```

## Rules

### Definition

Two interfaces styles are provided for convenience: `Graphql-Shield` and `Nexus`.

#### Graphql-Shield

```typescript
rule()((root, args, ctx) => {
return !!ctx.user
})
```

#### Nexus

```typescript
ruleType({
resolve: (root, args, ctx) => {
return !!ctx.user
},
})
```

#### Error

- A rule needs to return a `boolean`, a `Promise<boolean>` or throw an `Error`.
- If `false` is returned, the configured `defaultError` will be thrown by the plugin.

```typescript
const isAuthenticated = ruleType({
resolve: (root, args, ctx) => {
const allowed = !!ctx.user
if (!allowed) throw new Error('Bearer token required')
return allowed
},
})
```

### Caching

- The result of a rule can be cached to maximize performances. This is important when using generic or partial rules that require access to external data.
- The caching is **always** scoped to the request

The plugin offers 3 levels of caching:

- `NO_CACHE`: No caching is done (default)
- `CONTEXTUAL`: Use when the rule only depends on the `ctx`
- `STRICT`: Use when the rule depends on the `root` or `args`

Usage:

```typescript
rule({ cache: ShieldCache.STRICT })((root, args, ctx) => {
return true
})

ruleType({
cache: ShieldCache.STRICT,
resolve: (root, args, ctx) => {
return !!ctx.user
},
})
```

### Operators

Rules can be combined in a very flexible manner. The plugin provides the following operators:

### Context type
- `and`: Returns `true` if **all** rules return `true`
- `or`: Returns `true` if **one** rule returns `true`
- `not`: Inverts the result of a rule
- `chain`: Same as `and`, but rules are executed in order
- `race`: Same as `or`, but rules are executed in order
- `deny`: Returns `false`
- `allow`: Returns `true`

Simple example:

```typescript
import { chain, not, ruleType } from 'nexus-shield'

const hasScope = (scope: string) => {
return ruleType({
resolve: (root, args, ctx) => {
return ctx.user.permissions.includes(scope)
},
})
}

const backlist = ruleType({
resolve: (root, args, ctx) => {
return ctx.user.token === 'some-token'
},
})

const viewerIsAuthorized = chain(
isAuthenticated,
not(backlist),
hasScope('products:read')
)
```

## Schema

### Shield parameter

Rules can also be be applyed at schema level.
To use a rule, it must be assigned to the `shield` parameter of a field:

```typescript
export const Product = objectType({
name: 'Product',
definition(t) {
t.id('id')
t.string('prop', {
shield: ruleType({
resolve: (root, args, ctx) => {
return !!ctx.user
},
}),
})
},
})
```

### Type safety

This plugin will try its best to provide typing to the rules.

- It is **preferable** to define rules directly in the `definition` to have access to the full typing of `root` and `args`.
- The `ctx` is always typed if it was properly configured in nexus `makeSchema`.
- If creating generic or partial rules, use the appropriate helpers (see below).

```typescript
export type Context = {
user?: { id: string }
}

export const Product = objectType({
name: 'Product',
definition(t) {
t.id('id')
t.string('ownerId')
t.string('prop', {
args: {
filter: stringArg({ nullable: false }),
},
shield: ruleType({
resolve: (root, args, ctx) => {
// root => { id: string }, args => { filter: string }, ctx => Context
return true
},
}),
})
},
})
```

#### Generic rules

- Generic rules are rules that do not depend on the type of the `root` or `args`.
- The wrapper `generic` is provided for this purpose. It will wrap your rule in a generic function.

```typescript
const isAuthenticated = generic(
ruleType({
resolve: (root, args, ctx) => {
// Only ctx is typed
return !!ctx.user
},
})
)

// Usage
t.string('prop', {
shield: isAuthenticated(),
})
```

#### Partial rules

- Generic rules are rules that depend only on the type of the `root`.
- The wrapper `partial` is provided for this purpose. It will wrap your rule in a generic function.

```typescript
const viewerIsOwner = partial(
ruleType({
type: 'Product' // It is also possible to use the generic parameter of `partial`
resolve: (root, args, ctx) => {
// Both root and ctx are typed
return root.ownerId === ctx.user.id;
},
})
);

// Usage
t.string('prop', {
shield: viewerIsOwner(),
});
```

#### Combining rules

If you mix and match generic rules with partial rules, you will need to specify the type in the parent helper.

```typescript
const viewerIsAuthorized = partial<'Product'>(
chain(isAuthenticated(), viewerIsOwner())
)
```

Nexus provide a global `NexusContext` interface you can use in your rules:
However, if you specify it directly in the `shield` field, there is not need for an helper thus no need for a parameter.

```typescript
rule()(async (parent, args, context: NexusContext, info) => {
// logic
t.string('prop', {
shield: chain(isAuthenticated(), viewerIsOwner()),
})
```
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,7 @@
"trailingComma": "es5"
},
"dependencies": {
"graphql-middleware": "^4.0.2",
"graphql-shield": "^7.3.0"
"nexus-shield": "^1.0.9"
},
"devDependencies": {
"@types/object-hash": "^1.3.1",
Expand Down
13 changes: 1 addition & 12 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,7 @@
import { PluginEntrypoint } from 'nexus/plugin'
import { Settings } from './settings'

export {
IRule,
IRules,
allow,
and,
chain,
deny,
inputRule,
not,
or,
rule,
} from 'graphql-shield'
export * from 'nexus-shield'

export const shield: PluginEntrypoint<Settings, 'required'> = (settings) => ({
settings,
Expand Down
4 changes: 3 additions & 1 deletion src/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { RuntimePlugin } from 'nexus/plugin'
import { nexusShield } from 'nexus-shield'

import { Settings } from './settings'
import { schemaPlugin } from './schema'

Expand All @@ -7,7 +9,7 @@ export const plugin: RuntimePlugin<Settings, 'required'> = (settings) => (
) => {
return {
schema: {
plugins: [schemaPlugin(settings)],
plugins: [schemaPlugin(settings), nexusShield(settings.options || {})],
},
}
}
Loading