Skip to content
Draft
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
1 change: 1 addition & 0 deletions api/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ const startServer = async () => {
app.use('/api/projects', routes.projects);
app.use('/api/prompts', routes.prompts);
app.use('/api/skills', routes.skills);
app.use('/api/artifact-apps', routes.artifactApps);
app.use('/api/categories', routes.categories);
app.use('/api/endpoints', routes.endpoints);
app.use('/api/balance', routes.balance);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
const { ResourceType } = require('librechat-data-provider');
const { canAccessResource } = require('./canAccessResource');
const { resolveArtifactAppId } = require('~/models');

/**
* Artifact-app-specific middleware factory that checks access permissions.
* Wraps the generic `canAccessResource` with the ARTIFACT_APP resource type and
* `resolveArtifactAppId` (custom `app_*` id → ObjectId) as the ID resolver.
*
* @param {Object} options
* @param {number} options.requiredPermission - Permission bit required (1=view, 2=edit, 4=delete, 8=share)
* @param {string} [options.resourceIdParam='id'] - Route parameter holding the artifact app id
* @returns {Function} Express middleware
*/
const canAccessArtifactAppResource = (options) => {
const { requiredPermission, resourceIdParam = 'id' } = options || {};

if (!requiredPermission || typeof requiredPermission !== 'number') {
throw new Error(
'canAccessArtifactAppResource: requiredPermission is required and must be a number',
);
}

return canAccessResource({
resourceType: ResourceType.ARTIFACT_APP,
requiredPermission,
resourceIdParam,
idResolver: (artifactAppId) => resolveArtifactAppId({ artifactAppId }),
});
};

module.exports = {
canAccessArtifactAppResource,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { ResourceType, PrincipalType, PrincipalModel } = require('librechat-data-provider');
const { canAccessArtifactAppResource } = require('./canAccessArtifactAppResource');
const { User, Role, AclEntry } = require('~/db/models');
const { createArtifactAppWithVersion } = require('~/models');

const VIEW = 1;
const EDIT = 2;
const DELETE = 4;

/** Owner ACL bits (view+edit+delete+share), matching what publish grants. */
const OWNER_BITS = 15;

describe('canAccessArtifactAppResource middleware', () => {
let mongoServer;
let req, res, next;
let testUser;

const publishApp = async (createdBy) =>
createArtifactAppWithVersion({
title: 'Test App',
visibility: 'private',
createdBy: String(createdBy),
version: {
artifactType: 'react',
sourceSnapshot: 'export default () => <div>hi</div>;',
createdBy: String(createdBy),
},
});

const grant = async ({ principalId, resourceId, permBits, grantedBy }) =>
AclEntry.create({
principalType: PrincipalType.USER,
principalId,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.ARTIFACT_APP,
resourceId,
permBits,
grantedBy: grantedBy ?? principalId,
});

beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());
});

afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});

beforeEach(async () => {
await mongoose.connection.dropDatabase();
await Role.create({ name: 'test-role', permissions: {} });
testUser = await User.create({
email: 'test@example.com',
name: 'Test User',
username: 'testuser',
role: 'test-role',
});

req = { user: { id: testUser._id, role: testUser.role }, params: {} };
res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
next = jest.fn();
jest.clearAllMocks();
});

describe('middleware factory', () => {
test('throws when requiredPermission is missing', () => {
expect(() => canAccessArtifactAppResource({})).toThrow(
'canAccessArtifactAppResource: requiredPermission is required and must be a number',
);
});

test('throws when requiredPermission is not a number', () => {
expect(() => canAccessArtifactAppResource({ requiredPermission: '1' })).toThrow(
'canAccessArtifactAppResource: requiredPermission is required and must be a number',
);
});

test('throws when called with no options at all', () => {
expect(() => canAccessArtifactAppResource()).toThrow(
'canAccessArtifactAppResource: requiredPermission is required and must be a number',
);
});

test('returns an express middleware with the default resourceIdParam', () => {
const middleware = canAccessArtifactAppResource({ requiredPermission: VIEW });
expect(typeof middleware).toBe('function');
expect(middleware.length).toBe(3);
});
});

describe('permission checking against real ACL entries', () => {
test('allows the owner to view their own app', async () => {
const { app } = await publishApp(testUser._id);
await grant({ principalId: testUser._id, resourceId: app._id, permBits: OWNER_BITS });
req.params.id = app.artifactAppId;

await canAccessArtifactAppResource({ requiredPermission: VIEW })(req, res, next);

expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});

test('denies a user with no ACL entry for the app', async () => {
const otherUser = await User.create({
email: 'other@example.com',
name: 'Other User',
username: 'otheruser',
role: 'test-role',
});
const { app } = await publishApp(otherUser._id);
await grant({ principalId: otherUser._id, resourceId: app._id, permBits: OWNER_BITS });
req.params.id = app.artifactAppId;

await canAccessArtifactAppResource({ requiredPermission: VIEW })(req, res, next);

expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Forbidden',
message: `Insufficient permissions to access this ${ResourceType.ARTIFACT_APP}`,
});
});

/* The route table gates PATCH on EDIT and DELETE on DELETE, so a
* view-only grant must not satisfy either. */
test.each([
['edit', EDIT],
['delete', DELETE],
])('denies %s when the grant is view-only', async (_label, requiredPermission) => {
const { app } = await publishApp(testUser._id);
await grant({ principalId: testUser._id, resourceId: app._id, permBits: VIEW });
req.params.id = app.artifactAppId;

await canAccessArtifactAppResource({ requiredPermission })(req, res, next);

expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});

test('allows edit when the grant carries edit bits', async () => {
const { app } = await publishApp(testUser._id);
await grant({ principalId: testUser._id, resourceId: app._id, permBits: VIEW | EDIT });
req.params.id = app.artifactAppId;

await canAccessArtifactAppResource({ requiredPermission: EDIT })(req, res, next);

expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});

test('returns 404 for an unknown artifact app id', async () => {
req.params.id = 'app_does_not_exist';

await canAccessArtifactAppResource({ requiredPermission: VIEW })(req, res, next);

expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({
error: 'Not Found',
message: `${ResourceType.ARTIFACT_APP} not found`,
});
});

/* A grant on the same ObjectId under a different resourceType must not
* leak across: ACL rows are keyed by (resourceType, resourceId). */
test('ignores a grant recorded under a different resourceType', async () => {
const { app } = await publishApp(testUser._id);
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.AGENT,
resourceId: app._id,
permBits: OWNER_BITS,
grantedBy: testUser._id,
});
req.params.id = app.artifactAppId;

await canAccessArtifactAppResource({ requiredPermission: VIEW })(req, res, next);

expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});

test('reads the app id from a custom resourceIdParam', async () => {
const { app } = await publishApp(testUser._id);
await grant({ principalId: testUser._id, resourceId: app._id, permBits: OWNER_BITS });
req.params.appId = app.artifactAppId;

await canAccessArtifactAppResource({
requiredPermission: VIEW,
resourceIdParam: 'appId',
})(req, res, next);

expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
});
});
2 changes: 2 additions & 0 deletions api/server/middleware/accessResources/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const { canAccessPromptViaGroup } = require('./canAccessPromptViaGroup');
const { canAccessPromptGroupResource } = require('./canAccessPromptGroupResource');
const { canAccessMCPServerResource } = require('./canAccessMCPServerResource');
const { canAccessSkillResource } = require('./canAccessSkillResource');
const { canAccessArtifactAppResource } = require('./canAccessArtifactAppResource');

module.exports = {
canAccessResource,
Expand All @@ -14,4 +15,5 @@ module.exports = {
canAccessPromptGroupResource,
canAccessMCPServerResource,
canAccessSkillResource,
canAccessArtifactAppResource,
};
102 changes: 102 additions & 0 deletions api/server/routes/artifactApps.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
const express = require('express');
const { createArtifactAppHandlers } = require('@librechat/api');
const { PermissionBits } = require('librechat-data-provider');
const {
createArtifactAppWithVersion,
getArtifactAppByAppId,
resolveArtifactAppId,
listArtifactApps,
updateArtifactApp,
deleteArtifactApp,
getArtifactVersion,
listArtifactVersions,
createArtifactVersion,
releaseArtifactVersion,
activateArtifactVersion,
withdrawArtifactVersion,
recordAuditEntry,
} = require('~/models');
const { requireJwtAuth, canAccessArtifactAppResource } = require('~/server/middleware');
const {

Check failure on line 20 in api/server/routes/artifactApps.js

View workflow job for this annotation

GitHub Actions / Run ESLint Linting

Replace `⏎··findAccessibleResources,⏎··grantPermission,⏎` with `·findAccessibleResources,·grantPermission·`
findAccessibleResources,
grantPermission,
} = require('~/server/services/PermissionService');
const configMiddleware = require('~/server/middleware/config/app');

const router = express.Router();

router.use(requireJwtAuth);
router.use(configMiddleware);

const handlers = createArtifactAppHandlers({
createArtifactAppWithVersion,
getArtifactAppByAppId,
resolveArtifactAppId,
listArtifactApps,
updateArtifactApp,
deleteArtifactApp,
getArtifactVersion,
listArtifactVersions,
createArtifactVersion,
releaseArtifactVersion,
activateArtifactVersion,
withdrawArtifactVersion,
findAccessibleResources,
grantPermission,
recordAuditEntry,
});

// Collection
router.get('/', handlers.list);
router.post('/', handlers.publish);

// Single app
router.get(
'/:id',
canAccessArtifactAppResource({ requiredPermission: PermissionBits.VIEW }),
handlers.get,
);
router.patch(
'/:id',
canAccessArtifactAppResource({ requiredPermission: PermissionBits.EDIT }),
handlers.update,
);
router.delete(
'/:id',
canAccessArtifactAppResource({ requiredPermission: PermissionBits.DELETE }),
handlers.remove,
);

// Versions
router.get(
'/:id/versions',
canAccessArtifactAppResource({ requiredPermission: PermissionBits.VIEW }),
handlers.listVersions,
);
router.post(
'/:id/versions',
canAccessArtifactAppResource({ requiredPermission: PermissionBits.EDIT }),
handlers.createVersion,
);
router.get(
'/:id/versions/:versionId',
canAccessArtifactAppResource({ requiredPermission: PermissionBits.VIEW }),
handlers.getVersion,
);
router.post(
'/:id/versions/:versionId/release',
canAccessArtifactAppResource({ requiredPermission: PermissionBits.EDIT }),
handlers.releaseVersion,
);
router.post(
'/:id/versions/:versionId/activate',
canAccessArtifactAppResource({ requiredPermission: PermissionBits.EDIT }),
handlers.activateVersion,
);
router.post(
'/:id/versions/:versionId/withdraw',
canAccessArtifactAppResource({ requiredPermission: PermissionBits.EDIT }),
handlers.withdrawVersion,
);

module.exports = router;
2 changes: 2 additions & 0 deletions api/server/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const presets = require('./presets');
const projects = require('./projects');
const prompts = require('./prompts');
const skills = require('./skills');
const artifactApps = require('./artifactApps');
const balance = require('./balance');
const actions = require('./actions');
const apiKeys = require('./apiKeys');
Expand Down Expand Up @@ -68,6 +69,7 @@ module.exports = {
prompts,
projects,
skills,
artifactApps,
actions,
presets,
balance,
Expand Down
Loading
Loading