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(lightspeed): add permission support in lightspeed plugins #66

Merged
merged 7 commits into from
Nov 28, 2024
Merged
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
10 changes: 10 additions & 0 deletions workspaces/lightspeed/.changeset/fuzzy-suns-swim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@red-hat-developer-hub/backstage-plugin-lightspeed-backend': minor
---

- add RBAC permission support
- list all conversations for logged in users and generate conversation summary. [#2465](https://github.com/janus-idp/backstage-plugins/pull/2465)
- add conversation create API endpoint. [#2403](https://github.com/janus-idp/backstage-plugins/pull/2403)
- add streaming support in chat completion API. [#2238](https://github.com/janus-idp/backstage-plugins/pull/2238)
- add conversation GET and DELETE API endpoints. [#2211](https://github.com/janus-idp/backstage-plugins/pull/2211)
- Lightspeed backend plugin [#2115](https://github.com/janus-idp/backstage-plugins/pull/2115)
6 changes: 6 additions & 0 deletions workspaces/lightspeed/.changeset/hip-buses-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@red-hat-developer-hub/backstage-plugin-lightspeed': minor
---

- add RBAC permission support.
- add multiple chats, chat creation, deletion and searching. [#2501](https://github.com/janus-idp/backstage-plugins/pull/2501)
5 changes: 5 additions & 0 deletions workspaces/lightspeed/.changeset/tender-beans-invent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@red-hat-developer-hub/backstage-plugin-lightspeed-common': patch
---

add rbac permission support
25 changes: 25 additions & 0 deletions workspaces/lightspeed/plugins/lightspeed-backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,28 @@ lightspeed:
url: 'https://localhost:443/v1'
token: 'js92n-ssj28dbdk902' # dummy token
```

#### Permission Framework Support

The Lightspeed Backend plugin has support for the permission framework.

- When [RBAC permission](https://github.com/backstage/community-plugins/tree/main/workspaces/rbac/plugins/rbac-backend#installation) framework is enabled, for non-admin users to access lightspeed backend API, the role associated with your user should have the following permission policies associated with it. Add the following in your permission policies configuration file named `rbac-policy.csv`:

```CSV
p, role:default/team_a, lightspeed.conversations.read, read, allow
p, role:default/team_a, lightspeed.conversations.create, create, allow
p, role:default/team_a, lightspeed.conversations.delete, delete, allow

g, user:default/<your-user-name>, role:default/team_a

```

You can specify the path to this configuration file in your application configuration:

```yaml
permission:
enabled: true
rbac:
policies-csv-file: /some/path/rbac-policy.csv
policyFileReload: true
```
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,17 @@
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"publishConfig": {
"access": "public"
},
"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"
},
Expand Down Expand Up @@ -44,8 +45,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"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { Config } from '@backstage/config';
import express from 'express';
import type { HttpAuthService } from '@backstage/backend-plugin-api';
import type { LoggerService } from '@backstage/backend-plugin-api';
import type { PermissionsService } from '@backstage/backend-plugin-api';
import type { UserInfoService } from '@backstage/backend-plugin-api';

// @public
Expand All @@ -24,6 +25,7 @@ export type RouterOptions = {
config: Config;
httpAuth: HttpAuthService;
userInfo: UserInfoService;
permissions: PermissionsService;
};

// (No @packageDocumentation comment for this package)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
);

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*/
karthikjeeyar marked this conversation as resolved.
Show resolved Hide resolved
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');
});
});
Original file line number Diff line number Diff line change
@@ -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<void> {
const decision = (
await permissions.authorize(
[
{
permission,
},
],
{ credentials },
)
)[0];

if (decision.result !== AuthorizeResult.ALLOW) {
throw new NotAllowedError('Unauthorized');
}
},
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -129,7 +130,10 @@ describe('lightspeed router tests', () => {
server.resetHandlers();
});

async function startBackendServer(config?: Record<PropertyKey, unknown>) {
async function startBackendServer(
config?: Record<PropertyKey, unknown>,
authorizeResult?: AuthorizeResult.DENY | AuthorizeResult.ALLOW,
) {
const features: (BackendFeature | Promise<{ default: BackendFeature }>)[] =
[
lightspeedPlugin,
Expand All @@ -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;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand Down Expand Up @@ -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: {} };
Expand Down
Loading