Merge remote-tracking branch 'origin/dev' into marketplace2

This commit is contained in:
coopops
2026-05-08 05:43:55 +00:00
8 changed files with 327 additions and 204 deletions
+6 -3
View File
@@ -16,19 +16,22 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v3
# Версия pnpm берётся из `packageManager` корневого package.json,
# синхронно с publish-packages.yaml и Dockerfile'ами.
- name: Set up pnpm
uses: pnpm/action-setup@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Install pnpm
run: npm install -g pnpm
- name: Install Python requirements
run: |
python -m venv venv
+8 -2
View File
@@ -35,7 +35,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
RUN python3 -m venv /venv \
&& /venv/bin/pip install --no-cache-dir WeasyPrint==67
RUN npm install -g pnpm lerna --no-fund --no-audit
# pnpm пинится через `packageManager` поле в корневом package.json
# (corepack-стандарт). Без пина CI каждый раз тянет latest, а pnpm 10
# ужесточил политику build-скриптов и валит `--frozen-lockfile` с
# ERR_PNPM_IGNORED_BUILDS на native-пакетах (electron / esbuild / …).
RUN corepack enable && npm install -g lerna --no-fund --no-audit
# Сначала манифесты (для кэш-friendly install). `.dockerignore` уже
# выкидывает node_modules/dist/.git, поэтому `COPY .` лёгкий.
@@ -95,7 +99,9 @@ COPY --from=builder /app /app
# Глобальные pnpm/lerna — нужны чтобы потребители mono-base могли
# делать `CMD ["pnpm","-F","<pkg>","run","start"]` в production.
RUN npm install -g pnpm lerna --no-fund --no-audit
# pnpm активируется через corepack (версия из `packageManager` корневого
# package.json — синхронно со builder-стадией).
RUN corepack enable && npm install -g lerna --no-fund --no-audit
# Sanity-check: WeasyPrint работает и виден через PATH.
RUN weasyprint --version
@@ -31,14 +31,13 @@ export class IssuePermissionsService {
) {}
/**
* Определяет роль пользователя для конкретной задачи
* @param username - имя пользователя
* @param coopname - имя кооператива
* @param projectHash - хеш проекта
* @param issueSubmaster - ответственный задачи
* @param issueCreators - создатели задачи
* @param userRole - роль пользователя в системе (chairman, member, etc.)
* @returns роль пользователя
* Определяет НАБОР ролей пользователя для конкретной задачи.
*
* Пользователь может одновременно нести несколько ролей (например, член совета
* + соавтор + исполнитель), и итоговые права на задачу — UNION разрешений по
* всем его ролям. См. {@link IssueAccessPolicyService.hasPermission}.
*
* @returns множество ролей; минимум {@link UserRole.GUEST} / {@link UserRole.CONTRIBUTOR}.
*/
async getUserRoleForIssue(
username: string | undefined,
@@ -47,74 +46,64 @@ export class IssuePermissionsService {
issueSubmaster?: string,
issueCreators?: string[],
userRole?: string
): Promise<UserRole> {
// Гости не имеют доступа
): Promise<Set<UserRole>> {
const roles = new Set<UserRole>();
if (!username) {
return UserRole.GUEST;
roles.add(UserRole.GUEST);
return roles;
}
// Сначала проверяем специфические роли проекта (они имеют приоритет над системными ролями)
// Проверяем, является ли пользователь мастером проекта
// Project-specific роли — собираем все, что верны.
const isMaster = await this.isProjectMaster(username, coopname, projectHash);
if (isMaster) {
return UserRole.MASTER;
roles.add(UserRole.MASTER);
}
// Проверяем, является ли пользователь ответственным
if (issueSubmaster === username) {
return UserRole.SUBMASTER;
roles.add(UserRole.SUBMASTER);
}
// Проверяем, является ли пользователь исполнителем (соисполнителем)
if (issueCreators && issueCreators.includes(username)) {
return UserRole.CREATOR;
roles.add(UserRole.CREATOR);
}
// Проверяем, является ли пользователь автором проекта
const segment = await this.segmentRepository.findOne({
username,
project_hash: projectHash,
coopname,
});
if (segment?.is_author) {
return UserRole.AUTHOR;
roles.add(UserRole.AUTHOR);
}
// Проверяем, является ли пользователь участником проекта
const contributor = await this.contributorRepository.findByUsernameAndCoopname(username, coopname);
if (contributor && contributor.appendixes.includes(projectHash)) {
return UserRole.CONTRIBUTOR;
roles.add(UserRole.CONTRIBUTOR);
}
// Теперь проверяем системные роли (только если нет специфических ролей проекта)
// Системные роли совета — добавляем независимо от project-specific ролей.
if (userRole === 'chairman' || userRole === 'member') {
return UserRole.BOARD_MEMBER;
roles.add(UserRole.BOARD_MEMBER);
}
// По умолчанию - участник проекта (если есть доступ)
return UserRole.CONTRIBUTOR;
// Минимальный фолбэк, если ничего из вышеперечисленного не сработало,
// но username известен — оставляем семантику «участник проекта».
if (roles.size === 0) {
roles.add(UserRole.CONTRIBUTOR);
}
return roles;
}
/**
* Проверяет, есть ли у пользователя разрешение на действие
* @param userRole - роль пользователя
* @param action - действие
* @returns true если действие разрешено
*/
hasPermission(userRole: UserRole, action: IssueAction): boolean {
return this.issueAccessPolicyService.hasPermission(userRole, action);
/** UNION-проверка прав на действие над задачей по набору ролей. */
hasPermission(roles: Iterable<UserRole>, action: IssueAction): boolean {
return this.issueAccessPolicyService.hasPermission(roles, action);
}
/**
* Проверяет разрешение на переход между статусами
* @param userRole - роль пользователя
* @param currentStatus - текущий статус
* @param newStatus - новый статус
* @returns true если переход разрешен
*/
canTransitionStatus(userRole: UserRole, currentStatus: IssueStatus, newStatus: IssueStatus): boolean {
return this.issueAccessPolicyService.canTransitionStatus(userRole, currentStatus, newStatus);
/** UNION-проверка перехода статусов по набору ролей. */
canTransitionStatus(roles: Iterable<UserRole>, currentStatus: IssueStatus, newStatus: IssueStatus): boolean {
return this.issueAccessPolicyService.canTransitionStatus(roles, currentStatus, newStatus);
}
/**
@@ -173,53 +162,58 @@ export class IssuePermissionsService {
}
/**
* Определяет роль пользователя для проекта
* @param username - имя пользователя
* @param project - проект
* @param userRole - системная роль пользователя (chairman, member, etc.)
* @returns роль пользователя для проекта
* Определяет НАБОР project-ролей пользователя.
*
* UNION-семантика по ролям. Симметрично с
* {@link ProjectPermissionsService.getProjectUserRole}.
*/
async getProjectUserRole(
username: string | undefined,
project: any, // ProjectDomainEntity
userRole?: string
): Promise<ProjectUserRole> {
// Гости не имеют доступа
): Promise<Set<ProjectUserRole>> {
const roles = new Set<ProjectUserRole>();
if (!username) {
return ProjectUserRole.GUEST;
roles.add(ProjectUserRole.GUEST);
return roles;
}
// Члены совета имеют полные права
if (userRole === 'chairman' || userRole === 'member') {
return userRole === 'chairman' ? ProjectUserRole.CHAIRMAN : ProjectUserRole.BOARD_MEMBER;
if (userRole === 'chairman') {
roles.add(ProjectUserRole.CHAIRMAN);
} else if (userRole === 'member') {
roles.add(ProjectUserRole.BOARD_MEMBER);
}
// Проверяем, является ли пользователь мастером проекта
const isMaster = await this.isProjectMaster(username, project.coopname, project.project_hash);
if (isMaster) {
return ProjectUserRole.MASTER;
roles.add(ProjectUserRole.MASTER);
}
const segment = await this.segmentRepository.findOne({
username,
project_hash: project.project_hash,
coopname: project.coopname,
});
if (segment?.is_author) {
roles.add(ProjectUserRole.AUTHOR);
}
// Проверяем, является ли пользователь участником проекта
const contributor = await this.contributorRepository.findByUsernameAndCoopname(username, project.coopname);
const isContributor = contributor && contributor.appendixes.includes(project.project_hash);
if (isContributor) {
return ProjectUserRole.CONTRIBUTOR;
if (contributor && contributor.appendixes.includes(project.project_hash)) {
roles.add(ProjectUserRole.CONTRIBUTOR);
}
// По умолчанию - участник (если есть доступ к проекту)
return ProjectUserRole.CONTRIBUTOR;
if (roles.size === 0) {
roles.add(ProjectUserRole.CONTRIBUTOR);
}
return roles;
}
/**
* Проверяет, есть ли у пользователя разрешение на действие над проектом
* @param userRole - роль пользователя для проекта
* @param action - действие над проектом
* @returns true если действие разрешено
*/
hasProjectPermission(userRole: ProjectUserRole, action: ProjectAction): boolean {
return this.issueAccessPolicyService.hasProjectPermission(userRole, action);
/** UNION-проверка прав на действие над проектом по набору ролей. */
hasProjectPermission(roles: Iterable<ProjectUserRole>, action: ProjectAction): boolean {
return this.issueAccessPolicyService.hasProjectPermission(roles, action);
}
/**
@@ -243,25 +237,22 @@ export class IssuePermissionsService {
currentStatus: IssueStatus,
userRole?: string
): Promise<void> {
// Определяем роль пользователя
const role = await this.getUserRoleForIssue(username, coopname, projectHash, issueSubmaster, issueCreators, userRole);
// Определяем набор ролей пользователя
const roles = await this.getUserRoleForIssue(username, coopname, projectHash, issueSubmaster, issueCreators, userRole);
// Проверяем разрешение на изменение статуса
if (!this.hasPermission(role, IssueAction.CHANGE_STATUS)) {
if (!this.hasPermission(roles, IssueAction.CHANGE_STATUS)) {
throw new Error(`У вас нет прав на изменение статуса задачи`);
}
// Проверяем разрешение на переход между статусами
if (!this.canTransitionStatus(role, currentStatus, newStatus)) {
if (!this.canTransitionStatus(roles, currentStatus, newStatus)) {
throw new Error(`Переход из статуса "${currentStatus}" в "${newStatus}" запрещен для вашей роли`);
}
// Дополнительные проверки для специальных статусов
if (newStatus === IssueStatus.DONE && !this.hasPermission(role, IssueAction.SET_DONE)) {
if (newStatus === IssueStatus.DONE && !this.hasPermission(roles, IssueAction.SET_DONE)) {
throw new Error('Только мастер проекта может устанавливать статус "Выполнена"');
}
if (newStatus === IssueStatus.ON_REVIEW && !this.hasPermission(role, IssueAction.SET_ON_REVIEW)) {
if (newStatus === IssueStatus.ON_REVIEW && !this.hasPermission(roles, IssueAction.SET_ON_REVIEW)) {
throw new Error('Только ответственный исполнитель может устанавливать статус "На проверке"');
}
}
@@ -284,11 +275,10 @@ export class IssuePermissionsService {
issueCreators: string[] | undefined,
userRole?: string
): Promise<void> {
// Определяем роль пользователя
const role = await this.getUserRoleForIssue(username, coopname, projectHash, issueSubmaster, issueCreators, userRole);
// Определяем набор ролей пользователя
const roles = await this.getUserRoleForIssue(username, coopname, projectHash, issueSubmaster, issueCreators, userRole);
// Проверяем разрешение на установку оценки
if (!this.hasPermission(role, IssueAction.SET_ESTIMATE)) {
if (!this.hasPermission(roles, IssueAction.SET_ESTIMATE)) {
throw new Error('Только мастер проекта может устанавливать оценку на задачи');
}
}
@@ -311,22 +301,16 @@ export class IssuePermissionsService {
issueCreators: string[] | undefined,
userRole?: string
): Promise<void> {
// Определяем роль пользователя
const role = await this.getUserRoleForIssue(username, coopname, projectHash, issueSubmaster, issueCreators, userRole);
// Определяем набор ролей пользователя
const roles = await this.getUserRoleForIssue(username, coopname, projectHash, issueSubmaster, issueCreators, userRole);
// Проверяем разрешение на установку приоритета
if (!this.hasPermission(role, IssueAction.SET_PRIORITY)) {
if (!this.hasPermission(roles, IssueAction.SET_PRIORITY)) {
throw new Error('Только мастер проекта может устанавливать приоритет на задачи');
}
}
/**
* Получает список допустимых статусов для перехода из текущего статуса для данной роли
* @param userRole - роль пользователя
* @param currentStatus - текущий статус задачи
* @returns массив допустимых статусов для перехода
*/
getAllowedStatusTransitions(userRole: UserRole, currentStatus: IssueStatus): IssueStatus[] {
return this.issueAccessPolicyService.getAllowedStatusTransitions(userRole, currentStatus);
/** UNION-список допустимых переходов статуса по набору ролей. */
getAllowedStatusTransitions(roles: Iterable<UserRole>, currentStatus: IssueStatus): IssueStatus[] {
return this.issueAccessPolicyService.getAllowedStatusTransitions(roles, currentStatus);
}
}
@@ -138,8 +138,8 @@ export class PermissionsService {
const username = currentUser.username;
// Определяем роль пользователя для этой задачи
const userRole = await this.issuePermissionsService.getUserRoleForIssue(
// Определяем НАБОР ролей пользователя для этой задачи (UNION-семантика).
const roles = await this.issuePermissionsService.getUserRoleForIssue(
username,
issue.coopname,
issue.project_hash,
@@ -152,18 +152,18 @@ export class PermissionsService {
const has_clearance = await this.isProjectContributor(username, issue.coopname, issue.project_hash);
// Рассчитываем права на основе матрицы доступа
const can_edit_issue = this.issuePermissionsService.hasPermission(userRole, IssueAction.EDIT_ISSUE);
const can_change_status = this.issuePermissionsService.hasPermission(userRole, IssueAction.CHANGE_STATUS);
const can_assign_creator = this.issuePermissionsService.hasPermission(userRole, IssueAction.ASSIGN_CREATOR);
const can_set_done = this.issuePermissionsService.hasPermission(userRole, IssueAction.SET_DONE);
const can_set_on_review = this.issuePermissionsService.hasPermission(userRole, IssueAction.SET_ON_REVIEW);
const can_set_estimate = this.issuePermissionsService.hasPermission(userRole, IssueAction.SET_ESTIMATE);
const can_set_priority = this.issuePermissionsService.hasPermission(userRole, IssueAction.SET_PRIORITY);
const can_delete_issue = this.issuePermissionsService.hasPermission(userRole, IssueAction.DELETE_ISSUE);
const can_create_requirement = this.issuePermissionsService.hasPermission(userRole, IssueAction.CREATE_REQUIREMENT);
const can_edit_requirement = this.issuePermissionsService.hasPermission(userRole, IssueAction.EDIT_REQUIREMENT);
const can_delete_requirement = this.issuePermissionsService.hasPermission(userRole, IssueAction.DELETE_REQUIREMENT);
const can_complete_requirement = this.issuePermissionsService.hasPermission(userRole, IssueAction.COMPLETE_REQUIREMENT);
const can_edit_issue = this.issuePermissionsService.hasPermission(roles, IssueAction.EDIT_ISSUE);
const can_change_status = this.issuePermissionsService.hasPermission(roles, IssueAction.CHANGE_STATUS);
const can_assign_creator = this.issuePermissionsService.hasPermission(roles, IssueAction.ASSIGN_CREATOR);
const can_set_done = this.issuePermissionsService.hasPermission(roles, IssueAction.SET_DONE);
const can_set_on_review = this.issuePermissionsService.hasPermission(roles, IssueAction.SET_ON_REVIEW);
const can_set_estimate = this.issuePermissionsService.hasPermission(roles, IssueAction.SET_ESTIMATE);
const can_set_priority = this.issuePermissionsService.hasPermission(roles, IssueAction.SET_PRIORITY);
const can_delete_issue = this.issuePermissionsService.hasPermission(roles, IssueAction.DELETE_ISSUE);
const can_create_requirement = this.issuePermissionsService.hasPermission(roles, IssueAction.CREATE_REQUIREMENT);
const can_edit_requirement = this.issuePermissionsService.hasPermission(roles, IssueAction.EDIT_REQUIREMENT);
const can_delete_requirement = this.issuePermissionsService.hasPermission(roles, IssueAction.DELETE_REQUIREMENT);
const can_complete_requirement = this.issuePermissionsService.hasPermission(roles, IssueAction.COMPLETE_REQUIREMENT);
let can_move_issue = false;
const issueProject = await this.projectRepository.findByHash(issue.project_hash);
@@ -174,8 +174,8 @@ export class PermissionsService {
const projectOpenForMove = st === ProjectStatus.PENDING || st === ProjectStatus.ACTIVE;
can_move_issue = projectPerms.can_manage_issues && projectOpenForMove;
}
// Получаем допустимые переходы статусов для текущего статуса и роли
const allowed_status_transitions = this.issuePermissionsService.getAllowedStatusTransitions(userRole, issue.status);
// Получаем допустимые переходы статусов для текущего статуса (UNION по ролям).
const allowed_status_transitions = this.issuePermissionsService.getAllowedStatusTransitions(roles, issue.status);
return {
can_edit_issue,
@@ -229,8 +229,8 @@ export class PermissionsService {
const username = currentUser.username;
// Определяем роль пользователя для этого проекта
const userRole = await this.projectPermissionsService.getProjectUserRole(username, project, currentUser.role);
// Определяем НАБОР project-ролей пользователя (UNION-семантика).
const roles = await this.projectPermissionsService.getProjectUserRole(username, project, currentUser.role);
// Проверяем наличие clearance (доступа к проекту)
const has_clearance = project.coopname
@@ -243,30 +243,30 @@ export class PermissionsService {
: false;
// Рассчитываем права на основе матрицы доступа
const can_edit_project = this.projectPermissionsService.hasProjectPermission(userRole, ProjectAction.EDIT_PROJECT);
const can_manage_issues = this.projectPermissionsService.hasProjectPermission(userRole, ProjectAction.MANAGE_ISSUES);
const can_edit_project = this.projectPermissionsService.hasProjectPermission(roles, ProjectAction.EDIT_PROJECT);
const can_manage_issues = this.projectPermissionsService.hasProjectPermission(roles, ProjectAction.MANAGE_ISSUES);
const can_change_project_status = this.projectPermissionsService.hasProjectPermission(
userRole,
roles,
ProjectAction.CHANGE_PROJECT_STATUS
);
const can_delete_project = this.projectPermissionsService.hasProjectPermission(userRole, ProjectAction.DELETE_PROJECT);
const can_set_master = this.projectPermissionsService.hasProjectPermission(userRole, ProjectAction.SET_MASTER);
const can_manage_authors = this.projectPermissionsService.hasProjectPermission(userRole, ProjectAction.MANAGE_AUTHORS);
const can_set_plan = this.projectPermissionsService.hasProjectPermission(userRole, ProjectAction.SET_PLAN);
const can_delete_project = this.projectPermissionsService.hasProjectPermission(roles, ProjectAction.DELETE_PROJECT);
const can_set_master = this.projectPermissionsService.hasProjectPermission(roles, ProjectAction.SET_MASTER);
const can_manage_authors = this.projectPermissionsService.hasProjectPermission(roles, ProjectAction.MANAGE_AUTHORS);
const can_set_plan = this.projectPermissionsService.hasProjectPermission(roles, ProjectAction.SET_PLAN);
const can_create_requirement = this.projectPermissionsService.hasProjectPermission(
userRole,
roles,
ProjectAction.CREATE_REQUIREMENT
);
const can_edit_requirement = this.projectPermissionsService.hasProjectPermission(
userRole,
roles,
ProjectAction.EDIT_REQUIREMENT
);
const can_delete_requirement = this.projectPermissionsService.hasProjectPermission(
userRole,
roles,
ProjectAction.DELETE_REQUIREMENT
);
const can_complete_requirement = this.projectPermissionsService.hasProjectPermission(
userRole,
roles,
ProjectAction.COMPLETE_REQUIREMENT
);
@@ -30,52 +30,62 @@ export class ProjectPermissionsService {
) {}
/**
* Определяет роль пользователя для конкретного проекта
* Определяет НАБОР ролей пользователя для конкретного проекта.
*
* Один пользователь может одновременно быть, например, членом совета и
* соавтором — итоговые права на проект считаются как UNION разрешений по
* всем его ролям (см. {@link IssueAccessPolicyService.hasProjectPermission}).
*
* @param username - имя пользователя
* @param project - проект
* @param userRole - роль пользователя в системе (chairman, member, etc.)
* @returns роль пользователя для проекта
* @param userRole - системная роль пользователя (chairman / member / ...)
* @returns множество project-ролей пользователя; пустого множества не бывает —
* минимум {@link ProjectUserRole.GUEST}.
*/
async getProjectUserRole(username: string | undefined, project: any, userRole?: string): Promise<ProjectUserRole> {
// Гости не имеют доступа
async getProjectUserRole(
username: string | undefined,
project: any,
userRole?: string
): Promise<Set<ProjectUserRole>> {
const roles = new Set<ProjectUserRole>();
if (!username) {
return ProjectUserRole.GUEST;
roles.add(ProjectUserRole.GUEST);
return roles;
}
// Председатель совета имеет полные права
// Системные роли совета — добавляем независимо от project-specific ролей.
if (userRole === 'chairman') {
return ProjectUserRole.CHAIRMAN;
roles.add(ProjectUserRole.CHAIRMAN);
} else if (userRole === 'member') {
roles.add(ProjectUserRole.BOARD_MEMBER);
}
// Члены совета имеют расширенные права
if (userRole === 'member') {
return ProjectUserRole.BOARD_MEMBER;
}
// Проверяем, является ли пользователь мастером проекта
// Project-specific роли — собираем все, что верны.
const isMaster = await this.isProjectMaster(username, project.coopname, project.project_hash);
if (isMaster) {
return ProjectUserRole.MASTER;
roles.add(ProjectUserRole.MASTER);
}
// Проверяем, является ли пользователь автором проекта
const segment = await this.segmentRepository.findOne({
username,
project_hash: project.project_hash,
coopname: project.coopname,
});
if (segment?.is_author) {
return ProjectUserRole.AUTHOR;
roles.add(ProjectUserRole.AUTHOR);
}
// Проверяем, является ли пользователь участником проекта
const contributor = await this.contributorRepository.findByUsernameAndCoopname(username, project.coopname);
if (contributor && contributor.appendixes?.includes(project.project_hash)) {
return ProjectUserRole.CONTRIBUTOR;
roles.add(ProjectUserRole.CONTRIBUTOR);
}
// По умолчанию - гость
return ProjectUserRole.GUEST;
if (roles.size === 0) {
roles.add(ProjectUserRole.GUEST);
}
return roles;
}
/**
@@ -90,34 +100,18 @@ export class ProjectPermissionsService {
return project?.master === username;
}
/**
* Проверяет разрешение на действие
* @param userRole - роль пользователя
* @param action - действие
* @returns true если действие разрешено
*/
hasPermission(userRole: UserRole, action: IssueAction): boolean {
return this.issueAccessPolicyService.hasPermission(userRole, action);
/** UNION-проверка прав на действие над задачей по набору ролей. */
hasPermission(roles: Iterable<UserRole>, action: IssueAction): boolean {
return this.issueAccessPolicyService.hasPermission(roles, action);
}
/**
* Проверяет разрешение на действие над проектом
* @param userRole - роль пользователя для проекта
* @param action - действие над проектом
* @returns true если действие разрешено
*/
hasProjectPermission(userRole: ProjectUserRole, action: ProjectAction): boolean {
return this.issueAccessPolicyService.hasProjectPermission(userRole, action);
/** UNION-проверка прав на действие над проектом по набору ролей. */
hasProjectPermission(roles: Iterable<ProjectUserRole>, action: ProjectAction): boolean {
return this.issueAccessPolicyService.hasProjectPermission(roles, action);
}
/**
* Проверяет разрешение на переход между статусами
* @param userRole - роль пользователя
* @param currentStatus - текущий статус
* @param newStatus - новый статус
* @returns true если переход разрешен
*/
canTransitionStatus(userRole: UserRole, currentStatus: any, newStatus: any): boolean {
return this.issueAccessPolicyService.canTransitionStatus(userRole, currentStatus, newStatus);
/** UNION-проверка перехода статусов по набору ролей. */
canTransitionStatus(roles: Iterable<UserRole>, currentStatus: any, newStatus: any): boolean {
return this.issueAccessPolicyService.canTransitionStatus(roles, currentStatus, newStatus);
}
}
@@ -600,27 +600,29 @@ export const PROJECT_PERMISSION_MATRIX: Record<ProjectUserRole, Record<ProjectAc
/**
* Сервис политик доступа к задачам
* Содержит бизнес-правила доступа к задачам в доменном слое
*
* Все проверки прав работают по UNION-семантике: пользователь может одновременно
* нести несколько ролей (например, член совета + соавтор), и итоговое разрешение —
* это OR по всем его ролям. Так лечится регрессия, при которой board_member
* перебивал project-specific роль и обнулял её права.
*/
export class IssueAccessPolicyService {
/**
* Проверяет, есть ли у пользователя разрешение на действие
* @param userRole - роль пользователя
* @param action - действие
* @returns true если действие разрешено
* Проверяет, есть ли у пользователя разрешение на действие.
* UNION по ролям: достаточно одной роли с allow=true.
*/
hasPermission(userRole: UserRole, action: IssueAction): boolean {
return PERMISSION_MATRIX[userRole][action];
hasPermission(roles: Iterable<UserRole>, action: IssueAction): boolean {
for (const role of roles) {
if (PERMISSION_MATRIX[role]?.[action]) return true;
}
return false;
}
/**
* Проверяет разрешение на переход между статусами
* @param userRole - роль пользователя
* @param currentStatus - текущий статус
* @param newStatus - новый статус
* @returns true если переход разрешен
* Проверяет разрешение на переход между статусами.
* UNION по ролям: достаточно одной роли, для которой переход разрешён.
*/
canTransitionStatus(userRole: UserRole, currentStatus: IssueStatus, newStatus: IssueStatus): boolean {
canTransitionStatus(roles: Iterable<UserRole>, currentStatus: IssueStatus, newStatus: IssueStatus): boolean {
if (currentStatus === newStatus) {
return true; // Можно "перейти" в тот же статус
}
@@ -635,40 +637,42 @@ export class IssueAccessPolicyService {
return false;
}
return newStatusTransitions[userRole] || false;
for (const role of roles) {
if (newStatusTransitions[role]) return true;
}
return false;
}
/**
* Проверяет, есть ли у пользователя разрешение на действие над проектом
* @param userRole - роль пользователя для проекта
* @param action - действие над проектом
* @returns true если действие разрешено
* Проверяет, есть ли у пользователя разрешение на действие над проектом.
* UNION по ролям.
*/
hasProjectPermission(userRole: ProjectUserRole, action: ProjectAction): boolean {
return PROJECT_PERMISSION_MATRIX[userRole][action];
hasProjectPermission(roles: Iterable<ProjectUserRole>, action: ProjectAction): boolean {
for (const role of roles) {
if (PROJECT_PERMISSION_MATRIX[role]?.[action]) return true;
}
return false;
}
/**
* Получает список допустимых статусов для перехода из текущего статуса для данной роли
* @param userRole - роль пользователя
* @param currentStatus - текущий статус задачи
* @returns массив допустимых статусов для перехода (исключая текущий статус)
* Получает список допустимых статусов для перехода из текущего статуса.
* UNION по ролям: статус попадает в список, если хотя бы одна роль его разрешает.
*/
getAllowedStatusTransitions(userRole: UserRole, currentStatus: IssueStatus): IssueStatus[] {
getAllowedStatusTransitions(roles: Iterable<UserRole>, currentStatus: IssueStatus): IssueStatus[] {
const transitions = STATUS_TRANSITION_MATRIX[currentStatus];
if (!transitions) {
return [];
}
const rolesArr = [...roles];
const allowedStatuses: IssueStatus[] = [];
for (const [newStatus, rolePermissions] of Object.entries(transitions)) {
// Исключаем текущий статус из списка доступных переходов
if (newStatus === currentStatus) {
continue;
}
const isAllowed = rolePermissions[userRole] || false;
const isAllowed = rolesArr.some((r) => rolePermissions[r]);
if (isAllowed) {
allowedStatuses.push(newStatus as IssueStatus);
}
@@ -0,0 +1,109 @@
import {
IssueAccessPolicyService,
IssueAction,
ProjectAction,
ProjectUserRole,
UserRole,
} from '../../../src/extensions/capital/domain/services/access-policy.service';
import { IssueStatus } from '../../../src/extensions/capital/domain/enums/issue-status.enum';
describe('IssueAccessPolicyService — UNION-семантика по нескольким ролям', () => {
const policy = new IssueAccessPolicyService();
describe('hasProjectPermission', () => {
it('GUEST: запрещены все project-actions', () => {
const roles = new Set([ProjectUserRole.GUEST]);
expect(policy.hasProjectPermission(roles, ProjectAction.EDIT_REQUIREMENT)).toBe(false);
expect(policy.hasProjectPermission(roles, ProjectAction.EDIT_PROJECT)).toBe(false);
});
it('AUTHOR (соавтор): EDIT_REQUIREMENT=true, CHANGE_PROJECT_STATUS=false', () => {
const roles = new Set([ProjectUserRole.AUTHOR]);
expect(policy.hasProjectPermission(roles, ProjectAction.EDIT_REQUIREMENT)).toBe(true);
expect(policy.hasProjectPermission(roles, ProjectAction.CHANGE_PROJECT_STATUS)).toBe(false);
});
it('BOARD_MEMBER (чистый): CHANGE_PROJECT_STATUS=true, EDIT_REQUIREMENT=false (как раньше)', () => {
const roles = new Set([ProjectUserRole.BOARD_MEMBER]);
expect(policy.hasProjectPermission(roles, ProjectAction.CHANGE_PROJECT_STATUS)).toBe(true);
expect(policy.hasProjectPermission(roles, ProjectAction.EDIT_REQUIREMENT)).toBe(false);
});
// Главный кейс — починка регрессии «соавтор-член-совета не может править артефакты»
it('BOARD_MEMBER + AUTHOR: получает права обеих ролей', () => {
const roles = new Set([ProjectUserRole.BOARD_MEMBER, ProjectUserRole.AUTHOR]);
expect(policy.hasProjectPermission(roles, ProjectAction.EDIT_REQUIREMENT)).toBe(true);
expect(policy.hasProjectPermission(roles, ProjectAction.CREATE_REQUIREMENT)).toBe(true);
expect(policy.hasProjectPermission(roles, ProjectAction.DELETE_REQUIREMENT)).toBe(true);
expect(policy.hasProjectPermission(roles, ProjectAction.CHANGE_PROJECT_STATUS)).toBe(true);
expect(policy.hasProjectPermission(roles, ProjectAction.SET_MASTER)).toBe(true);
expect(policy.hasProjectPermission(roles, ProjectAction.MANAGE_ISSUES)).toBe(true);
});
it('CHAIRMAN + MASTER: union (председатель остаётся всемогущим, мастер ничего не отнимает)', () => {
const roles = new Set([ProjectUserRole.CHAIRMAN, ProjectUserRole.MASTER]);
expect(policy.hasProjectPermission(roles, ProjectAction.DELETE_PROJECT)).toBe(true);
expect(policy.hasProjectPermission(roles, ProjectAction.SET_MASTER)).toBe(true);
expect(policy.hasProjectPermission(roles, ProjectAction.EDIT_REQUIREMENT)).toBe(true);
});
it('пустое множество: всё запрещено', () => {
expect(policy.hasProjectPermission(new Set(), ProjectAction.EDIT_REQUIREMENT)).toBe(false);
});
});
describe('hasPermission (issue-actions)', () => {
it('CONTRIBUTOR (чистый): EDIT_REQUIREMENT=false', () => {
expect(policy.hasPermission(new Set([UserRole.CONTRIBUTOR]), IssueAction.EDIT_REQUIREMENT)).toBe(false);
});
it('BOARD_MEMBER + AUTHOR на задаче: EDIT_REQUIREMENT=true (через AUTHOR)', () => {
const roles = new Set([UserRole.BOARD_MEMBER, UserRole.AUTHOR]);
expect(policy.hasPermission(roles, IssueAction.EDIT_REQUIREMENT)).toBe(true);
expect(policy.hasPermission(roles, IssueAction.CHANGE_STATUS)).toBe(true);
});
it('SUBMASTER + AUTHOR: SET_ON_REVIEW (через SUBMASTER) + EDIT_REQUIREMENT (через AUTHOR)', () => {
const roles = new Set([UserRole.SUBMASTER, UserRole.AUTHOR]);
expect(policy.hasPermission(roles, IssueAction.SET_ON_REVIEW)).toBe(true);
expect(policy.hasPermission(roles, IssueAction.EDIT_REQUIREMENT)).toBe(true);
});
});
describe('canTransitionStatus / getAllowedStatusTransitions', () => {
it('BACKLOG → DONE доступен MASTER, недоступен AUTHOR; union → доступен', () => {
expect(
policy.canTransitionStatus(new Set([UserRole.AUTHOR]), IssueStatus.BACKLOG, IssueStatus.DONE)
).toBe(false);
expect(
policy.canTransitionStatus(new Set([UserRole.MASTER]), IssueStatus.BACKLOG, IssueStatus.DONE)
).toBe(true);
expect(
policy.canTransitionStatus(
new Set([UserRole.AUTHOR, UserRole.MASTER]),
IssueStatus.BACKLOG,
IssueStatus.DONE
)
).toBe(true);
});
it('getAllowedStatusTransitions: union собирает разрешения по всем ролям', () => {
const authorOnly = policy.getAllowedStatusTransitions(new Set([UserRole.AUTHOR]), IssueStatus.BACKLOG);
const masterOnly = policy.getAllowedStatusTransitions(new Set([UserRole.MASTER]), IssueStatus.BACKLOG);
const union = policy.getAllowedStatusTransitions(
new Set([UserRole.AUTHOR, UserRole.MASTER]),
IssueStatus.BACKLOG
);
const expectedUnion = Array.from(new Set([...authorOnly, ...masterOnly])).sort();
expect([...union].sort()).toEqual(expectedUnion);
// Sanity: master может больше автора → union строго ⊇ author-only
for (const s of authorOnly) expect(union).toContain(s);
});
it('переход в тот же статус — true, даже для GUEST', () => {
expect(
policy.canTransitionStatus(new Set([UserRole.GUEST]), IssueStatus.BACKLOG, IssueStatus.BACKLOG)
).toBe(true);
});
});
});
+23
View File
@@ -1,5 +1,28 @@
{
"name": "monocoop",
"packageManager": "pnpm@10.33.0",
"pnpm": {
"onlyBuiltDependencies": [
"@apollo/protobufjs",
"@eosrio/node-abieos",
"@nestjs/core",
"@parcel/watcher",
"@scarf/scarf",
"core-js",
"cpu-features",
"electron",
"esbuild",
"libxmljs2",
"msgpackr-extract",
"nx",
"protobufjs",
"puppeteer",
"simple-git-hooks",
"ssh2",
"unrs-resolver",
"vue-demi"
]
},
"dependencies": {
"@coopenomics/boot": "workspace:*",
"@coopenomics/cleos": "workspace:*",