diff --git a/packages/api/src/admin/config.handler.spec.ts b/packages/api/src/admin/config.handler.spec.ts index 7b122a0e00..581bd6116a 100644 --- a/packages/api/src/admin/config.handler.spec.ts +++ b/packages/api/src/admin/config.handler.spec.ts @@ -1728,7 +1728,7 @@ describe('createAdminConfigHandlers', () => { }); }); - describe('scope-lifecycle: __base__ short-circuit', () => { + describe('invariant: __base__ requires broad manage:configs', () => { it('upsert against __base__ returns 403 for assign-only caller', async () => { const { handlers, deps } = createHandlers({ hasConfigCapability: jest.fn().mockResolvedValue(false), @@ -1794,6 +1794,70 @@ describe('createAdminConfigHandlers', () => { expect(res.statusCode).toBe(201); expect(deps.upsertConfig).toHaveBeenCalled(); }); + + it('patch against __base__ returns 403 for a section-scoped manager', async () => { + const { handlers, deps } = createHandlers({ + hasConfigCapability: jest.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true), + }); + const req = mockReq({ + params: { principalType: 'role', principalId: '__base__' }, + body: { entries: [{ fieldPath: 'memory.context', value: 'updated' }] }, + }); + const res = mockRes(); + + await handlers.patchConfigField(req, res); + + expect(res.statusCode).toBe(403); + expect(deps.patchConfigFields).not.toHaveBeenCalled(); + }); + + it('tombstone against __base__ returns 403 for a section-scoped manager', async () => { + const { handlers, deps } = createHandlers({ + hasConfigCapability: jest.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true), + }); + const req = mockReq({ + params: { principalType: 'role', principalId: '__base__' }, + body: { fieldPath: 'memory.context' }, + }); + const res = mockRes(); + + await handlers.tombstoneConfigField(req, res); + + expect(res.statusCode).toBe(403); + expect(deps.tombstoneConfigField).not.toHaveBeenCalled(); + }); + + it('field delete against __base__ returns 403 for a section-scoped manager', async () => { + const { handlers, deps } = createHandlers({ + hasConfigCapability: jest.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true), + }); + const req = mockReq({ + params: { principalType: 'role', principalId: '__base__' }, + query: { fieldPath: 'memory.context' }, + }); + const res = mockRes(); + + await handlers.deleteConfigField(req, res); + + expect(res.statusCode).toBe(403); + expect(deps.unsetConfigField).not.toHaveBeenCalled(); + }); + + it('patch against __base__ succeeds for a broad-manage caller', async () => { + const { handlers, deps } = createHandlers({ + hasConfigCapability: jest.fn().mockResolvedValue(true), + }); + const req = mockReq({ + params: { principalType: 'role', principalId: '__base__' }, + body: { entries: [{ fieldPath: 'memory.context', value: 'updated' }] }, + }); + const res = mockRes(); + + await handlers.patchConfigField(req, res); + + expect(res.statusCode).toBe(200); + expect(deps.patchConfigFields).toHaveBeenCalled(); + }); }); describe('scope-lifecycle: atomic empty-state guard for assign-only callers', () => { diff --git a/packages/api/src/admin/config.ts b/packages/api/src/admin/config.ts index 5ed90383d8..6bb09f94c9 100644 --- a/packages/api/src/admin/config.ts +++ b/packages/api/src/admin/config.ts @@ -747,6 +747,10 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): { const hasBroadManage = await hasConfigCapability(user, null, 'manage'); + if (principalId === BASE_CONFIG_PRINCIPAL_ID && !hasBroadManage) { + return res.status(403).json({ error: 'Insufficient permissions' }); + } + if (validEntries.length === 0) { if (!hasBroadManage) { return res.status(403).json({ error: 'Insufficient permissions' }); @@ -858,6 +862,11 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): { const section = getTopLevelSection(fieldPath); const hasBroadManage = await hasConfigCapability(user, null, 'manage'); + + if (principalId === BASE_CONFIG_PRINCIPAL_ID && !hasBroadManage) { + return res.status(403).json({ error: 'Insufficient permissions' }); + } + if ( !hasBroadManage && !(await hasConfigCapability(user, section as ConfigSection, 'manage')) @@ -950,7 +959,16 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): { const section = getTopLevelSection(fieldPath); - if (!(await hasConfigCapability(user, section as ConfigSection, 'manage'))) { + const hasBroadManage = await hasConfigCapability(user, null, 'manage'); + + if (principalId === BASE_CONFIG_PRINCIPAL_ID && !hasBroadManage) { + return res.status(403).json({ error: 'Insufficient permissions' }); + } + + if ( + !hasBroadManage && + !(await hasConfigCapability(user, section as ConfigSection, 'manage')) + ) { return res.status(403).json({ error: `Insufficient permissions for config section: ${section}`, });