docs: add comprehensive README with full SDK API reference

feat: add AttributeAccessService for ABAC
chore: bump to v1.2.1, update CLI template dep
This commit is contained in:
Кирилл Осипков 2026-05-27 14:00:12 +03:00
parent ff39034a14
commit 6537f3c892
5 changed files with 783 additions and 3 deletions

626
README.md Normal file
View File

@ -0,0 +1,626 @@
# @erp/plugin-sdk
SDK для створення плагінів ERP-платформи. Містить бекенд (NestJS) та фронтенд (React) інструменти для інтеграції з ядром.
## Зміст
- [Встановлення](#встановлення)
- [Швидкий старт](#швидкий-старт)
- [Архітектура](#архітектура)
- [API Reference](#api-reference)
- [CoreService](#coreservice)
- [AttributeAccessService](#attributeaccessservice)
- [CoreClientService](#coreclientservice)
- [getRabbitMQOptions](#getrabbitmqoptions)
- [getRabbitMQClientOptions](#getrabbitmqclientoptions)
- [TransformInterceptor](#transforminterceptor)
- [RequestContextInterceptor](#requestcontextinterceptor)
- [AllExceptionsFilter](#allexceptionsfilter)
- [Інтерфейси](#інтерфейси)
- [Attribute Access Control (ABAC)](#attribute-access-control-abac)
- [Події та RabbitMQ](#події-та-rabbitmq)
- [CLI — create-erp-plugin](#cli--create-erp-plugin)
- [Експорт SDK](#експорт-sdk)
---
## Встановлення
```bash
npm install @erp/plugin-sdk
```
### `.npmrc`
SDK опубліковано в Gitea Packages. Додайте в `backend/.npmrc`:
```
@erp:registry=https://gitea.ss.3w.com.ua/api/packages/erp/npm/
```
### Dockerfile
```dockerfile
COPY backend/.npmrc ./backend/
RUN cd backend && npm install
```
---
## Швидкий старт
```bash
npx create-erp-plugin my-plugin --port 3007
```
Створює scaffold плагіна з:
- `backend/` — NestJS бекенд з CRUD, RabbitMQ, TypeORM
- `frontend/` — React (Module Federation) фронтенд
- `manifest.json` — реєстрація плагіна в ядрі
---
## Архітектура
```
┌───────────────────┐ HTTP/REST ┌──────────────┐
│ Plugin (BE) │ ◄──────────────────► │ Core (BE) │
│ CoreService │ RabbitMQ │ Port 3001 │
│ AttributeAccess │ ◄──────────────────► │ │
└────────┬──────────┘ └──────────────┘
Module Federation
┌────────▼──────────┐
│ Plugin (FE) │
│ React + Vite │
│ host/UIKit │
└───────────────────┘
```
**Комунікація:**
1. **HTTP (REST)**`CoreService.fetchCore()` для запитів до ядра
2. **RabbitMQ (асинхронно)**`CoreService.emit()` для подій, `@EventPattern()` для отримання
3. **Module Federation** — фронтенд плагіна монтується як remote module всередині host-застосунку
---
## API Reference
### CoreService
Головний сервіс для комунікації плагіна з ядром.
```typescript
import { CoreService } from '@erp/plugin-sdk';
```
#### `fetchCore(endpoint, options?)`
HTTP-запит до ядра. Автоматично прокидyє JWT токен (з RequestContext).
```typescript
const users = await coreService.fetchCore('/api/admin/users');
const created = await coreService.fetchCore('/api/admin/users', {
method: 'POST',
body: JSON.stringify({ username: 'newuser', password: '123' }),
});
```
#### `register()`
Реєструє плагін в ядрі. Читає `manifest.json`, відправляє POST на `/api/plugins/register`. Повторює з ретраями поки не зареєструється.
```typescript
await coreService.register();
```
#### `emit<T>(pattern, payload)`
Асинхронна подія через RabbitMQ (fire-and-forget).
```typescript
await coreService.emit('myplugin.data.synced', { recordId: 42 });
```
#### `auditLog(action, resource, details?)`
Запис в аудит-лог ядра.
```typescript
await coreService.auditLog('deals.create', 'deal:42', { dealName: 'Test' });
```
#### `getGlobalData<T>(key)` / `setGlobalData(key, value)` / `deleteGlobalData(key)`
Глобальне сховище (key-value) в ядрі.
```typescript
await coreService.setGlobalData('myplugin_config', { theme: 'dark' });
const config = await coreService.getGlobalData('myplugin_config');
```
#### `sendNotification(payload)`
Відправка сповіщення користувачу.
```typescript
await coreService.sendNotification({
userId: 1,
title: 'Deal updated',
message: 'Deal #42 has been modified',
});
```
#### `getDbName()`
Отримати назву БД (після `setupDatabase`).
```typescript
const dbName = coreService.getDbName();
```
#### `static setupDatabase({ pluginId })`
Запитує у ядра виділену БД для плагіна. Викликати ДО ініціалізації NestJS.
```typescript
// В main.ts перед створенням app
const dbName = await CoreService.setupDatabase({ pluginId: 'my-plugin' });
```
---
### AttributeAccessService
Сервіс для Attribute-Based Access Control (ABAC). Дозволяє керувати атрибутами користувачів і ресурсів та перевіряти доступ на основі правил.
```typescript
import { AttributeAccessService } from '@erp/plugin-sdk';
```
#### Типи
```typescript
type AttributeMatch = 'eq' | 'neq' | 'in' | 'contains' | 'exists' | 'gte' | 'lte';
interface AttributeRule {
userAttr: string; // ключ атрибута на користувачеві
match: AttributeMatch; // тип порівняння
resourceAttr?: string; // ключ атрибута на ресурсі
value?: string; // або фіксоване значення
}
interface ResourceRef {
type: string; // наприклад 'deal', 'server'
id: string;
}
```
#### Робота з атрибутами користувачів
```typescript
// Встановити атрибут
await attrService.setUserAttribute(1, 'team', 'alpha', 'arbitration');
// Встановити кілька атрибутів
await attrService.setUserAttributes(1, [
{ key: 'team', value: 'alpha' },
{ key: 'region', value: 'EU' },
{ key: 'clearance', value: '3' },
], 'arbitration');
// Отримати атрибути
const attrs = await attrService.getUserAttributes(1);
// Видалити
await attrService.deleteUserAttribute(1, 'team');
```
#### Робота з атрибутами ресурсів
```typescript
await attrService.setResourceAttribute('deal', '42', 'team', 'alpha', 'arbitration');
await attrService.setResourceAttributes('deal', '42', [
{ key: 'team', value: 'alpha' },
{ key: 'region', value: 'EU' },
], 'arbitration');
const attrs = await attrService.getResourceAttributes('deal', '42');
await attrService.deleteResourceAttribute('deal', '42', 'team');
```
#### Перевірка доступу
```typescript
// 1. Check: чи user в тій самій команді що й deal?
const allowed = await attrService.evaluate(1, [
{ userAttr: 'team', match: 'eq', resourceAttr: 'team' }
], { type: 'deal', id: '42' });
// 2. Check: чи має user рівень доступу >= 3?
const allowed = await attrService.evaluate(1, [
{ userAttr: 'clearance', match: 'gte', value: '3' }
]);
// 3. Check: чи є user менеджером власника ресурсу?
const allowed = await attrService.evaluate(1, [
{ userAttr: 'subordinates', match: 'contains', resourceAttr: 'owner_id' }
], { type: 'deal', id: '42' });
```
#### Фільтрація списку
Корисно коли вже маєте список об'єктів і треба відфільтрувати доступні.
```typescript
const visibleDeals = await attrService.filter(
userId,
[{ userAttr: 'team', match: 'eq', resourceAttr: 'team' }],
allDeals,
(deal) => ({ team: deal.team }) // екстрактор атрибутів з об'єкта
);
```
#### Типи порівняння
| `match` | Опис | Приклад |
|---------|------|---------|
| `eq` | Дорівнює | `user.team == resource.team` |
| `neq` | Не дорівнює | `user.region != 'blocked'` |
| `in` | Значення user в списку resource | `user.region in resource.allowed_regions` |
| `contains` | Список user містить resource | `user.roles contains 'admin'` |
| `exists` | Атрибут існує | `user.clearance exists` |
| `gte` | >= | `user.clearance >= 3` |
| `lte` | <= | `user.priority <= 2` |
---
### CoreClientService
Альтернативний спосіб комунікації з ядром через RabbitMQ RPC.
```typescript
import { CoreClientService } from '@erp/plugin-sdk';
```
```typescript
// Запитати дані користувача (синхронно через RabbitMQ RPC)
const userInfo = await coreClient.getUserInfo(1);
// Сповістити ядро про подію
await coreClient.notifyCore('myplugin.event', { data: 'value' });
```
---
### getRabbitMQOptions
Повертає `MicroserviceOptions` для NestJS. Використовується в `main.ts` для підключення до RabbitMQ.
```typescript
import { getRabbitMQOptions } from '@erp/plugin-sdk';
app.connectMicroservice(getRabbitMQOptions('plugin_my_queue'));
```
Конфігурація:
- Exchange: `system_events` (topic)
- Routing key: `#` (отримує всі події)
- Queue: вказана назва (durable)
- Повідомлення: persistent
---
### getRabbitMQClientOptions
Повертає конфігурацію для `ClientsModule.register()`.
```typescript
import { getRabbitMQClientOptions } from '@erp/plugin-sdk';
@Module({
imports: [
ClientsModule.register([
{ name: 'RABBITMQ_CLIENT', ...getRabbitMQClientOptions() },
]),
],
})
```
---
### TransformInterceptor
NestJS interceptor — обгортає всі відповіді в `ApiResponse` формат.
```typescript
import { TransformInterceptor } from '@erp/plugin-sdk';
app.useGlobalInterceptors(new TransformInterceptor());
```
Формат відповіді:
```json
{
"success": true,
"data": { ... },
"meta": {
"timestamp": 1716800000000,
"traceId": "trace-xxx",
"service": "my-plugin"
}
}
```
---
### RequestContextInterceptor
NestJS interceptor — зберігає `authorization` токен та IP в `AsyncLocalStorage`. Використовується `CoreService.fetchCore()` для автоматичного прокидвання JWT.
```typescript
import { RequestContextInterceptor } from '@erp/plugin-sdk';
app.useGlobalInterceptors(new RequestContextInterceptor());
```
---
### AllExceptionsFilter
NestJS exception filter — перехоплює всі необроблені помилки і повертає стандартизований `ApiResponse` з помилкою.
```typescript
import { AllExceptionsFilter } from '@erp/plugin-sdk';
app.useGlobalFilters(new AllExceptionsFilter());
```
Формат помилки:
```json
{
"success": false,
"error": {
"code": "ERR_404",
"message": "Item not found",
"details": null
},
"meta": {
"timestamp": 1716800000000,
"traceId": "uuid",
"service": "my-plugin"
}
}
```
---
### Інтерфейси
#### `ApiResponse<T>`
Стандартна відповідь API.
```typescript
interface ApiResponse<T = any> {
success: boolean;
data?: T;
error?: { code: string; message: string; details?: any };
meta: { timestamp: number; traceId: string; service: string };
}
```
#### `SystemEvent<T>`
Конверт для асинхронних подій RabbitMQ.
```typescript
interface SystemEvent<T = any> {
eventId: string;
pattern: string;
payload: T;
source: string;
timestamp: number;
priority?: 'low' | 'medium' | 'high';
}
```
#### `host/UIKit` (фронтенд)
Типи для компонентів інтерфейсу, що надаються host-застосунком:
```typescript
import { Button, Input, Card, Typography } from 'host/UIKit';
```
---
## Attribute Access Control (ABAC)
Система атрибутивного контролю доступу дозволяє плагінам визначати правила доступу на основі атрибутів користувачів і ресурсів, не прив'язуючи ядро до конкретних атрибутів.
### Як це працює
1. **Плагін визначає атрибути** — наприклад `team`, `region`, `clearance`
2. **Ядро зберігає атрибути** — key-value для юзерів і ресурсів
3. **Плагін задає правила** — наприклад `user.team == resource.team`
4. **Ядро перевіряє** — порівнює атрибути згідно з правилами
### Приклад: арбітражний плагін
```typescript
// 1. Адмін призначає атрибути
await attrService.setUserAttributes(userId, [
{ key: 'team', value: 'alpha' },
{ key: 'region', value: 'EU' },
], 'arbitration');
// 2. При створенні deal, зберігаємо атрибути ресурсу
await attrService.setResourceAttributes('deal', dealId, [
{ key: 'team', value: deal.team },
{ key: 'region', value: deal.region },
], 'arbitration');
// 3. При перегляді — перевіряємо
const canView = await attrService.evaluate(userId, [
{ userAttr: 'team', match: 'eq', resourceAttr: 'team' },
{ userAttr: 'region', match: 'eq', resourceAttr: 'region' },
], { type: 'deal', id: dealId });
if (!canView) throw new ForbiddenException();
```
### Раунд-тріп vs локальна фільтрація
`evaluate()` робить HTTP-запит до ядра (перевіряє атрибути ресурсу в БД).
`filter()` робить один запит для отримання атрибутів юзера, потім фільтрує локально.
### Інтеграція з RBAC
```typescript
async viewDeal(userId: number, dealId: string) {
// 1. RBAC — чи має дозвіл
const hasPerm = await this.usersService.hasPermission(userId, 'deals.view');
if (!hasPerm) throw new ForbiddenException();
// 2. ABAC — чи збігаються атрибути
const access = await this.attributeService.evaluate(userId, [
{ userAttr: 'team', match: 'eq', resourceAttr: 'team' }
], { type: 'deal', id: dealId });
if (!access) throw new ForbiddenException();
return this.dealRepo.findOne(dealId);
}
```
---
## Події та RabbitMQ
### Відправка подій (emit)
```typescript
// З плагіна в ядро (або інші плагіни)
await coreService.emit('myplugin.data.updated', { id: 42 });
// Конверт автоматично обгортається в SystemEvent:
// { eventId, pattern: 'myplugin.data.updated', payload: { id: 42 }, source: 'my-plugin', timestamp }
```
### Отримання подій (@EventPattern)
```typescript
import { EventPattern, Payload } from '@nestjs/microservices';
@Injectable()
export class MyHandler {
@EventPattern('core.search.request')
async handle(@Payload() data: any) {
const payload = data.payload || data;
// обробка...
}
}
```
Підключення мікросервісу в `main.ts`:
```typescript
import { getRabbitMQOptions } from '@erp/plugin-sdk';
app.connectMicroservice(getRabbitMQOptions('plugin_my_queue'));
await app.startAllMicroservices();
```
### Шаблон "Запит-Відповідь" (Request/Response)
Для двосторонньої комунікації через події:
```typescript
// Core відправляє запит
coreService.emit('plugin.some.request', { requestId, ... });
// Плагін відповідає
@EventPattern('plugin.some.request')
async handle(@Payload() data: any) {
const { requestId } = data.payload || data;
this.client.emit('core.some.response', { requestId, result: 'ok' });
}
```
---
## CLI — create-erp-plugin
Генерація нового плагіна:
```bash
npx create-erp-plugin <plugin-name> [--port <port>]
```
### Параметри
| Аргумент | Опис |
|----------|------|
| `plugin-name` | Назва в kebab-case (наприклад `my-plugin`) |
| `--port`, `-p` | Порт (102465535, за замовчуванням 3002) |
### Режими БД
У `manifest.json`:
| `database` | Опис |
|------------|------|
| `"self"` (default) | Плагін використовує спільну БД через `POSTGRES_*` змінні |
| `"provide"` | Ядро створює виділену БД `p_<pluginId>` |
### Структура згенерованого плагіна
```
my-plugin/
├── manifest.json # Реєстраційний маніфест
├── backend/
│ ├── .npmrc # @erp:registry=...
│ ├── package.json
│ ├── tsconfig.json
│ ├── nest-cli.json
│ ├── Dockerfile
│ └── src/
│ ├── main.ts
│ ├── app.module.ts
│ ├── app.controller.ts
│ ├── ping.controller.ts
│ ├── item.controller.ts
│ ├── item.service.ts
│ └── entities/
│ └── item.entity.ts
├── frontend/
│ ├── package.json
│ ├── tsconfig.json
│ ├── vite.config.ts
│ ├── index.html
│ └── src/
│ └── App.tsx
└── k8s-plugin.yaml
```
---
## Експорт SDK
```
@erp/plugin-sdk
├── CoreService # Головний сервіс (fetchCore, emit, register, ...)
├── CoreClientService # RabbitMQ RPC клієнт
├── AttributeAccessService # ABAC — керування атрибутами та перевірка доступу
├── getRabbitMQOptions() # NestJS Microservice options
├── getRabbitMQClientOptions() # NestJS Client options
├── TransformInterceptor # Обгортка відповідей в ApiResponse
├── RequestContextInterceptor # AsyncLocalStorage для JWT
├── AllExceptionsFilter # Глобальний фільтр помилок
├── ApiResponse # Інтерфейс відповіді
├── SystemEvent # Інтерфейс події
└── host/UIKit (types) # Типи UI компонентів
```

View File

@ -1,6 +1,6 @@
{
"name": "@erp/plugin-sdk",
"version": "1.1.2",
"version": "1.2.1",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"bin": {
@ -18,7 +18,7 @@
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"description": "ERP Plugin SDK — build micro-frontend/backend plugins for the ERP platform",
"files": ["dist/", "README.md"],
"publishConfig": {
"registry": "https://gitea.ss.3w.com.ua/api/packages/erp/npm/"

View File

@ -0,0 +1,153 @@
import { Injectable, Logger } from '@nestjs/common';
import { CoreService } from './core.service';
export type AttributeMatch = 'eq' | 'neq' | 'in' | 'contains' | 'exists' | 'gte' | 'lte';
export interface AttributeRule {
userAttr: string;
match: AttributeMatch;
resourceAttr?: string;
value?: string;
}
export interface ResourceRef {
type: string;
id: string;
}
@Injectable()
export class AttributeAccessService {
private readonly logger = new Logger(AttributeAccessService.name);
constructor(private readonly core: CoreService) {}
// ── User Attributes ──
async setUserAttribute(userId: number, key: string, value: string, pluginId?: string): Promise<void> {
await this.core.fetchCore(`/api/attributes/users/${userId}`, {
method: 'POST',
body: JSON.stringify({ key, value, pluginId }),
});
}
async setUserAttributes(userId: number, attributes: Array<{ key: string; value: string }>, pluginId?: string): Promise<void> {
await this.core.fetchCore('/api/attributes/batch', {
method: 'POST',
body: JSON.stringify({ userId, attributes, pluginId }),
});
}
async getUserAttributes(userId: number): Promise<Array<{ key: string; value: string; pluginId?: string }>> {
const res = await this.core.fetchCore(`/api/attributes/users/${userId}`);
return res.data || res || [];
}
async deleteUserAttribute(userId: number, key: string): Promise<void> {
await this.core.fetchCore(`/api/attributes/users/${userId}?key=${encodeURIComponent(key)}`, {
method: 'DELETE',
});
}
// ── Resource Attributes ──
async setResourceAttribute(resourceType: string, resourceId: string, key: string, value: string, pluginId?: string): Promise<void> {
await this.core.fetchCore(`/api/attributes/resources/${resourceType}/${resourceId}`, {
method: 'POST',
body: JSON.stringify({ key, value, pluginId }),
});
}
async setResourceAttributes(resourceType: string, resourceId: string, attributes: Array<{ key: string; value: string }>, pluginId?: string): Promise<void> {
await this.core.fetchCore('/api/attributes/resources/batch', {
method: 'POST',
body: JSON.stringify({ resourceType, resourceId, attributes, pluginId }),
});
}
async getResourceAttributes(resourceType: string, resourceId: string): Promise<Array<{ key: string; value: string; pluginId?: string }>> {
const res = await this.core.fetchCore(`/api/attributes/resources/${resourceType}/${resourceId}`);
return res.data || res || [];
}
async deleteResourceAttribute(resourceType: string, resourceId: string, key: string): Promise<void> {
await this.core.fetchCore(`/api/attributes/resources/${resourceType}/${resourceId}?key=${encodeURIComponent(key)}`, {
method: 'DELETE',
});
}
// ── Evaluation ──
async evaluate(
userId: number,
rules: AttributeRule[],
resource?: ResourceRef,
): Promise<boolean> {
const res = await this.core.fetchCore('/api/attributes/evaluate', {
method: 'POST',
body: JSON.stringify({ userId, rules, resource }),
});
const data = res.data || res;
return data.allowed === true;
}
// ── Convenience ──
async filter<T>(
userId: number,
rules: AttributeRule[],
items: T[],
extractAttrs: (item: T) => Record<string, string>,
): Promise<T[]> {
const userAttrs = await this.getUserAttributes(userId);
const userAttrMap = new Map(userAttrs.map(a => [a.key, a.value]));
return items.filter(item => {
const itemAttrs = extractAttrs(item);
for (const rule of rules) {
const userValue = userAttrMap.get(rule.userAttr);
if (rule.match === 'exists') {
if (!userValue) return false;
continue;
}
if (!userValue) return false;
if (rule.resourceAttr) {
const resourceValue = itemAttrs[rule.resourceAttr];
if (resourceValue === undefined) return false;
if (!this.compareValues(userValue, resourceValue, rule.match)) return false;
} else if (rule.value !== undefined) {
if (!this.compareWithValue(userValue, rule.value, rule.match)) return false;
}
}
return true;
});
}
private compareValues(userValue: string, resourceValue: string, match: AttributeMatch): boolean {
switch (match) {
case 'eq': return userValue === resourceValue;
case 'neq': return userValue !== resourceValue;
case 'in': return resourceValue.split(',').map(s => s.trim()).includes(userValue);
case 'contains': return userValue.split(',').map(s => s.trim()).includes(resourceValue);
case 'exists': return true;
case 'gte': return Number(userValue) >= Number(resourceValue);
case 'lte': return Number(userValue) <= Number(resourceValue);
default: return false;
}
}
private compareWithValue(userValue: string, matchValue: string, match: AttributeMatch): boolean {
switch (match) {
case 'eq': return userValue === matchValue;
case 'neq': return userValue !== matchValue;
case 'gte': return Number(userValue) >= Number(matchValue);
case 'lte': return Number(userValue) <= Number(matchValue);
case 'exists': return true;
default: return false;
}
}
}

View File

@ -110,7 +110,7 @@ spec:
'@nestjs/serve-static': '^4.0.2',
'amqp-connection-manager': '^4.1.14',
amqplib: '^0.10.3',
'@erp/plugin-sdk': '^1.1.0',
'@erp/plugin-sdk': '^1.2.0',
pg: '^8.12.0',
'reflect-metadata': '^0.1.13',
rxjs: '^7.8.1',

View File

@ -6,3 +6,4 @@ export * from './backend/filters/exception.filter';
export * from './backend/interceptors/request-context.interceptor';
export * from './backend/interceptors/transform.interceptor';
export * from './backend/config/rabbitmq.config';
export * from './backend/services/attribute-access.service';