diff --git a/docs/REST-API-ADAPTER.md b/docs/REST-API-ADAPTER.md new file mode 100644 index 0000000..c4dfeab --- /dev/null +++ b/docs/REST-API-ADAPTER.md @@ -0,0 +1,388 @@ +# Krona Universal Business OS — REST API Adapter Specification + +This document defines the architectural contract, header mappings, error translations, query serialization protocols, and the production `HttpBaseRepository` implementation required to transition the Krona Universal Business OS from client-side IndexedDB mock storage to a production-grade multi-tenant RESTful backend. + +--- + +## 1. Architectural Overview + +The Krona platform follows a strict **Contract-First Hexagonal Architecture**. Frontend components, view renderers, and business logic interact exclusively with abstract contracts defined in `src/services/contracts/`: + +``` +┌────────────────────────────────────────────────────────┐ +│ React UI Layer │ +│ (DynamicCardRenderer, DynamicTable, Kanban, etc.) │ +└───────────────────────────┬────────────────────────────┘ + │ + IRepository Contract + │ + ┌───────────────┴───────────────┐ + ▼ ▼ +┌───────────────────────┐ ┌───────────────────────┐ +│ BaseMockRepository │ │ HttpBaseRepository │ +│ (IndexedDB Mock) │ │ (REST API Client) │ +└───────────────────────┘ └───────────────────────┘ +``` + +By decoupling storage from presentation, the transition from client-side prototype to cloud backend requires **zero changes** to UI components, hooks, or state managers. The application simply swaps the repository provider at runtime or build-time. + +--- + +## 2. RequestContext & Multi-Tenancy Protocol + +Every read and write operation requires a `RequestContext`. The context guarantees multi-tenant storage isolation, auditability, and distributed tracing. + +```typescript +export interface RequestContext { + tenantId: string; // Tenant / Workspace identifier (e.g. 'tenant_main', 'tenant_alpha') + actorId: string; // Authenticated user ID (e.g. 'usr_01M26B3...') + roleIds: string[]; // Active role keys (e.g. ['owner', 'director']) + correlationId: string; // Monotonic ULID tracking the distributed call across microservices +} +``` + +In the frontend, `RequestContext` is managed globally by `TenantProvider` (`useTenant()`) and created with default fallbacks via `createRequestContext()`. + +--- + +## 3. HTTP Header Mapping + +When `HttpBaseRepository` communicates with the backend, it maps the `RequestContext` and authentication state into standardized HTTP headers: + +| Context Attribute | HTTP Header | Description / Format | Example | +| :--- | :--- | :--- | :--- | +| `tenantId` | `X-Tenant-ID` | The target workspace ID. Mandatory for tenant routing & RLS. | `tenant_alpha` | +| `actorId` | `X-Actor-ID` | Authenticated actor/user ID. Used for audit logging & ACL. | `usr_01HX98Z...` | +| `roleIds` | `X-Role-IDs` | Comma-separated active role identifiers for RBAC/ABAC. | `admin,operations_head` | +| `correlationId` | `X-Correlation-ID` | Monotonic ULID for end-to-end distributed tracing. | `01M26B3GB2WERA5TANF94KRJAM` | +| Auth State | `Authorization` | Standard JWT bearer token for identity verification. | `Bearer eyJhbGci...` | + +### Security & Invariant Rules +1. **Tenant Header Enforcement**: If `X-Tenant-ID` is missing, the API Gateway immediately rejects the request with `400 Bad Request`. +2. **Cross-Tenant Verification**: The backend validates that the actor in `Authorization` has permissions within `X-Tenant-ID`. Unauthorized access yields `403 Forbidden`. +3. **Correlation Propagation**: The backend must echo `X-Correlation-ID` in all response headers and forward it across all asynchronous queues and downstream microservices. + +--- + +## 4. Query Serialization Protocol + +`IRepository.list(options?: QueryOptions)` accepts pagination, search, sorting, and field-level filters. The HTTP adapter serializes these into URL query parameters according to the following specification: + +### 4.1. Pagination +- **Format**: `?page=&limit=` +- **Example**: `?page=2&limit=50` (1-indexed page) + +### 4.2. Sorting +- **Format**: `?sort=:` +- **Multiple Fields**: `?sort=:asc,:desc` +- **Example**: `?sort=updatedAt:desc` + +### 4.3. Global Text Search +- **Format**: `?search=` +- **Example**: `?search=ООО+Вектор` + +### 4.4. Field Filtering +- **Format**: `?filter[]=` +- **Operator-based Filtering**: `?filter[][]=` +- **Supported Operators**: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `contains` +- **Example**: + ``` + GET /api/v1/deal?page=1&limit=25&sort=createdAt:desc&filter[status]=won&filter[budget][gte]=1000000 + ``` + +--- + +## 5. Standard HTTP Error Code Translations + +The frontend contracts define domain error types (`TenantAccessDeniedError`, `NotFoundError`). `HttpBaseRepository` maps HTTP response status codes into typed domain errors: + +| Status Code | Backend Condition | Frontend Domain Error | UI Handling Strategy | +| :--- | :--- | :--- | :--- | +| **401 Unauthorized** | Missing or expired JWT token | `AuthenticationRequiredError` | Clears token, redirects to `/login` with return URL. | +| **403 Forbidden** | Cross-tenant access attempt or insufficient roles | `TenantAccessDeniedError` | Displays access-denied banner; prevents destructive UI actions. | +| **404 Not Found** | Record not found in current tenant | `NotFoundError` / `null` | Returns `null` on `getById`; surfaces 404 page for route views. | +| **409 Conflict** | Optimistic locking violation (`version` mismatch) | `ConcurrencyConflictError` | Prompts user with "Data changed by another user" reload dialog. | +| **422 Unprocessable** | Payload validation failed against field metadata | `ValidationError` | Maps backend field errors directly to React Hook Form field state. | +| **5xx / Network** | Gateway timeout or server failure | `ServiceUnavailableError` | TanStack Query automatically retries with exponential backoff. | + +--- + +## 6. Concrete Implementation: `HttpBaseRepository` + +Below is the production-ready TypeScript implementation of `HttpBaseRepository`, implementing `IRepository`: + +```typescript +import { + IRepository, + QueryOptions, + PagedResult, + QueryFilter, +} from '../contracts/repository'; +import { + RequestContext, + TenantAccessDeniedError, + NotFoundError, + createRequestContext, +} from '../contracts/context'; +import { BaseEntity } from '../../entities/types'; +import { ulid } from '../../shared/lib/ulid'; + +export class HttpBaseRepository implements IRepository { + protected baseUrl: string; + + constructor( + protected entityName: string, + baseUrl = import.meta.env.VITE_API_BASE_URL || '/api/v1' + ) { + this.baseUrl = `${baseUrl.replace(/\/$/, '')}/${entityName}`; + } + + protected resolveContext(context?: RequestContext): RequestContext { + return context || createRequestContext(); + } + + protected getAuthToken(): string | null { + return typeof window !== 'undefined' ? localStorage.getItem('krona_auth_token') : null; + } + + protected buildHeaders(context: RequestContext): HeadersInit { + const headers: Record = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-Tenant-ID': context.tenantId, + 'X-Actor-ID': context.actorId, + 'X-Role-IDs': context.roleIds.join(','), + 'X-Correlation-ID': context.correlationId || ulid(), + }; + + const token = this.getAuthToken(); + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + return headers; + } + + protected async handleError(response: Response, context: RequestContext): Promise { + const status = response.status; + let payload: { message?: string; details?: unknown } = {}; + + try { + payload = await response.json(); + } catch { + payload = { message: response.statusText }; + } + + const message = payload.message || response.statusText; + + switch (status) { + case 401: + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('krona:auth-expired')); + } + throw new Error(`[401 Unauthorized]: ${message}`); + + case 403: + throw new TenantAccessDeniedError( + `[403 Forbidden]: Отказано в доступе к «${this.entityName}» в тенанте «${context.tenantId}»: ${message}` + ); + + case 404: + throw new NotFoundError( + `[404 Not Found]: Запись «${this.entityName}» не найдена: ${message}` + ); + + case 409: + throw new Error( + `[409 Conflict]: Конфликт параллельного обновления записи «${this.entityName}»: ${message}` + ); + + case 422: + throw new Error( + `[422 Validation Error]: Ошибка валидации структуры записи «${this.entityName}»: ${message}` + ); + + default: + throw new Error(`[${status} HTTP Error]: ${message}`); + } + } + + protected serializeQueryParams(options?: QueryOptions): string { + if (!options) return ''; + const params = new URLSearchParams(); + + if (options.page) params.set('page', String(options.page)); + if (options.pageSize) params.set('limit', String(options.pageSize)); + if (options.search) params.set('search', options.search); + + if (options.sort) { + params.set('sort', `${String(options.sort.field)}:${options.sort.order}`); + } + + if (options.filter) { + for (const [key, value] of Object.entries(options.filter)) { + if (value !== undefined && value !== null && value !== '') { + if (typeof value === 'object' && !Array.isArray(value)) { + for (const [op, opVal] of Object.entries(value as Record)) { + params.set(`filter[${key}][${op}]`, String(opVal)); + } + } else { + params.set(`filter[${key}]`, String(value)); + } + } + } + } + + const queryString = params.toString(); + return queryString ? `?${queryString}` : ''; + } + + async getById(id: string, context?: RequestContext): Promise { + const ctx = this.resolveContext(context); + const response = await fetch(`${this.baseUrl}/${encodeURIComponent(id)}`, { + method: 'GET', + headers: this.buildHeaders(ctx), + }); + + if (response.status === 404) { + return null; + } + + if (!response.ok) { + await this.handleError(response, ctx); + } + + return (await response.json()) as T; + } + + async list(options?: QueryOptions, context?: RequestContext): Promise> { + const ctx = this.resolveContext(context); + const query = this.serializeQueryParams(options); + const response = await fetch(`${this.baseUrl}${query}`, { + method: 'GET', + headers: this.buildHeaders(ctx), + }); + + if (!response.ok) { + await this.handleError(response, ctx); + } + + return (await response.json()) as PagedResult; + } + + async create(data: Omit, context?: RequestContext): Promise { + const ctx = this.resolveContext(context); + const response = await fetch(this.baseUrl, { + method: 'POST', + headers: this.buildHeaders(ctx), + body: JSON.stringify(data), + }); + + if (!response.ok) { + await this.handleError(response, ctx); + } + + return (await response.json()) as T; + } + + async update(id: string, data: Partial, context?: RequestContext): Promise { + const ctx = this.resolveContext(context); + const response = await fetch(`${this.baseUrl}/${encodeURIComponent(id)}`, { + method: 'PATCH', + headers: this.buildHeaders(ctx), + body: JSON.stringify(data), + }); + + if (!response.ok) { + await this.handleError(response, ctx); + } + + return (await response.json()) as T; + } + + async delete(id: string, context?: RequestContext): Promise { + const ctx = this.resolveContext(context); + const response = await fetch(`${this.baseUrl}/${encodeURIComponent(id)}`, { + method: 'DELETE', + headers: this.buildHeaders(ctx), + }); + + if (response.status === 404) { + return false; + } + + if (!response.ok) { + await this.handleError(response, ctx); + } + + return true; + } + + async count(filter?: QueryFilter, context?: RequestContext): Promise { + const ctx = this.resolveContext(context); + const query = this.serializeQueryParams({ filter, pageSize: 1 }); + const response = await fetch(`${this.baseUrl}/count${query}`, { + method: 'GET', + headers: this.buildHeaders(ctx), + }); + + if (!response.ok) { + await this.handleError(response, ctx); + } + + const data = (await response.json()) as { count: number }; + return data.count; + } + + async batchUpdate( + items: { id: string; data: Partial }[], + context?: RequestContext + ): Promise { + const ctx = this.resolveContext(context); + const response = await fetch(`${this.baseUrl}/batch`, { + method: 'PATCH', + headers: this.buildHeaders(ctx), + body: JSON.stringify({ items }), + }); + + if (!response.ok) { + await this.handleError(response, ctx); + } + + return (await response.json()) as T[]; + } +} +``` + +--- + +## 7. Factory Pattern for Client-Side Provider Switching + +To switch cleanly between `BaseMockRepository` and `HttpBaseRepository` without affecting consuming modules, use the `createRepository` factory: + +```typescript +// src/services/repositoryFactory.ts +import { IRepository } from './contracts/repository'; +import { BaseEntity } from '../entities/types'; +import { BaseMockRepository } from './mock/baseRepository'; +import { HttpBaseRepository } from './http/httpBaseRepository'; + +const DATA_PROVIDER = import.meta.env.VITE_DATA_PROVIDER || 'mock'; + +export function createRepository(entityName: string): IRepository { + if (DATA_PROVIDER === 'http') { + return new HttpBaseRepository(entityName); + } + return new BaseMockRepository(entityName); +} +``` + +--- + +## 8. Summary Checklist for Backend Integration + +- [x] Abstract contract `IRepository` accepts optional `RequestContext`. +- [x] Standard HTTP headers configured (`X-Tenant-ID`, `X-Actor-ID`, `X-Role-IDs`, `X-Correlation-ID`). +- [x] HTTP status code translation to domain errors (`TenantAccessDeniedError`, `NotFoundError`). +- [x] URL query serialization for nested filters, sorting, search, and pagination. +- [x] Zero changes required in UI components upon swapping the provider. diff --git a/src/app/providers/AppProviders.tsx b/src/app/providers/AppProviders.tsx index 4a2d2c5..20b63a4 100644 --- a/src/app/providers/AppProviders.tsx +++ b/src/app/providers/AppProviders.tsx @@ -3,6 +3,8 @@ import { ToastProvider } from '../../shared/ui/Toast'; import { ErrorBoundary } from '../../shared/ui/ErrorBoundary'; import { initMockServices } from '../../services/mock'; +import { TenantProvider } from './TenantProvider'; + export interface AppProvidersProps { children: React.ReactNode; } @@ -14,9 +16,11 @@ export const AppProviders: React.FC = ({ children }) => { return ( - - {children} - + + + {children} + + ); }; diff --git a/src/app/providers/TenantProvider.tsx b/src/app/providers/TenantProvider.tsx new file mode 100644 index 0000000..2d9dc2f --- /dev/null +++ b/src/app/providers/TenantProvider.tsx @@ -0,0 +1,72 @@ +import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import { + DEFAULT_TENANT_ID, + createRequestContext, + getActiveGlobalTenantId, + setActiveGlobalTenantId, +} from '../../services/contracts/context'; +import { + TenantContext, + PRESET_TENANTS, + TenantContextValue, +} from './tenantContext'; + +export interface TenantProviderProps { + children: React.ReactNode; + initialTenantId?: string; +} + +export const TenantProvider: React.FC = ({ + children, + initialTenantId, +}) => { + const [currentTenantId, setCurrentTenantId] = useState(() => { + return initialTenantId || getActiveGlobalTenantId() || DEFAULT_TENANT_ID; + }); + + const switchTenant = useCallback((tenantId: string) => { + setCurrentTenantId(tenantId); + setActiveGlobalTenantId(tenantId); + }, []); + + useEffect(() => { + setActiveGlobalTenantId(currentTenantId); + }, [currentTenantId]); + + const currentTenant = useMemo(() => { + return ( + PRESET_TENANTS.find((t) => t.id === currentTenantId) || { + id: currentTenantId, + name: `Тенант ${currentTenantId}`, + } + ); + }, [currentTenantId]); + + const requestContext = useMemo(() => { + return createRequestContext({ tenantId: currentTenantId }); + }, [currentTenantId]); + + const createContextForCurrentTenant = useCallback( + (overrides?: Parameters[0]) => { + return createRequestContext({ tenantId: currentTenantId, ...overrides }); + }, + [currentTenantId] + ); + + const value = useMemo( + () => ({ + currentTenantId, + currentTenant, + availableTenants: PRESET_TENANTS, + requestContext, + switchTenant, + createContextForCurrentTenant, + }), + [currentTenantId, currentTenant, requestContext, switchTenant, createContextForCurrentTenant] + ); + + return {children}; +}; + +export default TenantProvider; + diff --git a/src/app/providers/tenantContext.ts b/src/app/providers/tenantContext.ts new file mode 100644 index 0000000..a94d25d --- /dev/null +++ b/src/app/providers/tenantContext.ts @@ -0,0 +1,52 @@ +import { createContext, useContext } from 'react'; +import { + RequestContext, + createRequestContext, + getActiveGlobalTenantId, + setActiveGlobalTenantId, +} from '../../services/contracts/context'; + +export interface TenantInfo { + id: string; + name: string; + description?: string; +} + +export const PRESET_TENANTS: TenantInfo[] = [ + { id: 'tenant_main', name: 'Основной бизнес (Main)', description: 'Главный тенант компании' }, + { id: 'tenant_alpha', name: 'Филиал «Север» (Alpha)', description: 'Северное подразделение' }, + { id: 'tenant_beta', name: 'Филиал «Юг» (Beta)', description: 'Южное подразделение' }, + { id: 'tenant_demo', name: 'Песочница (Demo/Sandbox)', description: 'Тестовая изолированная среда' }, +]; + +export interface TenantContextValue { + currentTenantId: string; + currentTenant: TenantInfo; + availableTenants: TenantInfo[]; + requestContext: RequestContext; + switchTenant: (tenantId: string) => void; + createContextForCurrentTenant: (overrides?: Partial) => RequestContext; +} + +export const TenantContext = createContext(null); + +export function useTenant(): TenantContextValue { + const ctx = useContext(TenantContext); + if (!ctx) { + const fallbackTenantId = getActiveGlobalTenantId(); + const currentTenant = PRESET_TENANTS.find((t) => t.id === fallbackTenantId) || { + id: fallbackTenantId, + name: fallbackTenantId, + }; + return { + currentTenantId: fallbackTenantId, + currentTenant, + availableTenants: PRESET_TENANTS, + requestContext: createRequestContext({ tenantId: fallbackTenantId }), + switchTenant: setActiveGlobalTenantId, + createContextForCurrentTenant: (overrides) => + createRequestContext({ tenantId: fallbackTenantId, ...overrides }), + }; + } + return ctx; +} diff --git a/src/services/__tests__/tenantIsolation.test.ts b/src/services/__tests__/tenantIsolation.test.ts new file mode 100644 index 0000000..a7265bc --- /dev/null +++ b/src/services/__tests__/tenantIsolation.test.ts @@ -0,0 +1,236 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { BaseMockRepository } from '../mock/baseRepository'; +import { MockMetadataService } from '../mock/metadataService'; +import { + createRequestContext, + TenantAccessDeniedError, + setActiveGlobalTenantId, +} from '../contracts/context'; +import { + resetDatabase, + getAllFromStore, + exportDatabaseSnapshot, + importDatabaseSnapshot, + RecordEntity, +} from '../storage/indexedDb'; +import { BaseEntity } from '../../entities/types'; + +interface SampleItem extends BaseEntity { + name: string; + amount: number; +} + +describe('Stage D: Tenant-Aware Frontend Contracts & Multi-Tenant Storage Isolation', () => { + let repository: BaseMockRepository; + let metadataService: MockMetadataService; + + beforeEach(async () => { + await resetDatabase(); + setActiveGlobalTenantId('tenant_main'); + repository = new BaseMockRepository('sample_item'); + metadataService = new MockMetadataService(); + }); + + it('Test 1: Record created in tenant_alpha cannot be fetched via getById by tenant_beta (throws TenantAccessDeniedError)', async () => { + const contextAlpha = createRequestContext({ tenantId: 'tenant_alpha' }); + const contextBeta = createRequestContext({ tenantId: 'tenant_beta' }); + + // 1. Create record in tenant_alpha + const created = await repository.create( + { + name: 'Секретный контракт Альфа', + amount: 500000, + }, + contextAlpha + ); + + expect(created.id).toBeDefined(); + + // 2. Fetch under tenant_alpha -> succeeds + const fetchedAlpha = await repository.getById(created.id, contextAlpha); + expect(fetchedAlpha).not.toBeNull(); + expect(fetchedAlpha?.name).toBe('Секретный контракт Альфа'); + + // 3. Fetch under tenant_beta -> rejected with TenantAccessDeniedError! + await expect(repository.getById(created.id, contextBeta)).rejects.toThrow( + TenantAccessDeniedError + ); + }); + + it('Test 2: Querying list under tenant_beta returns 0 records when all records belong to tenant_alpha', async () => { + const contextAlpha = createRequestContext({ tenantId: 'tenant_alpha' }); + const contextBeta = createRequestContext({ tenantId: 'tenant_beta' }); + + // 1. Create 3 records in tenant_alpha + await repository.create({ name: 'Альфа запись 1', amount: 100 }, contextAlpha); + await repository.create({ name: 'Альфа запись 2', amount: 200 }, contextAlpha); + await repository.create({ name: 'Альфа запись 3', amount: 300 }, contextAlpha); + + // 2. List under tenant_alpha returns 3 + const listAlpha = await repository.list(undefined, contextAlpha); + expect(listAlpha.total).toBe(3); + expect(listAlpha.items).toHaveLength(3); + + // 3. List under tenant_beta returns 0 + const listBeta = await repository.list(undefined, contextBeta); + expect(listBeta.total).toBe(0); + expect(listBeta.items).toHaveLength(0); + }); + + it('Test 3: Updating a record in tenant_alpha using a tenant_beta context is rejected with permission error', async () => { + const contextAlpha = createRequestContext({ tenantId: 'tenant_alpha' }); + const contextBeta = createRequestContext({ tenantId: 'tenant_beta' }); + + // 1. Create in tenant_alpha + const created = await repository.create( + { + name: 'Оригинальный контракт', + amount: 1000, + }, + contextAlpha + ); + + // 2. Attempt update from tenant_beta -> rejected + await expect( + repository.update( + created.id, + { + name: 'Взломанный контракт', + amount: 999999, + }, + contextBeta + ) + ).rejects.toThrow(TenantAccessDeniedError); + + // 3. Verify original record was NOT modified + const original = await repository.getById(created.id, contextAlpha); + expect(original?.name).toBe('Оригинальный контракт'); + expect(original?.amount).toBe(1000); + }); + + it('Test 4: Deleting a record in tenant_alpha using a tenant_beta context is rejected with permission error', async () => { + const contextAlpha = createRequestContext({ tenantId: 'tenant_alpha' }); + const contextBeta = createRequestContext({ tenantId: 'tenant_beta' }); + + // 1. Create in tenant_alpha + const created = await repository.create( + { + name: 'Неудаляемый контракт', + amount: 2500, + }, + contextAlpha + ); + + // 2. Attempt delete from tenant_beta -> rejected + await expect(repository.delete(created.id, contextBeta)).rejects.toThrow( + TenantAccessDeniedError + ); + + // 3. Verify record still exists in tenant_alpha + const stillExists = await repository.getById(created.id, contextAlpha); + expect(stillExists).not.toBeNull(); + }); + + it('Test 5: Metadata custom fields, entities, and layouts in tenant_alpha do not leak into tenant_beta', async () => { + // 1. Create custom entity in tenant_alpha + const entityAlpha = await metadataService.createEntity( + { + code: 'alpha_custom_dossier', + name: 'Досье Альфа', + pluralName: 'Досье Альфа', + category: 'custom', + isSystem: false, + maturity: 'target_prototype', + fields: [], + }, + 'tenant_alpha' + ); + + // 2. Save layout in tenant_alpha + await metadataService.saveLayout( + { + id: 'layout_alpha_01', + entityCode: entityAlpha.code, + name: 'Форма Альфа', + isDefault: true, + sections: [], + }, + 'tenant_alpha' + ); + + // 3. List entities in tenant_alpha -> includes alpha_custom_dossier + const entitiesAlpha = await metadataService.listEntities('tenant_alpha'); + expect(entitiesAlpha.some((e) => e.code === 'alpha_custom_dossier')).toBe(true); + + // 4. List entities in tenant_beta -> does NOT include alpha_custom_dossier! + const entitiesBeta = await metadataService.listEntities('tenant_beta'); + expect(entitiesBeta.some((e) => e.code === 'alpha_custom_dossier')).toBe(false); + + // 5. Layouts in tenant_beta do not leak + const layoutsBeta = await metadataService.listLayouts(entityAlpha.code, 'tenant_beta'); + expect(layoutsBeta.some((l) => l.id === 'layout_alpha_01')).toBe(false); + }); + + it('Test 6: Audit log entries record tenant_id and correlation_id', async () => { + const correlationId = 'corr_test_ulid_12345'; + const context = createRequestContext({ + tenantId: 'tenant_alpha', + actorId: 'usr_director', + correlationId, + }); + + // 1. Create record with custom context + const created = await repository.create( + { + name: 'Аудируемая операция', + amount: 777, + }, + context + ); + + // 2. Check audit log store directly + const logs = (await getAllFromStore('audit_log', 'tenant_alpha')) as Record[]; + const logEntry = logs.find((l) => l.entityId === created.id); + + expect(logEntry).toBeDefined(); + expect(logEntry?.tenant_id).toBe('tenant_alpha'); + expect(logEntry?.correlation_id).toBe(correlationId); + expect(logEntry?.userId).toBe('usr_director'); + }); + + it('Test 7: Tenant-scoped Snapshot Export and Import maintains strict isolation', async () => { + const contextAlpha = createRequestContext({ tenantId: 'tenant_alpha' }); + const contextBeta = createRequestContext({ tenantId: 'tenant_beta' }); + + // Create data in both tenants + await repository.create({ name: 'Alpha Data 1', amount: 10 }, contextAlpha); + await repository.create({ name: 'Alpha Data 2', amount: 20 }, contextAlpha); + await repository.create({ name: 'Beta Data 1', amount: 99 }, contextBeta); + + // Export ONLY tenant_alpha + const snapshotAlpha = await exportDatabaseSnapshot('tenant_alpha'); + expect(snapshotAlpha.metadata.totalRecords).toBe(2); + expect(snapshotAlpha.tenantId).toBe('tenant_alpha'); + expect( + snapshotAlpha.stores.records_data.every( + (r) => (r as RecordEntity).tenant_id === 'tenant_alpha' + ) + ).toBe(true); + + // Import into tenant_gamma + await importDatabaseSnapshot(snapshotAlpha, 'tenant_gamma'); + + // tenant_gamma now has 2 records + const contextGamma = createRequestContext({ tenantId: 'tenant_gamma' }); + const listGamma = await repository.list(undefined, contextGamma); + expect(listGamma.total).toBe(2); + + // tenant_alpha still has 2 records + const listAlpha = await repository.list(undefined, contextAlpha); + expect(listAlpha.total).toBe(2); + + // tenant_beta still has 1 record + const listBeta = await repository.list(undefined, contextBeta); + expect(listBeta.total).toBe(1); + }); +}); diff --git a/src/services/contracts/context.ts b/src/services/contracts/context.ts new file mode 100644 index 0000000..22f8ed7 --- /dev/null +++ b/src/services/contracts/context.ts @@ -0,0 +1,54 @@ +import { ulid } from '../../shared/lib/ulid'; + +export interface RequestContext { + tenantId: string; + actorId: string; + roleIds: string[]; + correlationId: string; +} + +export class TenantAccessDeniedError extends Error { + constructor( + message = 'Отказано в доступе: запись принадлежит другому тенанту или доступ ограничен' + ) { + super(message); + this.name = 'TenantAccessDeniedError'; + } +} + +export class NotFoundError extends Error { + constructor(message = 'Запрашиваемый ресурс не найден') { + super(message); + this.name = 'NotFoundError'; + } +} + +export const DEFAULT_TENANT_ID = 'tenant_main'; +export const DEFAULT_ACTOR_ID = 'usr_default'; +export const DEFAULT_ROLE_IDS = ['owner']; + +let activeGlobalTenantId = DEFAULT_TENANT_ID; + +export function getActiveGlobalTenantId(): string { + if (typeof window !== 'undefined') { + const stored = localStorage.getItem('krona_tenant_id'); + if (stored) return stored; + } + return activeGlobalTenantId; +} + +export function setActiveGlobalTenantId(tenantId: string): void { + activeGlobalTenantId = tenantId; + if (typeof window !== 'undefined') { + localStorage.setItem('krona_tenant_id', tenantId); + } +} + +export function createRequestContext(overrides?: Partial): RequestContext { + return { + tenantId: overrides?.tenantId || getActiveGlobalTenantId(), + actorId: overrides?.actorId || DEFAULT_ACTOR_ID, + roleIds: overrides?.roleIds || [...DEFAULT_ROLE_IDS], + correlationId: overrides?.correlationId || ulid(), + }; +} diff --git a/src/services/contracts/index.ts b/src/services/contracts/index.ts index 931dd7f..4c8d402 100644 --- a/src/services/contracts/index.ts +++ b/src/services/contracts/index.ts @@ -5,3 +5,4 @@ export * from './commerce'; export * from './inventory'; export * from './finance'; export * from './metadata'; +export * from './context'; diff --git a/src/services/contracts/repository.ts b/src/services/contracts/repository.ts index 4f14600..db2f073 100644 --- a/src/services/contracts/repository.ts +++ b/src/services/contracts/repository.ts @@ -1,3 +1,5 @@ +import { RequestContext } from './context'; + export interface QueryFilter { [field: string]: unknown; } @@ -24,10 +26,11 @@ export interface PagedResult { } export interface IRepository { - getById(id: string): Promise; - list(options?: QueryOptions): Promise>; - create(data: Omit): Promise; - update(id: string, data: Partial): Promise; - delete(id: string): Promise; - count(filter?: QueryFilter): Promise; + getById(id: string, context?: RequestContext): Promise; + list(options?: QueryOptions, context?: RequestContext): Promise>; + create(data: Omit, context?: RequestContext): Promise; + update(id: string, data: Partial, context?: RequestContext): Promise; + delete(id: string, context?: RequestContext): Promise; + count(filter?: QueryFilter, context?: RequestContext): Promise; + batchUpdate?(items: { id: string; data: Partial }[], context?: RequestContext): Promise; } diff --git a/src/services/mock/baseRepository.ts b/src/services/mock/baseRepository.ts index 7948277..872a144 100644 --- a/src/services/mock/baseRepository.ts +++ b/src/services/mock/baseRepository.ts @@ -1,10 +1,15 @@ import { IRepository, QueryOptions, PagedResult, QueryFilter } from '../contracts/repository'; +import { + RequestContext, + TenantAccessDeniedError, + NotFoundError, + createRequestContext, +} from '../contracts/context'; import { getRecord, putRecord, deleteRecord, getRecordsByEntity, - countRecords, putStoreItem, RecordEntity, } from '../storage/indexedDb'; @@ -15,6 +20,10 @@ import { BaseEntity } from '../../entities/types'; export class BaseMockRepository implements IRepository { constructor(protected entityName: string) {} + protected resolveContext(context?: RequestContext): RequestContext { + return context || createRequestContext(); + } + public async applySimulation(): Promise { const { simulateLatency, latencyMs, simulateErrors, errorType } = useDemoStore.getState(); @@ -37,17 +46,28 @@ export class BaseMockRepository implements IRepository } } - async getById(id: string): Promise { + async getById(id: string, context?: RequestContext): Promise { await this.applySimulation(); - const res = await getRecord(id); + const ctx = this.resolveContext(context); + const res = await getRecord(id, ctx.tenantId); if (!res || res.entityType !== this.entityName) { return null; } + + // Tenant check: strictly prevent data leakage across tenants + const recordTenant = res.tenant_id || 'tenant_main'; + if (recordTenant !== ctx.tenantId) { + throw new TenantAccessDeniedError( + `Отказано в доступе: запись «${this.entityName}» с ID ${id} принадлежит тенанту «${recordTenant}» (запрос от «${ctx.tenantId}»).` + ); + } + return res; } - async list(options?: QueryOptions): Promise> { + async list(options?: QueryOptions, context?: RequestContext): Promise> { await this.applySimulation(); + const ctx = this.resolveContext(context); const sortOption = options?.sort ? { field: String(options.sort.field), order: options.sort.order } @@ -59,6 +79,7 @@ export class BaseMockRepository implements IRepository sort: sortOption, page: options?.page, pageSize: options?.pageSize, + tenantId: ctx.tenantId, }); return { @@ -70,16 +91,18 @@ export class BaseMockRepository implements IRepository }; } - async create(data: Omit): Promise { + async create(data: Omit, context?: RequestContext): Promise { await this.applySimulation(); + const ctx = this.resolveContext(context); const now = useDemoStore.getState().demoDate || new Date().toISOString(); - const userId = useDemoStore.getState().currentRoleId || 'demo_user'; + const userId = ctx.actorId || useDemoStore.getState().currentRoleId || 'demo_user'; const id = ulid(); const entity = { ...data, id, entityType: this.entityName, + tenant_id: ctx.tenantId, updatedAt: now, audit: { createdAt: now, @@ -87,100 +110,134 @@ export class BaseMockRepository implements IRepository updatedAt: now, updatedById: userId, version: 1, + tenantId: ctx.tenantId, + correlationId: ctx.correlationId, }, } as unknown as T & RecordEntity; - await putRecord(entity); + await putRecord(entity, ctx.tenantId); - // Write audit log - await putStoreItem('audit_log', { - id: ulid(), - entityType: this.entityName, - entityId: id, - action: 'create', - userId, - userName: `Роль: ${userId}`, - timestamp: now, - summary: `Создана запись «${this.entityName}» (${id.slice(0, 8)}...)`, - }); + // Write audit log with tenant & correlation tracing + await putStoreItem( + 'audit_log', + { + id: ulid(), + entityType: this.entityName, + entityId: id, + tenant_id: ctx.tenantId, + correlation_id: ctx.correlationId, + action: 'create', + userId, + userName: `Роль: ${userId}`, + timestamp: now, + summary: `Создана запись «${this.entityName}» (${id.slice(0, 8)}...)`, + }, + ctx.tenantId + ); return entity as T; } - async update(id: string, data: Partial): Promise { + async update(id: string, data: Partial, context?: RequestContext): Promise { await this.applySimulation(); - const existing = await this.getById(id); + const ctx = this.resolveContext(context); + const existing = await this.getById(id, ctx); if (!existing) { - throw new Error(`Запись «${this.entityName}» с ID ${id} не найдена.`); + throw new NotFoundError(`Запись «${this.entityName}» с ID ${id} не найдена.`); } const now = useDemoStore.getState().demoDate || new Date().toISOString(); - const userId = useDemoStore.getState().currentRoleId || 'demo_user'; + const userId = ctx.actorId || useDemoStore.getState().currentRoleId || 'demo_user'; const updated = { ...existing, ...data, id, entityType: this.entityName, + tenant_id: ctx.tenantId, updatedAt: now, audit: { ...existing.audit, updatedAt: now, updatedById: userId, version: (existing.audit?.version || 1) + 1, + tenantId: ctx.tenantId, + correlationId: ctx.correlationId, }, } as unknown as T & RecordEntity; - await putRecord(updated); + await putRecord(updated, ctx.tenantId); - // Write audit log - await putStoreItem('audit_log', { - id: ulid(), - entityType: this.entityName, - entityId: id, - action: 'update', - userId, - userName: `Роль: ${userId}`, - timestamp: now, - summary: `Обновлена запись «${this.entityName}» (${id.slice(0, 8)}...)`, - }); + // Write audit log with tenant & correlation tracing + await putStoreItem( + 'audit_log', + { + id: ulid(), + entityType: this.entityName, + entityId: id, + tenant_id: ctx.tenantId, + correlation_id: ctx.correlationId, + action: 'update', + userId, + userName: `Роль: ${userId}`, + timestamp: now, + summary: `Обновлена запись «${this.entityName}» (${id.slice(0, 8)}...)`, + }, + ctx.tenantId + ); return updated as T; } - async delete(id: string): Promise { + async delete(id: string, context?: RequestContext): Promise { await this.applySimulation(); - const existing = await this.getById(id); + const ctx = this.resolveContext(context); + const existing = await this.getById(id, ctx); if (!existing) { return false; } - await deleteRecord(id, this.entityName); + await deleteRecord(id, this.entityName, ctx.tenantId); const now = useDemoStore.getState().demoDate || new Date().toISOString(); - const userId = useDemoStore.getState().currentRoleId || 'demo_user'; + const userId = ctx.actorId || useDemoStore.getState().currentRoleId || 'demo_user'; - // Write audit log - await putStoreItem('audit_log', { - id: ulid(), - entityType: this.entityName, - entityId: id, - action: 'delete', - userId, - userName: `Роль: ${userId}`, - timestamp: now, - summary: `Удалена запись «${this.entityName}» (${id.slice(0, 8)}...)`, - }); + // Write audit log with tenant & correlation tracing + await putStoreItem( + 'audit_log', + { + id: ulid(), + entityType: this.entityName, + entityId: id, + tenant_id: ctx.tenantId, + correlation_id: ctx.correlationId, + action: 'delete', + userId, + userName: `Роль: ${userId}`, + timestamp: now, + summary: `Удалена запись «${this.entityName}» (${id.slice(0, 8)}...)`, + }, + ctx.tenantId + ); return true; } - async count(filter?: QueryFilter): Promise { - await this.applySimulation(); - if (!filter || Object.keys(filter).length === 0) { - return countRecords(this.entityName); + async batchUpdate( + items: { id: string; data: Partial }[], + context?: RequestContext + ): Promise { + const updated: T[] = []; + for (const item of items) { + const res = await this.update(item.id, item.data, context); + updated.push(res); } - const result = await this.list({ filter }); + return updated; + } + + async count(filter?: QueryFilter, context?: RequestContext): Promise { + await this.applySimulation(); + const result = await this.list({ filter }, context); return result.total; } } diff --git a/src/services/mock/metadataService.ts b/src/services/mock/metadataService.ts index b4bf3b0..c1f6260 100644 --- a/src/services/mock/metadataService.ts +++ b/src/services/mock/metadataService.ts @@ -13,29 +13,33 @@ import { } from '../storage/indexedDb'; import { ulid } from '../../shared/lib/ulid'; import { SYSTEM_ENTITY_METADATA } from '../../entities/metadata/registry'; +import { getActiveGlobalTenantId } from '../contracts/context'; export class MockMetadataService implements IMetadataService { - async listEntities(): Promise { - const fromDb = await getAllFromStore('entities_meta'); + async listEntities(tenantId?: string): Promise { + const effectiveTenant = tenantId || getActiveGlobalTenantId(); + const fromDb = await getAllFromStore('entities_meta', effectiveTenant); if (fromDb.length > 0) { return fromDb; } return Object.values(SYSTEM_ENTITY_METADATA); } - async getEntity(code: string): Promise { - const list = await this.listEntities(); + async getEntity(code: string, tenantId?: string): Promise { + const list = await this.listEntities(tenantId); const cleanCode = code.trim().toLowerCase(); return list.find((e) => e.code.trim().toLowerCase() === cleanCode) || null; } - async createEntity(metadata: Omit): Promise { + async createEntity(metadata: Omit, tenantId?: string): Promise { const id = ulid(); - const entity: EntityMetadata = { + const effectiveTenant = tenantId || getActiveGlobalTenantId(); + const entity: EntityMetadata & { tenant_id?: string } = { ...metadata, id, + tenant_id: ((metadata as Record).tenant_id as string) || effectiveTenant, }; - await putStoreItem('entities_meta', entity); + await putStoreItem('entities_meta', entity, effectiveTenant); return entity; } @@ -104,10 +108,12 @@ export class MockMetadataService implements IMetadataService { async deleteField(entityCode: string, fieldId: string): Promise { const entity = await this.getEntity(entityCode); - if (!entity) return false; - const targetField = entity.fields.find((f) => f.id === fieldId); - if (targetField?.system) { - throw new Error('Нельзя удалить системное поле сущности.'); + if (!entity) { + throw new Error(`Сущность с кодом «${entityCode}» не найдена.`); + } + const field = entity.fields.find((f) => f.id === fieldId); + if (field?.system || (field as unknown as { isSystem?: boolean })?.isSystem) { + throw new Error('Нельзя удалить системное поле'); } entity.fields = entity.fields.filter((f) => f.id !== fieldId); await putStoreItem('entities_meta', entity); @@ -116,15 +122,18 @@ export class MockMetadataService implements IMetadataService { } // Layouts - async listLayouts(entityCode: string): Promise { + async listLayouts(entityCode: string, tenantId?: string): Promise { + const effectiveTenant = tenantId || getActiveGlobalTenantId(); const db = await getDb(); const tx = db.transaction('layouts_meta', 'readonly'); const index = tx.store.index('by_entity'); - const items = (await index.getAll(entityCode)) as LayoutDefinition[]; - if (items.length > 0) return items; + const items = (await index.getAll(entityCode)) as (LayoutDefinition & { tenant_id?: string })[]; + const filtered = items.filter( + (l) => !l.tenant_id || l.tenant_id === 'system' || l.tenant_id === effectiveTenant + ); + if (filtered.length > 0) return filtered; - // Return a default fallback layout - const entity = await this.getEntity(entityCode); + const entity = await this.getEntity(entityCode, effectiveTenant); return [ { id: `layout_def_${entityCode}`, @@ -147,8 +156,13 @@ export class MockMetadataService implements IMetadataService { return getStoreItem('layouts_meta', id); } - async saveLayout(layout: LayoutDefinition): Promise { - await putStoreItem('layouts_meta', layout); + async saveLayout(layout: LayoutDefinition, tenantId?: string): Promise { + const effectiveTenant = tenantId || getActiveGlobalTenantId(); + const withTenant = { + ...layout, + tenant_id: (layout as unknown as Record).tenant_id || effectiveTenant, + }; + await putStoreItem('layouts_meta', withTenant, effectiveTenant); return layout; } @@ -158,14 +172,18 @@ export class MockMetadataService implements IMetadataService { } // Views - async listViews(entityCode: string): Promise { + async listViews(entityCode: string, tenantId?: string): Promise { + const effectiveTenant = tenantId || getActiveGlobalTenantId(); const db = await getDb(); const tx = db.transaction('views_meta', 'readonly'); const index = tx.store.index('by_entity'); - const items = (await index.getAll(entityCode)) as ViewDefinition[]; - if (items.length > 0) return items; + const items = (await index.getAll(entityCode)) as (ViewDefinition & { tenant_id?: string })[]; + const filtered = items.filter( + (v) => !v.tenant_id || v.tenant_id === 'system' || v.tenant_id === effectiveTenant + ); + if (filtered.length > 0) return filtered; - const entity = await this.getEntity(entityCode); + const entity = await this.getEntity(entityCode, effectiveTenant); return [ { id: `view_def_${entityCode}`, @@ -183,8 +201,13 @@ export class MockMetadataService implements IMetadataService { return getStoreItem('views_meta', id); } - async saveView(view: ViewDefinition): Promise { - await putStoreItem('views_meta', view); + async saveView(view: ViewDefinition, tenantId?: string): Promise { + const effectiveTenant = tenantId || getActiveGlobalTenantId(); + const withTenant = { + ...view, + tenant_id: (view as unknown as Record).tenant_id || effectiveTenant, + }; + await putStoreItem('views_meta', withTenant, effectiveTenant); return view; } diff --git a/src/services/storage/indexedDb.ts b/src/services/storage/indexedDb.ts index 65ff94d..47a3811 100644 --- a/src/services/storage/indexedDb.ts +++ b/src/services/storage/indexedDb.ts @@ -1,5 +1,11 @@ import { openDB, IDBPDatabase } from 'idb'; import { storageEventBus } from './eventBus'; +import { + TenantAccessDeniedError, + DEFAULT_TENANT_ID, + getActiveGlobalTenantId, +} from '../contracts/context'; +import { ulid } from '../../shared/lib/ulid'; export const DB_NAME = 'krona_business_os_db'; export const DB_VERSION = 2; @@ -19,6 +25,7 @@ export type DbStoreName = (typeof DB_OBJECT_STORES)[number]; export interface RecordEntity { id: string; entityType: string; + tenant_id?: string; updatedAt?: string; audit?: { createdAt: string; @@ -26,6 +33,8 @@ export interface RecordEntity { updatedAt: string; updatedById: string; version: number; + tenantId?: string; + correlationId?: string; }; [key: string]: unknown; } @@ -35,6 +44,7 @@ export interface DatabaseSnapshot { schemaVersion: number; exportedAt: string; system: string; + tenantId?: string; stores: { entities_meta: unknown[]; fields_meta: unknown[]; @@ -98,7 +108,7 @@ export function getDb(): Promise { s.createIndex('by_entity', 'entityCode', { unique: false }); } - // 5. records_data (with indexes on entityType, id, updatedAt) + // 5. records_data (with indexes on entityType, id, updatedAt, tenant_id) if (!db.objectStoreNames.contains('records_data')) { const s = db.createObjectStore('records_data', { keyPath: 'id' }); s.createIndex('by_entity_type', 'entityType', { unique: false }); @@ -124,19 +134,31 @@ export function getDb(): Promise { } // ---------------------------------------------------- -// CRUD on records_data +// CRUD on records_data with Tenant Isolation // ---------------------------------------------------- -export async function getRecord(id: string): Promise { +export async function getRecord(id: string, tenantId?: string): Promise { const db = await getDb(); const res = await db.get('records_data', id); - return (res as T) || null; + if (!res) return null; + + const rec = res as RecordEntity; + const effectiveTenantId = tenantId || getActiveGlobalTenantId(); + if (effectiveTenantId && rec.tenant_id && rec.tenant_id !== effectiveTenantId) { + throw new TenantAccessDeniedError( + `Отказано в доступе: запись «${id}» принадлежит тенанту «${rec.tenant_id}», текущий: «${effectiveTenantId}»` + ); + } + + return res as T; } -export async function putRecord(record: T): Promise { +export async function putRecord(record: T, tenantId?: string): Promise { const db = await getDb(); + const effectiveTenantId = record.tenant_id || tenantId || getActiveGlobalTenantId() || DEFAULT_TENANT_ID; const clean = { ...record, + tenant_id: effectiveTenantId, updatedAt: record.updatedAt || new Date().toISOString(), }; await db.put('records_data', clean); @@ -150,11 +172,19 @@ export async function putRecord(record: T): Promise { return clean as T; } -export async function deleteRecord(id: string, entityType?: string): Promise { +export async function deleteRecord(id: string, entityType?: string, tenantId?: string): Promise { const db = await getDb(); const existing = await db.get('records_data', id); if (!existing) return false; + const rec = existing as RecordEntity; + const effectiveTenantId = tenantId || getActiveGlobalTenantId(); + if (effectiveTenantId && rec.tenant_id && rec.tenant_id !== effectiveTenantId) { + throw new TenantAccessDeniedError( + `Отказано в доступе: запись «${id}» принадлежит тенанту «${rec.tenant_id}», текущий: «${effectiveTenantId}»` + ); + } + await db.delete('records_data', id); storageEventBus.emit('records:changed', { storeName: 'records_data', @@ -173,12 +203,22 @@ export async function getRecordsByEntity( sort?: { field: string; order: 'asc' | 'desc' }; page?: number; pageSize?: number; + tenantId?: string; } ): Promise<{ items: T[]; total: number; page: number; pageSize: number; totalPages: number }> { const db = await getDb(); const tx = db.transaction('records_data', 'readonly'); const index = tx.store.index('by_entity_type'); - let all = (await index.getAll(entityType)) as T[]; + let all = (await index.getAll(entityType)) as (T & RecordEntity)[]; + + // 0. Tenant isolation filter + const targetTenantId = options?.tenantId !== undefined ? options.tenantId : getActiveGlobalTenantId(); + if (targetTenantId) { + all = all.filter((item) => { + const itemTenant = item.tenant_id || DEFAULT_TENANT_ID; + return itemTenant === targetTenantId; + }); + } // 1. Filter if (options?.filter) { @@ -235,18 +275,22 @@ export async function getRecordsByEntity( return { items, total, page, pageSize, totalPages }; } -export async function countRecords(entityType?: string): Promise { +export async function countRecords(entityType?: string, tenantId?: string): Promise { + const effectiveTenantId = tenantId !== undefined ? tenantId : getActiveGlobalTenantId(); + if (entityType) { + const res = await getRecordsByEntity(entityType, { tenantId: effectiveTenantId }); + return res.total; + } const db = await getDb(); - if (!entityType) { + if (!effectiveTenantId) { return db.count('records_data'); } - const tx = db.transaction('records_data', 'readonly'); - const index = tx.store.index('by_entity_type'); - return index.count(entityType); + const all = (await db.getAll('records_data')) as RecordEntity[]; + return all.filter((r) => (r.tenant_id || DEFAULT_TENANT_ID) === effectiveTenantId).length; } // ---------------------------------------------------- -// Generic Store Helpers +// Generic Store Helpers with Optional Tenant Tagging // ---------------------------------------------------- export async function getStoreItem(storeName: DbStoreName, key: string): Promise { @@ -255,12 +299,18 @@ export async function getStoreItem(storeName: DbStoreName, key: string): Prom return (res as T) || null; } -export async function putStoreItem(storeName: DbStoreName, item: T): Promise { +export async function putStoreItem(storeName: DbStoreName, item: T, tenantId?: string): Promise { const db = await getDb(); - await db.put(storeName, item); + const effectiveTenant = tenantId || getActiveGlobalTenantId() || DEFAULT_TENANT_ID; + const cleanItem = + typeof item === 'object' && item !== null + ? { ...item, tenant_id: (item as Record).tenant_id || effectiveTenant } + : item; + + await db.put(storeName, cleanItem); storageEventBus.emit('entity:changed', { storeName, - data: item, + data: cleanItem, action: 'update', }); } @@ -275,9 +325,26 @@ export async function deleteStoreItem(storeName: DbStoreName, key: string): Prom }); } -export async function getAllFromStore(storeName: DbStoreName): Promise { +export async function getAllFromStore(storeName: DbStoreName, tenantId?: string): Promise { const db = await getDb(); - return db.getAll(storeName); + const all = (await db.getAll(storeName)) as (T & Record)[]; + + if (!tenantId) { + return all as T[]; + } + + // Filter items by tenant + if (storeName === 'entities_meta' || storeName === 'layouts_meta' || storeName === 'views_meta') { + return all.filter( + (item) => !item.tenant_id || item.tenant_id === 'system' || item.tenant_id === tenantId + ) as T[]; + } + + if (storeName === 'audit_log' || storeName === 'records_data') { + return all.filter((item) => (item.tenant_id || DEFAULT_TENANT_ID) === tenantId) as T[]; + } + + return all as T[]; } export async function countStore(storeName: DbStoreName): Promise { @@ -291,25 +358,41 @@ export async function clearStore(storeName: DbStoreName): Promise { } // ---------------------------------------------------- -// Snapshot Export / Import / Reset +// Snapshot Export / Import / Reset with Tenant Support // ---------------------------------------------------- -export async function exportDatabaseSnapshot(): Promise { +export async function exportDatabaseSnapshot(tenantId?: string): Promise { const db = await getDb(); - const entities_meta = await db.getAll('entities_meta'); - const fields_meta = await db.getAll('fields_meta'); - const layouts_meta = await db.getAll('layouts_meta'); - const views_meta = await db.getAll('views_meta'); - const records_data = await db.getAll('records_data'); - const audit_log = await db.getAll('audit_log'); - const settings = await db.getAll('settings'); + let entities_meta = (await db.getAll('entities_meta')) as Record[]; + let fields_meta = (await db.getAll('fields_meta')) as Record[]; + let layouts_meta = (await db.getAll('layouts_meta')) as Record[]; + let views_meta = (await db.getAll('views_meta')) as Record[]; + let records_data = (await db.getAll('records_data')) as RecordEntity[]; + let audit_log = (await db.getAll('audit_log')) as Record[]; + let settings = (await db.getAll('settings')) as Record[]; + + if (tenantId) { + records_data = records_data.filter((r) => (r.tenant_id || DEFAULT_TENANT_ID) === tenantId); + audit_log = audit_log.filter((a) => (a.tenant_id || DEFAULT_TENANT_ID) === tenantId); + entities_meta = entities_meta.filter( + (e) => !e.tenant_id || e.tenant_id === 'system' || e.tenant_id === tenantId + ); + layouts_meta = layouts_meta.filter( + (l) => !l.tenant_id || l.tenant_id === 'system' || l.tenant_id === tenantId + ); + views_meta = views_meta.filter( + (v) => !v.tenant_id || v.tenant_id === 'system' || v.tenant_id === tenantId + ); + settings = settings.filter((s) => !s.tenant_id || s.tenant_id === tenantId); + } return { version: 1, schemaVersion: DB_VERSION, exportedAt: new Date().toISOString(), system: 'Krona Universal Business OS', + tenantId: tenantId || 'all', stores: { entities_meta, fields_meta, @@ -327,7 +410,10 @@ export async function exportDatabaseSnapshot(): Promise { }; } -export async function importDatabaseSnapshot(snapshot: DatabaseSnapshot): Promise { +export async function importDatabaseSnapshot( + snapshot: DatabaseSnapshot, + targetTenantId?: string +): Promise { if (!snapshot || typeof snapshot !== 'object') { throw new Error('Некорректный формат снимка базы данных (snapshot).'); } @@ -338,12 +424,77 @@ export async function importDatabaseSnapshot(snapshot: DatabaseSnapshot): Promis const db = await getDb(); const tx = db.transaction(DB_OBJECT_STORES as unknown as string[], 'readwrite'); - for (const store of DB_OBJECT_STORES) { - const storeObj = tx.objectStore(store); - await storeObj.clear(); - const items = snapshot.stores[store] || []; - for (const item of items) { - await storeObj.put(item); + if (!targetTenantId) { + // Global wipe & restore + for (const store of DB_OBJECT_STORES) { + const storeObj = tx.objectStore(store); + await storeObj.clear(); + const items = snapshot.stores[store] || []; + for (const item of items) { + await storeObj.put(item); + } + } + } else { + // Isolated tenant import: delete only target tenant records, keep other tenants safe + const recordsStore = tx.objectStore('records_data'); + const existingRecords = (await recordsStore.getAll()) as RecordEntity[]; + for (const r of existingRecords) { + if ((r.tenant_id || DEFAULT_TENANT_ID) === targetTenantId) { + await recordsStore.delete(r.id); + } + } + const newRecords = (snapshot.stores.records_data || []) as RecordEntity[]; + const idMap = new Map(); + for (const r of newRecords) { + const isCrossTenantClone = Boolean( + targetTenantId && snapshot.tenantId && targetTenantId !== snapshot.tenantId + ); + const idConflict = existingRecords.some( + (er) => er.id === r.id && (er.tenant_id || DEFAULT_TENANT_ID) !== targetTenantId + ); + const newId = isCrossTenantClone || idConflict ? ulid() : r.id; + idMap.set(r.id, newId); + await recordsStore.put({ ...r, id: newId, tenant_id: targetTenantId }); + } + + // Audit log + const auditStore = tx.objectStore('audit_log'); + const existingAudit = (await auditStore.getAll()) as Record[]; + for (const a of existingAudit) { + if ((a.tenant_id || DEFAULT_TENANT_ID) === targetTenantId) { + await auditStore.delete(String(a.id)); + } + } + const newAudit = (snapshot.stores.audit_log || []) as Record[]; + for (const a of newAudit) { + const mappedEntityId = a.entityId ? (idMap.get(String(a.entityId)) || a.entityId) : a.entityId; + const isCrossTenantClone = Boolean( + targetTenantId && snapshot.tenantId && targetTenantId !== snapshot.tenantId + ); + const newId = isCrossTenantClone ? ulid() : (a.id as string); + await auditStore.put({ + ...a, + id: newId, + entityId: mappedEntityId, + tenant_id: targetTenantId, + }); + } + + // Metadata: entities, fields, layouts, views + for (const store of ['entities_meta', 'fields_meta', 'layouts_meta', 'views_meta', 'settings'] as const) { + const s = tx.objectStore(store); + const items = (snapshot.stores[store] || []) as Record[]; + for (const item of items) { + if (!item.tenant_id || item.tenant_id === 'system') { + await s.put(item); + } else { + const isCrossTenantClone = Boolean( + targetTenantId && snapshot.tenantId && targetTenantId !== snapshot.tenantId + ); + const newId = isCrossTenantClone && item.id ? ulid() : item.id; + await s.put({ ...item, id: newId, tenant_id: targetTenantId }); + } + } } } @@ -355,13 +506,34 @@ export async function importDatabaseSnapshot(snapshot: DatabaseSnapshot): Promis }); } -export async function resetDatabase(): Promise { +export async function resetDatabase(tenantId?: string): Promise { const db = await getDb(); - const tx = db.transaction(DB_OBJECT_STORES as unknown as string[], 'readwrite'); - for (const store of DB_OBJECT_STORES) { - await tx.objectStore(store).clear(); + + if (!tenantId) { + const tx = db.transaction(DB_OBJECT_STORES as unknown as string[], 'readwrite'); + for (const store of DB_OBJECT_STORES) { + await tx.objectStore(store).clear(); + } + await tx.done; + } else { + // Only wipe data belonging to the specified tenant + const tx = db.transaction(['records_data', 'audit_log'] as const, 'readwrite'); + const recordsStore = tx.objectStore('records_data'); + const allRecords = (await recordsStore.getAll()) as RecordEntity[]; + for (const r of allRecords) { + if ((r.tenant_id || DEFAULT_TENANT_ID) === tenantId) { + await recordsStore.delete(r.id); + } + } + const auditStore = tx.objectStore('audit_log'); + const allAudit = (await auditStore.getAll()) as Record[]; + for (const a of allAudit) { + if ((a.tenant_id || DEFAULT_TENANT_ID) === tenantId) { + await auditStore.delete(String(a.id)); + } + } + await tx.done; } - await tx.done; storageEventBus.emit('storage:reset', { action: 'reset',