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

Proposal: Implementation of Casbin Middleware #676

Merged
merged 12 commits into from
Sep 9, 2024
Merged
Show file tree
Hide file tree
Changes from 6 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
5 changes: 5 additions & 0 deletions .changeset/curly-llamas-attack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hono/casbin': major
---

Initial release
25 changes: 25 additions & 0 deletions .github/workflows/ci-casbin.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: ci-cabin
on:
push:
branches: [main]
paths:
- 'packages/cabin/**'
pull_request:
branches: ['*']
paths:
- 'packages/cabin/**'

jobs:
ci:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./packages/cabin
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
- run: yarn install --frozen-lockfile
- run: yarn build
- run: yarn test
3 changes: 2 additions & 1 deletion package.json
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Change as following to export modules correctly:

diff --git a/packages/casbin/package.json b/packages/casbin/package.json
index 6d0be5f..da02259 100644
--- a/packages/casbin/package.json
+++ b/packages/casbin/package.json
@@ -3,9 +3,9 @@
   "version": "1.0.1",
   "description": "Casbin middleware for Hono",
   "type": "module",
-  "main": "dist/cjs/index.js",
-  "module": "dist/esm/index.js",
-  "types": "dist/esm/index.d.ts",
+  "main": "dist/index.cjs",
+  "module": "dist/index.js",
+  "types": "dist/index.d.ts",
   "exports": {
     ".": {
       "import": {
@@ -16,6 +16,16 @@
         "types": "./dist/index.d.cts",
         "default": "./dist/index.cjs"
       }
+    },
+    "./helper": {
+      "import": {
+        "types": "./dist/helper/index.d.ts",
+        "default": "./dist/helper/index.js"
+      },
+      "require": {
+        "types": "./dist/helper/index.d.cts",
+        "default": "./dist/helper/index.cjs"
+      }
     }
   },
   "files": [
@@ -23,7 +33,7 @@
   ],
   "scripts": {
     "test": "vitest --run",
-    "build": "tsup ./src/index.ts --format esm,cjs --dts",
+    "build": "tsup ./src/index.ts ./src/helper/index.ts --format esm,cjs --dts",
     "publint": "publint",
     "release": "yarn build && yarn test && yarn publint && yarn publish"
   },
@@ -48,4 +58,4 @@
     "typescript": "^5.5.3",
     "vitest": "^2.0.1"
   }
-}
+}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed!
18b9d2a

Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"build:react-compat": "yarn workspace @hono/react-compat build",
"build:effect-validator": "yarn workspace @hono/effect-validator build",
"build:conform-validator": "yarn workspace @hono/conform-validator build",
"build:casbin": "yarn workspace @hono/casbin build",
"build": "run-p 'build:*'",
"lint": "eslint 'packages/**/*.{ts,tsx}'",
"lint:fix": "eslint --fix 'packages/**/*.{ts,tsx}'",
Expand Down Expand Up @@ -66,4 +67,4 @@
"typescript": "^5.2.2"
},
"packageManager": "yarn@4.0.2"
}
}
143 changes: 143 additions & 0 deletions packages/casbin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# Casbin Middleware for Hono

This is a third-party [Casbin](https://casbin.org) middleware for [Hono](https://github.com/honojs/hono).

This middleware can be used to enforce authorization policies defined using Casbin in your Hono routes.

## Installation

```bash
npm i hono @hono/casbin casbin
```

## Configuration

Before using the middleware, you must set up your Casbin model and policy files.

For details on how to write authorization policies and other information, please refer to the [Casbin documentation](https://casbin.org/).

### Example model.conf

```conf
[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act

[role_definition]
g = _, _

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = r.sub == p.sub && r.obj == p.obj && r.act == p.act
```

### Example policy.csv

```csv
p, alice, /dataset1/*, *
yusukebe marked this conversation as resolved.
Show resolved Hide resolved
p, bob, /dataset1/*, GET
```

## Usage with Basic HTTP Authentication

You can perform authorization control after Basic authentication by combining it with `basicAuthorizer`.
(The client needs to send `Authentication: Basic {Base64Encoded(username:password)}`.)

Let's look at an example.
Use the `model` and `policy` files from the [Configuration](#configuration) section.
You can implement a scenario where `alice` and `bob` have different permissions. Alice has access to all methods on `/dataset1/test`, while Bob has access only to the `GET` method.

```ts
import { Hono } from 'hono'
import { basicAuth } from 'hono/basic-auth'
import { newEnforcer } from 'casbin'
import { casbin } from '@hono/cabin'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a typo. Should be @hono/casbin'.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed!
d0325f1

import { basicAuthorizer } from '@hono/cabin/helper'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also a typo.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed!
d0325f1


const app = new Hono()
app.use('*',
basicAuth(
{
username: 'alice', // alice has full access to /dataset1/test
password: 'password',
},
{
username: 'bob', // bob cannot post to /dataset1/test
password: 'password',
}
),
casbin({
newEnforcer: newEnforcer('examples/model.conf', 'examples/policy.csv'),
authorizer: basicAuthorizer
})
)
app.get('/dataset1/test', (c) => c.text('dataset1 test')) // alice and bob can access /dataset1/test
app.post('/dataset1/test', (c) => c.text('dataset1 test')) // Only alice can access /dataset1/test
```

## Usage with JWT Authentication

By using `jwtAuthorizer`, you can perform authorization control after JWT authentication.
By default, `jwtAuthorizer` uses the `sub` in the JWT payload as the username.

```ts
import { Hono } from 'hono'
import { jwt } from 'hono/jwt'
import { newEnforcer } from 'casbin'
import { casbin } from '@hono/cabin'
import { jwtAuthorizer } from '@hono/cabin/helper'

const app = new Hono()
app.use('*',
jwt({
secret: 'it-is-very-secret',
}),
casbin({
newEnforcer: newEnforcer('examples/model.conf', 'examples/policy.csv'),
authorizer: jwtAuthorizer
})
)
app.get('/dataset1/test', (c) => c.text('dataset1 test')) // alice and bob can access /dataset1/test
app.post('/dataset1/test', (c) => c.text('dataset1 test')) // Only alice can access /dataset1/test
```

Of course, you can use claims other than the `sub` claim.
Specify the `key` as a user-friendly name and the `value` as the JWT claim name. The `Payload` key used for evaluation in the enforcer will be the `value`.

```ts
const claimMapping = {
username: 'username',
}
// ...
casbin({
newEnforcer: newEnforcer('examples/model.conf', 'examples/policy.csv'),
authorizer: (c, e) => jwtAuthorizer(c, e, claimMapping)
})
```

## Usage with Customized Authorizer

You can also use a customized authorizer function to handle the authorization logic.

```ts
import { Hono } from 'hono'
import { newEnforcer } from 'casbin'
import { casbin } from '@hono/cabin'

const app = new Hono()
app.use('*', casbin({
newEnforcer: newEnforcer('path-to-your-model.conf', 'path-to-your-policy.csv'),
authorizer: async (c, enforcer) => {
const { user, path, method } = c
return await enforcer.enforce(user, path, method)
}
}))
```

## Author

sugar-cat https://github.com/sugar-cat7
14 changes: 14 additions & 0 deletions packages/casbin/examples/model.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act

[role_definition]
g = _, _

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = g(r.sub, p.sub) && keyMatch(r.obj, p.obj) && (r.act == p.act || p.act == "*")
5 changes: 5 additions & 0 deletions packages/casbin/examples/policy.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
p, dataset1_admin, /dataset1/*, *
p, dataset2_admin, /dataset2/*, *

g, alice, dataset1_admin
g, bob, dataset2_admin
51 changes: 51 additions & 0 deletions packages/casbin/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{
"name": "@hono/casbin",
"version": "1.0.1",
yusukebe marked this conversation as resolved.
Show resolved Hide resolved
"description": "Casbin middleware for Hono",
"type": "module",
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
"types": "dist/esm/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
},
"files": [
"dist"
],
"scripts": {
"test": "vitest --run",
"build": "tsup ./src/index.ts --format esm,cjs --dts",
"publint": "publint",
"release": "yarn build && yarn test && yarn publint && yarn publish"
},
"license": "MIT",
"publishConfig": {
"registry": "https://registry.npmjs.org",
"access": "public"
},
"repository": {
"type": "git",
"url": "https://github.com/honojs/middleware.git"
},
"homepage": "https://github.com/honojs/middleware",
"peerDependencies": {
"casbin": ">=5.30.0",
"hono": ">=4.5.11"
},
"devDependencies": {
"casbin": "^5.30.0",
"hono": "^4.5.11",
"tsup": "^8.1.0",
"typescript": "^5.5.3",
"vitest": "^2.0.1"
}
}
17 changes: 17 additions & 0 deletions packages/casbin/src/helper/basic-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { Enforcer } from 'casbin'
import type { Context } from 'hono'
import { auth } from 'hono/utils/basic-auth'

const getUserName = (c: Context): string => {
const requestUser = auth(c.req.raw)
if (!requestUser) {
return ''
}
return requestUser.username
}

export const basicAuthorizer = async (c: Context, enforcer: Enforcer): Promise<boolean> => {
const { path, method } = c.req
const user = getUserName(c)
return enforcer.enforce(user, path, method)
}
2 changes: 2 additions & 0 deletions packages/casbin/src/helper/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './jwt'
export * from './basic-auth'
36 changes: 36 additions & 0 deletions packages/casbin/src/helper/jwt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { decode } from 'hono/jwt'
import type { Enforcer } from 'casbin'
import type { Context } from 'hono'
import type { JWTPayload } from 'hono/utils/jwt/types'

export const jwtAuthorizer = async (
c: Context,
enforcer: Enforcer,
claimMapping: Record<string, string> = { userID: 'sub' }
): Promise<boolean> => {
// Note: if use hono/jwt, the payload is stored in c.get('jwtPayload')
// https://github.com/honojs/hono/blob/8ba02273e829318d7f8797267f52229e531b8bd5/src/middleware/jwt/jwt.ts#L136
let payload: JWTPayload = c.get('jwtPayload')

if (!payload) {
const credentials = c.req.header('Authorization')
if (!credentials) return false

const parts = credentials.split(/\s+/)
if (parts.length !== 2 || parts[0] !== 'Bearer') return false

const token = parts[1]

try {
const decoded = decode(token)
payload = decoded.payload
} catch {
return false
}
}

const args = Object.values(claimMapping).map((key) => payload[key])

const { path, method } = c.req
return await enforcer.enforce(...args, path, method)
}
22 changes: 22 additions & 0 deletions packages/casbin/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Enforcer } from 'casbin'
import { type Context, MiddlewareHandler } from 'hono'

interface CasbinOptions {
newEnforcer: Promise<Enforcer>
authorizer: (c: Context, enforcer: Enforcer) => Promise<boolean>
}

export const casbin = (opt: CasbinOptions): MiddlewareHandler => {
return async (c, next) => {
const enforcer = await opt.newEnforcer
if (!(enforcer instanceof Enforcer)) {
return c.json({ error: 'Invalid enforcer' }, 500)
}

const isAllowed = await opt.authorizer(c, enforcer)
if (!isAllowed) {
return c.json({ error: 'Forbidden' }, 403)
}
await next()
}
}
Loading