diff --git a/IMPLEMENTATION_LOG.md b/IMPLEMENTATION_LOG.md index 548e945..8e6047f 100644 --- a/IMPLEMENTATION_LOG.md +++ b/IMPLEMENTATION_LOG.md @@ -60,3 +60,19 @@ - Как проверялось: повторный grep `status(5xx)` + `err.message` по `server/` — 0 совпадений; `npm run check` чисто; `npx vitest run` 45/45. - Влияние на поиск/UX: нет (только тексты 5xx-ответов). - Подводные камни: остались ответы `200 + success:false` с `err?.message` (llm-providers, rag, finance-di2) и 4xx с `err.message` (documents, data-tables) — сознательно вне скоупа шага, кандидаты для фазы 2. + +--- + +## [0.8] Убрать passwordHash из выдачи пользователей + +- Статус: ✅ done +- Зачем: хэши паролей и токены не должны уходить клиенту (в т.ч. через offline-sync). +- Что изменено: + - `shared/schema.ts` — тип `SafeUser` (User без passwordHash/verificationToken/resetPasswordToken/resetPasswordExpires) + набор колонок `safeUserColumns` (единая точка правды). + - `server/storage/users.storage.ts` — `getUsersByOrganization` и `listUsers` → `SafeUser[]`. + - `server/storage/social.storage.ts` (`searchUsers`), `server/storage/task-meta.storage.ts` (`getUsersByRole`, `getUsersByOrgRoleId`), `server/documents/data-resolution.service.ts` (getUser для шаблонов) — тоже на `safeUserColumns`. + - Сигнатуры в `server/storage.ts`; типовые правки у caller'ов (логика не менялась). Методы аутентификации (где passwordHash нужен) не тронуты. + - Побочный эффект: `ctx.users.list()` в автоматизациях и MCP `list_users` тоже sanitized. +- Как проверялось: ни один из 43 caller'ов не использовал исключённые поля (tsc); sync (initial + delta) теперь отдаёт пользователей без хэшей; `npm run check` чисто; `npx vitest run` 45/45; grep `passwordHash` по server/ — только auth-флоу. +- Влияние на поиск/UX: нет. +- Подводные камни: при явном списке колонок drizzle возвращает плоские строки даже с join — мёртвый маппинг в listUsers убран, на это опираться нельзя в будущих правках. diff --git a/server/documents/data-resolution.service.ts b/server/documents/data-resolution.service.ts index ee9c307..307742c 100644 --- a/server/documents/data-resolution.service.ts +++ b/server/documents/data-resolution.service.ts @@ -1,5 +1,5 @@ import { db } from "../db"; -import { tasks, taskFieldValues, users, organizations, forms, formFields, formTabs, regularTableRows, taskRelations } from "@shared/schema"; +import { tasks, taskFieldValues, users, organizations, forms, formFields, formTabs, regularTableRows, taskRelations, safeUserColumns, type SafeUser } from "@shared/schema"; import { eq, and, asc, sql } from "drizzle-orm"; import type { TemplateVariable } from "@shared/schema"; import { TransformService, type TransformConfig, type TransformType } from "./transform.service"; @@ -14,10 +14,10 @@ export interface ResolutionContext { type: string; name: string; }>; - assignee: typeof users.$inferSelect | null; - creator: typeof users.$inferSelect | null; + assignee: SafeUser | null; + creator: SafeUser | null; organization: typeof organizations.$inferSelect | null; - currentUser: typeof users.$inferSelect | null; + currentUser: SafeUser | null; } export interface ResolutionResult { @@ -89,8 +89,9 @@ export class DataResolutionService { .where(eq(taskFieldValues.taskId, taskId)); } - private async getUser(userId: number) { - const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1); + private async getUser(userId: number): Promise { + // Для подстановки в шаблоны документов достаточно безопасных колонок (без хэша пароля и токенов) + const [user] = await db.select(safeUserColumns).from(users).where(eq(users.id, userId)).limit(1); return user || null; } @@ -243,7 +244,7 @@ export class DataResolutionService { } } - private getUserField(user: typeof users.$inferSelect, key: string): string | null { + private getUserField(user: SafeUser, key: string): string | null { switch (key) { case 'fullName': return `${user.lastName || ''} ${user.firstName || ''} ${user.middleName || ''}`.trim() || user.email || ''; diff --git a/server/mcp.ts b/server/mcp.ts index 389c3af..83461fd 100644 --- a/server/mcp.ts +++ b/server/mcp.ts @@ -36,7 +36,7 @@ async function _notifyAdminsLegacyKeyMcp(organizationId: number, keyPrefix: stri } } import type { Request, Response } from "express"; -import type { Task, ApiKeyScopes, OrganizationApiKey, User, Bot } from "@shared/schema"; +import type { Task, ApiKeyScopes, OrganizationApiKey, SafeUser, Bot } from "@shared/schema"; import { normalizeApiKeyScopes, isFormAllowedByScopes } from "./utils/api-key"; import { createUploadTicket } from "./utils/upload-tickets"; import beautify from "js-beautify"; @@ -256,7 +256,7 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes, apiKeyReco // bot — бот ключа (если привязан); displayName — имя для аудита // (имя бота или «Ключ "label"», чтобы в истории было видно, что это не человек). interface McpActor { - user: User; + user: SafeUser; bot: Bot | null; displayName: string; } @@ -264,7 +264,7 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes, apiKeyReco const getActor = (): Promise => { if (!actorPromise) { actorPromise = (async () => { - let user: User | null | undefined = apiKeyRecord?.createdBy + let user: SafeUser | null | undefined = apiKeyRecord?.createdBy ? await storage.getUser(apiKeyRecord.createdBy).catch(() => null) : null; if (!user || user.organizationId !== organizationId) { diff --git a/server/routes/task-crud-list.routes.ts b/server/routes/task-crud-list.routes.ts index 5cc1840..c2523c1 100644 --- a/server/routes/task-crud-list.routes.ts +++ b/server/routes/task-crud-list.routes.ts @@ -2,7 +2,7 @@ import { Router } from "express"; import { storage } from "../storage"; import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware"; import { tenantIsolation } from "../middleware/tenant.middleware"; -import { type Task, type FormStatus, type User, roles, tasks } from "@shared/schema"; +import { type Task, type FormStatus, type SafeUser, roles, tasks } from "@shared/schema"; import { db } from "../db"; import { eq, inArray } from "drizzle-orm"; import { tasksMinimalCache } from "../utils/cache"; @@ -204,7 +204,7 @@ export function registerTaskCrudListRoutes(router: ReturnType [s.id, s])); - const userMap = new Map(users.map((u: User) => [u.id, u])); + const userMap = new Map(users.map((u: SafeUser) => [u.id, u])); const roleMap = new Map(orgRoles.map((r) => [r.id, r])); const contractNumberFields = formFields.filter((f) => f.type === 'contract-number' && (f as any).options?.contractNumber?.legalEntityFormId); @@ -250,7 +250,7 @@ export function registerTaskCrudListRoutes(router: ReturnType [s.id, s])); - const userMap = new Map(users.map((u: User) => [u.id, u])); + const userMap = new Map(users.map((u: SafeUser) => [u.id, u])); const roleMap = new Map(orgRoles.map((r) => [r.id, r])); const contractNumberFields = formFields.filter((f) => f.type === 'contract-number' && (f as any).options?.contractNumber?.legalEntityFormId); @@ -348,7 +348,7 @@ export function registerTaskCrudListRoutes(router: ReturnType` / `role:` / legacy-число) function resolveUserColumnEntry( entry: string, - userMap: Map, + userMap: Map, roleMap?: Map ): string { if (entry.startsWith('user:')) { @@ -373,7 +373,7 @@ export function registerTaskCrudListRoutes(router: ReturnType; assignees?: Array<{ userId: number; user?: { firstName: string | null; middleName: string | null; lastName: string | null } }> }, column: string, statusMap: Map, - userMap: Map, + userMap: Map, fields?: Array<{ id: number; type: string; options?: unknown }>, roleMap?: Map, tableRowMap?: Map>, @@ -678,7 +678,7 @@ export function registerTaskCrudListRoutes(router: ReturnType [s.id, s])); - const userMap = new Map(users.map((u: User) => [u.id, u])); + const userMap = new Map(users.map((u: SafeUser) => [u.id, u])); Object.entries(context).forEach(([col, val]) => { if (!val) return; @@ -703,7 +703,7 @@ export function registerTaskCrudListRoutes(router: ReturnType [s.id, s])); - const userMap = new Map(users.map((u: User) => [u.id, u])); + const userMap = new Map(users.map((u: SafeUser) => [u.id, u])); // Отладка: логируем select/radio-group/table поля и их options if (column.startsWith('field_')) { diff --git a/server/services/ai-bot.service.ts b/server/services/ai-bot.service.ts index 9fbde90..f4bd0a1 100644 --- a/server/services/ai-bot.service.ts +++ b/server/services/ai-bot.service.ts @@ -10,7 +10,7 @@ import { McpClient } from '../utils/mcp-client'; import { eventBus } from '../routes/shared'; import { publishConvTypingEvent } from '../routes/messenger.helpers'; import { getOllamaNumThread, getOllamaNumCtx } from '../utils/ollama-config'; -import type { Bot, Task, User } from '@shared/schema'; +import type { Bot, Task, SafeUser } from '@shared/schema'; import { db } from '../db'; import { conversationMessages, bots } from '@shared/schema'; import { eq, and, desc } from 'drizzle-orm'; @@ -48,7 +48,7 @@ interface AiBotContext { task: Task; message: string; attachments?: Array<{ url: string; name: string; size: number; mimeType?: string }>; - user: User; + user: SafeUser; organizationId: number; } @@ -189,7 +189,7 @@ async function callLlm( } } -export function checkBotAccess(bot: Bot, user: User): boolean { +export function checkBotAccess(bot: Bot, user: SafeUser): boolean { const policy = bot.accessPolicy; if (!policy || policy.mode === 'all') return true; if (policy.mode === 'roles') { @@ -491,7 +491,7 @@ export async function handleAiBotDirectMessage(ctx: { bot: Bot; conversationId: number; message: string; - user: User; + user: SafeUser; organizationId: number; }): Promise { const { bot, conversationId, message, user, organizationId } = ctx; diff --git a/server/services/task-message.service.ts b/server/services/task-message.service.ts index 4afa06e..c9ccba3 100644 --- a/server/services/task-message.service.ts +++ b/server/services/task-message.service.ts @@ -1,5 +1,5 @@ import { storage } from "../storage"; -import type { NotificationEvent, Bot, Task, User, TaskMessage } from "@shared/schema"; +import type { NotificationEvent, Bot, Task, SafeUser, TaskMessage } from "@shared/schema"; import { notificationService, EVENT_TYPES } from "./notification.service"; import { webhookService } from "./webhook.service"; import { generateBotServiceToken } from "../utils/jwt"; @@ -23,7 +23,7 @@ type MessageAttachment = { url: string; name: string; size: number; mimeType?: s export interface SendTaskMessageParams { task: Task; // задача (уже загружена и проверена вызывающим кодом) - user?: User; // автор сообщения; необязателен, если передан botId + user?: SafeUser; // автор сообщения; необязателен, если передан botId botId?: number | null; // сообщение от бота: authorId=null, botId, messageType='bot' organizationId: number; message: string; diff --git a/server/storage.ts b/server/storage.ts index 2f585b8..a90fc6a 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -12,6 +12,7 @@ import { systemConfig } from "@shared/schema"; import { taskAuditLog, type TaskAuditLog, type InsertTaskAuditLog } from "@shared/schema"; import { taskRoles, type TaskRole, taskUserAccess, type TaskUserAccess, roleMembers, roles, taskAssignees, type TaskAssignee } from "@shared/schema"; import { userOfflineSubscriptions, type UserOfflineSubscription, type InsertUserOfflineSubscription } from "@shared/schema"; +import type { SafeUser } from "@shared/schema"; import { db } from "./db"; import { eq, and, or, desc, asc, exists, isNull, isNotNull, sql, ilike, inArray, lte } from "drizzle-orm"; import { alias } from "drizzle-orm/pg-core"; @@ -41,7 +42,7 @@ export interface IStorage { getUserWithOrganization(id: number): Promise; getUserByEmail(email: string, organizationId?: number): Promise; getUserByEmailAndSlug(email: string, slug: string): Promise; - getUsersByOrganization(organizationId: number): Promise; + getUsersByOrganization(organizationId: number): Promise; listUsers(organizationId: number, options?: import('./storage/users.storage').ListUsersOptions): Promise; getLinkedTasksForUser(userId: number, organizationId: number): Promise< Array<{ @@ -175,7 +176,7 @@ export interface IStorage { fieldValues: TaskFieldValue[]; subtasks: Task[]; messages: TaskMessageWithAuthor[]; - users: User[]; + users: Array; hasRelations: boolean; assignees: Array; } | null>; @@ -250,7 +251,7 @@ export interface IStorage { deleteNotification(id: number, userId: number, organizationId: number): Promise; // User Search - searchUsers(query: string, organizationId: number, limit?: number): Promise; + searchUsers(query: string, organizationId: number, limit?: number): Promise; // Bots getBot(id: number, organizationId: number): Promise; @@ -416,8 +417,8 @@ export interface IStorage { deleteTaskReminder(id: number, organizationId: number): Promise; getDueReminders(): Promise; markReminderSent(id: number): Promise; - getUsersByRole(role: string, organizationId: number): Promise; - getUsersByOrgRoleId(roleId: number, organizationId: number): Promise; + getUsersByRole(role: string, organizationId: number): Promise; + getUsersByOrgRoleId(roleId: number, organizationId: number): Promise; // Task Audit Log addTaskAuditLog(entry: InsertTaskAuditLog): Promise; diff --git a/server/storage/social.storage.ts b/server/storage/social.storage.ts index e3da293..1ea60d3 100644 --- a/server/storage/social.storage.ts +++ b/server/storage/social.storage.ts @@ -1,4 +1,4 @@ -import { users, userNotifications, bookmarkFolders, bookmarks, type User, type UserNotification, type BookmarkFolder, type Bookmark, type InsertUserNotification, type InsertBookmarkFolder, type InsertBookmark } from "@shared/schema"; +import { users, userNotifications, bookmarkFolders, bookmarks, safeUserColumns, type User, type SafeUser, type UserNotification, type BookmarkFolder, type Bookmark, type InsertUserNotification, type InsertBookmarkFolder, type InsertBookmark } from "@shared/schema"; import { db } from "../db"; import { eq, and, desc, isNull, sql } from "drizzle-orm"; import crypto from "crypto"; @@ -347,7 +347,8 @@ export class SocialStorage extends TasksStorage { } // User Search - async searchUsers(query: string, organizationId: number, limit = 10): Promise { + // Поиск уходит клиенту — выбираем только безопасные колонки (без хэша пароля и токенов) + async searchUsers(query: string, organizationId: number, limit = 10): Promise { // Поддержка похожих символов: і/и для корректного поиска const normalizedQuery = query .replace(/і/g, 'и') @@ -355,7 +356,7 @@ export class SocialStorage extends TasksStorage { .trim(); const result = await db - .select() + .select(safeUserColumns) .from(users) .where(and( eq(users.organizationId, organizationId), diff --git a/server/storage/task-meta.storage.ts b/server/storage/task-meta.storage.ts index 512fe84..8a15702 100644 --- a/server/storage/task-meta.storage.ts +++ b/server/storage/task-meta.storage.ts @@ -1,4 +1,4 @@ -import { users, organizations, forms, tasks, type User, type Organization, type Task } from "@shared/schema"; +import { users, organizations, forms, tasks, safeUserColumns, type User, type SafeUser, type Organization, type Task } from "@shared/schema"; import { taskRelations, type TaskRelation } from "@shared/schema"; import { taskReminders, type TaskReminder, type InsertTaskReminder } from "@shared/schema"; import { taskAuditLog, type TaskAuditLog, type InsertTaskAuditLog } from "@shared/schema"; @@ -96,9 +96,10 @@ export class TaskMetaStorage extends ContentStorage { .where(eq(taskReminders.id, id)); } - async getUsersByRole(role: string, organizationId: number): Promise { + // Массовые выборки пользователей — только безопасные колонки (без хэша пароля и токенов) + async getUsersByRole(role: string, organizationId: number): Promise { return db - .select() + .select(safeUserColumns) .from(users) .where(and( eq(users.organizationId, organizationId), @@ -107,18 +108,17 @@ export class TaskMetaStorage extends ContentStorage { )); } - async getUsersByOrgRoleId(roleId: number, organizationId: number): Promise { - const rows = await db - .select({ user: users }) - .from(roleMembers) - .innerJoin(users, eq(roleMembers.userId, users.id)) + async getUsersByOrgRoleId(roleId: number, organizationId: number): Promise { + return db + .select(safeUserColumns) + .from(users) + .innerJoin(roleMembers, eq(roleMembers.userId, users.id)) .innerJoin(roles, eq(roleMembers.roleId, roles.id)) .where(and( eq(roleMembers.roleId, roleId), eq(roles.organizationId, organizationId), eq(users.isActive, true), )); - return rows.map(r => r.user); } // === Task Audit Log === diff --git a/server/storage/tasks.storage.ts b/server/storage/tasks.storage.ts index 617dfe3..5b5c774 100644 --- a/server/storage/tasks.storage.ts +++ b/server/storage/tasks.storage.ts @@ -1,4 +1,4 @@ -import { users, forms, tasks, taskMessages, messageReads, bots, roles, taskViews, type User, type Form, type FormTab, type FormField, type FormStatus, type StatusTransition, type Task, type TaskFieldValue, type TaskMessage, type TaskMessageWithAuthor, type MessageRead, type DataTableRow } from "@shared/schema"; +import { users, forms, tasks, taskMessages, messageReads, bots, roles, taskViews, type SafeUser, type Form, type FormTab, type FormField, type FormStatus, type StatusTransition, type Task, type TaskFieldValue, type TaskMessage, type TaskMessageWithAuthor, type MessageRead, type DataTableRow } from "@shared/schema"; import { taskRelations } from "@shared/schema"; import { taskAssignees, type TaskAssignee } from "@shared/schema"; import { db } from "../db"; @@ -18,7 +18,7 @@ export class TasksStorage extends TasksCoreStorage { fieldValues: TaskFieldValue[]; subtasks: Task[]; messages: TaskMessageWithAuthor[]; - users: User[]; + users: Array; hasRelations: boolean; assignees: Array; } | null> { @@ -53,7 +53,7 @@ export class TasksStorage extends TasksCoreStorage { this.getTaskAssignees(task.id, organizationId), ]); - const usersWithFullName = orgUsers.map((u: User) => ({ + const usersWithFullName = orgUsers.map((u: SafeUser) => ({ ...u, fullName: formatUserName(u) })); diff --git a/server/storage/users.storage.ts b/server/storage/users.storage.ts index 4d4be0a..721915a 100644 --- a/server/storage/users.storage.ts +++ b/server/storage/users.storage.ts @@ -1,4 +1,4 @@ -import { users, organizations, userSessions, invitations, userCustomFields, userCustomValues, roleMembers, forms, formFields, formStatuses, tasks, taskFieldValues, userOfflineSubscriptions, type User, type Organization, type UserSession, type Invitation, type InsertUser, type InsertOrganization, type InsertUserSession, type InsertInvitation, type UserWithOrganization, type UserCustomField, type UserCustomValue, type CreateUserCustomField, type UpdateUserCustomField, type Task, type FormField, type FormStatus, type UserOfflineSubscription, type InsertUserOfflineSubscription } from "@shared/schema"; +import { users, organizations, userSessions, invitations, userCustomFields, userCustomValues, roleMembers, forms, formFields, formStatuses, tasks, taskFieldValues, userOfflineSubscriptions, safeUserColumns, type User, type SafeUser, type Organization, type UserSession, type Invitation, type InsertUser, type InsertOrganization, type InsertUserSession, type InsertInvitation, type UserWithOrganization, type UserCustomField, type UserCustomValue, type CreateUserCustomField, type UpdateUserCustomField, type Task, type FormField, type FormStatus, type UserOfflineSubscription, type InsertUserOfflineSubscription } from "@shared/schema"; import { db } from "../db"; import { eq, and, desc, asc, sql, ilike, or, gte, lte, inArray } from "drizzle-orm"; import crypto from "crypto"; @@ -16,7 +16,7 @@ export interface ListUsersOptions { } export interface ListUsersResult { - users: User[]; + users: SafeUser[]; total: number; } @@ -90,9 +90,10 @@ export class UsersStorage { }; } - async getUsersByOrganization(organizationId: number): Promise { + // Массовая выдача пользователей клиенту — только безопасные колонки (без хэша пароля и токенов) + async getUsersByOrganization(organizationId: number): Promise { return await db - .select() + .select(safeUserColumns) .from(users) .where(eq(users.organizationId, organizationId)) .orderBy(desc(users.createdAt)); @@ -142,7 +143,7 @@ export class UsersStorage { .where(whereClause); let query = db - .select() + .select(safeUserColumns) .from(users) .where(whereClause) .orderBy(orderDir) @@ -157,9 +158,10 @@ export class UsersStorage { ) as any; } - const rows = await query; + // При явном списке колонок drizzle возвращает плоские строки даже с join + const rows: SafeUser[] = await query; return { - users: rows.map((r: any) => ('users' in r ? r.users : r)), + users: rows, total: countResult?.total || 0, }; } diff --git a/shared/schema.ts b/shared/schema.ts index 573baef..ba4cac4 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -1,4 +1,4 @@ -import { sql, relations } from "drizzle-orm"; +import { sql, relations, getTableColumns } from "drizzle-orm"; import { pgTable, text, varchar, integer, boolean, timestamp, jsonb, serial, bigserial, doublePrecision, unique, foreignKey, uniqueIndex, index, numeric, real, date, uuid, type AnyPgColumn, customType } from "drizzle-orm/pg-core"; import { createInsertSchema } from "drizzle-zod"; import { z } from "zod"; @@ -119,6 +119,11 @@ export const users = pgTable("users", { orgRoleIdx: index("users_org_role_idx").on(table.organizationId, table.appRole), })); +// Колонки users без чувствительных данных (хэш пароля, токены подтверждения и сброса). +// Использовать во всех массовых/клиентских выборках пользователей. +const { passwordHash: _ph, verificationToken: _vt, resetPasswordToken: _rpt, resetPasswordExpires: _rpe, ...restSafeUserColumns } = getTableColumns(users); +export const safeUserColumns = restSafeUserColumns; + export const userStatuses = pgTable("user_statuses", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), @@ -1345,6 +1350,8 @@ export const searchUsersSchema = z.object({ // Types export type Organization = typeof organizations.$inferSelect; export type User = typeof users.$inferSelect; +// Пользователь без чувствительных полей — безопасен для массовой выдачи клиенту +export type SafeUser = Omit; export type UserSession = typeof userSessions.$inferSelect; export type Invitation = typeof invitations.$inferSelect; export type Form = typeof forms.$inferSelect;