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:
vvzvlad
2026-06-20 18:30:33 +03:00
parent 20a1780977
commit 4b31128e24
8 changed files with 325 additions and 65 deletions

View File

@@ -52,6 +52,17 @@ export async function up(db: Kysely<any>): Promise<void> {
.column('workspace_id')
.execute();
// A role name is unique per workspace. Partial (WHERE deleted_at IS NULL) so a
// soft-deleted role does not block re-creating a role with the same name.
await db.schema
.createIndex('ai_agent_roles_workspace_id_name_unique')
.ifNotExists()
.on('ai_agent_roles')
.columns(['workspace_id', 'name'])
.unique()
.where(sql.ref('deleted_at'), 'is', null)
.execute();
// Bind a chat to a role. ON DELETE SET NULL: a hard-deleted role degrades the
// chat to the universal assistant instead of breaking it. The role is read
// from this column on every turn — the client only sends roleId on chat
@@ -66,5 +77,9 @@ export async function up(db: Kysely<any>): Promise<void> {
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('ai_chats').dropColumn('role_id').execute();
await db.schema
.dropIndex('ai_agent_roles_workspace_id_name_unique')
.ifExists()
.execute();
await db.schema.dropTable('ai_agent_roles').execute();
}