mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
refactor: move skill sync admin access to api package
This commit is contained in:
parent
13508fcbe1
commit
c47bc1ecfc
5 changed files with 427 additions and 260 deletions
|
|
@ -1,6 +1,5 @@
|
|||
const express = require('express');
|
||||
const { skillSyncConfigSchema } = require('librechat-data-provider');
|
||||
const { createAdminSkillsSyncHandlers } = require('@librechat/api');
|
||||
const { createAdminSkillsSyncAccess, createAdminSkillsSyncHandlers } = require('@librechat/api');
|
||||
const { SystemCapabilities } = require('@librechat/data-schemas');
|
||||
const { hasCapability, requireCapability } = require('~/server/middleware/roles/capabilities');
|
||||
const { requireJwtAuth } = require('~/server/middleware');
|
||||
|
|
@ -12,117 +11,9 @@ const configMiddleware = require('~/server/middleware/config/app');
|
|||
const router = express.Router();
|
||||
const requireAdminAccess = requireCapability(SystemCapabilities.ACCESS_ADMIN);
|
||||
|
||||
function getCapabilityUser(req, { platformOnly = false } = {}) {
|
||||
const id = req.user?.id ?? req.user?._id?.toString?.();
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
role: req.user?.role ?? '',
|
||||
...(platformOnly ? {} : { tenantId: req.user?.tenantId }),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSkillSyncConfig(raw) {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = skillSyncConfigSchema.safeParse(raw);
|
||||
return parsed.success ? parsed.data : undefined;
|
||||
}
|
||||
|
||||
function isSameSkillSyncConfig(left, right) {
|
||||
return JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
|
||||
}
|
||||
|
||||
function hasResolvedSkillSyncOverride(req) {
|
||||
const resolved = parseSkillSyncConfig(req.config?.skillSync);
|
||||
const base = parseSkillSyncConfig(req.config?.config?.skillSync);
|
||||
return Boolean(resolved?.github && !isSameSkillSyncConfig(resolved, base));
|
||||
}
|
||||
|
||||
async function attachBaseSkillSyncConfig(req, res, next) {
|
||||
try {
|
||||
const baseConfig = await getAppConfig({ baseOnly: true });
|
||||
req.config = {
|
||||
...(req.config ?? {}),
|
||||
config: {
|
||||
...(req.config?.config ?? {}),
|
||||
skillSync: baseConfig?.skillSync,
|
||||
},
|
||||
};
|
||||
return next();
|
||||
} catch {
|
||||
return res.status(500).json({ message: 'Internal Server Error' });
|
||||
}
|
||||
}
|
||||
|
||||
async function hasSkillCapability(req, capability, { platformOnly = false } = {}) {
|
||||
const user = getCapabilityUser(req, { platformOnly });
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
return hasCapability(user, capability);
|
||||
}
|
||||
|
||||
function requireSkillCapability(capability, { platformOnly = false } = {}) {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
const user = getCapabilityUser(req, { platformOnly });
|
||||
if (!user) {
|
||||
return res.status(401).json({ message: 'Authentication required' });
|
||||
}
|
||||
if (await hasCapability(user, capability)) {
|
||||
return next();
|
||||
}
|
||||
return res.status(403).json({ message: 'Forbidden' });
|
||||
} catch {
|
||||
return res.status(500).json({ message: 'Internal Server Error' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function attachCredentialReadAccess(req, res, next) {
|
||||
try {
|
||||
const canReadCredentials = await hasSkillCapability(req, SystemCapabilities.READ_SKILLS, {
|
||||
platformOnly: true,
|
||||
});
|
||||
req.skillSyncCanReadCredentials = canReadCredentials;
|
||||
req.skillSyncAllowServerCredentials = canReadCredentials;
|
||||
return next();
|
||||
} catch {
|
||||
return res.status(500).json({ message: 'Internal Server Error' });
|
||||
}
|
||||
}
|
||||
|
||||
async function requireSyncRunCapability(req, res, next) {
|
||||
try {
|
||||
const canManagePlatform = await hasSkillCapability(req, SystemCapabilities.MANAGE_SKILLS, {
|
||||
platformOnly: true,
|
||||
});
|
||||
if (canManagePlatform) {
|
||||
req.skillSyncAllowServerCredentials = true;
|
||||
req.skillSyncCanReadCredentials = true;
|
||||
return next();
|
||||
}
|
||||
if (
|
||||
hasResolvedSkillSyncOverride(req) &&
|
||||
(await hasSkillCapability(req, SystemCapabilities.MANAGE_SKILLS))
|
||||
) {
|
||||
return res.status(403).json({
|
||||
message: 'Tenant-scoped manual skill sync requires platform credential access',
|
||||
});
|
||||
}
|
||||
return res.status(403).json({ message: 'Forbidden' });
|
||||
} catch {
|
||||
return res.status(500).json({ message: 'Internal Server Error' });
|
||||
}
|
||||
}
|
||||
|
||||
const requireReadSkills = requireSkillCapability(SystemCapabilities.READ_SKILLS);
|
||||
const requirePlatformManageSkills = requireSkillCapability(SystemCapabilities.MANAGE_SKILLS, {
|
||||
platformOnly: true,
|
||||
const syncAccess = createAdminSkillsSyncAccess({
|
||||
getAppConfig,
|
||||
hasCapability,
|
||||
});
|
||||
|
||||
const handlers = createAdminSkillsSyncHandlers({
|
||||
|
|
@ -131,14 +22,28 @@ const handlers = createAdminSkillsSyncHandlers({
|
|||
deleteCredential: deleteSkillSyncCredential,
|
||||
});
|
||||
|
||||
router.use(requireJwtAuth, requireAdminAccess, configMiddleware, attachBaseSkillSyncConfig);
|
||||
router.use(
|
||||
requireJwtAuth,
|
||||
requireAdminAccess,
|
||||
configMiddleware,
|
||||
syncAccess.attachBaseSkillSyncConfig,
|
||||
);
|
||||
|
||||
router.get('/sync/status', requireReadSkills, attachCredentialReadAccess, handlers.getSyncStatus);
|
||||
router.post('/sync/run', requireSyncRunCapability, handlers.runSync);
|
||||
router.put('/sync/credentials/:credentialKey', requirePlatformManageSkills, handlers.setCredential);
|
||||
router.get(
|
||||
'/sync/status',
|
||||
syncAccess.requireReadSkills,
|
||||
syncAccess.attachCredentialReadAccess,
|
||||
handlers.getSyncStatus,
|
||||
);
|
||||
router.post('/sync/run', syncAccess.requireSyncRunCapability, handlers.runSync);
|
||||
router.put(
|
||||
'/sync/credentials/:credentialKey',
|
||||
syncAccess.requirePlatformManageSkills,
|
||||
handlers.setCredential,
|
||||
);
|
||||
router.delete(
|
||||
'/sync/credentials/:credentialKey',
|
||||
requirePlatformManageSkills,
|
||||
syncAccess.requirePlatformManageSkills,
|
||||
handlers.deleteCredential,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -8,9 +8,8 @@ const mockRequireJwtAuth = jest.fn((req, res, next) => {
|
|||
const mockCapabilityMiddleware = jest.fn((req, res, next) => next());
|
||||
const mockRequireCapability = jest.fn(() => mockCapabilityMiddleware);
|
||||
const mockHasCapability = jest.fn().mockResolvedValue(true);
|
||||
let mockResolvedConfig = { skillSync: { github: { enabled: false, sources: [] } } };
|
||||
const mockConfigMiddleware = jest.fn((req, res, next) => {
|
||||
req.config = mockResolvedConfig;
|
||||
req.config = { skillSync: { github: { enabled: false, sources: [] } } };
|
||||
next();
|
||||
});
|
||||
const mockGetAppConfig = jest.fn();
|
||||
|
|
@ -21,16 +20,22 @@ const mockHandlers = {
|
|||
setCredential: jest.fn((req, res) => res.status(200).json({ ok: true })),
|
||||
deleteCredential: jest.fn((req, res) => res.status(200).json({ ok: true })),
|
||||
};
|
||||
const mockSyncAccess = {
|
||||
attachBaseSkillSyncConfig: jest.fn((req, res, next) => next()),
|
||||
requireReadSkills: jest.fn((req, res, next) => next()),
|
||||
attachCredentialReadAccess: jest.fn((req, res, next) => next()),
|
||||
requireSyncRunCapability: jest.fn((req, res, next) => next()),
|
||||
requirePlatformManageSkills: jest.fn((req, res, next) => next()),
|
||||
};
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
SystemCapabilities: {
|
||||
ACCESS_ADMIN: 'access:admin',
|
||||
READ_SKILLS: 'read:skills',
|
||||
MANAGE_SKILLS: 'manage:skills',
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
createAdminSkillsSyncAccess: jest.fn(() => mockSyncAccess),
|
||||
createAdminSkillsSyncHandlers: jest.fn(() => mockHandlers),
|
||||
}));
|
||||
|
||||
|
|
@ -61,12 +66,10 @@ jest.mock('~/server/services/Skills/sync', () => ({
|
|||
describe('admin skills sync routes', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockHasCapability.mockResolvedValue(true);
|
||||
mockResolvedConfig = { skillSync: { github: { enabled: false, sources: [] } } };
|
||||
mockGetAppConfig.mockResolvedValue({ skillSync: undefined });
|
||||
});
|
||||
|
||||
function createApp() {
|
||||
delete require.cache[require.resolve('./skills')];
|
||||
const router = require('./skills');
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
|
@ -74,143 +77,43 @@ describe('admin skills sync routes', () => {
|
|||
return app;
|
||||
}
|
||||
|
||||
it('requires JWT auth and admin capabilities for sync endpoints', async () => {
|
||||
it('delegates skill sync access policy to the API package', async () => {
|
||||
const app = createApp();
|
||||
|
||||
await request(app).get('/api/admin/skills/sync/status').expect(200);
|
||||
|
||||
const {
|
||||
createAdminSkillsSyncAccess,
|
||||
createAdminSkillsSyncHandlers,
|
||||
} = require('@librechat/api');
|
||||
expect(mockRequireCapability).toHaveBeenCalledWith('access:admin');
|
||||
expect(createAdminSkillsSyncAccess).toHaveBeenCalledWith({
|
||||
getAppConfig: mockGetAppConfig,
|
||||
hasCapability: mockHasCapability,
|
||||
});
|
||||
expect(createAdminSkillsSyncHandlers).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ getRunner: mockGetGitHubSkillSyncRunnerForRequest }),
|
||||
);
|
||||
expect(mockRequireJwtAuth).toHaveBeenCalled();
|
||||
expect(mockCapabilityMiddleware).toHaveBeenCalled();
|
||||
expect(mockConfigMiddleware).toHaveBeenCalled();
|
||||
expect(mockSyncAccess.attachBaseSkillSyncConfig).toHaveBeenCalled();
|
||||
expect(mockSyncAccess.requireReadSkills).toHaveBeenCalled();
|
||||
expect(mockSyncAccess.attachCredentialReadAccess).toHaveBeenCalled();
|
||||
expect(mockHandlers.getSyncStatus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('mounts package access middlewares before each sync endpoint handler', async () => {
|
||||
const app = createApp();
|
||||
|
||||
await request(app).post('/api/admin/skills/sync/run').expect(200);
|
||||
await request(app).put('/api/admin/skills/sync/credentials/default').send({}).expect(200);
|
||||
await request(app).delete('/api/admin/skills/sync/credentials/default').expect(200);
|
||||
|
||||
expect(mockRequireCapability).toHaveBeenCalledWith('access:admin');
|
||||
expect(mockRequireJwtAuth).toHaveBeenCalled();
|
||||
expect(mockCapabilityMiddleware).toHaveBeenCalled();
|
||||
expect(mockConfigMiddleware).toHaveBeenCalled();
|
||||
expect(mockHasCapability).toHaveBeenCalledTimes(5);
|
||||
expect(mockHasCapability).toHaveBeenCalledWith(
|
||||
{ id: 'user-1', role: 'ADMIN', tenantId: 'tenant-a' },
|
||||
'read:skills',
|
||||
);
|
||||
expect(mockHasCapability).toHaveBeenCalledWith({ id: 'user-1', role: 'ADMIN' }, 'read:skills');
|
||||
expect(mockHasCapability).toHaveBeenCalledWith(
|
||||
{ id: 'user-1', role: 'ADMIN' },
|
||||
'manage:skills',
|
||||
);
|
||||
const { createAdminSkillsSyncHandlers } = require('@librechat/api');
|
||||
expect(createAdminSkillsSyncHandlers).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ getRunner: mockGetGitHubSkillSyncRunnerForRequest }),
|
||||
);
|
||||
});
|
||||
|
||||
it('marks credential metadata hidden for tenant-scoped status reads', async () => {
|
||||
mockHasCapability.mockImplementation(async (user, capability) => {
|
||||
if (capability === 'read:skills') {
|
||||
return Boolean(user.tenantId);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const app = createApp();
|
||||
|
||||
await request(app).get('/api/admin/skills/sync/status').expect(200);
|
||||
|
||||
const req = mockHandlers.getSyncStatus.mock.calls[0][0];
|
||||
expect(req.skillSyncCanReadCredentials).toBe(false);
|
||||
expect(req.skillSyncAllowServerCredentials).toBe(false);
|
||||
});
|
||||
|
||||
it('prevents tenant admins from running overrides that require server credentials', async () => {
|
||||
const skillSync = {
|
||||
github: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
runOnStartup: false,
|
||||
sources: [
|
||||
{
|
||||
id: 'tenant-skills',
|
||||
owner: 'LibreChat',
|
||||
repo: 'skills',
|
||||
ref: 'main',
|
||||
paths: ['skills'],
|
||||
token: '${GITHUB_SKILLS_TOKEN}',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
mockResolvedConfig = { skillSync, config: {} };
|
||||
mockHasCapability.mockImplementation(async (user, capability) => {
|
||||
if (capability === 'manage:skills') {
|
||||
return Boolean(user.tenantId);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
mockGetAppConfig.mockResolvedValue({ skillSync: undefined });
|
||||
const app = createApp();
|
||||
|
||||
await request(app).post('/api/admin/skills/sync/run').expect(403);
|
||||
|
||||
expect(mockHandlers.runSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prevents tenant admins from manually running base skill sync config', async () => {
|
||||
const skillSync = {
|
||||
github: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
runOnStartup: false,
|
||||
sources: [
|
||||
{
|
||||
id: 'base-skills',
|
||||
owner: 'LibreChat',
|
||||
repo: 'skills',
|
||||
ref: 'main',
|
||||
paths: ['skills'],
|
||||
token: '${GITHUB_SKILLS_TOKEN}',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
mockResolvedConfig = { skillSync };
|
||||
mockGetAppConfig.mockResolvedValue({ skillSync });
|
||||
mockHasCapability.mockImplementation(async (user, capability) => {
|
||||
if (capability === 'manage:skills') {
|
||||
return Boolean(user.tenantId);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const app = createApp();
|
||||
|
||||
await request(app).post('/api/admin/skills/sync/run').expect(403);
|
||||
|
||||
expect(mockHandlers.runSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows platform admins to manually run base skill sync config with server credentials', async () => {
|
||||
const skillSync = {
|
||||
github: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
runOnStartup: false,
|
||||
sources: [
|
||||
{
|
||||
id: 'base-skills',
|
||||
owner: 'LibreChat',
|
||||
repo: 'skills',
|
||||
ref: 'main',
|
||||
paths: ['skills'],
|
||||
token: '${GITHUB_SKILLS_TOKEN}',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
mockResolvedConfig = { skillSync };
|
||||
mockGetAppConfig.mockResolvedValue({ skillSync });
|
||||
const app = createApp();
|
||||
|
||||
await request(app).post('/api/admin/skills/sync/run').expect(200);
|
||||
|
||||
const req = mockHandlers.runSync.mock.calls[0][0];
|
||||
expect(req.skillSyncAllowServerCredentials).toBe(true);
|
||||
expect(req.skillSyncCanReadCredentials).toBe(true);
|
||||
expect(req.config.config.skillSync).toEqual(skillSync);
|
||||
expect(mockSyncAccess.requireSyncRunCapability).toHaveBeenCalled();
|
||||
expect(mockHandlers.runSync).toHaveBeenCalled();
|
||||
expect(mockSyncAccess.requirePlatformManageSkills).toHaveBeenCalledTimes(2);
|
||||
expect(mockHandlers.setCredential).toHaveBeenCalled();
|
||||
expect(mockHandlers.deleteCredential).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@ export { createAdminConfigHandlers } from './config';
|
|||
export { createAdminGrantsHandlers } from './grants';
|
||||
export { createAdminGroupsHandlers } from './groups';
|
||||
export { createAdminRolesHandlers } from './roles';
|
||||
export { createAdminSkillsSyncHandlers } from './skills';
|
||||
export { createAdminSkillsSyncAccess, createAdminSkillsSyncHandlers } from './skills';
|
||||
export { createAdminUsersHandlers } from './users';
|
||||
export type { AdminConfigDeps } from './config';
|
||||
export type { AdminGrantsDeps, GrantPrincipalType } from './grants';
|
||||
export type { AdminGroupsDeps } from './groups';
|
||||
export type { AdminRolesDeps } from './roles';
|
||||
export type { AdminSkillSyncAccessDeps, AdminSkillSyncDeps } from './skills';
|
||||
export type { AdminUsersDeps } from './users';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Response } from 'express';
|
||||
import { createAdminSkillsSyncHandlers } from './skills';
|
||||
import { SystemCapabilities } from '@librechat/data-schemas';
|
||||
import type { NextFunction, Response } from 'express';
|
||||
import { createAdminSkillsSyncAccess, createAdminSkillsSyncHandlers } from './skills';
|
||||
|
||||
function createResponse() {
|
||||
const res = {
|
||||
|
|
@ -12,6 +13,10 @@ function createResponse() {
|
|||
};
|
||||
}
|
||||
|
||||
function createNext(): NextFunction & jest.Mock {
|
||||
return jest.fn() as NextFunction & jest.Mock;
|
||||
}
|
||||
|
||||
function createHandlers({
|
||||
statusErrorCode,
|
||||
statusErrorMessage,
|
||||
|
|
@ -184,3 +189,167 @@ describe('createAdminSkillsSyncHandlers', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAdminSkillsSyncAccess', () => {
|
||||
const baseSkillSync = {
|
||||
github: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
runOnStartup: false,
|
||||
sources: [
|
||||
{
|
||||
id: 'base-skills',
|
||||
owner: 'LibreChat',
|
||||
repo: 'skills',
|
||||
ref: 'main',
|
||||
paths: ['skills'],
|
||||
token: '${GITHUB_SKILLS_TOKEN}',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
function createAccess({
|
||||
hasCapability = jest.fn().mockResolvedValue(true),
|
||||
getAppConfig = jest.fn().mockResolvedValue({ skillSync: undefined }),
|
||||
}: {
|
||||
hasCapability?: jest.Mock;
|
||||
getAppConfig?: jest.Mock;
|
||||
} = {}) {
|
||||
return {
|
||||
access: createAdminSkillsSyncAccess({ getAppConfig, hasCapability }),
|
||||
getAppConfig,
|
||||
hasCapability,
|
||||
};
|
||||
}
|
||||
|
||||
it('attaches the base skill sync config for override comparison', async () => {
|
||||
const getAppConfig = jest.fn().mockResolvedValue({ skillSync: baseSkillSync });
|
||||
const { access } = createAccess({ getAppConfig });
|
||||
const req = { config: { skillSync: undefined, config: { endpoints: {} } } };
|
||||
const res = createResponse();
|
||||
const next = createNext();
|
||||
|
||||
await access.attachBaseSkillSyncConfig(req as never, res, next);
|
||||
|
||||
expect(getAppConfig).toHaveBeenCalledWith({ baseOnly: true });
|
||||
expect(req.config.config).toEqual({ endpoints: {}, skillSync: baseSkillSync });
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('marks credential metadata hidden for tenant-scoped status reads', async () => {
|
||||
const hasCapability = jest.fn(
|
||||
async (user: { tenantId?: string }, capability: string): Promise<boolean> => {
|
||||
if (capability === SystemCapabilities.READ_SKILLS) {
|
||||
return Boolean(user.tenantId);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
);
|
||||
const { access } = createAccess({ hasCapability });
|
||||
const req = { user: { id: 'user-1', role: 'ADMIN', tenantId: 'tenant-a' } };
|
||||
const res = createResponse();
|
||||
const next = createNext();
|
||||
|
||||
await access.requireReadSkills(req as never, res, next);
|
||||
await access.attachCredentialReadAccess(req as never, res, next);
|
||||
|
||||
expect(req).toMatchObject({
|
||||
skillSyncCanReadCredentials: false,
|
||||
skillSyncAllowServerCredentials: false,
|
||||
});
|
||||
expect(hasCapability).toHaveBeenCalledWith(
|
||||
{ id: 'user-1', role: 'ADMIN', tenantId: 'tenant-a' },
|
||||
SystemCapabilities.READ_SKILLS,
|
||||
);
|
||||
expect(hasCapability).toHaveBeenCalledWith(
|
||||
{ id: 'user-1', role: 'ADMIN' },
|
||||
SystemCapabilities.READ_SKILLS,
|
||||
);
|
||||
});
|
||||
|
||||
it('prevents tenant admins from running overrides that require server credentials', async () => {
|
||||
const tenantSkillSync = {
|
||||
github: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
runOnStartup: false,
|
||||
sources: [
|
||||
{
|
||||
id: 'tenant-skills',
|
||||
owner: 'LibreChat',
|
||||
repo: 'skills',
|
||||
ref: 'main',
|
||||
paths: ['skills'],
|
||||
token: '${GITHUB_SKILLS_TOKEN}',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const hasCapability = jest.fn(
|
||||
async (user: { tenantId?: string }, capability: string): Promise<boolean> => {
|
||||
if (capability === SystemCapabilities.MANAGE_SKILLS) {
|
||||
return Boolean(user.tenantId);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
);
|
||||
const { access } = createAccess({ hasCapability });
|
||||
const req = {
|
||||
user: { id: 'user-1', role: 'ADMIN', tenantId: 'tenant-a' },
|
||||
config: { skillSync: tenantSkillSync, config: {} },
|
||||
};
|
||||
const res = createResponse();
|
||||
const next = createNext();
|
||||
|
||||
await access.requireSyncRunCapability(req as never, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
message: 'Tenant-scoped manual skill sync requires platform credential access',
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prevents tenant admins from manually running base skill sync config', async () => {
|
||||
const hasCapability = jest.fn(
|
||||
async (user: { tenantId?: string }, capability: string): Promise<boolean> => {
|
||||
if (capability === SystemCapabilities.MANAGE_SKILLS) {
|
||||
return Boolean(user.tenantId);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
);
|
||||
const { access } = createAccess({ hasCapability });
|
||||
const req = {
|
||||
user: { id: 'user-1', role: 'ADMIN', tenantId: 'tenant-a' },
|
||||
config: { skillSync: baseSkillSync, config: { skillSync: baseSkillSync } },
|
||||
};
|
||||
const res = createResponse();
|
||||
const next = createNext();
|
||||
|
||||
await access.requireSyncRunCapability(req as never, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({ message: 'Forbidden' });
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows platform admins to manually run base skill sync config with server credentials', async () => {
|
||||
const { access } = createAccess();
|
||||
const req = {
|
||||
user: { id: 'user-1', role: 'ADMIN', tenantId: 'tenant-a' },
|
||||
config: { skillSync: baseSkillSync, config: { skillSync: baseSkillSync } },
|
||||
};
|
||||
const res = createResponse();
|
||||
const next = createNext();
|
||||
|
||||
await access.requireSyncRunCapability(req as never, res, next);
|
||||
|
||||
expect(req).toMatchObject({
|
||||
skillSyncAllowServerCredentials: true,
|
||||
skillSyncCanReadCredentials: true,
|
||||
});
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,16 +1,20 @@
|
|||
import { SystemCapabilities } from '@librechat/data-schemas';
|
||||
import { skillSyncConfigSchema } from 'librechat-data-provider';
|
||||
import type {
|
||||
TGitHubSkillSyncStatusResponse,
|
||||
TGitHubSkillSyncSourceStatus,
|
||||
TGitHubSkillSyncCredentialSummary,
|
||||
TGitHubSkillSyncManualRunResponse,
|
||||
SkillSyncConfig,
|
||||
} from 'librechat-data-provider';
|
||||
import type {
|
||||
ISkillSyncStatus,
|
||||
SkillSyncProvider,
|
||||
SkillSyncCredentialSummary,
|
||||
UpsertSkillSyncCredentialInput,
|
||||
SystemCapability,
|
||||
} from '@librechat/data-schemas';
|
||||
import type { Request, Response } from 'express';
|
||||
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
||||
import type { Types } from 'mongoose';
|
||||
import type { GitHubSkillSyncRunner } from '~/skills/sync';
|
||||
|
||||
|
|
@ -23,6 +27,31 @@ type AdminSkillsRequest = Request & {
|
|||
skillSyncCanReadCredentials?: boolean;
|
||||
};
|
||||
|
||||
type SkillSyncConfigContainer = {
|
||||
skillSync?: unknown;
|
||||
config?: {
|
||||
skillSync?: unknown;
|
||||
} & Record<string, unknown>;
|
||||
} & Record<string, unknown>;
|
||||
|
||||
type AdminSkillSyncAccessRequest = Request & {
|
||||
user?: {
|
||||
_id?: Types.ObjectId | { toString(): string };
|
||||
id?: string;
|
||||
role?: string;
|
||||
tenantId?: string;
|
||||
};
|
||||
config?: SkillSyncConfigContainer;
|
||||
skillSyncAllowServerCredentials?: boolean;
|
||||
skillSyncCanReadCredentials?: boolean;
|
||||
};
|
||||
|
||||
type SkillSyncCapabilityUser = {
|
||||
id: string;
|
||||
role: string;
|
||||
tenantId?: string;
|
||||
};
|
||||
|
||||
export type AdminSkillSyncDeps = {
|
||||
runner?: GitHubSkillSyncRunner;
|
||||
getRunner?: (req: Request) => GitHubSkillSyncRunner;
|
||||
|
|
@ -33,6 +62,11 @@ export type AdminSkillSyncDeps = {
|
|||
) => Promise<{ deleted: boolean }>;
|
||||
};
|
||||
|
||||
export type AdminSkillSyncAccessDeps = {
|
||||
getAppConfig: (options: { baseOnly: true }) => Promise<{ skillSync?: unknown } | undefined>;
|
||||
hasCapability: (user: SkillSyncCapabilityUser, capability: SystemCapability) => Promise<boolean>;
|
||||
};
|
||||
|
||||
const CREDENTIAL_KEY_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
|
||||
|
||||
function toIso(date: Date | undefined): string | undefined {
|
||||
|
|
@ -109,6 +143,161 @@ function getUserObjectId(req: AdminSkillsRequest): Types.ObjectId | undefined {
|
|||
return req.user?._id;
|
||||
}
|
||||
|
||||
function getCapabilityUser(
|
||||
req: AdminSkillSyncAccessRequest,
|
||||
{ platformOnly = false }: { platformOnly?: boolean } = {},
|
||||
): SkillSyncCapabilityUser | null {
|
||||
const id = req.user?.id ?? req.user?._id?.toString?.();
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
role: req.user?.role ?? '',
|
||||
...(platformOnly ? {} : { tenantId: req.user?.tenantId }),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSkillSyncConfig(raw: unknown): SkillSyncConfig | undefined {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = skillSyncConfigSchema.safeParse(raw);
|
||||
return parsed.success ? parsed.data : undefined;
|
||||
}
|
||||
|
||||
function isSameSkillSyncConfig(left: SkillSyncConfig, right: SkillSyncConfig): boolean {
|
||||
return JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
|
||||
}
|
||||
|
||||
function hasResolvedSkillSyncOverride(req: AdminSkillSyncAccessRequest): boolean {
|
||||
const resolved = parseSkillSyncConfig(req.config?.skillSync);
|
||||
const base = parseSkillSyncConfig(req.config?.config?.skillSync);
|
||||
return Boolean(resolved?.github && !isSameSkillSyncConfig(resolved, base));
|
||||
}
|
||||
|
||||
function sendInternalServerError(res: Response): void {
|
||||
res.status(500).json({ message: 'Internal Server Error' });
|
||||
}
|
||||
|
||||
export function createAdminSkillsSyncAccess(deps: AdminSkillSyncAccessDeps) {
|
||||
async function hasSkillCapability(
|
||||
req: AdminSkillSyncAccessRequest,
|
||||
capability: SystemCapability,
|
||||
{ platformOnly = false }: { platformOnly?: boolean } = {},
|
||||
): Promise<boolean> {
|
||||
const user = getCapabilityUser(req, { platformOnly });
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
return deps.hasCapability(user, capability);
|
||||
}
|
||||
|
||||
function requireSkillCapability(
|
||||
capability: SystemCapability,
|
||||
{ platformOnly = false }: { platformOnly?: boolean } = {},
|
||||
): RequestHandler {
|
||||
return async (
|
||||
req: AdminSkillSyncAccessRequest,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const user = getCapabilityUser(req, { platformOnly });
|
||||
if (!user) {
|
||||
res.status(401).json({ message: 'Authentication required' });
|
||||
return;
|
||||
}
|
||||
if (await deps.hasCapability(user, capability)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
res.status(403).json({ message: 'Forbidden' });
|
||||
} catch {
|
||||
sendInternalServerError(res);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const attachBaseSkillSyncConfig: RequestHandler = async (
|
||||
req: AdminSkillSyncAccessRequest,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const baseConfig = await deps.getAppConfig({ baseOnly: true });
|
||||
const existingConfig = req.config ?? {};
|
||||
req.config = {
|
||||
...existingConfig,
|
||||
config: {
|
||||
...(existingConfig.config ?? {}),
|
||||
skillSync: baseConfig?.skillSync,
|
||||
},
|
||||
};
|
||||
next();
|
||||
} catch {
|
||||
sendInternalServerError(res);
|
||||
}
|
||||
};
|
||||
|
||||
const attachCredentialReadAccess: RequestHandler = async (
|
||||
req: AdminSkillSyncAccessRequest,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const canReadCredentials = await hasSkillCapability(req, SystemCapabilities.READ_SKILLS, {
|
||||
platformOnly: true,
|
||||
});
|
||||
req.skillSyncCanReadCredentials = canReadCredentials;
|
||||
req.skillSyncAllowServerCredentials = canReadCredentials;
|
||||
next();
|
||||
} catch {
|
||||
sendInternalServerError(res);
|
||||
}
|
||||
};
|
||||
|
||||
const requireSyncRunCapability: RequestHandler = async (
|
||||
req: AdminSkillSyncAccessRequest,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const canManagePlatform = await hasSkillCapability(req, SystemCapabilities.MANAGE_SKILLS, {
|
||||
platformOnly: true,
|
||||
});
|
||||
if (canManagePlatform) {
|
||||
req.skillSyncAllowServerCredentials = true;
|
||||
req.skillSyncCanReadCredentials = true;
|
||||
next();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
hasResolvedSkillSyncOverride(req) &&
|
||||
(await hasSkillCapability(req, SystemCapabilities.MANAGE_SKILLS))
|
||||
) {
|
||||
res.status(403).json({
|
||||
message: 'Tenant-scoped manual skill sync requires platform credential access',
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.status(403).json({ message: 'Forbidden' });
|
||||
} catch {
|
||||
sendInternalServerError(res);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
attachBaseSkillSyncConfig,
|
||||
attachCredentialReadAccess,
|
||||
requireReadSkills: requireSkillCapability(SystemCapabilities.READ_SKILLS),
|
||||
requirePlatformManageSkills: requireSkillCapability(SystemCapabilities.MANAGE_SKILLS, {
|
||||
platformOnly: true,
|
||||
}),
|
||||
requireSyncRunCapability,
|
||||
};
|
||||
}
|
||||
|
||||
export function createAdminSkillsSyncHandlers(deps: AdminSkillSyncDeps) {
|
||||
function getRunner(req: Request): GitHubSkillSyncRunner {
|
||||
const runner = deps.getRunner?.(req) ?? deps.runner;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue