From 6537f3c892a02a4cc1bdce17327c211ee847dfa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=B8=D1=80=D0=B8=D0=BB=D0=BB=20=D0=9E=D1=81=D0=B8?= =?UTF-8?q?=D0=BF=D0=BA=D0=BE=D0=B2?= Date: Wed, 27 May 2026 14:00:12 +0300 Subject: [PATCH] docs: add comprehensive README with full SDK API reference feat: add AttributeAccessService for ABAC chore: bump to v1.2.1, update CLI template dep --- README.md | 626 ++++++++++++++++++ package.json | 4 +- .../services/attribute-access.service.ts | 153 +++++ src/cli/index.ts | 2 +- src/index.ts | 1 + 5 files changed, 783 insertions(+), 3 deletions(-) create mode 100644 README.md create mode 100644 src/backend/services/attribute-access.service.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..3072ccd --- /dev/null +++ b/README.md @@ -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(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(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` + +Стандартна відповідь API. + +```typescript +interface ApiResponse { + success: boolean; + data?: T; + error?: { code: string; message: string; details?: any }; + meta: { timestamp: number; traceId: string; service: string }; +} +``` + +#### `SystemEvent` + +Конверт для асинхронних подій RabbitMQ. + +```typescript +interface SystemEvent { + 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 [--port ] +``` + +### Параметри + +| Аргумент | Опис | +|----------|------| +| `plugin-name` | Назва в kebab-case (наприклад `my-plugin`) | +| `--port`, `-p` | Порт (1024–65535, за замовчуванням 3002) | + +### Режими БД + +У `manifest.json`: + +| `database` | Опис | +|------------|------| +| `"self"` (default) | Плагін використовує спільну БД через `POSTGRES_*` змінні | +| `"provide"` | Ядро створює виділену БД `p_` | + +### Структура згенерованого плагіна + +``` +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 компонентів +``` diff --git a/package.json b/package.json index d00bcc0..1349912 100644 --- a/package.json +++ b/package.json @@ -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/" diff --git a/src/backend/services/attribute-access.service.ts b/src/backend/services/attribute-access.service.ts new file mode 100644 index 0000000..2cf6958 --- /dev/null +++ b/src/backend/services/attribute-access.service.ts @@ -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 { + 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 { + await this.core.fetchCore('/api/attributes/batch', { + method: 'POST', + body: JSON.stringify({ userId, attributes, pluginId }), + }); + } + + async getUserAttributes(userId: number): Promise> { + const res = await this.core.fetchCore(`/api/attributes/users/${userId}`); + return res.data || res || []; + } + + async deleteUserAttribute(userId: number, key: string): Promise { + 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 { + 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 { + await this.core.fetchCore('/api/attributes/resources/batch', { + method: 'POST', + body: JSON.stringify({ resourceType, resourceId, attributes, pluginId }), + }); + } + + async getResourceAttributes(resourceType: string, resourceId: string): Promise> { + const res = await this.core.fetchCore(`/api/attributes/resources/${resourceType}/${resourceId}`); + return res.data || res || []; + } + + async deleteResourceAttribute(resourceType: string, resourceId: string, key: string): Promise { + await this.core.fetchCore(`/api/attributes/resources/${resourceType}/${resourceId}?key=${encodeURIComponent(key)}`, { + method: 'DELETE', + }); + } + + // ── Evaluation ── + + async evaluate( + userId: number, + rules: AttributeRule[], + resource?: ResourceRef, + ): Promise { + 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( + userId: number, + rules: AttributeRule[], + items: T[], + extractAttrs: (item: T) => Record, + ): Promise { + 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; + } + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 7452f89..a048dcf 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -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', diff --git a/src/index.ts b/src/index.ts index 276d0a6..433c0e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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';