Skip to content
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
14 changes: 13 additions & 1 deletion backend/__tests__/unit/routes/grants.read.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const mongoose = require('mongoose');
jest.mock('../../../middleware/auth', () => (req, res, next) => {
const id = req.get('x-test-user');
if (!id) return res.status(401).json({ error: 'unauthorized' });
req.user = { id };
req.user = { id, role: req.get('x-test-role') || 'user' };
req.userId = id;
return next();
});
Expand Down Expand Up @@ -267,6 +267,18 @@ describe('GET /api/grants/:grantId', () => {
});

describe('POST /api/grants/:grantId/revoke', () => {
test('refuses a non-owner member and lets an instance admin revoke', async () => {
const row = await RoomGrant.create(grant());
const denied = await request(app).post(`/api/grants/${row.grantId}/revoke`).set('x-test-user', MEMBER);
expect(denied.status).toBe(403);
expect((await RoomGrant.findOne({ grantId: row.grantId })).revokedAt).toBeNull();

const admin = await request(app).post(`/api/grants/${row.grantId}/revoke`)
.set('x-test-user', STRANGER).set('x-test-role', 'admin');
expect(admin.status).toBe(200);
expect((await RoomGrant.findOne({ grantId: row.grantId })).revokedBy).toBe(STRANGER);
});

test('records the authenticated caller as revokedBy', async () => {
const row = await RoomGrant.create(grant());
const res = await request(app).post(`/api/grants/${row.grantId}/revoke`).set('x-test-user', OWNER);
Expand Down
111 changes: 111 additions & 0 deletions backend/__tests__/unit/routes/registry.manage-grants.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
const express = require('express');
const request = require('supertest');

jest.mock('../../../middleware/auth', () => (req, res, next) => {
const id = req.get('x-test-user');
if (!id) return res.status(401).json({ error: 'Unauthorized' });
req.userId = id;
req.user = { id, role: req.get('x-test-role') || 'user' };
return next();
});
jest.mock('../../../models/AgentRegistry', () => ({ AgentInstallation: { find: jest.fn() } }));
jest.mock('../../../models/RoomGrant', () => ({ find: jest.fn() }));
jest.mock('../../../models/Integration', () => ({ findById: jest.fn(), findOne: jest.fn() }));
jest.mock('../../../models/Pod', () => ({ find: jest.fn() }));
jest.mock('../../../models/User', () => ({ findOne: jest.fn(), find: jest.fn() }));
jest.mock('../../../services/agentIdentityService', () => ({ buildAgentUsername: () => 'agent-nib' }));
jest.mock('../../../routes/registry/helpers', () => ({
getUserId: (req) => req.userId,
isGlobalAdminUser: async () => false,
normalizeInstanceId: (id) => id || 'default',
}));

const { AgentInstallation } = require('../../../models/AgentRegistry');
const RoomGrant = require('../../../models/RoomGrant');
const Integration = require('../../../models/Integration');
const Pod = require('../../../models/Pod');
const User = require('../../../models/User');

const app = express();
app.use('/api/registry', require('../../../routes/registry/manage-grants'));
const URL = '/api/registry/agents/nib/instances/default/grants';

beforeEach(() => {
jest.clearAllMocks();
AgentInstallation.find.mockReturnValue({ lean: async () => [{
agentName: 'nib', instanceId: 'default', installedBy: 'owner', podId: 'pod-1',
}] });
User.findOne.mockReturnValue({ select: () => ({ lean: async () => ({ _id: 'agent-1' }) }) });
RoomGrant.find.mockReturnValue({ sort: () => ({ lean: async () => [{
grantId: 'grant-1', connectionId: 'connection-1', target: { kind: 'pod', id: 'pod-1' },
audience: ['agent-1'], tools: ['linear.list_issues'], createdAt: new Date('2026-10-01'),
expiresAt: new Date('2026-11-01'), revokedAt: null,
}] }) });
Pod.find.mockReturnValue({ select: () => ({ lean: async () => [{
_id: 'pod-1', name: 'Work', members: ['agent-1'],
}] }) });
Integration.findById.mockReturnValue({ select: () => ({ lean: async () => null }) });
Integration.findOne.mockReturnValue({ select: () => ({ lean: async () => ({
type: 'hosted-mcp', config: { entryId: 'linear' }, createdBy: 'owner',
}) }) });
User.find.mockReturnValue({ select: () => ({ lean: async () => [{
_id: 'owner', displayName: 'Owner', username: 'owner',
}] }) });
});

test('only the installer or an instance admin can read an agent grants', async () => {
const denied = await request(app).get(URL).set('x-test-user', 'member');
expect(denied.status).toBe(403);
expect(RoomGrant.find).not.toHaveBeenCalled();

const owner = await request(app).get(URL).set('x-test-user', 'owner');
expect(owner.status).toBe(200);
expect(owner.body.grants).toMatchObject([{
grantId: 'grant-1', connector: 'linear', podName: 'Work', grantedBy: 'Owner',
canRevoke: true,
}]);

const admin = await request(app).get(URL).set('x-test-user', 'admin').set('x-test-role', 'admin');
expect(admin.status).toBe(200);
expect(admin.body.grants[0].canRevoke).toBe(true);
});

test('a second installer cannot inspect a shared identity through their row', async () => {
AgentInstallation.find.mockReturnValue({ lean: async () => [
{ installedBy: 'owner', status: 'active', config: {} },
{ installedBy: 'someone-else', status: 'paused', config: {} },
] });
const res = await request(app).get(URL).set('x-test-user', 'owner');
expect(res.status).toBe(403);
expect(RoomGrant.find).not.toHaveBeenCalled();
expect(AgentInstallation.find).toHaveBeenCalledWith({
agentName: 'nib', instanceId: 'default', status: { $ne: 'uninstalled' },
});
});

test('a paused-only installer can read their agent grants', async () => {
AgentInstallation.find.mockReturnValue({ lean: async () => [
{ installedBy: 'owner', status: 'paused', config: {} },
] });
const res = await request(app).get(URL).set('x-test-user', 'owner');
expect(res.status).toBe(200);
expect(res.body.grants).toHaveLength(1);
});

test('a grant for a pod the agent left does not appear as current access', async () => {
Pod.find.mockReturnValue({ select: () => ({ lean: async () => [{
_id: 'pod-1', name: 'Work', members: ['someone-else'],
}] }) });
const res = await request(app).get(URL).set('x-test-user', 'owner');
expect(res.status).toBe(200);
expect(res.body.grants).toEqual([]);
});

test('linked user can revoke when the connection has no createdBy', async () => {
Integration.findOne.mockReturnValue({ select: () => ({ lean: async () => ({
type: 'hosted-mcp', config: { entryId: 'linear', linkedUserId: 'owner' },
}) }) });
const res = await request(app).get(URL).set('x-test-user', 'owner');
expect(res.status).toBe(200);
expect(res.body.grants[0]).toMatchObject({ grantedBy: 'Owner', canRevoke: true });
});
6 changes: 5 additions & 1 deletion backend/routes/grants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,7 +466,11 @@ const revokeHandler = async (req: AuthenticatedRequest, res: express.Response):
if (!grant) return res.status(404).json({ error: 'grant_not_found' });
const userId = callerId(req);
const connection = await findConnection(grant.connectionId);
if (!connection || connectionOwnerId(connection) !== userId) {
if (!connection) return res.status(403).json({ error: 'access_denied' });
const isOwner = connectionOwnerId(connection) === userId;
const isAdmin = !isOwner && (req.user?.role === 'admin'
|| (Boolean(userId) && (await User.findById(userId).select('role').lean())?.role === 'admin'));
if (!isOwner && !isAdmin) {
return res.status(403).json({ error: 'access_denied' });
}
const revoked = await revokeGrant(grant.grantId, userId);
Expand Down
2 changes: 2 additions & 0 deletions backend/routes/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ const router: ReturnType<typeof express.Router> = express.Router();
// eslint-disable-next-line global-require
router.use(require('./registry/runtime'));
// eslint-disable-next-line global-require
router.use(require('./registry/manage-grants'));
// eslint-disable-next-line global-require
router.use(require('./registry/templates'));
// eslint-disable-next-line global-require
router.use(require('./registry/catalog'));
Expand Down
114 changes: 114 additions & 0 deletions backend/routes/registry/manage-grants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import express from 'express';
import rateLimit from 'express-rate-limit';
import { createHash } from 'crypto';
import { cloudflareIpRateLimitKeyGenerator } from '../../middleware/ipRateLimit';
import { AgentInstallation } from '../../models/AgentRegistry';
import RoomGrant from '../../models/RoomGrant';
import Integration from '../../models/Integration';
import Pod from '../../models/Pod';
import User from '../../models/User';
import AgentIdentityService from '../../services/agentIdentityService';

// eslint-disable-next-line @typescript-eslint/no-require-imports
const auth = require('../../middleware/auth');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const {
getUserId, isGlobalAdminUser, normalizeInstanceId,
} = require('./helpers');

const router = express.Router();
const grantManageLimit = rateLimit({
windowMs: 60_000,
max: 60,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req: express.Request): string => {
const bearer = req.get('Authorization');
if (bearer) return `grant-manage:${createHash('sha256').update(bearer).digest('hex').slice(0, 16)}`;
return cloudflareIpRateLimitKeyGenerator(req as never);
},
handler: (_req, res) => res.status(429).json({ code: 'rate_limited' }),
});

/** The manage page reads one identity, not every grant in a pod. */
router.get('/agents/:name/instances/:instanceId/grants', grantManageLimit, auth, async (req: any, res: any) => {
try {
const userId = String(getUserId(req) || '');
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
const agentName = String(req.params.name || '').toLowerCase();
const instanceId = normalizeInstanceId(req.params.instanceId);
const installations = await AgentInstallation.find({
agentName, instanceId, status: { $ne: 'uninstalled' },
}).lean();
if (!installations.length) return res.status(404).json({ error: 'Agent not installed' });

const isAdmin = req.user?.role === 'admin' || await isGlobalAdminUser(userId);
// A shared identity reaches every installation. One installer must not
// inspect another installer's grants through the shared agent principal.
if (!isAdmin && !installations.every((row: any) => String(row.installedBy || '') === userId)) {
return res.status(403).json({ error: 'Only the agent installer or an instance admin can manage this agent' });
}

const username = AgentIdentityService.buildAgentUsername(agentName, instanceId);
const agentUser = await User.findOne({ username, isBot: true }).select('_id').lean();
if (!agentUser) return res.json({ grants: [] });

const agentId = String(agentUser._id);
const grants = await RoomGrant.find({ audience: agentId }).sort({ createdAt: -1 }).lean();
const podIds = grants.filter((row) => row.target.kind === 'pod').map((row) => row.target.id);
const pods = podIds.length
? await Pod.find({ _id: { $in: podIds } }).select('name members').lean()
: [];
const podById = new Map(pods.map((pod: any) => [String(pod._id), pod]));
const visibleGrants = grants.filter((row) => {
if (row.target.kind === 'seat') return row.target.id === agentId;
const pod: any = podById.get(row.target.id);
return Boolean(pod?.members?.some((member: any) => String(member?.userId || member) === agentId));
});

const connections = await Promise.all(Array.from(new Set(visibleGrants.map((row) => row.connectionId)))
.map(async (connectionId) => {
const byId = /^[a-f\d]{24}$/i.test(connectionId)
? await Integration.findById(connectionId).select('type createdBy config.entryId config.linkedUserId').lean()
: null;
const connection = byId || await Integration.findOne({ installationId: connectionId })
.select('type createdBy config.entryId config.linkedUserId').lean();
return [connectionId, connection] as const;
}));
const connectionById = new Map(connections);
const granterIds = Array.from(new Set(connections
.map(([, connection]) => String(connection?.createdBy || connection?.config?.linkedUserId || ''))
.filter(Boolean)));
const granters = granterIds.length
? await User.find({ _id: { $in: granterIds } }).select('username displayName').lean()
: [];
const granterById = new Map(granters.map((user: any) => [String(user._id), user]));

return res.json({
grants: visibleGrants.map((grant) => {
const connection = connectionById.get(grant.connectionId);
const granterId = String(connection?.createdBy || connection?.config?.linkedUserId || '');
const granter: any = granterById.get(granterId);
return {
grantId: grant.grantId,
tools: grant.tools,
connector: connection?.type === 'github-app' ? 'GitHub' : connection?.config?.entryId || null,
podId: grant.target.kind === 'pod' ? grant.target.id : null,
podName: grant.target.kind === 'pod'
? (podById.get(grant.target.id) as any)?.name || null : null,
grantedBy: granter?.displayName || granter?.username || null,
createdAt: grant.createdAt,
expiresAt: grant.expiresAt,
revokedAt: grant.revokedAt || null,
canRevoke: Boolean(connection) && (isAdmin || granterId === userId),
};
}),
});
} catch (error) {
console.error('Error loading agent grants:', error);
return res.status(500).json({ error: 'Failed to load agent grants' });
}
});

module.exports = router;
export {};
35 changes: 35 additions & 0 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2254,6 +2254,41 @@
"listSeparator": ", ",
"sentenceSeparator": " "
},
"agentManage": {
"runtime": {
"title": "Runtime",
"how": "How it runs",
"hosted": "Hosted",
"daemon": "On a computer, via the daemon",
"cli": "Single CLI",
"unknown": "Runtime details unavailable",
"adapter": "Adapter",
"model": "Model",
"modelUnknown": "Set by the runtime",
"effort": "Effort",
"changeModel": "Model",
"changeEffort": "Effort",
"effortUnset": "Choose effort",
"save": "Save runtime",
"saveError": "Could not save the model."
},
"grants": {
"title": "Connector grants",
"loading": "Loading grants…",
"empty": "This agent has no connector grants.",
"tool": "Tool",
"direct": "Direct to this agent",
"by": "Granted by",
"unknown": "unknown",
"ended": "Ended",
"ends": "Ends",
"revoke": "Revoke",
"confirmRevoke": "Yes, revoke it",
"cancel": "Cancel",
"loadError": "Could not load connector grants.",
"revokeError": "Could not revoke the grant."
}
},
"agentSettings": {
"wakeTitle": "Wake",
"mentionsAlwaysWake": "Mentions always wake this agent.",
Expand Down
35 changes: 35 additions & 0 deletions frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -2245,6 +2245,41 @@
"listSeparator": "、",
"sentenceSeparator": ""
},
"agentManage": {
"runtime": {
"title": "运行环境",
"how": "运行方式",
"hosted": "托管运行",
"daemon": "通过守护进程在电脑上运行",
"cli": "单个 CLI",
"unknown": "暂无运行环境详情",
"adapter": "适配器",
"model": "模型",
"modelUnknown": "由运行环境决定",
"effort": "推理强度",
"changeModel": "模型",
"changeEffort": "推理强度",
"effortUnset": "选择推理强度",
"save": "保存运行设置",
"saveError": "无法保存模型。"
},
"grants": {
"title": "连接器授权",
"loading": "正在加载授权…",
"empty": "此智能体没有连接器授权。",
"tool": "工具",
"direct": "直接授权给此智能体",
"by": "授权人",
"unknown": "未知",
"ended": "已结束",
"ends": "结束于",
"revoke": "撤销",
"confirmRevoke": "确认撤销",
"cancel": "取消",
"loadError": "无法加载连接器授权。",
"revokeError": "无法撤销授权。"
}
},
"agentSettings": {
"wakeTitle": "唤醒",
"mentionsAlwaysWake": "提及始终会唤醒此智能体。",
Expand Down
Loading
Loading