Merge remote-tracking branch 'origin/dev' into marketplace2
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
+81
-97
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+29
-29
@@ -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
|
||||
);
|
||||
|
||||
|
||||
+40
-46
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+31
-27
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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:*",
|
||||
|
||||
Reference in New Issue
Block a user