🪦 fix: Add Durable MCP Config Tombstones (#13534)

* fix: add durable MCP config tombstones

* fix: preserve scoped config tombstones

* fix: clean up config tombstone lint

* fix: handle empty model spec skill allowlist

* fix: preserve inactive config tombstones
This commit is contained in:
Danny Avila 2026-06-05 15:05:40 -04:00 committed by GitHub
parent 5118a566df
commit aeb5adff34
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 506 additions and 6 deletions

View file

@ -49,6 +49,9 @@ function createHandlers(overrides = {}) {
patchConfigFields: jest
.fn()
.mockResolvedValue({ _id: 'c1', overrides: { registration: { enabled: false } } }),
tombstoneConfigField: jest
.fn()
.mockResolvedValue({ _id: 'c1', tombstones: ['mcpServers.github'] }),
unsetConfigField: jest.fn().mockResolvedValue({ _id: 'c1', overrides: {} }),
deleteConfig: jest.fn().mockResolvedValue({ _id: 'c1' }),
toggleConfigActive: jest.fn().mockResolvedValue({ _id: 'c1', isActive: false }),
@ -377,6 +380,78 @@ describe('createAdminConfigHandlers', () => {
});
});
describe('tombstoneConfigField', () => {
it('writes an explicit tombstone for a valid field path', async () => {
const { handlers, deps } = createHandlers();
const req = mockReq({
params: { principalType: 'role', principalId: 'admin' },
body: { fieldPath: 'mcpServers.github' },
});
const res = mockRes();
await handlers.tombstoneConfigField(req, res);
expect(res.statusCode).toBe(200);
expect(deps.tombstoneConfigField).toHaveBeenCalledWith(
'role',
'admin',
expect.anything(),
'mcpServers.github',
10,
);
});
it('uses the existing config priority when priority is omitted', async () => {
const { handlers, deps } = createHandlers({
findConfigByPrincipal: jest.fn().mockResolvedValue({ _id: 'c1', priority: 42 }),
});
const req = mockReq({
params: { principalType: 'role', principalId: 'admin' },
body: { fieldPath: 'mcpServers.github' },
});
const res = mockRes();
await handlers.tombstoneConfigField(req, res);
expect(deps.tombstoneConfigField).toHaveBeenCalledWith(
'role',
'admin',
expect.anything(),
'mcpServers.github',
42,
);
});
it('blocks interface permission paths', async () => {
const { handlers, deps } = createHandlers();
const req = mockReq({
params: { principalType: 'role', principalId: 'admin' },
body: { fieldPath: 'interface.mcpServers.use' },
});
const res = mockRes();
await handlers.tombstoneConfigField(req, res);
expect(res.statusCode).toBe(200);
expect(res.body!.message).toBeDefined();
expect(deps.tombstoneConfigField).not.toHaveBeenCalled();
});
it('rejects unsafe field paths', async () => {
const { handlers, deps } = createHandlers();
const req = mockReq({
params: { principalType: 'role', principalId: 'admin' },
body: { fieldPath: '__proto__.polluted' },
});
const res = mockRes();
await handlers.tombstoneConfigField(req, res);
expect(res.statusCode).toBe(400);
expect(deps.tombstoneConfigField).not.toHaveBeenCalled();
});
});
describe('patchConfigField', () => {
it('returns 403 when user lacks capability for section', async () => {
const { handlers } = createHandlers({
@ -641,6 +716,13 @@ describe('createAdminConfigHandlers', () => {
query: { fieldPath: 'interface.modelSelect' },
},
},
{
name: 'tombstoneConfigField',
reqOverrides: {
params: { principalType: 'role', principalId: 'admin' },
body: { fieldPath: 'mcpServers.github' },
},
},
{
name: 'deleteConfigOverrides',
reqOverrides: {

View file

@ -82,6 +82,14 @@ export interface AdminConfigDeps {
priority: number,
session?: ClientSession,
) => Promise<IConfig | null>;
tombstoneConfigField: (
principalType: PrincipalType,
principalId: string | Types.ObjectId,
principalModel: PrincipalModel,
fieldPath: string,
priority: number,
session?: ClientSession,
) => Promise<IConfig | null>;
unsetConfigField: (
principalType: PrincipalType,
principalId: string | Types.ObjectId,
@ -163,6 +171,7 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps) {
findConfigByPrincipal,
upsertConfig,
patchConfigFields,
tombstoneConfigField: writeConfigTombstone,
unsetConfigField,
deleteConfig,
toggleConfigActive,
@ -479,6 +488,80 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps) {
}
}
/**
* POST /:principalType/:principalId/fields/tombstone Suppress an inherited config path.
*/
async function tombstoneConfigField(req: ServerRequest, res: Response) {
try {
const { principalType, principalId } = req.params as {
principalType: string;
principalId: string;
};
if (!validatePrincipalType(principalType)) {
return res.status(400).json({ error: `Invalid principalType: ${principalType}` });
}
const { fieldPath, priority } = req.body as {
fieldPath?: string;
priority?: number;
};
if (!fieldPath || typeof fieldPath !== 'string') {
return res.status(400).json({ error: 'fieldPath is required' });
}
if (priority != null && (typeof priority !== 'number' || priority < 0)) {
return res.status(400).json({ error: 'priority must be a non-negative number' });
}
if (!isValidFieldPath(fieldPath)) {
return res.status(400).json({ error: `Invalid or unsafe field path: ${fieldPath}` });
}
const user = getCapabilityUser(req);
if (!user) {
return res.status(401).json({ error: 'Authentication required' });
}
const section = getTopLevelSection(fieldPath);
if (!(await hasConfigCapability(user, section as ConfigSection, 'manage'))) {
return res.status(403).json({
error: `Insufficient permissions for config section: ${section}`,
});
}
if (isInterfacePermissionPath(fieldPath)) {
logger.warn(
`[adminConfig] Ignoring tombstone for interface permission field "${fieldPath}" — use role permissions instead`,
);
return res.status(200).json({ message: 'No actionable field path provided' });
}
const existing =
priority == null
? await findConfigByPrincipal(principalType, principalId, { includeInactive: true })
: null;
const config = await writeConfigTombstone(
principalType,
principalId,
principalModel(principalType),
fieldPath,
priority ?? existing?.priority ?? DEFAULT_PRIORITY,
);
invalidateConfigCaches?.(user.tenantId)?.catch((err) =>
logger.error('[adminConfig] Cache invalidation failed after field tombstone:', err),
);
return res.status(200).json({ config });
} catch (error) {
logger.error('[adminConfig] tombstoneConfigField error:', error);
return res.status(500).json({ error: 'Failed to tombstone config field' });
}
}
/**
* DELETE /:principalType/:principalId/fields?fieldPath=dotted.path
*/
@ -624,6 +707,7 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps) {
getConfig,
upsertConfigOverrides,
patchConfigField,
tombstoneConfigField,
deleteConfigField,
deleteConfigOverrides,
toggleConfig,