fix(ai-roles): harden model override, role-name uniqueness, id validation, list least-privilege
Follow-up fixes on the agent-roles feature: - ai.service: a cross-driver override to the ollama driver (when the workspace driver is not ollama) now fails with an explicit 503 instead of silently reusing the workspace base URL, which belongs to a different provider. Same-driver ollama and openai/gemini overrides are unchanged. - migration: add a partial unique index on (workspace_id, name) WHERE deleted_at IS NULL so role names are unique per workspace without soft-deleted rows blocking re-creation; map Postgres 23505 to a 409 ConflictException on create/update. - dto: validate the role id as @IsUUID instead of @IsString. - roles list: do not expose instructions/modelConfig to non-admin members. The list endpoint now returns a picker view (id/name/emoji/description/ enabled) to members and the full view only to admins (same gate as the CRUD endpoints). Client IAiRole fields made optional accordingly. Adds tests for the cross-driver-ollama throw, the 23505->409 mapping, and the non-admin picker-view security invariant. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -105,6 +105,56 @@ describe('AiService.getChatModel role model override', () => {
|
||||
expect(secretBox.decryptSecret).toHaveBeenCalledWith('enc-gemini-key');
|
||||
});
|
||||
|
||||
it('cross-driver override to ollama (workspace driver != ollama): throws 503, does NOT silently reuse the workspace baseUrl', async () => {
|
||||
// Workspace driver is openai with a configured (gateway) baseUrl. A role that
|
||||
// overrides to ollama has no dedicated ollama endpoint, so pointing the
|
||||
// ollama client at the workspace's openai baseUrl would be wrong — it must
|
||||
// fail explicitly instead.
|
||||
const aiSettings = {
|
||||
resolve: jest.fn().mockResolvedValue({
|
||||
driver: 'openai',
|
||||
chatModel: 'gpt-4o-mini',
|
||||
apiKey: 'workspace-key',
|
||||
baseUrl: 'https://openrouter.example/v1',
|
||||
}),
|
||||
};
|
||||
const aiProviderCredentialsRepo = { find: jest.fn() };
|
||||
const secretBox = { decryptSecret: jest.fn() };
|
||||
const service = new AiService(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
aiSettings as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
aiProviderCredentialsRepo as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
secretBox as any,
|
||||
);
|
||||
|
||||
await service
|
||||
.getChatModel('ws-1', {
|
||||
driver: 'ollama',
|
||||
chatModel: 'llama3',
|
||||
roleName: 'Local',
|
||||
})
|
||||
.then(
|
||||
() => {
|
||||
throw new Error('expected getChatModel to throw');
|
||||
},
|
||||
(err: unknown) => {
|
||||
expect(err).toBeInstanceOf(AiNotConfiguredException);
|
||||
const message = (err as AiNotConfiguredException).message;
|
||||
// Names the role and the workspace driver, and mentions ollama.
|
||||
expect(message).toContain('ollama');
|
||||
expect(message).toContain('openai');
|
||||
expect(message).toContain('Local');
|
||||
// Must NOT leak / reuse the workspace gateway baseUrl in the path.
|
||||
expect(message).not.toContain('openrouter.example');
|
||||
},
|
||||
);
|
||||
|
||||
// No ollama creds lookup happens (ollama needs no key); we fail before that.
|
||||
expect(aiProviderCredentialsRepo.find).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('chatModel-only override (no driver): reuses the workspace driver+creds, no creds lookup/decrypt', async () => {
|
||||
// No override.driver => the workspace openai driver + its apiKey are reused;
|
||||
// ai_provider_credentials must NOT be queried and nothing is decrypted.
|
||||
|
||||
@@ -83,9 +83,20 @@ export class AiService {
|
||||
// driver's own creds (the workspace driver's key would be wrong/absent).
|
||||
if (overrideDriver && overrideDriver !== cfg.driver) {
|
||||
if (overrideDriver === 'ollama') {
|
||||
// Ollama needs no key; baseUrl is taken from the workspace config (it is
|
||||
// the only configurable endpoint for a local model).
|
||||
apiKey = undefined;
|
||||
// Cross-driver override to ollama: the workspace driver is NOT ollama, so
|
||||
// there is no configured ollama endpoint. `cfg.baseUrl` belongs to the
|
||||
// workspace driver (e.g. an OpenAI/OpenRouter gateway) and pointing the
|
||||
// ollama client at it would silently send requests to the wrong server.
|
||||
// Fail explicitly (503) — a dedicated per-driver ollama endpoint is not
|
||||
// supported yet. The same-driver ollama case (handled outside this block)
|
||||
// legitimately reuses the workspace's ollama endpoint and is unaffected.
|
||||
const who = override?.roleName ? ` for role "${override.roleName}"` : '';
|
||||
throw new AiNotConfiguredException(
|
||||
`An ollama model override${who} requires a dedicated ollama endpoint, ` +
|
||||
`which is not supported when the workspace driver is "${cfg.driver}". ` +
|
||||
`Set the role's driver to "${cfg.driver}" or switch the workspace ` +
|
||||
`to ollama.`,
|
||||
);
|
||||
} else {
|
||||
const creds = await this.aiProviderCredentialsRepo.find(
|
||||
workspaceId,
|
||||
|
||||
Reference in New Issue
Block a user