diff --git a/.changeset/curly-llamas-attack.md b/.changeset/curly-llamas-attack.md new file mode 100644 index 00000000..0565d555 --- /dev/null +++ b/.changeset/curly-llamas-attack.md @@ -0,0 +1,5 @@ +--- +'@hono/casbin': major +--- + +Initial release diff --git a/.github/workflows/ci-casbin.yml b/.github/workflows/ci-casbin.yml new file mode 100644 index 00000000..8d3d36c3 --- /dev/null +++ b/.github/workflows/ci-casbin.yml @@ -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 diff --git a/package.json b/package.json index b06fc332..e47891d9 100644 --- a/package.json +++ b/package.json @@ -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}'", diff --git a/packages/casbin/README.md b/packages/casbin/README.md new file mode 100644 index 00000000..d6a64540 --- /dev/null +++ b/packages/casbin/README.md @@ -0,0 +1,140 @@ +# 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 + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = r.sub == p.sub && keyMatch(r.obj, p.obj) && (r.act == p.act || p.act == "*") +``` + +### Example policy.csv + +```csv +p, alice, /dataset1/*, * +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/casbin' +import { basicAuthorizer } from '@hono/casbin/helper' + +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/casbin' +import { jwtAuthorizer } from '@hono/casbin/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/casbin' + +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 diff --git a/packages/casbin/examples/model.conf b/packages/casbin/examples/model.conf new file mode 100644 index 00000000..fd2f08df --- /dev/null +++ b/packages/casbin/examples/model.conf @@ -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 == "*") diff --git a/packages/casbin/examples/policy.csv b/packages/casbin/examples/policy.csv new file mode 100644 index 00000000..79f831e7 --- /dev/null +++ b/packages/casbin/examples/policy.csv @@ -0,0 +1,5 @@ +p, dataset1_admin, /dataset1/*, * +p, dataset2_admin, /dataset2/*, * + +g, alice, dataset1_admin +g, bob, dataset2_admin diff --git a/packages/casbin/package.json b/packages/casbin/package.json new file mode 100644 index 00000000..766e24e3 --- /dev/null +++ b/packages/casbin/package.json @@ -0,0 +1,61 @@ +{ + "name": "@hono/casbin", + "version": "0.0.0", + "description": "Casbin middleware for Hono", + "type": "module", + "main": "dist/index.cjs", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "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": [ + "dist" + ], + "scripts": { + "test": "vitest --run", + "build": "tsup ./src/index.ts ./src/helper/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" + } +} diff --git a/packages/casbin/src/helper/basic-auth.ts b/packages/casbin/src/helper/basic-auth.ts new file mode 100644 index 00000000..f19f436d --- /dev/null +++ b/packages/casbin/src/helper/basic-auth.ts @@ -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 => { + const { path, method } = c.req + const user = getUserName(c) + return enforcer.enforce(user, path, method) +} diff --git a/packages/casbin/src/helper/index.ts b/packages/casbin/src/helper/index.ts new file mode 100644 index 00000000..5e3a00a7 --- /dev/null +++ b/packages/casbin/src/helper/index.ts @@ -0,0 +1,2 @@ +export * from './jwt' +export * from './basic-auth' diff --git a/packages/casbin/src/helper/jwt.ts b/packages/casbin/src/helper/jwt.ts new file mode 100644 index 00000000..ee0bbc42 --- /dev/null +++ b/packages/casbin/src/helper/jwt.ts @@ -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 = { userID: 'sub' } +): Promise => { + // 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) +} diff --git a/packages/casbin/src/index.ts b/packages/casbin/src/index.ts new file mode 100644 index 00000000..77a9f3ee --- /dev/null +++ b/packages/casbin/src/index.ts @@ -0,0 +1,22 @@ +import { Enforcer } from 'casbin' +import { type Context, MiddlewareHandler } from 'hono' + +interface CasbinOptions { + newEnforcer: Promise + authorizer: (c: Context, enforcer: Enforcer) => Promise +} + +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() + } +} diff --git a/packages/casbin/test/index.test.ts b/packages/casbin/test/index.test.ts new file mode 100644 index 00000000..7b680bf1 --- /dev/null +++ b/packages/casbin/test/index.test.ts @@ -0,0 +1,738 @@ +import { describe, it, expect } from 'vitest' +import { Hono } from 'hono' +import { newEnforcer } from 'casbin' +import { casbin } from '../src' +import { basicAuthorizer, jwtAuthorizer } from '../src/helper' +import { jwt, sign } from 'hono/jwt' +import { basicAuth } from 'hono/basic-auth' + +describe('Casbin Middleware Tests', () => { + describe('BasicAuthorizer', () => { + const app = new Hono() + const enforcer = newEnforcer('examples/model.conf', 'examples/policy.csv') + app.use('*', casbin({ newEnforcer: enforcer, authorizer: basicAuthorizer })) + app.get('/dataset1/test', (c) => c.text('dataset1 test')) + app.post('/dataset1/test', (c) => c.text('dataset1 test')) + app.put('/dataset1/test', (c) => c.text('dataset1 test')) + app.delete('/dataset1/test', (c) => c.text('dataset1 test')) + + it('test[Success]: p, alice, /dataset1/*, GET', async () => { + const req = new Request('http://localhost/dataset1/test', { + method: 'GET', + headers: { + Authorization: `Basic ${Buffer.from('alice:password').toString('base64')}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(200) + }) + + it('test[Success]: p, alice, /dataset1/*, POST', async () => { + const req = new Request('http://localhost/dataset1/test', { + method: 'POST', + headers: { + Authorization: `Basic ${Buffer.from('alice:password').toString('base64')}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(200) + }) + + it('test[Success]: p, alice, /dataset1/*, PUT', async () => { + const req = new Request('http://localhost/dataset1/test', { + method: 'PUT', + headers: { + Authorization: `Basic ${Buffer.from('alice:password').toString('base64')}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(200) + }) + + it('test[Success]: p, alice, /dataset1/*, DELETE', async () => { + const req = new Request('http://localhost/dataset1/test', { + method: 'DELETE', + headers: { + Authorization: `Basic ${Buffer.from('alice:password').toString('base64')}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(200) + }) + + it('test[Not Exist User]: p, cathy, /dataset1/*, - GET 403', async () => { + const req = new Request('http://localhost/dataset1/test', { + method: 'GET', + headers: { + Authorization: `Basic ${Buffer.from('cathy:password').toString('base64')}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(403) + }) + + it('test[Not Exist User]: p, cathy, /dataset1/*, - POST 403', async () => { + const req = new Request('http://localhost/dataset1/test', { + method: 'POST', + headers: { + Authorization: `Basic ${Buffer.from('cathy:password').toString('base64')}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(403) + }) + + it('test[Not Exist User]: p, cathy, /dataset1/*, - PUT 403', async () => { + const req = new Request('http://localhost/dataset1/test', { + method: 'PUT', + headers: { + Authorization: `Basic ${Buffer.from('cathy:password').toString('base64')}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(403) + }) + + it('test[Not Exist User]: p, cathy, /dataset1/*, - DELETE 403', async () => { + const req = new Request('http://localhost/dataset1/test', { + method: 'DELETE', + headers: { + Authorization: `Basic ${Buffer.from('cathy:password').toString('base64')}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(403) + }) + + it('test[Insufficient Permissions]: p, bob, /dataset1/*, - GET 403', async () => { + const req = new Request('http://localhost/dataset1/test', { + method: 'GET', + headers: { + Authorization: `Basic ${Buffer.from('bob:password').toString('base64')}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(403) + }) + + it('test[Insufficient Permissions]: p, bob, /dataset1/*, - POST 403', async () => { + const req = new Request('http://localhost/dataset1/test', { + method: 'POST', + headers: { + Authorization: `Basic ${Buffer.from('bob:password').toString('base64')}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(403) + }) + + it('test[Insufficient Permissions]: p, bob, /dataset1/*, - PUT 403', async () => { + const req = new Request('http://localhost/dataset1/test', { + method: 'PUT', + headers: { + Authorization: `Basic ${Buffer.from('bob:password').toString('base64')}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(403) + }) + + it('test[Insufficient Permissions]: p, bob, /dataset1/*, - DELETE 403', async () => { + const req = new Request('http://localhost/dataset1/test', { + method: 'DELETE', + headers: { + Authorization: `Basic ${Buffer.from('bob:password').toString('base64')}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(403) + }) + }) + + describe('BasicAuthorizer with hono/basic-auth', () => { + const app = new Hono() + const enforcer = newEnforcer('examples/model.conf', 'examples/policy.csv') + app.use( + '*', + basicAuth( + { + username: 'alice', + password: 'password', + }, + { + username: 'bob', + password: 'password', + } + ), + casbin({ newEnforcer: enforcer, authorizer: basicAuthorizer }) + ) + app.get('/dataset1/test', (c) => c.text('dataset1 test')) + app.post('/dataset1/test', (c) => c.text('dataset1 test')) + app.put('/dataset1/test', (c) => c.text('dataset1 test')) + app.delete('/dataset1/test', (c) => c.text('dataset1 test')) + + it('test[Success]: p, alice, /dataset1/*, GET', async () => { + const req = new Request('http://localhost/dataset1/test', { + method: 'GET', + headers: { + Authorization: `Basic ${Buffer.from('alice:password').toString('base64')}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(200) + }) + + it('test[Success]: p, alice, /dataset1/*, POST', async () => { + const req = new Request('http://localhost/dataset1/test', { + method: 'POST', + headers: { + Authorization: `Basic ${Buffer.from('alice:password').toString('base64')}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(200) + }) + + it('test[Success]: p, alice, /dataset1/*, PUT', async () => { + const req = new Request('http://localhost/dataset1/test', { + method: 'PUT', + headers: { + Authorization: `Basic ${Buffer.from('alice:password').toString('base64')}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(200) + }) + + it('test[Success]: p, alice, /dataset1/*, DELETE', async () => { + const req = new Request('http://localhost/dataset1/test', { + method: 'DELETE', + headers: { + Authorization: `Basic ${Buffer.from('alice:password').toString('base64')}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(200) + }) + + it('test[Insufficient Permissions]: p, bob, /dataset1/*, - GET 403', async () => { + const req = new Request('http://localhost/dataset1/test', { + method: 'GET', + headers: { + Authorization: `Basic ${Buffer.from('bob:password').toString('base64')}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(403) + }) + + it('test[Insufficient Permissions]: p, bob, /dataset1/*, - POST 403', async () => { + const req = new Request('http://localhost/dataset1/test', { + method: 'POST', + headers: { + Authorization: `Basic ${Buffer.from('bob:password').toString('base64')}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(403) + }) + + it('test[Insufficient Permissions]: p, bob, /dataset1/*, - PUT 403', async () => { + const req = new Request('http://localhost/dataset1/test', { + method: 'PUT', + headers: { + Authorization: `Basic ${Buffer.from('bob:password').toString('base64')}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(403) + }) + + it('test[Insufficient Permissions]: p, bob, /dataset1/*, - DELETE 403', async () => { + const req = new Request('http://localhost/dataset1/test', { + method: 'DELETE', + headers: { + Authorization: `Basic ${Buffer.from('bob:password').toString('base64')}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(403) + }) + }) + + describe('JWTAuthorizer', () => { + const app = new Hono() + const enforcer = newEnforcer('examples/model.conf', 'examples/policy.csv') + + app.use('*', casbin({ newEnforcer: enforcer, authorizer: jwtAuthorizer })) + app.get('/dataset1/test', (c) => c.text('dataset1 test')) + app.post('/dataset1/test', (c) => c.text('dataset1 test')) + app.put('/dataset1/test', (c) => c.text('dataset1 test')) + app.delete('/dataset1/test', (c) => c.text('dataset1 test')) + + it('test[Success]: p, alice, /dataset1/*, GET 200', async () => { + const token = await sign({ sub: 'alice' }, 'secret') + const req = new Request('http://localhost/dataset1/test', { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(200) + }) + + it('test[Success]: p, alice, /dataset1/*, POST 200', async () => { + const token = await sign({ sub: 'alice' }, 'secret') + const req = new Request('http://localhost/dataset1/test', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(200) + }) + + it('test[Success]: p, alice, /dataset1/*, PUT 200', async () => { + const token = await sign({ sub: 'alice' }, 'secret') + const req = new Request('http://localhost/dataset1/test', { + method: 'PUT', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(200) + }) + + it('test[Success]: p, alice, /dataset1/*, DELETE 200', async () => { + const token = await sign({ sub: 'alice' }, 'secret') + const req = new Request('http://localhost/dataset1/test', { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(200) + }) + + it('test[Not Exist User]: p & g, dataset1_admin, /dataset1/*, * - GET 403', async () => { + const token = await sign({ sub: 'cathy' }, 'secret') + const req = new Request('http://localhost/dataset1/test', { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(403) + }) + + it('test[Not Exist User]: p & g, dataset1_admin, /dataset1/*, * - POST 403', async () => { + const token = await sign({ sub: 'cathy' }, 'secret') + const req = new Request('http://localhost/dataset1/test', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(403) + }) + + it('test[Not Exist User]: p & g, dataset1_admin, /dataset1/*, * - PUT 403', async () => { + const token = await sign({ sub: 'cathy' }, 'secret') + const req = new Request('http://localhost/dataset1/test', { + method: 'PUT', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(403) + }) + + it('test[Not Exist User]: p & g, dataset1_admin, /dataset1/*, * - DELETE 403', async () => { + const token = await sign({ sub: 'cathy' }, 'secret') + const req = new Request('http://localhost/dataset1/test', { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(403) + }) + + it('test[Insufficient Permissions]: p, bob, /dataset1/test - GET 403', async () => { + const token = await sign({ sub: 'bob' }, 'secret') + const req = new Request('http://localhost/dataset1/test', { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(403) + }) + + it('test[Insufficient Permissions]: p, bob, /dataset1/test - POST 403', async () => { + const token = await sign({ sub: 'bob' }, 'secret') + const req = new Request('http://localhost/dataset1/test', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(403) + }) + + it('test[Insufficient Permissions]: p, bob, /dataset1/test - PUT 403', async () => { + const token = await sign({ sub: 'bob' }, 'secret') + const req = new Request('http://localhost/dataset1/test', { + method: 'PUT', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(403) + }) + + it('test[Insufficient Permissions]: p, bob, /dataset1/test - DELETE 403', async () => { + const token = await sign({ sub: 'bob' }, 'secret') + const req = new Request('http://localhost/dataset1/test', { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(403) + }) + }) + + describe('JWTAuthorizer With Custom Claim', () => { + const app = new Hono() + const enforcer = newEnforcer('examples/model.conf', 'examples/policy.csv') + const customClaimMapping = { userID: 'custom_id' } + app.use( + '*', + casbin({ + newEnforcer: enforcer, + authorizer: (c, e) => jwtAuthorizer(c, e, customClaimMapping), + }) + ) + app.get('/dataset1/test', (c) => c.text('dataset1 test')) + app.post('/dataset1/test', (c) => c.text('dataset1 test')) + app.put('/dataset1/test', (c) => c.text('dataset1 test')) + app.delete('/dataset1/test', (c) => c.text('dataset1 test')) + + it('test[Success]: p, alice, /dataset1/*, GET 200', async () => { + const token = await sign({ custom_id: 'alice' }, 'secret') + const req = new Request('http://localhost/dataset1/test', { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(200) + }) + + it('test[Success]: p, alice, /dataset1/*, POST 200', async () => { + const token = await sign({ custom_id: 'alice' }, 'secret') + const req = new Request('http://localhost/dataset1/test', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(200) + }) + + it('test[Success]: p, alice, /dataset1/*, PUT 200', async () => { + const token = await sign({ custom_id: 'alice' }, 'secret') + const req = new Request('http://localhost/dataset1/test', { + method: 'PUT', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(200) + }) + + it('test[Success]: p, alice, /dataset1/*, DELETE 200', async () => { + const token = await sign({ custom_id: 'alice' }, 'secret') + const req = new Request('http://localhost/dataset1/test', { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(200) + }) + + it('test[Not Exist User]: p & g, dataset1_admin, /dataset1/*, * - GET 403', async () => { + const token = await sign({ custom_id: 'cathy' }, 'secret') + const req = new Request('http://localhost/dataset1/test', { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(403) + }) + + it('test[Not Exist User]: p & g, dataset1_admin, /dataset1/*, * - POST 403', async () => { + const token = await sign({ custom_id: 'cathy' }, 'secret') + const req = new Request('http://localhost/dataset1/test', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(403) + }) + + it('test[Not Exist User]: p & g, dataset1_admin, /dataset1/*, * - PUT 403', async () => { + const token = await sign({ custom_id: 'cathy' }, 'secret') + const req = new Request('http://localhost/dataset1/test', { + method: 'PUT', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(403) + }) + + it('test[Not Exist User]: p & g, dataset1_admin, /dataset1/*, * - DELETE 403', async () => { + const token = await sign({ custom_id: 'cathy' }, 'secret') + const req = new Request('http://localhost/dataset1/test', { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(403) + }) + + it('test[Insufficient Permissions]: p, bob, /dataset1/test - GET 403', async () => { + const token = await sign({ custom_id: 'bob' }, 'secret') + const req = new Request('http://localhost/dataset1/test', { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(403) + }) + + it('test[Insufficient Permissions]: p, bob, /dataset1/test - POST 403', async () => { + const token = await sign({ custom_id: 'bob' }, 'secret') + const req = new Request('http://localhost/dataset1/test', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(403) + }) + + it('test[Insufficient Permissions]: p, bob, /dataset1/test - PUT 403', async () => { + const token = await sign({ custom_id: 'bob' }, 'secret') + const req = new Request('http://localhost/dataset1/test', { + method: 'PUT', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(403) + }) + + it('test[Insufficient Permissions]: p, bob, /dataset1/test - DELETE 403', async () => { + const token = await sign({ custom_id: 'bob' }, 'secret') + const req = new Request('http://localhost/dataset1/test', { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(403) + }) + }) + + describe('JWTAuthorizer With hono/jwt', () => { + const app = new Hono() + const enforcer = newEnforcer('examples/model.conf', 'examples/policy.csv') + app.use( + '*', + jwt({ + secret: 'secret', + }), + casbin({ newEnforcer: enforcer, authorizer: jwtAuthorizer }) + ) + app.get('/dataset1/test', (c) => c.text('dataset1 test')) + app.post('/dataset1/test', (c) => c.text('dataset1 test')) + app.put('/dataset1/test', (c) => c.text('dataset1 test')) + app.delete('/dataset1/test', (c) => c.text('dataset1 test')) + + it('test[Success]: p, alice, /dataset1/*, GET 200', async () => { + const token = await sign({ sub: 'alice' }, 'secret') + const req = new Request('http://localhost/dataset1/test', { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(200) + }) + + it('test[Success]: p, alice, /dataset1/*, POST 200', async () => { + const token = await sign({ sub: 'alice' }, 'secret') + const req = new Request('http://localhost/dataset1/test', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(200) + }) + + it('test[Success]: p, alice, /dataset1/*, PUT 200', async () => { + const token = await sign({ sub: 'alice' }, 'secret') + const req = new Request('http://localhost/dataset1/test', { + method: 'PUT', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(200) + }) + + it('test[Success]: p, alice, /dataset1/*, DELETE 200', async () => { + const token = await sign({ sub: 'alice' }, 'secret') + const req = new Request('http://localhost/dataset1/test', { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(200) + }) + + it('test[Not Exist User]: p & g, dataset1_admin, /dataset1/*, * - GET 403', async () => { + const token = await sign({ sub: 'cathy' }, 'secret') + const req = new Request('http://localhost/dataset1/test', { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(403) + }) + + it('test[Not Exist User]: p & g, dataset1_admin, /dataset1/*, * - POST 403', async () => { + const token = await sign({ sub: 'cathy' }, 'secret') + const req = new Request('http://localhost/dataset1/test', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(403) + }) + + it('test[Not Exist User]: p & g, dataset1_admin, /dataset1/*, * - PUT 403', async () => { + const token = await sign({ sub: 'cathy' }, 'secret') + const req = new Request('http://localhost/dataset1/test', { + method: 'PUT', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(403) + }) + + it('test[Not Exist User]: p & g, dataset1_admin, /dataset1/*, * - DELETE 403', async () => { + const token = await sign({ sub: 'cathy' }, 'secret') + const req = new Request('http://localhost/dataset1/test', { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(403) + }) + + it('test[Insufficient Permissions]: p, bob, /dataset1/test - GET 403', async () => { + const token = await sign({ sub: 'bob' }, 'secret') + const req = new Request('http://localhost/dataset1/test', { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(403) + }) + + it('test[Insufficient Permissions]: p, bob, /dataset1/test - POST 403', async () => { + const token = await sign({ sub: 'bob' }, 'secret') + const req = new Request('http://localhost/dataset1/test', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(403) + }) + + it('test[Insufficient Permissions]: p, bob, /dataset1/test - PUT 403', async () => { + const token = await sign({ sub: 'bob' }, 'secret') + const req = new Request('http://localhost/dataset1/test', { + method: 'PUT', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(403) + }) + + it('test[Insufficient Permissions]: p, bob, /dataset1/test - DELETE 403', async () => { + const token = await sign({ sub: 'bob' }, 'secret') + const req = new Request('http://localhost/dataset1/test', { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + const res = await app.fetch(req) + expect(res.status).toBe(403) + }) + }) +}) diff --git a/packages/casbin/tsconfig.json b/packages/casbin/tsconfig.json new file mode 100644 index 00000000..dc9e8811 --- /dev/null +++ b/packages/casbin/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "exactOptionalPropertyTypes": true + }, + "include": [ + "src/**/*.ts" + ], +} diff --git a/packages/casbin/vitest.config.ts b/packages/casbin/vitest.config.ts new file mode 100644 index 00000000..17b54e48 --- /dev/null +++ b/packages/casbin/vitest.config.ts @@ -0,0 +1,8 @@ +/// +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: true, + }, +}) diff --git a/yarn.lock b/yarn.lock index 7538e0c8..89230e15 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2278,6 +2278,21 @@ __metadata: languageName: unknown linkType: soft +"@hono/casbin@workspace:packages/casbin": + version: 0.0.0-use.local + resolution: "@hono/casbin@workspace:packages/casbin" + dependencies: + casbin: "npm:^5.30.0" + hono: "npm:^4.5.11" + tsup: "npm:^8.1.0" + typescript: "npm:^5.5.3" + vitest: "npm:^2.0.1" + peerDependencies: + casbin: ">=5.30.0" + hono: ">=4.5.11" + languageName: unknown + linkType: soft + "@hono/clerk-auth@workspace:packages/clerk-auth": version: 0.0.0-use.local resolution: "@hono/clerk-auth@workspace:packages/clerk-auth" @@ -6303,6 +6318,13 @@ __metadata: languageName: node linkType: hard +"await-lock@npm:^2.0.1": + version: 2.2.2 + resolution: "await-lock@npm:2.2.2" + checksum: bedf00dad44c6325a655bf3bd523ab9e1ce41023da6a8c379990c76ac1d942ac7e5301627ab84ba37917ab5247506ba429b7f6e4bf77074093f255571b9ad5ee + languageName: node + linkType: hard + "babel-jest@npm:^28.1.3": version: 28.1.3 resolution: "babel-jest@npm:28.1.3" @@ -6689,6 +6711,16 @@ __metadata: languageName: node linkType: hard +"buffer@npm:^6.0.3": + version: 6.0.3 + resolution: "buffer@npm:6.0.3" + dependencies: + base64-js: "npm:^1.3.1" + ieee754: "npm:^1.2.1" + checksum: 2a905fbbcde73cc5d8bd18d1caa23715d5f83a5935867c2329f0ac06104204ba7947be098fe1317fbd8830e26090ff8e764f08cd14fefc977bb248c3487bcbd0 + languageName: node + linkType: hard + "builtins@npm:^1.0.3": version: 1.0.3 resolution: "builtins@npm:1.0.3" @@ -6903,6 +6935,19 @@ __metadata: languageName: node linkType: hard +"casbin@npm:^5.30.0": + version: 5.30.0 + resolution: "casbin@npm:5.30.0" + dependencies: + await-lock: "npm:^2.0.1" + buffer: "npm:^6.0.3" + csv-parse: "npm:^5.3.5" + expression-eval: "npm:^5.0.0" + minimatch: "npm:^7.4.2" + checksum: e3fee7b9b94f4cb6ae9b550fda80f64c46998828fb1c57b3516f6d66e1c98c6e928fa21e5c48af9d831d2dc066a24199d324d4f0866990d244ce3652ae8af509 + languageName: node + linkType: hard + "catharsis@npm:^0.9.0": version: 0.9.0 resolution: "catharsis@npm:0.9.0" @@ -7745,6 +7790,13 @@ __metadata: languageName: node linkType: hard +"csv-parse@npm:^5.3.5": + version: 5.5.6 + resolution: "csv-parse@npm:5.5.6" + checksum: b4f6e9b885e4488829356455157bd009f3fed4119c5fbaadab1a879e85f0a9a1b62cd01e6de68ff77a50f820a6261722bce1b799da1ace2e2126e0b7c8d86760 + languageName: node + linkType: hard + "csv-stringify@npm:^5.6.5": version: 5.6.5 resolution: "csv-stringify@npm:5.6.5" @@ -9735,6 +9787,15 @@ __metadata: languageName: node linkType: hard +"expression-eval@npm:^5.0.0": + version: 5.0.1 + resolution: "expression-eval@npm:5.0.1" + dependencies: + jsep: "npm:^0.3.0" + checksum: 74f9e1e54e50b3c924a71bcddf1550c51f15e24646f2b6cb8c45c7dd3731eb3f0e1e9a6dbf895549ddc445fe66909c373779ea6bf7f6f1e90d6bcef1590543ff + languageName: node + linkType: hard + "extend@npm:^3.0.0, extend@npm:^3.0.2": version: 3.0.2 resolution: "extend@npm:3.0.2" @@ -11050,9 +11111,16 @@ __metadata: linkType: hard "hono@npm:^4.5.1": - version: 4.5.1 - resolution: "hono@npm:4.5.1" - checksum: 71228cefd3808b4bb42c10de23d67f135678eb0ab7fdfd1728d934ec2ee1241be9b1260f279b81acd2a6a57346d06d047a2650ff9bddd48fda6581751a690d80 + version: 4.5.3 + resolution: "hono@npm:4.5.3" + checksum: 360fec2ea66b85d688dd9ce50eb6cdf94f6a2b8508e0b37b688fda3a94e7438cc4c143c0282b7aea41f7be8fa69f0b6d0039931fc8e1526c11ae09303dccce30 + languageName: node + linkType: hard + +"hono@npm:^4.5.11": + version: 4.5.11 + resolution: "hono@npm:4.5.11" + checksum: 839c90273b17ed3797d34a19d12dc577d6f98590a4261bf87488880db13137c0c84a165e7810dc50af2ce4680c8fc915c0bb65673bf5fc54c6d951f18454b2c5 languageName: node linkType: hard @@ -11218,7 +11286,7 @@ __metadata: languageName: node linkType: hard -"ieee754@npm:^1.1.13": +"ieee754@npm:^1.1.13, ieee754@npm:^1.2.1": version: 1.2.1 resolution: "ieee754@npm:1.2.1" checksum: b0782ef5e0935b9f12883a2e2aa37baa75da6e66ce6515c168697b42160807d9330de9a32ec1ed73149aea02e0d822e572bca6f1e22bdcbd2149e13b050b17bb @@ -13118,6 +13186,13 @@ __metadata: languageName: node linkType: hard +"jsep@npm:^0.3.0": + version: 0.3.5 + resolution: "jsep@npm:0.3.5" + checksum: fb5def7a4ba1cee41d144ebdd0d477785dc84b6bc1fed6cf5169f106de980dbe363bf99cb36a450435d7fd952d22b1d76e1609aeb5c7e7cbbbdb6d15fad03614 + languageName: node + linkType: hard + "jsesc@npm:^2.5.1": version: 2.5.2 resolution: "jsesc@npm:2.5.2" @@ -14843,9 +14918,19 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^9.0.0, minimatch@npm:^9.0.3, minimatch@npm:^9.0.4, minimatch@npm:^9.0.5": - version: 9.0.5 - resolution: "minimatch@npm:9.0.5" +"minimatch@npm:^7.4.2": + version: 7.4.6 + resolution: "minimatch@npm:7.4.6" + dependencies: + brace-expansion: "npm:^2.0.1" + checksum: e587bf3d90542555a3d58aca94c549b72d58b0a66545dd00eef808d0d66e5d9a163d3084da7f874e83ca8cc47e91c670e6c6f6593a3e7bb27fcc0e6512e87c67 + languageName: node + linkType: hard + +"minimatch@npm:^9.0.1": + version: 9.0.3 + resolution: "minimatch@npm:9.0.3" + dependencies: brace-expansion: "npm:^2.0.1" checksum: de96cf5e35bdf0eab3e2c853522f98ffbe9a36c37797778d2665231ec1f20a9447a7e567cb640901f89e4daaa95ae5d70c65a9e8aa2bb0019b6facbc3c0575ed