feat(stage-d): tenant-aware frontend contracts, multi-tenant indexeddb storage isolation, negative tests and rest api adapter spec
This commit is contained in:
parent
1e54bc6935
commit
e234aa9d4a
11 changed files with 1188 additions and 126 deletions
388
docs/REST-API-ADAPTER.md
Normal file
388
docs/REST-API-ADAPTER.md
Normal 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.
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
72
src/app/providers/TenantProvider.tsx
Normal file
72
src/app/providers/TenantProvider.tsx
Normal 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;
|
||||||
|
|
||||||
52
src/app/providers/tenantContext.ts
Normal file
52
src/app/providers/tenantContext.ts
Normal 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;
|
||||||
|
}
|
||||||
236
src/services/__tests__/tenantIsolation.test.ts
Normal file
236
src/services/__tests__/tenantIsolation.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
54
src/services/contracts/context.ts
Normal file
54
src/services/contracts/context.ts
Normal 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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[]>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue