diff --git a/apps/server/src/common/auth/permission.service.spec.ts b/apps/server/src/common/auth/permission.service.spec.ts index 95d070c..38dcd4e 100644 --- a/apps/server/src/common/auth/permission.service.spec.ts +++ b/apps/server/src/common/auth/permission.service.spec.ts @@ -5,16 +5,19 @@ import type { CurrentUser } from './auth-context.service'; describe('PermissionService', () => { const versionFindUnique = jest.fn(); const projectMemberFindUnique = jest.fn(); + const appDataFindUnique = jest.fn(); const prisma = { version: { findUnique: versionFindUnique }, projectMember: { findUnique: projectMemberFindUnique }, + appData: { findUnique: appDataFindUnique }, } as any; const service = new PermissionService(prisma); beforeEach(() => { jest.clearAllMocks(); + appDataFindUnique.mockResolvedValue(null); }); it('allows the built-in super admin wildcard for any permission without a scope lookup', async () => { @@ -26,13 +29,13 @@ describe('PermissionService', () => { expect(projectMemberFindUnique).not.toHaveBeenCalled(); }); - it('requires scoped users to belong to the project resolved from a version', async () => { + it('denies version work mutations for project members outside the version members list', async () => { versionFindUnique.mockResolvedValue({ id: 'version-1', projectId: 'project-1', members: [] }); projectMemberFindUnique.mockResolvedValue({ projectId: 'project-1', userId: 'dev-1', role: 'member' }); await expect(service.can(currentUser({ id: 'dev-1', roleId: 'role-dev' }), 'version.devtask:manage', { versionId: 'version-1', - })).resolves.toBe(true); + })).resolves.toBe(false); expect(versionFindUnique).toHaveBeenCalledWith({ where: { id: 'version-1' }, @@ -43,6 +46,39 @@ describe('PermissionService', () => { }); }); + it('allows version work mutations when the user has the role permission and is a version member', async () => { + versionFindUnique.mockResolvedValue({ + id: 'version-1', + projectId: 'project-1', + members: [{ id: 'dev-1', name: 'Dev One' }], + }); + projectMemberFindUnique.mockResolvedValue({ projectId: 'project-1', userId: 'dev-1', role: 'member' }); + + await expect(service.can(currentUser({ id: 'dev-1', name: 'Dev One', roleId: 'role-dev' }), 'version.devtask:manage', { + versionId: 'version-1', + })).resolves.toBe(true); + }); + + it('uses configured role permissions before built-in role defaults', async () => { + appDataFindUnique.mockResolvedValue({ + value: { + roles: [ + { id: 'role-dev', permissions: ['version.devtask:view'] }, + ], + }, + }); + versionFindUnique.mockResolvedValue({ + id: 'version-1', + projectId: 'project-1', + members: [{ id: 'dev-1', name: 'Dev One' }], + }); + projectMemberFindUnique.mockResolvedValue({ projectId: 'project-1', userId: 'dev-1', role: 'member' }); + + await expect(service.can(currentUser({ id: 'dev-1', name: 'Dev One', roleId: 'role-dev' }), 'version.devtask:manage', { + versionId: 'version-1', + })).resolves.toBe(false); + }); + it('allows a version member when no project membership exists', async () => { versionFindUnique.mockResolvedValue({ id: 'version-1', diff --git a/apps/server/src/common/auth/permission.service.ts b/apps/server/src/common/auth/permission.service.ts index ee0376a..90b7050 100644 --- a/apps/server/src/common/auth/permission.service.ts +++ b/apps/server/src/common/auth/permission.service.ts @@ -101,13 +101,26 @@ const PROJECT_ROLE_PERMISSIONS: Record = { ], }; +const VERSION_WORK_MUTATION_PERMISSIONS = new Set([ + 'version.req:manage', + 'version.research:manage', + 'version.product_plan:manage', + 'version.ui_plan:manage', + 'version.devtask:manage', + 'version.testcase:manage', + 'version.bug:create', + 'version.bug:edit', + 'version.bug:delete', +]); + @Injectable() export class PermissionService { constructor(private readonly prisma: PrismaService) {} async can(user: CurrentUser | null | undefined, permission: string, scope: ResourceScope = {}): Promise { if (!user) return false; - const systemPermissions = SYSTEM_ROLE_PERMISSIONS[user.roleId] ?? []; + if ((SYSTEM_ROLE_PERMISSIONS[user.roleId] ?? []).includes('*')) return true; + const systemPermissions = await this.resolveRolePermissions(user.roleId); if (systemPermissions.includes('*')) return true; const hasSystemPermission = systemPermissions.includes(permission); @@ -119,6 +132,10 @@ export class PermissionService { where: { projectId_userId: { projectId: resolvedScope.projectId, userId: user.id } }, }) : null; + if (requiresVersionWorkMembership(permission, resolvedScope)) { + return hasSystemPermission && isVersionMember(user, resolvedScope.versionMembers); + } + if (projectMember && roleAllows(PROJECT_ROLE_PERMISSIONS[projectMember.role] ?? [], permission)) { return true; } @@ -146,6 +163,26 @@ export class PermissionService { versionMembers: version?.members, }; } + + private async resolveRolePermissions(roleId: string): Promise { + const configuredPermissions = await this.findConfiguredRolePermissions(roleId); + return configuredPermissions ?? SYSTEM_ROLE_PERMISSIONS[roleId] ?? []; + } + + private async findConfiguredRolePermissions(roleId: string): Promise { + const findUnique = (this.prisma as unknown as { + appData?: { findUnique?: (args: unknown) => Promise<{ value?: unknown } | null> }; + }).appData?.findUnique; + if (!findUnique) return null; + + const row = await findUnique({ + where: { key: 'members' }, + select: { value: true }, + }).catch(() => null); + + if (!row) return null; + return readRolePermissions(row.value, roleId); + } } function std4(module: string): string[] { @@ -156,6 +193,25 @@ function roleAllows(permissions: string[], permission: string): boolean { return permissions.includes('*') || permissions.includes(permission); } +function requiresVersionWorkMembership(permission: string, scope: ResourceScope): boolean { + return Boolean(scope.versionId && VERSION_WORK_MUTATION_PERMISSIONS.has(permission)); +} + +function readRolePermissions(value: unknown, roleId: string): string[] | null { + if (!value || typeof value !== 'object') return null; + const roles = (value as { roles?: unknown }).roles; + if (!Array.isArray(roles)) return null; + const role = roles.find((item) => ( + item && + typeof item === 'object' && + (item as { id?: unknown }).id === roleId + )); + if (!role || typeof role !== 'object') return null; + const permissions = (role as { permissions?: unknown }).permissions; + if (!Array.isArray(permissions)) return null; + return permissions.filter((item): item is string => typeof item === 'string'); +} + function isVersionMember(user: CurrentUser, members: unknown): boolean { if (!Array.isArray(members)) return false; return members.some((member) => { diff --git a/apps/web/app/versions/[id]/page.tsx b/apps/web/app/versions/[id]/page.tsx index 7b718ac..009ce43 100644 --- a/apps/web/app/versions/[id]/page.tsx +++ b/apps/web/app/versions/[id]/page.tsx @@ -31,7 +31,7 @@ import { formatDateTime, formatLocalDate } from '@/lib/format'; import { getVersionLinkedRequirementCandidates } from '@/lib/requirement-selector'; import { getRequirementVersionSelectionPatch, getRequirementVersionUnlinkPatch } from '@/lib/requirement-version-link'; import { calcBugSeverityRanking, calcPersonalEffortRanking, calcStageEffortMetrics, calcVersionOverviewEffortTotals } from '@/lib/version-overview'; -import { addVersionMembers, DEFAULT_VERSION_MEMBER_ROLE, filterVersionMemberCandidates } from '@/lib/version-members'; +import { addVersionMembers, canWriteVersionWork, DEFAULT_VERSION_MEMBER_ROLE, filterVersionMemberCandidates } from '@/lib/version-members'; import { addRecommendedVersionMembers, getDefaultRecommendedMemberNames, recommendVersionMembers, type MemberRecommendationGroup, type RecommendableRole } from '@/lib/member-recommendation'; import { getRequirementCoverageSummary } from '@/lib/version-plan'; import { buildVersionDataScope, selectVersionDataScope, type VersionDataScope } from '@/lib/version-data-scope'; @@ -85,6 +85,12 @@ const TABS = [ { key: 'bugs', label: 'BUG', permission: 'version.bug:view' }, ]; +const PLAN_MANAGE_PERMISSION = { + research: 'version.research:manage', + product: 'version.product_plan:manage', + ui: 'version.ui_plan:manage', +} as const; + export default function VersionDetailPage() { const params = useParams(); const router = useRouter(); @@ -302,6 +308,20 @@ export default function VersionDetailPage() { ); } + const versionMembers = version.members ?? []; + const canWriteVersionPermission = (permission: string) => canWriteVersionWork({ + role: currentRole, + permission, + currentUserName, + versionMembers, + readOnly: versionReadonly, + }); + const canManageRequirements = canWriteVersionPermission('version.req:manage'); + const canManageDevTasks = canWriteVersionPermission('version.devtask:manage'); + const canManageTestCases = canWriteVersionPermission('version.testcase:manage'); + const canManageBugs = ['version.bug:create', 'version.bug:edit', 'version.bug:delete'] + .some((permission) => canWriteVersionPermission(permission)); + const scopedVersionData = versionScope!; const appDataScopedVersionData = appDataVersionScope ?? scopedVersionData; const displayRequirements = requirementsLoaded ? requirements : scopedVersionData.requirements; @@ -987,9 +1007,9 @@ export default function VersionDetailPage() { devTasks={displayDevTasks} versionMembers={version.members ?? []} currentUserName={user?.name ?? ''} - readOnly={versionReadonly || !requirementWriteReady} + readOnly={versionReadonly || !requirementWriteReady || !canManageRequirements} onLink={(ids, addedBy) => { - if (versionReadonly || !requirementWriteReady) return; + if (versionReadonly || !requirementWriteReady || !canManageRequirements) return; ids.forEach((id) => { const requirement = displayRequirements.find((item) => item.id === id); if (!requirement) return; @@ -1000,7 +1020,7 @@ export default function VersionDetailPage() { }); }} onUnlink={(id) => { - if (versionReadonly || !requirementWriteReady) return; + if (versionReadonly || !requirementWriteReady || !canManageRequirements) return; const requirement = displayRequirements.find((item) => item.id === id); if (!requirement) return; updateRequirement(id, { @@ -1009,7 +1029,7 @@ export default function VersionDetailPage() { }); }} onCreateChange={(data) => { - if (versionReadonly || !requirementWriteReady) return; + if (versionReadonly || !requirementWriteReady || !canManageRequirements) return; createRequirement({ ...data, productId: version.productId, @@ -1043,9 +1063,9 @@ export default function VersionDetailPage() { versionMembers={version.members ?? []} linkedRequirements={versionLinkedReqs} allRequirements={displayRequirements} - readOnly={versionReadonly || !planWriteReady} + readOnly={versionReadonly || !planWriteReady || !canWriteVersionPermission(PLAN_MANAGE_PERMISSION[pt])} onCreate={(data) => { - if (versionReadonly || !planWriteReady) return; + if (versionReadonly || !planWriteReady || !canWriteVersionPermission(PLAN_MANAGE_PERMISSION[pt])) return; createPlan(data); if ((pt === 'product') && data.linkedRequirementIds?.length) { data.linkedRequirementIds.forEach((rid) => { @@ -1061,7 +1081,7 @@ export default function VersionDetailPage() { } }} onUpdate={(id, data) => { - if (versionReadonly || !planWriteReady) return; + if (versionReadonly || !planWriteReady || !canWriteVersionPermission(PLAN_MANAGE_PERMISSION[pt])) return; updatePlan(id, data); if ((pt === 'product') && data.linkedRequirementIds && data.owner) { data.linkedRequirementIds.forEach((rid) => { @@ -1071,11 +1091,11 @@ export default function VersionDetailPage() { } }} onComplete={(id, result) => { - if (versionReadonly || !planWriteReady) return; + if (versionReadonly || !planWriteReady || !canWriteVersionPermission(PLAN_MANAGE_PERMISSION[pt])) return; return completePlan(id, result); }} onDelete={(id) => { - if (versionReadonly || !planWriteReady) return; + if (versionReadonly || !planWriteReady || !canWriteVersionPermission(PLAN_MANAGE_PERMISSION[pt])) return; deletePlan(id); }} /> @@ -1086,7 +1106,7 @@ export default function VersionDetailPage() { versionId={version.id} requirementIds={displayRequirementIds} versionDeadline={version.expectedReleaseDate ?? undefined} - readOnly={versionReadonly || !devTaskWriteReady} + readOnly={versionReadonly || !devTaskWriteReady || !canManageDevTasks} versionTasks={displayDevTasks} versionRequirements={displayRequirements} /> @@ -1094,7 +1114,7 @@ export default function VersionDetailPage() { diff --git a/apps/web/lib/version-members.test.ts b/apps/web/lib/version-members.test.ts index 23077e1..c999f16 100644 --- a/apps/web/lib/version-members.test.ts +++ b/apps/web/lib/version-members.test.ts @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { addVersionMembers, filterVersionMemberCandidates, inferVersionMemberRole } from './version-members'; +import { addVersionMembers, canWriteVersionWork, filterVersionMemberCandidates, inferVersionMemberRole } from './version-members'; test('filterVersionMemberCandidates searches people and excludes already added members', () => { const result = filterVersionMemberCandidates( @@ -63,3 +63,45 @@ test('addVersionMembers infers each selected person role from department', () => { name: 'QA', role: 'testing' }, ]); }); + +test('canWriteVersionWork requires role permission and version membership', () => { + const devRole = { id: 'role-dev', name: '开发', permissions: ['version.devtask:manage'] } as any; + const testRole = { id: 'role-test', name: '测试', permissions: ['version.testcase:manage'] } as any; + const adminRole = { id: 'role-admin', name: '超管', permissions: ['*'] } as any; + const versionMembers = [{ name: 'Dev One', role: 'frontend' as const }]; + + assert.equal(canWriteVersionWork({ + role: devRole, + permission: 'version.devtask:manage', + currentUserName: 'Dev One', + versionMembers, + }), true); + + assert.equal(canWriteVersionWork({ + role: devRole, + permission: 'version.devtask:manage', + currentUserName: 'Outside Dev', + versionMembers, + }), false); + + assert.equal(canWriteVersionWork({ + role: testRole, + permission: 'version.devtask:manage', + currentUserName: 'Dev One', + versionMembers, + }), false); + + assert.equal(canWriteVersionWork({ + role: devRole, + permission: 'version.devtask:manage', + currentUserName: 'Dev One', + versionMembers: [], + }), false); + + assert.equal(canWriteVersionWork({ + role: adminRole, + permission: 'version.devtask:manage', + currentUserName: 'Admin', + versionMembers: [], + }), true); +}); diff --git a/apps/web/lib/version-members.ts b/apps/web/lib/version-members.ts index de174c4..be69406 100644 --- a/apps/web/lib/version-members.ts +++ b/apps/web/lib/version-members.ts @@ -56,3 +56,23 @@ export function addVersionMembers( return [...currentMembers, ...additions]; } + +export function isVersionMemberName(currentUserName: string | undefined, versionMembers: VersionMember[]): boolean { + const name = currentUserName?.trim(); + if (!name) return false; + return versionMembers.some((member) => member.name === name); +} + +export function canWriteVersionWork(input: { + role?: { permissions?: string[] } | null; + permission: string; + currentUserName?: string; + versionMembers: VersionMember[]; + readOnly?: boolean; +}): boolean { + if (input.readOnly) return false; + const permissions = input.role?.permissions ?? []; + if (permissions.includes('*')) return true; + if (!permissions.includes(input.permission)) return false; + return isVersionMemberName(input.currentUserName, input.versionMembers); +}