diff --git a/workspaces/lightspeed/plugins/lightspeed-backend/package.json b/workspaces/lightspeed/plugins/lightspeed-backend/package.json index 6772f90c..fdbc5d77 100644 --- a/workspaces/lightspeed/plugins/lightspeed-backend/package.json +++ b/workspaces/lightspeed/plugins/lightspeed-backend/package.json @@ -11,9 +11,11 @@ "backstage": { "role": "backend-plugin", "pluginId": "lightspeed", + "pluginPackage": "@red-hat-developer-hub/backstage-plugin-lightspeed-backend", "pluginPackages": [ "@red-hat-developer-hub/backstage-plugin-lightspeed", - "@red-hat-developer-hub/backstage-plugin-lightspeed-backend" + "@red-hat-developer-hub/backstage-plugin-lightspeed-backend", + "@red-hat-developer-hub/backstage-plugin-lightspeed-common" ], "supported-versions": "1.32.5" }, @@ -44,8 +46,12 @@ "dependencies": { "@backstage/backend-defaults": "^0.5.2", "@backstage/backend-plugin-api": "^1.0.1", + "@backstage/errors": "^1.2.4", + "@backstage/plugin-permission-common": "^0.8.1", + "@backstage/plugin-permission-node": "^0.8.5", "@langchain/core": "^0.2.30", "@langchain/openai": "^0.2.8", + "@red-hat-developer-hub/backstage-plugin-lightspeed-common": "workspace:^", "express": "^4.21.1", "http-proxy-middleware": "^3.0.2" }, diff --git a/workspaces/lightspeed/plugins/lightspeed-backend/src/plugin.ts b/workspaces/lightspeed/plugins/lightspeed-backend/src/plugin.ts index f330a45f..0c64f8c6 100644 --- a/workspaces/lightspeed/plugins/lightspeed-backend/src/plugin.ts +++ b/workspaces/lightspeed/plugins/lightspeed-backend/src/plugin.ts @@ -34,14 +34,16 @@ export const lightspeedPlugin = createBackendPlugin({ http: coreServices.httpRouter, httpAuth: coreServices.httpAuth, userInfo: coreServices.userInfo, + permissions: coreServices.permissions, }, - async init({ logger, config, http, httpAuth, userInfo }) { + async init({ logger, config, http, httpAuth, userInfo, permissions }) { http.use( await createRouter({ config: config, logger: logger, httpAuth: httpAuth, userInfo: userInfo, + permissions, }), ); diff --git a/workspaces/lightspeed/plugins/lightspeed-backend/src/service/permission.test.ts b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/permission.test.ts new file mode 100644 index 00000000..a43e3f75 --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/permission.test.ts @@ -0,0 +1,63 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { mockCredentials } from '@backstage/backend-test-utils'; +import { + AuthorizeResult, + createPermission, +} from '@backstage/plugin-permission-common'; + +import { userPermissionAuthorization } from './permission'; + +describe('userPermissionAuthorization', () => { + let mockPermissionsService: any; + const mockPermission = createPermission({ + name: 'test.permission', + attributes: { action: 'read' }, + }); + + beforeEach(() => { + mockPermissionsService = { + authorize: jest.fn(), + }; + }); + + it('should not throw NotAllowedError', async () => { + mockPermissionsService.authorize.mockResolvedValueOnce([ + { result: AuthorizeResult.ALLOW }, + ]); + + const { authorizeUser } = userPermissionAuthorization( + mockPermissionsService, + ); + + const result = await authorizeUser(mockPermission, mockCredentials.user()); + expect(() => result).not.toThrow(); + }); + + it('should throw NotAllowedError when authorization is denied', async () => { + mockPermissionsService.authorize.mockResolvedValueOnce([ + { result: AuthorizeResult.DENY }, + ]); + + const { authorizeUser } = userPermissionAuthorization( + mockPermissionsService, + ); + + await expect( + authorizeUser(mockPermission, mockCredentials.user()), + ).rejects.toThrow('Unauthorized'); + }); +}); diff --git a/workspaces/lightspeed/plugins/lightspeed-backend/src/service/permission.ts b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/permission.ts new file mode 100644 index 00000000..800c344e --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/permission.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + BackstageCredentials, + PermissionsService, +} from '@backstage/backend-plugin-api'; +import { NotAllowedError } from '@backstage/errors'; +import { + AuthorizeResult, + BasicPermission, +} from '@backstage/plugin-permission-common'; + +export function userPermissionAuthorization( + permissionsService: PermissionsService, +) { + const permissions = permissionsService; + + return { + async authorizeUser( + permission: BasicPermission, + credentials: BackstageCredentials, + ): Promise { + const decision = ( + await permissions.authorize( + [ + { + permission, + }, + ], + { credentials }, + ) + )[0]; + + if (decision.result !== AuthorizeResult.ALLOW) { + throw new NotAllowedError('Unauthorized'); + } + }, + }; +} diff --git a/workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.test.ts b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.test.ts index 382db45a..4a1b4d7b 100644 --- a/workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.test.ts +++ b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.test.ts @@ -19,6 +19,7 @@ import { mockServices, startTestBackend, } from '@backstage/backend-test-utils'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; import { AIMessage, HumanMessage } from '@langchain/core/messages'; import { ChatPromptTemplate } from '@langchain/core/prompts'; @@ -129,7 +130,10 @@ describe('lightspeed router tests', () => { server.resetHandlers(); }); - async function startBackendServer(config?: Record) { + async function startBackendServer( + config?: Record, + authorizeResult?: AuthorizeResult.DENY | AuthorizeResult.ALLOW, + ) { const features: (BackendFeature | Promise<{ default: BackendFeature }>)[] = [ lightspeedPlugin, @@ -140,6 +144,11 @@ describe('lightspeed router tests', () => { mockServices.httpAuth.factory({ defaultCredentials: mockCredentials.user(mockUserId), }), + mockServices.permissions.mock({ + authorize: async () => [ + { result: authorizeResult ?? AuthorizeResult.ALLOW }, + ], + }).factory, mockServices.userInfo.factory(), ]; return (await startTestBackend({ features })).server; @@ -182,6 +191,14 @@ describe('lightspeed router tests', () => { }); describe('POST /conversations', () => { + it('should fail with unauthorized error while creating new conversation_id', async () => { + const backendServer = await startBackendServer({}, AuthorizeResult.DENY); + const response = await request(backendServer).post( + `/api/lightspeed/conversations`, + ); + + expect(response.statusCode).toEqual(403); + }); it('generate new conversation_id', async () => { const backendServer = await startBackendServer(); const response = await request(backendServer).post( @@ -244,6 +261,14 @@ describe('lightspeed router tests', () => { expect(responseData[1].kwargs?.response_metadata.model).toBe(mockModel); }); + it('should fail with unauthorized error while fetching conversation history', async () => { + const backendServer = await startBackendServer({}, AuthorizeResult.DENY); + const response = await request(backendServer).get( + `/api/lightspeed/conversations/${encodedConversationId}`, + ); + expect(response.statusCode).toEqual(403); + }); + it('delete history', async () => { // delete request const backendServer = await startBackendServer(); @@ -253,6 +278,15 @@ describe('lightspeed router tests', () => { expect(deleteResponse.statusCode).toEqual(200); }); + it('should fail with unauthorized error while deleting a conversation history', async () => { + // delete request + const backendServer = await startBackendServer({}, AuthorizeResult.DENY); + const deleteResponse = await request(backendServer).delete( + `/api/lightspeed/conversations/${encodedConversationId}`, + ); + expect(deleteResponse.statusCode).toEqual(403); + }); + it('load history with deleted conversation_id', async () => { await deleteHistory(mockConversationId); const backendServer = await startBackendServer(); @@ -344,6 +378,19 @@ describe('lightspeed router tests', () => { expect(receivedData).toEqual(expectedData); }); + it('should fail with unauthorized error in chat completion API', async () => { + const backendServer = await startBackendServer({}, AuthorizeResult.DENY); + const chatCompletionResponse = await request(backendServer) + .post('/api/lightspeed/v1/query') + .send({ + model: mockModel, + conversation_id: mockConversationId, + query: 'Hello', + serverURL: LOCAL_AI_ADDR, + }); + expect(chatCompletionResponse.statusCode).toEqual(403); + }); + it('should not have any history for the initial conversation', async () => { const mockStream = jest.fn().mockImplementation(async function* stream() { yield { content: 'Chunk 1', response_metadata: {} }; diff --git a/workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts index 8312d353..fc63a022 100644 --- a/workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts +++ b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts @@ -14,6 +14,8 @@ * limitations under the License. */ import { MiddlewareFactory } from '@backstage/backend-defaults/rootHttpRouter'; +import { NotAllowedError } from '@backstage/errors'; +import { createPermissionIntegrationRouter } from '@backstage/plugin-permission-node'; import { BaseMessage, HumanMessage } from '@langchain/core/messages'; import { @@ -24,6 +26,13 @@ import { ChatOpenAI } from '@langchain/openai'; import express, { Router } from 'express'; import { createProxyMiddleware } from 'http-proxy-middleware'; +import { + lightspeedConversationsCreatePermission, + lightspeedConversationsDeletePermission, + lightspeedConversationsReadPermission, + lightspeedPermissions, +} from '@red-hat-developer-hub/backstage-plugin-lightspeed-common'; + import { deleteHistory, loadAllConversations, @@ -35,6 +44,7 @@ import { generateConversationId, validateUserRequest, } from '../handlers/conversationId'; +import { userPermissionAuthorization } from './permission'; import { ConversationSummary, DEFAULT_HISTORY_LENGTH, @@ -54,7 +64,7 @@ import { export async function createRouter( options: RouterOptions, ): Promise { - const { logger, config, httpAuth, userInfo } = options; + const { logger, config, httpAuth, userInfo, permissions } = options; const router = Router(); router.use(express.json()); @@ -63,17 +73,35 @@ export async function createRouter( response.json({ status: 'ok' }); }); + const permissionIntegrationRouter = createPermissionIntegrationRouter({ + permissions: lightspeedPermissions, + }); + router.use(permissionIntegrationRouter); + + const authorizer = userPermissionAuthorization(permissions); + // Middleware proxy to exclude /v1/query router.use('/v1', async (req, res, next) => { if (req.path === '/query') { return next(); // This will skip proxying and go to /v1/query endpoint } - // TODO: parse server_id from req.body and get URL and token when multi-server is supported - const user = await userInfo.getUserInfo(await httpAuth.credentials(req)); + const credentials = await httpAuth.credentials(req); + const user = await userInfo.getUserInfo(credentials); const userEntity = user.userEntityRef; logger.info(`/v1 receives call from user: ${userEntity}`); + try { + await authorizer.authorizeUser( + lightspeedConversationsReadPermission, + credentials, + ); + } catch (error) { + if (error instanceof NotAllowedError) { + logger.error(error.message); + res.status(403).json({ error: error.message }); + } + } // Proxy middleware configuration const apiProxy = createProxyMiddleware({ @@ -90,31 +118,46 @@ export async function createRouter( router.post('/conversations', async (request, response) => { try { - const userEntity = await userInfo.getUserInfo( - await httpAuth.credentials(request), - ); + const credentials = await httpAuth.credentials(request, { + allow: ['user'], + }); + + const userEntity = await userInfo.getUserInfo(credentials); const user_id = userEntity.userEntityRef; logger.info(`POST /conversations receives call from user: ${user_id}`); - const conversation_id = generateConversationId(user_id); + await authorizer.authorizeUser( + lightspeedConversationsCreatePermission, + credentials, + ); + const conversation_id = generateConversationId(user_id); response.status(200).json({ conversation_id: conversation_id }); response.end(); } catch (error) { const errormsg = `${error}`; logger.error(errormsg); - response.status(500).json({ error: errormsg }); + if (error instanceof NotAllowedError) { + response.status(403).json({ error: error.message }); + } else { + response.status(500).json({ error: errormsg }); + } } }); router.get('/conversations', async (request, response) => { try { - const userEntity = await userInfo.getUserInfo( - await httpAuth.credentials(request), - ); + const credentials = await httpAuth.credentials(request); + const userEntity = await userInfo.getUserInfo(credentials); const user_id = userEntity.userEntityRef; logger.info(`GET /conversations receives call from user: ${user_id}`); + + await authorizer.authorizeUser( + lightspeedConversationsReadPermission, + credentials, + ); + const conversationList = await loadAllConversations(user_id); const conversationSummaryList: ConversationSummary[] = []; @@ -208,7 +251,12 @@ export async function createRouter( const errormsg = `${error}`; logger.error(errormsg); console.log(errormsg); - response.status(500).json({ error: errormsg }); + + if (error instanceof NotAllowedError) { + response.status(403).json({ error: error.message }); + } else { + response.status(500).json({ error: errormsg }); + } } }); @@ -221,13 +269,16 @@ export async function createRouter( const loadhistoryLength: number = historyLength || DEFAULT_HISTORY_LENGTH; try { - const userEntity = await userInfo.getUserInfo( - await httpAuth.credentials(request), - ); + const credentials = await httpAuth.credentials(request); + const userEntity = await userInfo.getUserInfo(credentials); const user_id = userEntity.userEntityRef; logger.info( `GET /conversations/:conversation_id receives call from user: ${user_id}`, ); + await authorizer.authorizeUser( + lightspeedConversationsReadPermission, + credentials, + ); validateUserRequest(conversation_id, user_id); // will throw error and return 500 with error message if user_id does not match @@ -240,7 +291,11 @@ export async function createRouter( } catch (error) { const errormsg = `${error}`; logger.error(errormsg); - response.status(500).json({ error: errormsg }); + if (error instanceof NotAllowedError) { + response.status(403).json({ error: error.message }); + } else { + response.status(500).json({ error: errormsg }); + } } }, ); @@ -250,15 +305,20 @@ export async function createRouter( async (request, response) => { const conversation_id = request.params.conversation_id; try { - const userEntity = await userInfo.getUserInfo( - await httpAuth.credentials(request), - ); + const credentials = await httpAuth.credentials(request); + const userEntity = await userInfo.getUserInfo(credentials); + const user_id = userEntity.userEntityRef; logger.info( `DELETE /conversations/:conversation_id receives call from user: ${user_id}`, ); + await authorizer.authorizeUser( + lightspeedConversationsDeletePermission, + credentials, + ); + validateUserRequest(conversation_id, user_id); // will throw error and return 500 with error message if user_id does not match response.status(200).json(await deleteHistory(conversation_id)); @@ -266,7 +326,11 @@ export async function createRouter( } catch (error) { const errormsg = `${error}`; logger.error(errormsg); - response.status(500).json({ error: errormsg }); + if (error instanceof NotAllowedError) { + response.status(403).json({ error: error.message }); + } else { + response.status(500).json({ error: errormsg }); + } } }, ); @@ -278,14 +342,19 @@ export async function createRouter( const { conversation_id, model, query, serverURL }: QueryRequestBody = request.body; try { - const userEntity = await userInfo.getUserInfo( - await httpAuth.credentials(request), - ); + const credentials = await httpAuth.credentials(request); + const userEntity = await userInfo.getUserInfo(credentials); const user_id = userEntity.userEntityRef; logger.info(`/v1/query receives call from user: ${user_id}`); + validateUserRequest(conversation_id, user_id); // will throw error and return 500 with error message if user_id does not match + await authorizer.authorizeUser( + lightspeedConversationsCreatePermission, + credentials, + ); + // currently only supports single server const apiToken = config .getConfigArray('lightspeed.servers')[0] @@ -370,7 +439,12 @@ export async function createRouter( } catch (error) { const errormsg = `Error fetching completions from ${serverURL}: ${error}`; logger.error(errormsg); - response.status(500).json({ error: errormsg }); + + if (error instanceof NotAllowedError) { + response.status(403).json({ error: error.message }); + } else { + response.status(500).json({ error: errormsg }); + } } }, ); diff --git a/workspaces/lightspeed/plugins/lightspeed-backend/src/service/types.ts b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/types.ts index 89718596..bc7f3e34 100644 --- a/workspaces/lightspeed/plugins/lightspeed-backend/src/service/types.ts +++ b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/types.ts @@ -16,6 +16,7 @@ import type { HttpAuthService, LoggerService, + PermissionsService, UserInfoService, } from '@backstage/backend-plugin-api'; import type { Config } from '@backstage/config'; @@ -29,6 +30,7 @@ export type RouterOptions = { config: Config; httpAuth: HttpAuthService; userInfo: UserInfoService; + permissions: PermissionsService; }; /** diff --git a/workspaces/lightspeed/yarn.lock b/workspaces/lightspeed/yarn.lock index 9f843fcf..2e665b1f 100644 --- a/workspaces/lightspeed/yarn.lock +++ b/workspaces/lightspeed/yarn.lock @@ -10821,9 +10821,13 @@ __metadata: "@backstage/backend-test-utils": 1.0.2 "@backstage/cli": 0.28.2 "@backstage/config": 1.2.0 + "@backstage/errors": ^1.2.4 + "@backstage/plugin-permission-common": ^0.8.1 + "@backstage/plugin-permission-node": ^0.8.5 "@ianvs/prettier-plugin-sort-imports": ^4.4.0 "@langchain/core": ^0.2.30 "@langchain/openai": ^0.2.8 + "@red-hat-developer-hub/backstage-plugin-lightspeed-common": "workspace:^" "@spotify/prettier-config": ^15.0.0 "@types/express": 4.17.21 "@types/supertest": 2.0.16 @@ -10835,6 +10839,18 @@ __metadata: languageName: unknown linkType: soft +"@red-hat-developer-hub/backstage-plugin-lightspeed-common@workspace:^, @red-hat-developer-hub/backstage-plugin-lightspeed-common@workspace:plugins/lightspeed-common": + version: 0.0.0-use.local + resolution: "@red-hat-developer-hub/backstage-plugin-lightspeed-common@workspace:plugins/lightspeed-common" + dependencies: + "@backstage/cli": ^0.28.0 + "@backstage/plugin-permission-common": ^0.8.1 + "@ianvs/prettier-plugin-sort-imports": ^4.4.0 + "@spotify/prettier-config": ^15.0.0 + prettier: 3.3.3 + languageName: unknown + linkType: soft + "@red-hat-developer-hub/backstage-plugin-lightspeed@*, @red-hat-developer-hub/backstage-plugin-lightspeed@workspace:plugins/lightspeed": version: 0.0.0-use.local resolution: "@red-hat-developer-hub/backstage-plugin-lightspeed@workspace:plugins/lightspeed" @@ -10844,13 +10860,16 @@ __metadata: "@backstage/core-components": ^0.15.1 "@backstage/core-plugin-api": ^1.10.0 "@backstage/dev-utils": 1.1.2 + "@backstage/plugin-permission-react": ^0.4.28 "@backstage/test-utils": 1.7.0 "@backstage/theme": ^0.6.0 "@emotion/is-prop-valid": ^1.3.1 "@ianvs/prettier-plugin-sort-imports": ^4.4.0 "@material-ui/core": ^4.9.13 + "@material-ui/lab": ^4.0.0-alpha.61 "@patternfly/react-core": 6.0.0-prerelease.21 "@patternfly/virtual-assistant": 2.0.0-alpha.61 + "@red-hat-developer-hub/backstage-plugin-lightspeed-common": "workspace:^" "@spotify/prettier-config": ^15.0.0 "@tanstack/react-query": ^5.59.15 "@testing-library/dom": ^10.0.0