fix(marketplace): own-KU ownership scoping in read/payload resolvers
Operators and КУ-chairmen could read inventory, issuances and acceptance acts (АПП) of other branches: list resolvers returned coop-wide data and signable-payload resolvers fetched documents by client-supplied id without membership checks. Add own-KU scoping (isMemberOfBranch / orderer ownership) across marketplaceListInventory, marketplaceListIssuancesByBraname, issuance + apl-reception signable payloads, and tighten canAccess so that an :all qualifier subsumes :own/:own-KU rather than requiring literal match. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+29
-6
@@ -101,12 +101,24 @@ export const marketplaceAccessMatrix: Record<MarketplaceRole, Record<string, str
|
||||
|
||||
/**
|
||||
* Проверяет, что хотя бы одна из `roles` пайщика имеет в матрице запись
|
||||
* `resource` → `action`. Ownership-квалификаторы (`:own`/`:all`/...)
|
||||
* матчатся буквально — это значит resolver должен передать ровно тот
|
||||
* action, что записан в матрице (например, `'read:own'`, не `'read'`).
|
||||
* Для проверки «может ли роль вообще читать resource в любой форме» —
|
||||
* используйте `roleHasAnyAction(role, resource, baseAction)`.
|
||||
* `resource` → `action`. Ownership-квалификаторы матчатся буквально, с одним
|
||||
* исключением — иерархией охвата: право `<base>:all` (над всеми объектами в
|
||||
* скоупе) удовлетворяет требование `<base>:own` / `<base>:own-KU` /
|
||||
* `<base>:to-self` того же базового action, потому что «все объекты» — это
|
||||
* надмножество «своих / своего КУ / адресованных себе». Обратное неверно:
|
||||
* `<base>:own*` НЕ удовлетворяет требование `<base>:all`.
|
||||
*
|
||||
* Зачем: резолверы декларируют узкий action (например, `Warehouse:read:own-KU`
|
||||
* для оператора), а роль с широким правом (`admin` → `Warehouse:read:all`)
|
||||
* должна проходить тот же гейт — иначе председатель кооператива получает
|
||||
* Forbidden на сводном складе, имея более широкое право.
|
||||
*
|
||||
* Ownership-фильтрацию данных (вернуть ровно свои/свой-КУ записи) матрица НЕ
|
||||
* делает — это ответственность resolver'а. Для проверки «может ли роль вообще
|
||||
* читать resource в любой форме» используйте `roleHasAnyAction`.
|
||||
*/
|
||||
const SUBSET_QUALIFIERS = new Set(['own', 'own-KU', 'to-self']);
|
||||
|
||||
export function canAccess(
|
||||
roles: MarketplaceRole[],
|
||||
resource: string,
|
||||
@@ -114,7 +126,18 @@ export function canAccess(
|
||||
): boolean {
|
||||
return roles.some((role) => {
|
||||
const resourceActions = marketplaceAccessMatrix[role]?.[resource];
|
||||
return resourceActions?.includes(action) ?? false;
|
||||
if (!resourceActions) return false;
|
||||
if (resourceActions.includes(action)) return true;
|
||||
|
||||
const colon = action.indexOf(':');
|
||||
if (colon > 0) {
|
||||
const base = action.slice(0, colon);
|
||||
const qualifier = action.slice(colon + 1);
|
||||
if (SUBSET_QUALIFIERS.has(qualifier)) {
|
||||
return resourceActions.includes(`${base}:all`);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+77
-6
@@ -1,4 +1,4 @@
|
||||
import { Inject, Injectable, UseGuards } from '@nestjs/common';
|
||||
import { ForbiddenException, Inject, Injectable, NotFoundException, UseGuards } from '@nestjs/common';
|
||||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
import config from '~/config/config';
|
||||
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
|
||||
@@ -6,6 +6,12 @@ import { CurrentMarketplaceMember } from '../decorators/current-marketplace-memb
|
||||
import { RequireMarketplaceAccess } from '../decorators/marketplace-access.decorator';
|
||||
import { MarketplaceMembershipGuard } from '../guards/marketplace-membership.guard';
|
||||
import { MarketplaceRoleGuard } from '../guards/marketplace-role.guard';
|
||||
import { canAccess } from '../access/marketplace-access-matrix';
|
||||
import type { MarketplaceRole } from '../membership/marketplace-roles.mapper';
|
||||
import {
|
||||
MARKETPLACE_KU_CHAIRMEN_SERVICE,
|
||||
type MarketplaceKuChairmenService,
|
||||
} from '../services/marketplace-ku-chairmen.service';
|
||||
import type { IMarketplaceCurrentMember } from '../dto/marketplace-current-member.dto';
|
||||
import {
|
||||
MarketplaceAplReceptionByIdInputDTO,
|
||||
@@ -45,7 +51,9 @@ export class MarketplaceAplReceptionResolver {
|
||||
@Inject(MARKETPLACE_APL_RECEPTION_SERVICE)
|
||||
private readonly service: MarketplaceAplReceptionService,
|
||||
@Inject(MARKETPLACE_APL_RECEPTION_REPOSITORY)
|
||||
private readonly receptionRepo: MarketplaceAplReceptionDomainRepository
|
||||
private readonly receptionRepo: MarketplaceAplReceptionDomainRepository,
|
||||
@Inject(MARKETPLACE_KU_CHAIRMEN_SERVICE)
|
||||
private readonly kuChairmenService: MarketplaceKuChairmenService
|
||||
) {}
|
||||
|
||||
@Mutation(() => MarketplaceAplReceptionResultDTO, {
|
||||
@@ -122,10 +130,30 @@ export class MarketplaceAplReceptionResolver {
|
||||
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
|
||||
@RequireMarketplaceAccess('Receiving', 'sign:first')
|
||||
async marketplaceAplReceptionSupplierSignablePayloads(
|
||||
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
|
||||
@Args('data') data: MarketplaceAplReceptionByIdInputDTO
|
||||
): Promise<GeneratedDocumentDTO[]> {
|
||||
const coopname = config.coopname;
|
||||
const roles = member.marketplace_roles as MarketplaceRole[];
|
||||
|
||||
// Ownership-фильтрация — ответственность резолвера. `sign:first` есть у
|
||||
// роли offerer (поставщика), поэтому без проверки владельца любой поставщик
|
||||
// прочитал бы акт приёмки чужой партии по подставленному apl_reception_id.
|
||||
// Превью подписи поставщика доступно только поставщику этой приёмки.
|
||||
if (!canAccess(roles, 'Receiving', 'read:all')) {
|
||||
const reception = await this.receptionRepo.findById(data.apl_reception_id);
|
||||
if (!reception || reception.coopname !== coopname) {
|
||||
throw new NotFoundException('Акт приёмки не найден.');
|
||||
}
|
||||
if (reception.offerer_account !== member.username) {
|
||||
throw new ForbiddenException(
|
||||
'Превью акта приёмки доступно только поставщику этой партии.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const docs = await this.service.getSupplierSignablePayloads(
|
||||
config.coopname,
|
||||
coopname,
|
||||
data.apl_reception_id
|
||||
);
|
||||
return docs.map(toGeneratedDocumentDTO);
|
||||
@@ -142,8 +170,32 @@ export class MarketplaceAplReceptionResolver {
|
||||
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
|
||||
@Args('data') data: MarketplaceAplReceptionByIdInputDTO
|
||||
): Promise<DocumentAggregateDTO[]> {
|
||||
const coopname = config.coopname;
|
||||
const roles = member.marketplace_roles as MarketplaceRole[];
|
||||
|
||||
// Ownership-фильтрация — ответственность резолвера (сервис игнорирует
|
||||
// chairman_account). `sign:closing` есть у роли operator, поэтому оператор
|
||||
// только с правами своего КУ обязан быть членом КУ приёмки, иначе утечёт
|
||||
// акт чужого участка по подставленному apl_reception_id.
|
||||
if (!canAccess(roles, 'Receiving', 'read:all')) {
|
||||
const reception = await this.receptionRepo.findById(data.apl_reception_id);
|
||||
if (!reception || reception.coopname !== coopname) {
|
||||
throw new NotFoundException('Акт приёмки не найден.');
|
||||
}
|
||||
const isMember = await this.kuChairmenService.isMemberOfBranch(
|
||||
coopname,
|
||||
reception.braname,
|
||||
member.username
|
||||
);
|
||||
if (!isMember) {
|
||||
throw new ForbiddenException(
|
||||
'Превью акта приёмки доступно только по участку, на котором вы являетесь председателем или доверенным лицом.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const aggregates = await this.service.getChairmanSignablePayloads(
|
||||
config.coopname,
|
||||
coopname,
|
||||
data.apl_reception_id,
|
||||
member.username
|
||||
);
|
||||
@@ -157,10 +209,29 @@ export class MarketplaceAplReceptionResolver {
|
||||
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
|
||||
@RequireMarketplaceAccess('Receiving', 'create')
|
||||
async marketplaceListAplReceptionsByBraname(
|
||||
@CurrentMarketplaceMember() _member: IMarketplaceCurrentMember,
|
||||
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
|
||||
@Args('data') data: MarketplaceListAplReceptionsByBranameInputDTO
|
||||
): Promise<MarketplaceAplReceptionDTO[]> {
|
||||
const list = await this.receptionRepo.listByBraname(config.coopname, data.braname);
|
||||
const coopname = config.coopname;
|
||||
const roles = member.marketplace_roles as MarketplaceRole[];
|
||||
|
||||
// Ownership-фильтрация — ответственность резолвера (matrix даёт только
|
||||
// capability `Receiving:create`). Оператор только с правами своего КУ обязан
|
||||
// быть членом запрашиваемого участка, иначе утечёт лента приёмок чужого КУ.
|
||||
if (!canAccess(roles, 'Receiving', 'read:all')) {
|
||||
const isMember = await this.kuChairmenService.isMemberOfBranch(
|
||||
coopname,
|
||||
data.braname,
|
||||
member.username
|
||||
);
|
||||
if (!isMember) {
|
||||
throw new ForbiddenException(
|
||||
'Лента приёмок доступна только по участку, на котором вы являетесь председателем или доверенным лицом.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const list = await this.receptionRepo.listByBraname(coopname, data.braname);
|
||||
return list.map(toMarketplaceAplReceptionDTO);
|
||||
}
|
||||
|
||||
|
||||
+41
-5
@@ -1,4 +1,4 @@
|
||||
import { Inject, Injectable, UseGuards } from '@nestjs/common';
|
||||
import { ForbiddenException, Inject, Injectable, UseGuards } from '@nestjs/common';
|
||||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
import config from '~/config/config';
|
||||
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
|
||||
@@ -6,6 +6,12 @@ import { CurrentMarketplaceMember } from '../decorators/current-marketplace-memb
|
||||
import { RequireMarketplaceAccess } from '../decorators/marketplace-access.decorator';
|
||||
import { MarketplaceMembershipGuard } from '../guards/marketplace-membership.guard';
|
||||
import { MarketplaceRoleGuard } from '../guards/marketplace-role.guard';
|
||||
import { canAccess } from '../access/marketplace-access-matrix';
|
||||
import type { MarketplaceRole } from '../membership/marketplace-roles.mapper';
|
||||
import {
|
||||
MARKETPLACE_KU_CHAIRMEN_SERVICE,
|
||||
type MarketplaceKuChairmenService,
|
||||
} from '../services/marketplace-ku-chairmen.service';
|
||||
import type { IMarketplaceCurrentMember } from '../dto/marketplace-current-member.dto';
|
||||
import {
|
||||
MarketplaceInventoryItemDTO,
|
||||
@@ -38,7 +44,9 @@ export class MarketplaceInventoryResolver {
|
||||
@Inject(MARKETPLACE_INVENTORY_LABEL_SERVICE)
|
||||
private readonly labelService: MarketplaceInventoryLabelService,
|
||||
@Inject(MARKETPLACE_INVENTORY_REPOSITORY)
|
||||
private readonly inventoryRepo: MarketplaceInventoryDomainRepository
|
||||
private readonly inventoryRepo: MarketplaceInventoryDomainRepository,
|
||||
@Inject(MARKETPLACE_KU_CHAIRMEN_SERVICE)
|
||||
private readonly kuChairmenService: MarketplaceKuChairmenService
|
||||
) {}
|
||||
|
||||
@Mutation(() => MarketplaceLabelInventoryResultDTO, {
|
||||
@@ -102,14 +110,42 @@ export class MarketplaceInventoryResolver {
|
||||
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
|
||||
@RequireMarketplaceAccess('Warehouse', 'read:own-KU')
|
||||
async marketplaceListInventory(
|
||||
@CurrentMarketplaceMember() _member: IMarketplaceCurrentMember,
|
||||
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
|
||||
@Args('data', { nullable: true }) data?: MarketplaceListInventoryInputDTO
|
||||
): Promise<MarketplaceInventoryItemDTO[]> {
|
||||
const coopname = config.coopname;
|
||||
const roles = member.marketplace_roles as MarketplaceRole[];
|
||||
|
||||
// Ownership-фильтрация данных — ответственность резолвера, а не матрицы
|
||||
// (matrix отвечает только за capability). Роль с `Warehouse:read:all`
|
||||
// (admin/совет) видит склад всего кооператива; роль только с
|
||||
// `read:own-KU` (оператор/председатель КУ) ограничивается своими КУ.
|
||||
let branameFilter: string | string[] | undefined = data?.braname;
|
||||
if (!canAccess(roles, 'Warehouse', 'read:all')) {
|
||||
const ownBranames = await this.kuChairmenService.listBranamesForMember(
|
||||
coopname,
|
||||
member.username
|
||||
);
|
||||
if (ownBranames.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (data?.braname) {
|
||||
if (!ownBranames.includes(data.braname)) {
|
||||
throw new ForbiddenException(
|
||||
'Склад доступен только по участку, на котором вы являетесь председателем или доверенным лицом.'
|
||||
);
|
||||
}
|
||||
branameFilter = data.braname;
|
||||
} else {
|
||||
branameFilter = ownBranames;
|
||||
}
|
||||
}
|
||||
|
||||
const filter: MarketplaceInventoryListFilter = {
|
||||
coopname: config.coopname,
|
||||
coopname,
|
||||
order_id: data?.order_id,
|
||||
shipment_id: data?.shipment_id,
|
||||
braname: data?.braname,
|
||||
braname: branameFilter,
|
||||
status: data?.statuses?.length
|
||||
? (data.statuses as MarketplaceInventoryStatus[])
|
||||
: undefined,
|
||||
|
||||
+78
-5
@@ -1,4 +1,4 @@
|
||||
import { Inject, Injectable, UseGuards } from '@nestjs/common';
|
||||
import { ForbiddenException, Inject, Injectable, NotFoundException, UseGuards } from '@nestjs/common';
|
||||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
import config from '~/config/config';
|
||||
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
|
||||
@@ -6,6 +6,12 @@ import { CurrentMarketplaceMember } from '../decorators/current-marketplace-memb
|
||||
import { RequireMarketplaceAccess } from '../decorators/marketplace-access.decorator';
|
||||
import { MarketplaceMembershipGuard } from '../guards/marketplace-membership.guard';
|
||||
import { MarketplaceRoleGuard } from '../guards/marketplace-role.guard';
|
||||
import { canAccess } from '../access/marketplace-access-matrix';
|
||||
import type { MarketplaceRole } from '../membership/marketplace-roles.mapper';
|
||||
import {
|
||||
MARKETPLACE_KU_CHAIRMEN_SERVICE,
|
||||
type MarketplaceKuChairmenService,
|
||||
} from '../services/marketplace-ku-chairmen.service';
|
||||
import type { IMarketplaceCurrentMember } from '../dto/marketplace-current-member.dto';
|
||||
import {
|
||||
MarketplaceFinalizeIssuanceInputDTO,
|
||||
@@ -44,7 +50,9 @@ export class MarketplaceIssuanceResolver {
|
||||
@Inject(MARKETPLACE_ISSUANCE_SERVICE)
|
||||
private readonly service: MarketplaceIssuanceService,
|
||||
@Inject(MARKETPLACE_ORDER_REPOSITORY)
|
||||
private readonly orderRepo: MarketplaceOrderDomainRepository
|
||||
private readonly orderRepo: MarketplaceOrderDomainRepository,
|
||||
@Inject(MARKETPLACE_KU_CHAIRMEN_SERVICE)
|
||||
private readonly kuChairmenService: MarketplaceKuChairmenService
|
||||
) {}
|
||||
|
||||
@Mutation(() => MarketplaceIssuanceResultDTO, {
|
||||
@@ -106,8 +114,32 @@ export class MarketplaceIssuanceResolver {
|
||||
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
|
||||
@Args('data') data: MarketplaceIssueActPayloadInputDTO
|
||||
): Promise<GeneratedDocumentDTO> {
|
||||
const coopname = config.coopname;
|
||||
const roles = member.marketplace_roles as MarketplaceRole[];
|
||||
|
||||
// Ownership-фильтрация — ответственность резолвера (matrix даёт только
|
||||
// capability). Превью акта раскрывает ФИО/состав заказа, поэтому оператор
|
||||
// только с `read:own-KU` обязан быть членом КУ выдачи запрашиваемого заказа,
|
||||
// иначе утечёт акт чужого участка по подставленному order_id.
|
||||
if (!canAccess(roles, 'Issuance', 'read:all')) {
|
||||
const order = await this.orderRepo.findById(data.order_id);
|
||||
if (!order || order.coopname !== coopname) {
|
||||
throw new NotFoundException('Заказ не найден.');
|
||||
}
|
||||
const isMember = await this.kuChairmenService.isMemberOfBranch(
|
||||
coopname,
|
||||
order.delivery_braname,
|
||||
member.username
|
||||
);
|
||||
if (!isMember) {
|
||||
throw new ForbiddenException(
|
||||
'Превью акта выдачи доступно только по участку, на котором вы являетесь председателем или доверенным лицом.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const doc = await this.service.getOpenIssuanceSignablePayload(
|
||||
config.coopname,
|
||||
coopname,
|
||||
data.order_id,
|
||||
member.username
|
||||
);
|
||||
@@ -122,10 +154,30 @@ export class MarketplaceIssuanceResolver {
|
||||
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
|
||||
@RequireMarketplaceAccess('Issuance', 'sign:final')
|
||||
async marketplaceIssueActOrdererSignablePayload(
|
||||
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
|
||||
@Args('data') data: MarketplaceIssueActPayloadInputDTO
|
||||
): Promise<DocumentAggregateDTO> {
|
||||
const coopname = config.coopname;
|
||||
const roles = member.marketplace_roles as MarketplaceRole[];
|
||||
|
||||
// Ownership-фильтрация — ответственность резолвера. `sign:final` есть у
|
||||
// каждого пайщика (роль orderer), поэтому без проверки владельца любой
|
||||
// пайщик прочитал бы акт чужого заказа по подставленному order_id. Превью
|
||||
// финальной подписи доступно только заказчику этого заказа.
|
||||
if (!canAccess(roles, 'Issuance', 'read:all')) {
|
||||
const order = await this.orderRepo.findById(data.order_id);
|
||||
if (!order || order.coopname !== coopname) {
|
||||
throw new NotFoundException('Заказ не найден.');
|
||||
}
|
||||
if (order.orderer_account !== member.username) {
|
||||
throw new ForbiddenException(
|
||||
'Превью акта выдачи доступно только заказчику этого заказа.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const aggregate = await this.service.getFinalizeIssuanceSignablePayload(
|
||||
config.coopname,
|
||||
coopname,
|
||||
data.order_id
|
||||
);
|
||||
return new DocumentAggregateDTO(aggregate);
|
||||
@@ -139,10 +191,31 @@ export class MarketplaceIssuanceResolver {
|
||||
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
|
||||
@RequireMarketplaceAccess('Issuance', 'read:own-KU')
|
||||
async marketplaceListIssuancesByBraname(
|
||||
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
|
||||
@Args('data') data: MarketplaceListIssuancesByBranameInputDTO
|
||||
): Promise<MarketplaceOrderDTO[]> {
|
||||
const coopname = config.coopname;
|
||||
const roles = member.marketplace_roles as MarketplaceRole[];
|
||||
|
||||
// Ownership-фильтрация — ответственность резолвера (matrix даёт только
|
||||
// capability). Роль с `Issuance:read:all` видит ленту любого КУ; роль
|
||||
// только с `read:own-KU` (оператор/председатель КУ) обязана быть членом
|
||||
// запрашиваемого участка, иначе утечёт лента выдач чужого КУ.
|
||||
if (!canAccess(roles, 'Issuance', 'read:all')) {
|
||||
const isMember = await this.kuChairmenService.isMemberOfBranch(
|
||||
coopname,
|
||||
data.delivery_braname,
|
||||
member.username
|
||||
);
|
||||
if (!isMember) {
|
||||
throw new ForbiddenException(
|
||||
'Лента выдач доступна только по участку, на котором вы являетесь председателем или доверенным лицом.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const orders = await this.orderRepo.listForIssuanceByBraname(
|
||||
config.coopname,
|
||||
coopname,
|
||||
data.delivery_braname
|
||||
);
|
||||
return orders.map(toMarketplaceOrderDTO);
|
||||
|
||||
+2
-1
@@ -26,7 +26,8 @@ export interface MarketplaceInventoryListFilter {
|
||||
coopname: string;
|
||||
order_id?: string;
|
||||
shipment_id?: string;
|
||||
braname?: string;
|
||||
// Массив branames — для ownership-скоупинга оператора по нескольким своим КУ.
|
||||
braname?: string | string[];
|
||||
status?: MarketplaceInventoryStatus | MarketplaceInventoryStatus[];
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -60,7 +60,9 @@ export class MarketplaceInventoryRepositoryAdapter implements MarketplaceInvento
|
||||
const where: Record<string, unknown> = { coopname: filter.coopname };
|
||||
if (filter.order_id) where.order_id = filter.order_id;
|
||||
if (filter.shipment_id) where.shipment_id = filter.shipment_id;
|
||||
if (filter.braname) where.braname = filter.braname;
|
||||
if (filter.braname) {
|
||||
where.braname = Array.isArray(filter.braname) ? In(filter.braname) : filter.braname;
|
||||
}
|
||||
if (filter.status) {
|
||||
where.status = Array.isArray(filter.status) ? In(filter.status) : filter.status;
|
||||
}
|
||||
|
||||
@@ -52,11 +52,30 @@ describe('canAccess', () => {
|
||||
expect(canAccess(['orderer', 'board_readonly'], 'Order', 'read:all')).toBe(true);
|
||||
});
|
||||
|
||||
it('admin может KU:manage, но не KU:read:own-KU (operator-specific)', () => {
|
||||
it('admin может KU:manage, но не KU:read:own-KU (нет KU:read:all → иерархия не применяется)', () => {
|
||||
expect(canAccess(['admin'], 'KU', 'manage')).toBe(true);
|
||||
expect(canAccess(['admin'], 'KU', 'read:own-KU')).toBe(false);
|
||||
});
|
||||
|
||||
it('иерархия охвата: admin с Warehouse:read:all проходит гейт Warehouse:read:own-KU', () => {
|
||||
// admin имеет Warehouse:['read:all']; резолвер склада декларирует read:own-KU.
|
||||
expect(canAccess(['admin'], 'Warehouse', 'read:own-KU')).toBe(true);
|
||||
expect(canAccess(['board_readonly'], 'Warehouse', 'read:own-KU')).toBe(true);
|
||||
});
|
||||
|
||||
it('иерархия охвата: Order:read:all удовлетворяет Order:read:own / read:to-self', () => {
|
||||
expect(canAccess(['admin'], 'Order', 'read:own')).toBe(true);
|
||||
expect(canAccess(['board_readonly'], 'Order', 'read:to-self')).toBe(true);
|
||||
});
|
||||
|
||||
it('иерархия НЕ работает в обратную сторону: orderer с read:own не получает read:all', () => {
|
||||
expect(canAccess(['orderer'], 'Order', 'read:all')).toBe(false);
|
||||
});
|
||||
|
||||
it('operator с read:own-KU НЕ проходит гейт read:all (нет :all → не суперсет)', () => {
|
||||
expect(canAccess(['operator'], 'Warehouse', 'read:all')).toBe(false);
|
||||
});
|
||||
|
||||
it('roles=[] → false для любого resource:action', () => {
|
||||
expect(canAccess([], 'Order', 'create')).toBe(false);
|
||||
});
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Unit-тесты ownership-скоупинга apl-reception-резолверов (#208).
|
||||
*
|
||||
* Зеркало #205/#206/#207 на стороне приёмки. Инвариант: matrix даёт
|
||||
* capability, скоуп ДАННЫХ — ответственность резолвера:
|
||||
* - listByBraname (operator, Receiving:create) → член запрашиваемого КУ;
|
||||
* - supplierSignablePayloads (offerer, sign:first) → поставщик этой приёмки;
|
||||
* - chairmanSignablePayloads (operator, sign:closing) → член КУ приёмки
|
||||
* (сервис делает `void chairman_account`, скоупа сам не делает).
|
||||
* В каждом блоке: владелец/член → метод дёргается; чужой → ForbiddenException,
|
||||
* нижележащий метод не дёргается.
|
||||
*/
|
||||
jest.mock('~/config/config', () => ({
|
||||
__esModule: true,
|
||||
default: { coopname: 'voskhod' },
|
||||
}));
|
||||
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { MarketplaceAplReceptionResolver } from '~/extensions/marketplace/application/resolvers/marketplace-apl-reception.resolver';
|
||||
|
||||
const receptionOf = (overrides: Record<string, unknown>) =>
|
||||
({ coopname: 'voskhod', braname: 'krg', offerer_account: 'op', ...overrides } as any);
|
||||
|
||||
const makeResolver = (reception: any, isMember: boolean) => {
|
||||
const service = {
|
||||
getSupplierSignablePayloads: jest.fn().mockResolvedValue([]),
|
||||
getChairmanSignablePayloads: jest.fn().mockResolvedValue([]),
|
||||
} as any;
|
||||
const receptionRepo = {
|
||||
findById: jest.fn().mockResolvedValue(reception),
|
||||
listByBraname: jest.fn().mockResolvedValue([]),
|
||||
} as any;
|
||||
const kuChairmenService = {
|
||||
isMemberOfBranch: jest.fn().mockResolvedValue(isMember),
|
||||
} as any;
|
||||
const resolver = new MarketplaceAplReceptionResolver(service, receptionRepo, kuChairmenService);
|
||||
return { resolver, service, receptionRepo, kuChairmenService };
|
||||
};
|
||||
|
||||
const asMember = (roles: string[]) =>
|
||||
({ username: 'op', core_roles: ['User'], marketplace_roles: roles } as any);
|
||||
|
||||
describe('marketplaceListAplReceptionsByBraname ownership-scoping', () => {
|
||||
it('operator-член запрашиваемого КУ → лента отдаётся', async () => {
|
||||
const { resolver, receptionRepo, kuChairmenService } = makeResolver(receptionOf({}), true);
|
||||
await resolver.marketplaceListAplReceptionsByBraname(asMember(['operator']), {
|
||||
braname: 'krg',
|
||||
} as any);
|
||||
expect(kuChairmenService.isMemberOfBranch).toHaveBeenCalledWith('voskhod', 'krg', 'op');
|
||||
expect(receptionRepo.listByBraname).toHaveBeenCalledWith('voskhod', 'krg');
|
||||
});
|
||||
|
||||
it('operator НЕ член запрашиваемого КУ → ForbiddenException, репозиторий не дёргается', async () => {
|
||||
const { resolver, receptionRepo } = makeResolver(receptionOf({}), false);
|
||||
await expect(
|
||||
resolver.marketplaceListAplReceptionsByBraname(asMember(['operator']), {
|
||||
braname: 'msk',
|
||||
} as any)
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(receptionRepo.listByBraname).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('marketplaceAplReceptionSupplierSignablePayloads ownership-scoping', () => {
|
||||
it('поставщик своей приёмки → превью отдаётся', async () => {
|
||||
const { resolver, service } = makeResolver(receptionOf({ offerer_account: 'op' }), false);
|
||||
await resolver.marketplaceAplReceptionSupplierSignablePayloads(asMember(['offerer']), {
|
||||
apl_reception_id: 'r1',
|
||||
} as any);
|
||||
expect(service.getSupplierSignablePayloads).toHaveBeenCalledWith('voskhod', 'r1');
|
||||
});
|
||||
|
||||
it('НЕ поставщик приёмки → ForbiddenException, сервис не дёргается', async () => {
|
||||
const { resolver, service } = makeResolver(receptionOf({ offerer_account: 'someoneelse' }), false);
|
||||
await expect(
|
||||
resolver.marketplaceAplReceptionSupplierSignablePayloads(asMember(['offerer']), {
|
||||
apl_reception_id: 'r1',
|
||||
} as any)
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(service.getSupplierSignablePayloads).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('marketplaceAplReceptionChairmanSignablePayloads ownership-scoping', () => {
|
||||
it('operator-член КУ приёмки → превью отдаётся', async () => {
|
||||
const { resolver, service, kuChairmenService } = makeResolver(receptionOf({}), true);
|
||||
await resolver.marketplaceAplReceptionChairmanSignablePayloads(asMember(['operator']), {
|
||||
apl_reception_id: 'r1',
|
||||
} as any);
|
||||
expect(kuChairmenService.isMemberOfBranch).toHaveBeenCalledWith('voskhod', 'krg', 'op');
|
||||
expect(service.getChairmanSignablePayloads).toHaveBeenCalledWith('voskhod', 'r1', 'op');
|
||||
});
|
||||
|
||||
it('operator НЕ член КУ приёмки → ForbiddenException, сервис не дёргается', async () => {
|
||||
const { resolver, service } = makeResolver(receptionOf({ braname: 'msk' }), false);
|
||||
await expect(
|
||||
resolver.marketplaceAplReceptionChairmanSignablePayloads(asMember(['operator']), {
|
||||
apl_reception_id: 'r1',
|
||||
} as any)
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(service.getChairmanSignablePayloads).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Unit-тесты ownership-скоупинга marketplaceListInventory (#205).
|
||||
*
|
||||
* Инвариант: matrix даёт capability (`Warehouse:read:own-KU` оператору,
|
||||
* `read:all` админу/совету), а скоуп ДАННЫХ — ответственность резолвера.
|
||||
* - admin/совет (read:all) → склад всего кооператива, braname как пришёл;
|
||||
* - оператор (только read:own-KU) → ограничивается своими КУ:
|
||||
* • без braname → IN (все свои КУ);
|
||||
* • свой braname → этот braname;
|
||||
* • чужой braname → ForbiddenException;
|
||||
* • ноль своих КУ → пустой результат без запроса в репозиторий.
|
||||
*/
|
||||
jest.mock('~/config/config', () => ({
|
||||
__esModule: true,
|
||||
default: { coopname: 'voskhod' },
|
||||
}));
|
||||
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { MarketplaceInventoryResolver } from '~/extensions/marketplace/application/resolvers/marketplace-inventory.resolver';
|
||||
|
||||
const makeResolver = (ownBranames: string[]) => {
|
||||
const inventoryRepo = { list: jest.fn().mockResolvedValue([]) } as any;
|
||||
const labelService = {} as any;
|
||||
const kuChairmenService = {
|
||||
listBranamesForMember: jest.fn().mockResolvedValue(ownBranames),
|
||||
} as any;
|
||||
const resolver = new MarketplaceInventoryResolver(
|
||||
labelService,
|
||||
inventoryRepo,
|
||||
kuChairmenService
|
||||
);
|
||||
return { resolver, inventoryRepo, kuChairmenService };
|
||||
};
|
||||
|
||||
const asMember = (roles: string[]) =>
|
||||
({ username: 'op', core_roles: ['User'], marketplace_roles: roles } as any);
|
||||
|
||||
describe('marketplaceListInventory ownership-scoping', () => {
|
||||
it('admin (read:all) без braname → НЕ скоупит, braname=undefined, КУ-сервис не дёргается', async () => {
|
||||
const { resolver, inventoryRepo, kuChairmenService } = makeResolver(['krg']);
|
||||
await resolver.marketplaceListInventory(asMember(['admin']), undefined);
|
||||
expect(kuChairmenService.listBranamesForMember).not.toHaveBeenCalled();
|
||||
expect(inventoryRepo.list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ coopname: 'voskhod', braname: undefined })
|
||||
);
|
||||
});
|
||||
|
||||
it('board_readonly (read:all) с braname → braname как пришёл, без скоупа', async () => {
|
||||
const { resolver, inventoryRepo, kuChairmenService } = makeResolver(['krg']);
|
||||
await resolver.marketplaceListInventory(asMember(['board_readonly']), {
|
||||
braname: 'krg',
|
||||
} as any);
|
||||
expect(kuChairmenService.listBranamesForMember).not.toHaveBeenCalled();
|
||||
expect(inventoryRepo.list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ braname: 'krg' })
|
||||
);
|
||||
});
|
||||
|
||||
it('operator без braname → скоуп IN по всем своим КУ', async () => {
|
||||
const { resolver, inventoryRepo } = makeResolver(['krg', 'msk']);
|
||||
await resolver.marketplaceListInventory(asMember(['operator']), undefined);
|
||||
expect(inventoryRepo.list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ braname: ['krg', 'msk'] })
|
||||
);
|
||||
});
|
||||
|
||||
it('operator со своим braname → этот braname', async () => {
|
||||
const { resolver, inventoryRepo } = makeResolver(['krg', 'msk']);
|
||||
await resolver.marketplaceListInventory(asMember(['operator']), {
|
||||
braname: 'krg',
|
||||
} as any);
|
||||
expect(inventoryRepo.list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ braname: 'krg' })
|
||||
);
|
||||
});
|
||||
|
||||
it('operator с ЧУЖИМ braname → ForbiddenException, репозиторий не дёргается', async () => {
|
||||
const { resolver, inventoryRepo } = makeResolver(['krg']);
|
||||
await expect(
|
||||
resolver.marketplaceListInventory(asMember(['operator']), {
|
||||
braname: 'msk',
|
||||
} as any)
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(inventoryRepo.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('operator без своих КУ → пустой массив, репозиторий не дёргается', async () => {
|
||||
const { resolver, inventoryRepo } = makeResolver([]);
|
||||
const res = await resolver.marketplaceListInventory(asMember(['operator']), undefined);
|
||||
expect(res).toEqual([]);
|
||||
expect(inventoryRepo.list).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Unit-тесты ownership-скоупинга marketplaceListIssuancesByBraname (#206).
|
||||
*
|
||||
* Сиблинг #205. Инвариант: matrix даёт capability (`Issuance:read:own-KU`
|
||||
* оператору), а скоуп ДАННЫХ — ответственность резолвера:
|
||||
* - роль с `Issuance:read:all` → лента любого КУ, КУ-сервис не дёргается;
|
||||
* - оператор (только read:own-KU) обязан быть членом запрашиваемого КУ:
|
||||
* • член КУ → лента этого КУ;
|
||||
* • чужой КУ → ForbiddenException, репозиторий не дёргается.
|
||||
*/
|
||||
jest.mock('~/config/config', () => ({
|
||||
__esModule: true,
|
||||
default: { coopname: 'voskhod' },
|
||||
}));
|
||||
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { MarketplaceIssuanceResolver } from '~/extensions/marketplace/application/resolvers/marketplace-issuance.resolver';
|
||||
|
||||
const makeResolver = (isMember: boolean) => {
|
||||
const service = {} as any;
|
||||
const orderRepo = { listForIssuanceByBraname: jest.fn().mockResolvedValue([]) } as any;
|
||||
const kuChairmenService = {
|
||||
isMemberOfBranch: jest.fn().mockResolvedValue(isMember),
|
||||
} as any;
|
||||
const resolver = new MarketplaceIssuanceResolver(service, orderRepo, kuChairmenService);
|
||||
return { resolver, orderRepo, kuChairmenService };
|
||||
};
|
||||
|
||||
const asMember = (roles: string[]) =>
|
||||
({ username: 'op', core_roles: ['User'], marketplace_roles: roles } as any);
|
||||
|
||||
describe('marketplaceListIssuancesByBraname ownership-scoping', () => {
|
||||
it('operator-член запрашиваемого КУ → лента отдаётся', async () => {
|
||||
const { resolver, orderRepo, kuChairmenService } = makeResolver(true);
|
||||
await resolver.marketplaceListIssuancesByBraname(asMember(['operator']), {
|
||||
delivery_braname: 'krg',
|
||||
} as any);
|
||||
expect(kuChairmenService.isMemberOfBranch).toHaveBeenCalledWith('voskhod', 'krg', 'op');
|
||||
expect(orderRepo.listForIssuanceByBraname).toHaveBeenCalledWith('voskhod', 'krg');
|
||||
});
|
||||
|
||||
it('operator НЕ член запрашиваемого КУ → ForbiddenException, репозиторий не дёргается', async () => {
|
||||
const { resolver, orderRepo } = makeResolver(false);
|
||||
await expect(
|
||||
resolver.marketplaceListIssuancesByBraname(asMember(['operator']), {
|
||||
delivery_braname: 'msk',
|
||||
} as any)
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(orderRepo.listForIssuanceByBraname).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Сиблинги #207. Те же инварианты для payload-резолверов (превью акта по
|
||||
* order_id раскрывает ФИО/состав заказа):
|
||||
* - chairman-payload (sign:first, operator) → член КУ заказа;
|
||||
* - orderer-payload (sign:final, есть у каждого пайщика) → заказчик заказа.
|
||||
*/
|
||||
const orderOf = (overrides: Record<string, unknown>) =>
|
||||
({ coopname: 'voskhod', orderer_account: 'owner', delivery_braname: 'krg', ...overrides } as any);
|
||||
|
||||
const makePayloadResolver = (order: any, isMember: boolean) => {
|
||||
const service = {
|
||||
getOpenIssuanceSignablePayload: jest.fn().mockResolvedValue({}),
|
||||
getFinalizeIssuanceSignablePayload: jest.fn().mockResolvedValue({
|
||||
hash: 'h',
|
||||
document: { version: '1', hash: 'dh', doc_hash: 'dh', meta_hash: 'mh', meta: '{}', signatures: [] },
|
||||
}),
|
||||
} as any;
|
||||
const orderRepo = { findById: jest.fn().mockResolvedValue(order) } as any;
|
||||
const kuChairmenService = { isMemberOfBranch: jest.fn().mockResolvedValue(isMember) } as any;
|
||||
const resolver = new MarketplaceIssuanceResolver(service, orderRepo, kuChairmenService);
|
||||
return { resolver, service, orderRepo, kuChairmenService };
|
||||
};
|
||||
|
||||
describe('marketplaceIssueActChairmanSignablePayload ownership-scoping', () => {
|
||||
it('operator-член КУ заказа → превью отдаётся', async () => {
|
||||
const { resolver, service, kuChairmenService } = makePayloadResolver(orderOf({}), true);
|
||||
await resolver.marketplaceIssueActChairmanSignablePayload(asMember(['operator']), {
|
||||
order_id: 'o1',
|
||||
} as any);
|
||||
expect(kuChairmenService.isMemberOfBranch).toHaveBeenCalledWith('voskhod', 'krg', 'op');
|
||||
expect(service.getOpenIssuanceSignablePayload).toHaveBeenCalledWith('voskhod', 'o1', 'op');
|
||||
});
|
||||
|
||||
it('operator НЕ член КУ заказа → ForbiddenException, сервис не дёргается', async () => {
|
||||
const { resolver, service } = makePayloadResolver(orderOf({ delivery_braname: 'msk' }), false);
|
||||
await expect(
|
||||
resolver.marketplaceIssueActChairmanSignablePayload(asMember(['operator']), {
|
||||
order_id: 'o1',
|
||||
} as any)
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(service.getOpenIssuanceSignablePayload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('marketplaceIssueActOrdererSignablePayload ownership-scoping', () => {
|
||||
it('заказчик своего заказа → превью отдаётся', async () => {
|
||||
const { resolver, service } = makePayloadResolver(orderOf({ orderer_account: 'op' }), false);
|
||||
await resolver.marketplaceIssueActOrdererSignablePayload(asMember(['orderer']), {
|
||||
order_id: 'o1',
|
||||
} as any);
|
||||
expect(service.getFinalizeIssuanceSignablePayload).toHaveBeenCalledWith('voskhod', 'o1');
|
||||
});
|
||||
|
||||
it('НЕ заказчик заказа → ForbiddenException, сервис не дёргается', async () => {
|
||||
const { resolver, service } = makePayloadResolver(orderOf({ orderer_account: 'someoneelse' }), false);
|
||||
await expect(
|
||||
resolver.marketplaceIssueActOrdererSignablePayload(asMember(['orderer']), {
|
||||
order_id: 'o1',
|
||||
} as any)
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(service.getFinalizeIssuanceSignablePayload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user