feat(stage-d): tenant-aware frontend contracts, multi-tenant indexeddb storage isolation, negative tests and rest api adapter spec

This commit is contained in:
ochenstarik 2026-09-11 02:27:21 +07:00
parent 1e54bc6935
commit e234aa9d4a
11 changed files with 1188 additions and 126 deletions

388
docs/REST-API-ADAPTER.md Normal file
View file

@ -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<T> 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<T>.list(options?: QueryOptions<T>)` 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=<number>&limit=<pageSize>`
- **Example**: `?page=2&limit=50` (1-indexed page)
### 4.2. Sorting
- **Format**: `?sort=<fieldName>:<asc|desc>`
- **Multiple Fields**: `?sort=<field1>:asc,<field2>:desc`
- **Example**: `?sort=updatedAt:desc`
### 4.3. Global Text Search
- **Format**: `?search=<query>`
- **Example**: `?search=ООО+Вектор`
### 4.4. Field Filtering
- **Format**: `?filter[<fieldKey>]=<value>`
- **Operator-based Filtering**: `?filter[<fieldKey>][<operator>]=<value>`
- **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<T>`
Below is the production-ready TypeScript implementation of `HttpBaseRepository<T>`, implementing `IRepository<T>`:
```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<T extends BaseEntity> implements IRepository<T> {
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<string, string> = {
'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<never> {
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<T>): 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<string, unknown>)) {
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<T | null> {
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<T>, context?: RequestContext): Promise<PagedResult<T>> {
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<T>;
}
async create(data: Omit<T, 'id' | 'audit'>, context?: RequestContext): Promise<T> {
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<T>, context?: RequestContext): Promise<T> {
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<boolean> {
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<number> {
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<T> }[],
context?: RequestContext
): Promise<T[]> {
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<T extends BaseEntity>(entityName: string): IRepository<T> {
if (DATA_PROVIDER === 'http') {
return new HttpBaseRepository<T>(entityName);
}
return new BaseMockRepository<T>(entityName);
}
```
---
## 8. Summary Checklist for Backend Integration
- [x] Abstract contract `IRepository<T>` 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.

View file

@ -3,6 +3,8 @@ import { ToastProvider } from '../../shared/ui/Toast';
import { ErrorBoundary } from '../../shared/ui/ErrorBoundary'; import { ErrorBoundary } from '../../shared/ui/ErrorBoundary';
import { initMockServices } from '../../services/mock'; import { initMockServices } from '../../services/mock';
import { TenantProvider } from './TenantProvider';
export interface AppProvidersProps { export interface AppProvidersProps {
children: React.ReactNode; children: React.ReactNode;
} }
@ -14,9 +16,11 @@ export const AppProviders: React.FC<AppProvidersProps> = ({ children }) => {
return ( return (
<ErrorBoundary scope="Корневое приложение Krona"> <ErrorBoundary scope="Корневое приложение Krona">
<ToastProvider> <TenantProvider>
{children} <ToastProvider>
</ToastProvider> {children}
</ToastProvider>
</TenantProvider>
</ErrorBoundary> </ErrorBoundary>
); );
}; };

View file

@ -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<TenantProviderProps> = ({
children,
initialTenantId,
}) => {
const [currentTenantId, setCurrentTenantId] = useState<string>(() => {
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<TenantContextValue['createContextForCurrentTenant']>[0]) => {
return createRequestContext({ tenantId: currentTenantId, ...overrides });
},
[currentTenantId]
);
const value = useMemo<TenantContextValue>(
() => ({
currentTenantId,
currentTenant,
availableTenants: PRESET_TENANTS,
requestContext,
switchTenant,
createContextForCurrentTenant,
}),
[currentTenantId, currentTenant, requestContext, switchTenant, createContextForCurrentTenant]
);
return <TenantContext.Provider value={value}>{children}</TenantContext.Provider>;
};
export default TenantProvider;

View file

@ -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>) => RequestContext;
}
export const TenantContext = createContext<TenantContextValue | null>(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;
}

View file

@ -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<SampleItem>;
let metadataService: MockMetadataService;
beforeEach(async () => {
await resetDatabase();
setActiveGlobalTenantId('tenant_main');
repository = new BaseMockRepository<SampleItem>('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<string, unknown>[];
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);
});
});

View file

@ -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>): RequestContext {
return {
tenantId: overrides?.tenantId || getActiveGlobalTenantId(),
actorId: overrides?.actorId || DEFAULT_ACTOR_ID,
roleIds: overrides?.roleIds || [...DEFAULT_ROLE_IDS],
correlationId: overrides?.correlationId || ulid(),
};
}

View file

@ -5,3 +5,4 @@ export * from './commerce';
export * from './inventory'; export * from './inventory';
export * from './finance'; export * from './finance';
export * from './metadata'; export * from './metadata';
export * from './context';

View file

@ -1,3 +1,5 @@
import { RequestContext } from './context';
export interface QueryFilter { export interface QueryFilter {
[field: string]: unknown; [field: string]: unknown;
} }
@ -24,10 +26,11 @@ export interface PagedResult<T> {
} }
export interface IRepository<T extends { id: string }> { export interface IRepository<T extends { id: string }> {
getById(id: string): Promise<T | null>; getById(id: string, context?: RequestContext): Promise<T | null>;
list(options?: QueryOptions<T>): Promise<PagedResult<T>>; list(options?: QueryOptions<T>, context?: RequestContext): Promise<PagedResult<T>>;
create(data: Omit<T, 'id' | 'audit'>): Promise<T>; create(data: Omit<T, 'id' | 'audit'>, context?: RequestContext): Promise<T>;
update(id: string, data: Partial<T>): Promise<T>; update(id: string, data: Partial<T>, context?: RequestContext): Promise<T>;
delete(id: string): Promise<boolean>; delete(id: string, context?: RequestContext): Promise<boolean>;
count(filter?: QueryFilter): Promise<number>; count(filter?: QueryFilter, context?: RequestContext): Promise<number>;
batchUpdate?(items: { id: string; data: Partial<T> }[], context?: RequestContext): Promise<T[]>;
} }

View file

@ -1,10 +1,15 @@
import { IRepository, QueryOptions, PagedResult, QueryFilter } from '../contracts/repository'; import { IRepository, QueryOptions, PagedResult, QueryFilter } from '../contracts/repository';
import {
RequestContext,
TenantAccessDeniedError,
NotFoundError,
createRequestContext,
} from '../contracts/context';
import { import {
getRecord, getRecord,
putRecord, putRecord,
deleteRecord, deleteRecord,
getRecordsByEntity, getRecordsByEntity,
countRecords,
putStoreItem, putStoreItem,
RecordEntity, RecordEntity,
} from '../storage/indexedDb'; } from '../storage/indexedDb';
@ -15,6 +20,10 @@ import { BaseEntity } from '../../entities/types';
export class BaseMockRepository<T extends BaseEntity> implements IRepository<T> { export class BaseMockRepository<T extends BaseEntity> implements IRepository<T> {
constructor(protected entityName: string) {} constructor(protected entityName: string) {}
protected resolveContext(context?: RequestContext): RequestContext {
return context || createRequestContext();
}
public async applySimulation(): Promise<void> { public async applySimulation(): Promise<void> {
const { simulateLatency, latencyMs, simulateErrors, errorType } = useDemoStore.getState(); const { simulateLatency, latencyMs, simulateErrors, errorType } = useDemoStore.getState();
@ -37,17 +46,28 @@ export class BaseMockRepository<T extends BaseEntity> implements IRepository<T>
} }
} }
async getById(id: string): Promise<T | null> { async getById(id: string, context?: RequestContext): Promise<T | null> {
await this.applySimulation(); await this.applySimulation();
const res = await getRecord<T & RecordEntity>(id); const ctx = this.resolveContext(context);
const res = await getRecord<T & RecordEntity>(id, ctx.tenantId);
if (!res || res.entityType !== this.entityName) { if (!res || res.entityType !== this.entityName) {
return null; 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; return res;
} }
async list(options?: QueryOptions<T>): Promise<PagedResult<T>> { async list(options?: QueryOptions<T>, context?: RequestContext): Promise<PagedResult<T>> {
await this.applySimulation(); await this.applySimulation();
const ctx = this.resolveContext(context);
const sortOption = options?.sort const sortOption = options?.sort
? { field: String(options.sort.field), order: options.sort.order } ? { field: String(options.sort.field), order: options.sort.order }
@ -59,6 +79,7 @@ export class BaseMockRepository<T extends BaseEntity> implements IRepository<T>
sort: sortOption, sort: sortOption,
page: options?.page, page: options?.page,
pageSize: options?.pageSize, pageSize: options?.pageSize,
tenantId: ctx.tenantId,
}); });
return { return {
@ -70,16 +91,18 @@ export class BaseMockRepository<T extends BaseEntity> implements IRepository<T>
}; };
} }
async create(data: Omit<T, 'id' | 'audit'>): Promise<T> { async create(data: Omit<T, 'id' | 'audit'>, context?: RequestContext): Promise<T> {
await this.applySimulation(); await this.applySimulation();
const ctx = this.resolveContext(context);
const now = useDemoStore.getState().demoDate || new Date().toISOString(); 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 id = ulid();
const entity = { const entity = {
...data, ...data,
id, id,
entityType: this.entityName, entityType: this.entityName,
tenant_id: ctx.tenantId,
updatedAt: now, updatedAt: now,
audit: { audit: {
createdAt: now, createdAt: now,
@ -87,100 +110,134 @@ export class BaseMockRepository<T extends BaseEntity> implements IRepository<T>
updatedAt: now, updatedAt: now,
updatedById: userId, updatedById: userId,
version: 1, version: 1,
tenantId: ctx.tenantId,
correlationId: ctx.correlationId,
}, },
} as unknown as T & RecordEntity; } as unknown as T & RecordEntity;
await putRecord(entity); await putRecord(entity, ctx.tenantId);
// Write audit log // Write audit log with tenant & correlation tracing
await putStoreItem('audit_log', { await putStoreItem(
id: ulid(), 'audit_log',
entityType: this.entityName, {
entityId: id, id: ulid(),
action: 'create', entityType: this.entityName,
userId, entityId: id,
userName: `Роль: ${userId}`, tenant_id: ctx.tenantId,
timestamp: now, correlation_id: ctx.correlationId,
summary: `Создана запись «${this.entityName}» (${id.slice(0, 8)}...)`, action: 'create',
}); userId,
userName: `Роль: ${userId}`,
timestamp: now,
summary: `Создана запись «${this.entityName}» (${id.slice(0, 8)}...)`,
},
ctx.tenantId
);
return entity as T; return entity as T;
} }
async update(id: string, data: Partial<T>): Promise<T> { async update(id: string, data: Partial<T>, context?: RequestContext): Promise<T> {
await this.applySimulation(); await this.applySimulation();
const existing = await this.getById(id); const ctx = this.resolveContext(context);
const existing = await this.getById(id, ctx);
if (!existing) { 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 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 = { const updated = {
...existing, ...existing,
...data, ...data,
id, id,
entityType: this.entityName, entityType: this.entityName,
tenant_id: ctx.tenantId,
updatedAt: now, updatedAt: now,
audit: { audit: {
...existing.audit, ...existing.audit,
updatedAt: now, updatedAt: now,
updatedById: userId, updatedById: userId,
version: (existing.audit?.version || 1) + 1, version: (existing.audit?.version || 1) + 1,
tenantId: ctx.tenantId,
correlationId: ctx.correlationId,
}, },
} as unknown as T & RecordEntity; } as unknown as T & RecordEntity;
await putRecord(updated); await putRecord(updated, ctx.tenantId);
// Write audit log // Write audit log with tenant & correlation tracing
await putStoreItem('audit_log', { await putStoreItem(
id: ulid(), 'audit_log',
entityType: this.entityName, {
entityId: id, id: ulid(),
action: 'update', entityType: this.entityName,
userId, entityId: id,
userName: `Роль: ${userId}`, tenant_id: ctx.tenantId,
timestamp: now, correlation_id: ctx.correlationId,
summary: `Обновлена запись «${this.entityName}» (${id.slice(0, 8)}...)`, action: 'update',
}); userId,
userName: `Роль: ${userId}`,
timestamp: now,
summary: `Обновлена запись «${this.entityName}» (${id.slice(0, 8)}...)`,
},
ctx.tenantId
);
return updated as T; return updated as T;
} }
async delete(id: string): Promise<boolean> { async delete(id: string, context?: RequestContext): Promise<boolean> {
await this.applySimulation(); await this.applySimulation();
const existing = await this.getById(id); const ctx = this.resolveContext(context);
const existing = await this.getById(id, ctx);
if (!existing) { if (!existing) {
return false; return false;
} }
await deleteRecord(id, this.entityName); await deleteRecord(id, this.entityName, ctx.tenantId);
const now = useDemoStore.getState().demoDate || new Date().toISOString(); 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 // Write audit log with tenant & correlation tracing
await putStoreItem('audit_log', { await putStoreItem(
id: ulid(), 'audit_log',
entityType: this.entityName, {
entityId: id, id: ulid(),
action: 'delete', entityType: this.entityName,
userId, entityId: id,
userName: `Роль: ${userId}`, tenant_id: ctx.tenantId,
timestamp: now, correlation_id: ctx.correlationId,
summary: `Удалена запись «${this.entityName}» (${id.slice(0, 8)}...)`, action: 'delete',
}); userId,
userName: `Роль: ${userId}`,
timestamp: now,
summary: `Удалена запись «${this.entityName}» (${id.slice(0, 8)}...)`,
},
ctx.tenantId
);
return true; return true;
} }
async count(filter?: QueryFilter): Promise<number> { async batchUpdate(
await this.applySimulation(); items: { id: string; data: Partial<T> }[],
if (!filter || Object.keys(filter).length === 0) { context?: RequestContext
return countRecords(this.entityName); ): Promise<T[]> {
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<number> {
await this.applySimulation();
const result = await this.list({ filter }, context);
return result.total; return result.total;
} }
} }

View file

@ -13,29 +13,33 @@ import {
} from '../storage/indexedDb'; } from '../storage/indexedDb';
import { ulid } from '../../shared/lib/ulid'; import { ulid } from '../../shared/lib/ulid';
import { SYSTEM_ENTITY_METADATA } from '../../entities/metadata/registry'; import { SYSTEM_ENTITY_METADATA } from '../../entities/metadata/registry';
import { getActiveGlobalTenantId } from '../contracts/context';
export class MockMetadataService implements IMetadataService { export class MockMetadataService implements IMetadataService {
async listEntities(): Promise<EntityMetadata[]> { async listEntities(tenantId?: string): Promise<EntityMetadata[]> {
const fromDb = await getAllFromStore<EntityMetadata>('entities_meta'); const effectiveTenant = tenantId || getActiveGlobalTenantId();
const fromDb = await getAllFromStore<EntityMetadata & { tenant_id?: string }>('entities_meta', effectiveTenant);
if (fromDb.length > 0) { if (fromDb.length > 0) {
return fromDb; return fromDb;
} }
return Object.values(SYSTEM_ENTITY_METADATA); return Object.values(SYSTEM_ENTITY_METADATA);
} }
async getEntity(code: string): Promise<EntityMetadata | null> { async getEntity(code: string, tenantId?: string): Promise<EntityMetadata | null> {
const list = await this.listEntities(); const list = await this.listEntities(tenantId);
const cleanCode = code.trim().toLowerCase(); const cleanCode = code.trim().toLowerCase();
return list.find((e) => e.code.trim().toLowerCase() === cleanCode) || null; return list.find((e) => e.code.trim().toLowerCase() === cleanCode) || null;
} }
async createEntity(metadata: Omit<EntityMetadata, 'id'>): Promise<EntityMetadata> { async createEntity(metadata: Omit<EntityMetadata, 'id'>, tenantId?: string): Promise<EntityMetadata> {
const id = ulid(); const id = ulid();
const entity: EntityMetadata = { const effectiveTenant = tenantId || getActiveGlobalTenantId();
const entity: EntityMetadata & { tenant_id?: string } = {
...metadata, ...metadata,
id, id,
tenant_id: ((metadata as Record<string, unknown>).tenant_id as string) || effectiveTenant,
}; };
await putStoreItem('entities_meta', entity); await putStoreItem('entities_meta', entity, effectiveTenant);
return entity; return entity;
} }
@ -104,10 +108,12 @@ export class MockMetadataService implements IMetadataService {
async deleteField(entityCode: string, fieldId: string): Promise<boolean> { async deleteField(entityCode: string, fieldId: string): Promise<boolean> {
const entity = await this.getEntity(entityCode); const entity = await this.getEntity(entityCode);
if (!entity) return false; if (!entity) {
const targetField = entity.fields.find((f) => f.id === fieldId); throw new Error(`Сущность с кодом «${entityCode}» не найдена.`);
if (targetField?.system) { }
throw new Error('Нельзя удалить системное поле сущности.'); 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); entity.fields = entity.fields.filter((f) => f.id !== fieldId);
await putStoreItem('entities_meta', entity); await putStoreItem('entities_meta', entity);
@ -116,15 +122,18 @@ export class MockMetadataService implements IMetadataService {
} }
// Layouts // Layouts
async listLayouts(entityCode: string): Promise<LayoutDefinition[]> { async listLayouts(entityCode: string, tenantId?: string): Promise<LayoutDefinition[]> {
const effectiveTenant = tenantId || getActiveGlobalTenantId();
const db = await getDb(); const db = await getDb();
const tx = db.transaction('layouts_meta', 'readonly'); const tx = db.transaction('layouts_meta', 'readonly');
const index = tx.store.index('by_entity'); const index = tx.store.index('by_entity');
const items = (await index.getAll(entityCode)) as LayoutDefinition[]; const items = (await index.getAll(entityCode)) as (LayoutDefinition & { tenant_id?: string })[];
if (items.length > 0) return items; 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, effectiveTenant);
const entity = await this.getEntity(entityCode);
return [ return [
{ {
id: `layout_def_${entityCode}`, id: `layout_def_${entityCode}`,
@ -147,8 +156,13 @@ export class MockMetadataService implements IMetadataService {
return getStoreItem<LayoutDefinition>('layouts_meta', id); return getStoreItem<LayoutDefinition>('layouts_meta', id);
} }
async saveLayout(layout: LayoutDefinition): Promise<LayoutDefinition> { async saveLayout(layout: LayoutDefinition, tenantId?: string): Promise<LayoutDefinition> {
await putStoreItem('layouts_meta', layout); const effectiveTenant = tenantId || getActiveGlobalTenantId();
const withTenant = {
...layout,
tenant_id: (layout as unknown as Record<string, unknown>).tenant_id || effectiveTenant,
};
await putStoreItem('layouts_meta', withTenant, effectiveTenant);
return layout; return layout;
} }
@ -158,14 +172,18 @@ export class MockMetadataService implements IMetadataService {
} }
// Views // Views
async listViews(entityCode: string): Promise<ViewDefinition[]> { async listViews(entityCode: string, tenantId?: string): Promise<ViewDefinition[]> {
const effectiveTenant = tenantId || getActiveGlobalTenantId();
const db = await getDb(); const db = await getDb();
const tx = db.transaction('views_meta', 'readonly'); const tx = db.transaction('views_meta', 'readonly');
const index = tx.store.index('by_entity'); const index = tx.store.index('by_entity');
const items = (await index.getAll(entityCode)) as ViewDefinition[]; const items = (await index.getAll(entityCode)) as (ViewDefinition & { tenant_id?: string })[];
if (items.length > 0) return items; 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 [ return [
{ {
id: `view_def_${entityCode}`, id: `view_def_${entityCode}`,
@ -183,8 +201,13 @@ export class MockMetadataService implements IMetadataService {
return getStoreItem<ViewDefinition>('views_meta', id); return getStoreItem<ViewDefinition>('views_meta', id);
} }
async saveView(view: ViewDefinition): Promise<ViewDefinition> { async saveView(view: ViewDefinition, tenantId?: string): Promise<ViewDefinition> {
await putStoreItem('views_meta', view); const effectiveTenant = tenantId || getActiveGlobalTenantId();
const withTenant = {
...view,
tenant_id: (view as unknown as Record<string, unknown>).tenant_id || effectiveTenant,
};
await putStoreItem('views_meta', withTenant, effectiveTenant);
return view; return view;
} }

View file

@ -1,5 +1,11 @@
import { openDB, IDBPDatabase } from 'idb'; import { openDB, IDBPDatabase } from 'idb';
import { storageEventBus } from './eventBus'; 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_NAME = 'krona_business_os_db';
export const DB_VERSION = 2; export const DB_VERSION = 2;
@ -19,6 +25,7 @@ export type DbStoreName = (typeof DB_OBJECT_STORES)[number];
export interface RecordEntity { export interface RecordEntity {
id: string; id: string;
entityType: string; entityType: string;
tenant_id?: string;
updatedAt?: string; updatedAt?: string;
audit?: { audit?: {
createdAt: string; createdAt: string;
@ -26,6 +33,8 @@ export interface RecordEntity {
updatedAt: string; updatedAt: string;
updatedById: string; updatedById: string;
version: number; version: number;
tenantId?: string;
correlationId?: string;
}; };
[key: string]: unknown; [key: string]: unknown;
} }
@ -35,6 +44,7 @@ export interface DatabaseSnapshot {
schemaVersion: number; schemaVersion: number;
exportedAt: string; exportedAt: string;
system: string; system: string;
tenantId?: string;
stores: { stores: {
entities_meta: unknown[]; entities_meta: unknown[];
fields_meta: unknown[]; fields_meta: unknown[];
@ -98,7 +108,7 @@ export function getDb(): Promise<IDBPDatabase> {
s.createIndex('by_entity', 'entityCode', { unique: false }); 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')) { if (!db.objectStoreNames.contains('records_data')) {
const s = db.createObjectStore('records_data', { keyPath: 'id' }); const s = db.createObjectStore('records_data', { keyPath: 'id' });
s.createIndex('by_entity_type', 'entityType', { unique: false }); s.createIndex('by_entity_type', 'entityType', { unique: false });
@ -124,19 +134,31 @@ export function getDb(): Promise<IDBPDatabase> {
} }
// ---------------------------------------------------- // ----------------------------------------------------
// CRUD on records_data // CRUD on records_data with Tenant Isolation
// ---------------------------------------------------- // ----------------------------------------------------
export async function getRecord<T = RecordEntity>(id: string): Promise<T | null> { export async function getRecord<T = RecordEntity>(id: string, tenantId?: string): Promise<T | null> {
const db = await getDb(); const db = await getDb();
const res = await db.get('records_data', id); 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<T extends RecordEntity>(record: T): Promise<T> { export async function putRecord<T extends RecordEntity>(record: T, tenantId?: string): Promise<T> {
const db = await getDb(); const db = await getDb();
const effectiveTenantId = record.tenant_id || tenantId || getActiveGlobalTenantId() || DEFAULT_TENANT_ID;
const clean = { const clean = {
...record, ...record,
tenant_id: effectiveTenantId,
updatedAt: record.updatedAt || new Date().toISOString(), updatedAt: record.updatedAt || new Date().toISOString(),
}; };
await db.put('records_data', clean); await db.put('records_data', clean);
@ -150,11 +172,19 @@ export async function putRecord<T extends RecordEntity>(record: T): Promise<T> {
return clean as T; return clean as T;
} }
export async function deleteRecord(id: string, entityType?: string): Promise<boolean> { export async function deleteRecord(id: string, entityType?: string, tenantId?: string): Promise<boolean> {
const db = await getDb(); const db = await getDb();
const existing = await db.get('records_data', id); const existing = await db.get('records_data', id);
if (!existing) return false; 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); await db.delete('records_data', id);
storageEventBus.emit('records:changed', { storageEventBus.emit('records:changed', {
storeName: 'records_data', storeName: 'records_data',
@ -173,12 +203,22 @@ export async function getRecordsByEntity<T = RecordEntity>(
sort?: { field: string; order: 'asc' | 'desc' }; sort?: { field: string; order: 'asc' | 'desc' };
page?: number; page?: number;
pageSize?: number; pageSize?: number;
tenantId?: string;
} }
): Promise<{ items: T[]; total: number; page: number; pageSize: number; totalPages: number }> { ): Promise<{ items: T[]; total: number; page: number; pageSize: number; totalPages: number }> {
const db = await getDb(); const db = await getDb();
const tx = db.transaction('records_data', 'readonly'); const tx = db.transaction('records_data', 'readonly');
const index = tx.store.index('by_entity_type'); 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 // 1. Filter
if (options?.filter) { if (options?.filter) {
@ -235,18 +275,22 @@ export async function getRecordsByEntity<T = RecordEntity>(
return { items, total, page, pageSize, totalPages }; return { items, total, page, pageSize, totalPages };
} }
export async function countRecords(entityType?: string): Promise<number> { export async function countRecords(entityType?: string, tenantId?: string): Promise<number> {
const effectiveTenantId = tenantId !== undefined ? tenantId : getActiveGlobalTenantId();
if (entityType) {
const res = await getRecordsByEntity(entityType, { tenantId: effectiveTenantId });
return res.total;
}
const db = await getDb(); const db = await getDb();
if (!entityType) { if (!effectiveTenantId) {
return db.count('records_data'); return db.count('records_data');
} }
const tx = db.transaction('records_data', 'readonly'); const all = (await db.getAll('records_data')) as RecordEntity[];
const index = tx.store.index('by_entity_type'); return all.filter((r) => (r.tenant_id || DEFAULT_TENANT_ID) === effectiveTenantId).length;
return index.count(entityType);
} }
// ---------------------------------------------------- // ----------------------------------------------------
// Generic Store Helpers // Generic Store Helpers with Optional Tenant Tagging
// ---------------------------------------------------- // ----------------------------------------------------
export async function getStoreItem<T>(storeName: DbStoreName, key: string): Promise<T | null> { export async function getStoreItem<T>(storeName: DbStoreName, key: string): Promise<T | null> {
@ -255,12 +299,18 @@ export async function getStoreItem<T>(storeName: DbStoreName, key: string): Prom
return (res as T) || null; return (res as T) || null;
} }
export async function putStoreItem<T>(storeName: DbStoreName, item: T): Promise<void> { export async function putStoreItem<T>(storeName: DbStoreName, item: T, tenantId?: string): Promise<void> {
const db = await getDb(); 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<string, unknown>).tenant_id || effectiveTenant }
: item;
await db.put(storeName, cleanItem);
storageEventBus.emit('entity:changed', { storageEventBus.emit('entity:changed', {
storeName, storeName,
data: item, data: cleanItem,
action: 'update', action: 'update',
}); });
} }
@ -275,9 +325,26 @@ export async function deleteStoreItem(storeName: DbStoreName, key: string): Prom
}); });
} }
export async function getAllFromStore<T>(storeName: DbStoreName): Promise<T[]> { export async function getAllFromStore<T>(storeName: DbStoreName, tenantId?: string): Promise<T[]> {
const db = await getDb(); const db = await getDb();
return db.getAll(storeName); const all = (await db.getAll(storeName)) as (T & Record<string, unknown>)[];
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<number> { export async function countStore(storeName: DbStoreName): Promise<number> {
@ -291,25 +358,41 @@ export async function clearStore(storeName: DbStoreName): Promise<void> {
} }
// ---------------------------------------------------- // ----------------------------------------------------
// Snapshot Export / Import / Reset // Snapshot Export / Import / Reset with Tenant Support
// ---------------------------------------------------- // ----------------------------------------------------
export async function exportDatabaseSnapshot(): Promise<DatabaseSnapshot> { export async function exportDatabaseSnapshot(tenantId?: string): Promise<DatabaseSnapshot> {
const db = await getDb(); const db = await getDb();
const entities_meta = await db.getAll('entities_meta'); let entities_meta = (await db.getAll('entities_meta')) as Record<string, unknown>[];
const fields_meta = await db.getAll('fields_meta'); let fields_meta = (await db.getAll('fields_meta')) as Record<string, unknown>[];
const layouts_meta = await db.getAll('layouts_meta'); let layouts_meta = (await db.getAll('layouts_meta')) as Record<string, unknown>[];
const views_meta = await db.getAll('views_meta'); let views_meta = (await db.getAll('views_meta')) as Record<string, unknown>[];
const records_data = await db.getAll('records_data'); let records_data = (await db.getAll('records_data')) as RecordEntity[];
const audit_log = await db.getAll('audit_log'); let audit_log = (await db.getAll('audit_log')) as Record<string, unknown>[];
const settings = await db.getAll('settings'); let settings = (await db.getAll('settings')) as Record<string, unknown>[];
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 { return {
version: 1, version: 1,
schemaVersion: DB_VERSION, schemaVersion: DB_VERSION,
exportedAt: new Date().toISOString(), exportedAt: new Date().toISOString(),
system: 'Krona Universal Business OS', system: 'Krona Universal Business OS',
tenantId: tenantId || 'all',
stores: { stores: {
entities_meta, entities_meta,
fields_meta, fields_meta,
@ -327,7 +410,10 @@ export async function exportDatabaseSnapshot(): Promise<DatabaseSnapshot> {
}; };
} }
export async function importDatabaseSnapshot(snapshot: DatabaseSnapshot): Promise<void> { export async function importDatabaseSnapshot(
snapshot: DatabaseSnapshot,
targetTenantId?: string
): Promise<void> {
if (!snapshot || typeof snapshot !== 'object') { if (!snapshot || typeof snapshot !== 'object') {
throw new Error('Некорректный формат снимка базы данных (snapshot).'); throw new Error('Некорректный формат снимка базы данных (snapshot).');
} }
@ -338,12 +424,77 @@ export async function importDatabaseSnapshot(snapshot: DatabaseSnapshot): Promis
const db = await getDb(); const db = await getDb();
const tx = db.transaction(DB_OBJECT_STORES as unknown as string[], 'readwrite'); const tx = db.transaction(DB_OBJECT_STORES as unknown as string[], 'readwrite');
for (const store of DB_OBJECT_STORES) { if (!targetTenantId) {
const storeObj = tx.objectStore(store); // Global wipe & restore
await storeObj.clear(); for (const store of DB_OBJECT_STORES) {
const items = snapshot.stores[store] || []; const storeObj = tx.objectStore(store);
for (const item of items) { await storeObj.clear();
await storeObj.put(item); 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<string, string>();
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<string, unknown>[];
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<string, unknown>[];
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<string, unknown>[];
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<void> { export async function resetDatabase(tenantId?: string): Promise<void> {
const db = await getDb(); const db = await getDb();
const tx = db.transaction(DB_OBJECT_STORES as unknown as string[], 'readwrite');
for (const store of DB_OBJECT_STORES) { if (!tenantId) {
await tx.objectStore(store).clear(); 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<string, unknown>[];
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', { storageEventBus.emit('storage:reset', {
action: 'reset', action: 'reset',