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",
|
||||
"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/"
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
'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',
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in New Issue