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:
parent
ff39034a14
commit
6537f3c892
|
|
@ -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` | Порт (1024–65535, за замовчуванням 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 компонентів
|
||||||
|
```
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@erp/plugin-sdk",
|
"name": "@erp/plugin-sdk",
|
||||||
"version": "1.1.2",
|
"version": "1.2.1",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"description": "",
|
"description": "ERP Plugin SDK — build micro-frontend/backend plugins for the ERP platform",
|
||||||
"files": ["dist/", "README.md"],
|
"files": ["dist/", "README.md"],
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"registry": "https://gitea.ss.3w.com.ua/api/packages/erp/npm/"
|
"registry": "https://gitea.ss.3w.com.ua/api/packages/erp/npm/"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -110,7 +110,7 @@ spec:
|
||||||
'@nestjs/serve-static': '^4.0.2',
|
'@nestjs/serve-static': '^4.0.2',
|
||||||
'amqp-connection-manager': '^4.1.14',
|
'amqp-connection-manager': '^4.1.14',
|
||||||
amqplib: '^0.10.3',
|
amqplib: '^0.10.3',
|
||||||
'@erp/plugin-sdk': '^1.1.0',
|
'@erp/plugin-sdk': '^1.2.0',
|
||||||
pg: '^8.12.0',
|
pg: '^8.12.0',
|
||||||
'reflect-metadata': '^0.1.13',
|
'reflect-metadata': '^0.1.13',
|
||||||
rxjs: '^7.8.1',
|
rxjs: '^7.8.1',
|
||||||
|
|
|
||||||
|
|
@ -6,3 +6,4 @@ export * from './backend/filters/exception.filter';
|
||||||
export * from './backend/interceptors/request-context.interceptor';
|
export * from './backend/interceptors/request-context.interceptor';
|
||||||
export * from './backend/interceptors/transform.interceptor';
|
export * from './backend/interceptors/transform.interceptor';
|
||||||
export * from './backend/config/rabbitmq.config';
|
export * from './backend/config/rabbitmq.config';
|
||||||
|
export * from './backend/services/attribute-access.service';
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue