diff --git a/README.md b/README.md index 0057551..a1598c5 100644 --- a/README.md +++ b/README.md @@ -80,11 +80,6 @@ app.register(opaAuthPlugin, { }) ``` -## OPA Options - -This plugins uses Styra's `@styra/opa` SDK to perform queries to the OPA server. Its options can be changed via configuration. -See the [official documentation](https://styrainc.github.io/opa-typescript/) - ```typescript app.register(opaAuthPlugin, { // ... @@ -94,11 +89,6 @@ app.register(opaAuthPlugin, { }) ``` -## Native Node.js fetch - -This plugin relies on `@styra/opa` which internally relies on _native Node.js fetch_ which is available as an **experimental** -feature from _Node.js 16_ and as a **stable** feature in _Node.js 22_ - ## 🌳 Join Us in Making a Difference! 🌳 We invite all developers who use Treedom's open-source code to support our mission of sustainability by planting a tree with us. By contributing to reforestation efforts, you help create a healthier planet and give back to the environment. Visit our [Treedom Open Source Forest](https://www.treedom.net/en/organization/treedom/event/treedom-open-source) to plant your tree today and join our community of eco-conscious developers. diff --git a/package.json b/package.json index 997ce78..7f14e84 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@treedom/mercurius-auth-opa", - "version": "1.1.0", + "version": "2.0.0", "main": "lib/index.js", "scripts": { "test": "borp", @@ -24,9 +24,10 @@ "license": "MIT", "description": "Mercurius OPA authentication directive plugin based on mercurius-auth", "dependencies": { - "@styra/opa": "1.0.0", "mercurius": ">=12.0.0", - "mercurius-auth": ">=4.0.0" + "mercurius-auth": ">=4.0.0", + "object-hash": ">=3.0.0", + "undici": ">=6.0.0" }, "devDependencies": { "@types/node": "20.14.0", @@ -46,8 +47,7 @@ "pino-pretty": "11.1.0", "prettier": "3.3.0", "sinon": "18.0.0", - "typescript": "5.4.5", - "undici": "6.18.2" + "typescript": "5.4.5" }, "files": [ "lib" diff --git a/src/OpenPolicyAgentClient.ts b/src/OpenPolicyAgentClient.ts new file mode 100644 index 0000000..89c182d --- /dev/null +++ b/src/OpenPolicyAgentClient.ts @@ -0,0 +1,70 @@ +import type { Cache } from './types' +import { request } from 'undici' +import { getCacheKey } from './getCacheKey' +import { OpenPolicyAgentClientProps } from './types/openPolicyAgentClientProps' + +export class OpenPolicyAgentClient { + public readonly cache: TCache | undefined + private readonly url: string + + constructor( + config: OpenPolicyAgentClientProps | string, + private readonly opaVersion = 'v1', + private readonly method: 'POST' | 'GET' = 'POST' + ) { + /* istanbul ignore next */ + if (typeof config === 'object') { + this.url = config.url + + if (config.opaVersion) { + this.opaVersion = config.opaVersion + } + + if (config.method) { + this.method = config.method + } + + if (config.cache) { + this.cache = config.cache + } + } else { + this.url = config + } + } + + /** + * Query the requested OPA resource. + * + * @param resource Can be expressed both in dot notation or slash notation. + * @param input OPA Query input. + */ + public async query( + resource: string, + input?: unknown + ): Promise { + const resourcePath = resource.replace(/\./gi, '/') + + const cacheKey = getCacheKey(resourcePath, input) + const cached = this.cache?.get(cacheKey) as TResponse | undefined + + if (cached) { + return cached + } + + const res = await request( + `${this.url}/${this.opaVersion}/data/${resourcePath}`, + { + method: this.method, + body: input ? JSON.stringify({ input }) : undefined, + } + ) + + const body = (await res.body.json()) as TResponse + + if (this.cache) { + this.cache.set(cacheKey, body) + } + + return body + } +} diff --git a/src/getCacheKey.ts b/src/getCacheKey.ts new file mode 100644 index 0000000..78bf419 --- /dev/null +++ b/src/getCacheKey.ts @@ -0,0 +1,8 @@ +import { sha1 } from 'object-hash' + +export const getCacheKey = (resource: string, input?: unknown) => { + return sha1({ + resource, + input, + }) +} diff --git a/src/opaAuthPlugin.ts b/src/opaAuthPlugin.ts index 6d64c83..020f489 100644 --- a/src/opaAuthPlugin.ts +++ b/src/opaAuthPlugin.ts @@ -1,14 +1,16 @@ import type { FastifyInstance, FastifyPluginCallback } from 'fastify' -import type { PluginProps } from './types/pluginProps' +import type { Cache, PluginProps } from './types' import fp from 'fastify-plugin' -import { OPAClient } from '@styra/opa' import mercuriusAuth, { MercuriusAuthOptions } from 'mercurius-auth' import mercurius from 'mercurius' import { parseDirectiveArgumentsAST } from './parseDirectiveArgumentsAST' +import { OpenPolicyAgentClient } from './OpenPolicyAgentClient' export const opaAuthPlugin: FastifyPluginCallback = fp( async (app: FastifyInstance, props: PluginProps) => { - const opa = new OPAClient(props.opaEndpoint, props.opaOptions) + const opa = new OpenPolicyAgentClient(props.opaOptions) + + app.decorate('opa', opa) app.register(mercuriusAuth, { /** @@ -19,14 +21,14 @@ export const opaAuthPlugin: FastifyPluginCallback = fp( /** * Validate directive */ - async applyPolicy(ast, parent, args, context, info) { + async applyPolicy(ast, parent, args, context) { const { path, options } = parseDirectiveArgumentsAST(ast.arguments) as { path: string options?: object } - const allowed = await opa - .evaluate(path, { + const allowed = await app.opa + .query(path, { headers: context.reply.request.headers, parent, args, @@ -40,7 +42,7 @@ export const opaAuthPlugin: FastifyPluginCallback = fp( }) }) - if (!allowed) { + if (!allowed.result) { throw new mercurius.ErrorWithProps('Not authorized', { code: 'NOT_AUTHORIZED', }) @@ -55,3 +57,9 @@ export const opaAuthPlugin: FastifyPluginCallback = fp( }, { name: 'opa-auth', dependencies: ['mercurius'] } ) + +declare module 'fastify' { + interface FastifyInstance { + opa: OpenPolicyAgentClient + } +} diff --git a/src/types/cache.ts b/src/types/cache.ts new file mode 100644 index 0000000..a3fd82f --- /dev/null +++ b/src/types/cache.ts @@ -0,0 +1,4 @@ +export type Cache = { + get(key: string): any + set(key: string, value: any): void +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..3f50528 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,2 @@ +export * from './cache' +export * from './pluginProps' diff --git a/src/types/openPolicyAgentClientProps.ts b/src/types/openPolicyAgentClientProps.ts new file mode 100644 index 0000000..fab8521 --- /dev/null +++ b/src/types/openPolicyAgentClientProps.ts @@ -0,0 +1,8 @@ +import type { Cache } from './cache' + +export type OpenPolicyAgentClientProps = { + url: string + opaVersion?: string + method?: 'POST' | 'GET' + cache?: TCache +} diff --git a/src/types/pluginProps.ts b/src/types/pluginProps.ts index 8fac687..9718870 100644 --- a/src/types/pluginProps.ts +++ b/src/types/pluginProps.ts @@ -1,10 +1,13 @@ import type { AuthContextHandler } from 'mercurius-auth' import type { MercuriusContext } from 'mercurius' -import type { Options } from '@styra/opa' +import { OpenPolicyAgentClientProps } from './openPolicyAgentClientProps' +import { Cache } from './cache' -export type PluginProps = { - opaEndpoint?: string - opaOptions?: Options +export type PluginProps< + TContext = MercuriusContext, + TCache extends Cache = Cache, +> = { + opaOptions?: OpenPolicyAgentClientProps /** * @default opa diff --git a/test/opaAuthPlugin.test.ts b/test/opaAuthPlugin.test.ts index 4ca122b..3f4a54b 100644 --- a/test/opaAuthPlugin.test.ts +++ b/test/opaAuthPlugin.test.ts @@ -1,4 +1,4 @@ -import { before, beforeEach, mock, test } from 'node:test' +import { beforeEach, test } from 'node:test' import { deepStrictEqual } from 'node:assert' import fastify from 'fastify' import { testLogger } from './helpers/testLogger' @@ -7,7 +7,7 @@ import mercurius from 'mercurius' import { createMercuriusTestClient } from 'mercurius-integration-testing' import fs from 'node:fs' import path from 'node:path' -import { MockAgent, setGlobalDispatcher, fetch } from 'undici' +import { MockAgent, setGlobalDispatcher } from 'undici' import sinon from 'sinon' import { MockInterceptor } from 'undici-types/mock-interceptor' @@ -15,36 +15,6 @@ const mockAgent = new MockAgent() mockAgent.disableNetConnect() setGlobalDispatcher(mockAgent) -before(() => { - // Replacing node builtin fetch with undici to be able to use undici's mock functionality - mock.method(global, 'fetch', async (request: Request) => { - /* - * Styra's OPA client validates the response type with Zod, ensuring it is an instance of the node built-in Response - * object. - * However, undici's Response is not actually as an instance of the node built-in Response, which causes - * Zod to raise an error. This workaround is implemented to satisfy Zod's requirements while still utilizing - * undici's mock functionality in this test suite - */ - // eslint-disable-next-line - // @ts-ignore - return fetch(request.url, { - method: request.method, - headers: Object.fromEntries(request.headers.entries()), - body: await request.body - .getReader() - .read() - .then(({ value }) => Buffer.from(value).toString('utf-8')), - }).then( - (response) => - new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }) - ) - }) -}) - beforeEach(() => { mockAgent.removeAllListeners() }) @@ -68,7 +38,9 @@ ping(message: String!): String! }) app.register(opaAuthPlugin, { - opaEndpoint: 'http://opa.test:3000', + opaOptions: { + url: 'http://opa.test:3000', + }, }) const testClient = createMercuriusTestClient(app) @@ -98,7 +70,9 @@ ping(message: String!): String! @opa(path: "query/ping", options: { bar: "foo", }) app.register(opaAuthPlugin, { - opaEndpoint: 'http://opa.test:3000', + opaOptions: { + url: 'http://opa.test:3000', + }, }) const opaPolicyMock = sinon @@ -157,7 +131,9 @@ type Query { }) app.register(opaAuthPlugin, { - opaEndpoint: 'http://opa.test:3000', + opaOptions: { + url: 'http://opa.test:3000', + }, }) mockAgent