feat: 优化项目权限与发布鉴权

This commit is contained in:
湛兮
2026-06-12 10:53:30 +08:00
parent c8716b3a1a
commit c2afe6e693
7 changed files with 118 additions and 38 deletions
+7
View File
@@ -215,6 +215,13 @@ export class AuthService implements OnModuleInit {
return this.userRepository.listAllowedProjectKeys(user); return this.userRepository.listAllowedProjectKeys(user);
} }
async getProjectPermissionLevel(
user: AuthenticatedUser,
projectKey: string,
): Promise<ProjectPermissionLevel> {
return this.userRepository.getProjectPermissionLevel(user, projectKey);
}
assertSuperAdmin(user: AuthenticatedUser, message = '无权限访问该功能'): void { assertSuperAdmin(user: AuthenticatedUser, message = '无权限访问该功能'): void {
if (user.role !== 'super_admin') { if (user.role !== 'super_admin') {
throw new AppError('FORBIDDEN', message, 403); throw new AppError('FORBIDDEN', message, 403);
+27 -23
View File
@@ -278,20 +278,32 @@ export class UserRepository {
userId: string, userId: string,
permissions: ProjectPermissionInput[], permissions: ProjectPermissionInput[],
): Promise<UserRecord> { ): Promise<UserRecord> {
const normalizedPermissions = permissions.filter( const normalizedPermissions: Array<{
(permission) => permission.level !== 'none', projectKey: string;
level: Exclude<ProjectPermissionLevel, 'none'>;
}> = permissions.map((permission) => ({
projectKey: permission.projectKey,
level: permission.level === 'build' ? 'build' : 'read',
}));
const uniquePermissions = Array.from(
new Map(
normalizedPermissions.map((permission) => [
permission.projectKey,
permission,
]),
).values(),
); );
if (!this.useDatabase()) { if (!this.useDatabase()) {
const user = this.requireMemoryUser(userId); const user = this.requireMemoryUser(userId);
user.projectPermissions = normalizedPermissions.map((permission) => { user.projectPermissions = uniquePermissions.map((permission) => {
const project = this.findSeedProject(permission.projectKey); const project = this.findSeedProject(permission.projectKey);
return { return {
projectId: project.id, projectId: project.id,
projectKey: project.key, projectKey: project.key,
projectName: project.name, projectName: project.name,
level: permission.level as Exclude<ProjectPermissionLevel, 'none'>, level: permission.level,
}; };
}); });
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
@@ -301,11 +313,11 @@ export class UserRepository {
const projects = await this.prisma.project.findMany({ const projects = await this.prisma.project.findMany({
where: { where: {
key: { key: {
in: normalizedPermissions.map((permission) => permission.projectKey), in: uniquePermissions.map((permission) => permission.projectKey),
}, },
}, },
}); });
const missingProjectKeys = normalizedPermissions const missingProjectKeys = uniquePermissions
.map((permission) => permission.projectKey) .map((permission) => permission.projectKey)
.filter((projectKey) => .filter((projectKey) =>
projects.every((project) => project.key !== projectKey), projects.every((project) => project.key !== projectKey),
@@ -325,9 +337,9 @@ export class UserRepository {
where: { userId }, where: { userId },
}); });
if (normalizedPermissions.length > 0) { if (uniquePermissions.length > 0) {
await tx.projectMemberPermission.createMany({ await tx.projectMemberPermission.createMany({
data: normalizedPermissions.map((permission) => { data: uniquePermissions.map((permission) => {
const project = projects.find( const project = projects.find(
(item) => item.key === permission.projectKey, (item) => item.key === permission.projectKey,
); );
@@ -368,7 +380,7 @@ export class UserRepository {
return ( return (
currentUser?.projectPermissions.find( currentUser?.projectPermissions.find(
(permission) => permission.projectKey === projectKey, (permission) => permission.projectKey === projectKey,
)?.level ?? 'none' )?.level ?? 'read'
); );
} }
@@ -381,7 +393,7 @@ export class UserRepository {
}, },
}); });
return permission ? this.toPermissionLevel(permission.level) : 'none'; return permission ? this.toPermissionLevel(permission.level) : 'read';
} }
async listAllowedProjectKeys(user: AuthenticatedUser): Promise<string[]> { async listAllowedProjectKeys(user: AuthenticatedUser): Promise<string[]> {
@@ -400,24 +412,16 @@ export class UserRepository {
} }
if (!this.useDatabase()) { if (!this.useDatabase()) {
const currentUser = this.memoryUsers.find((item) => item.id === user.id); return seedProjects.map((project) => project.key);
return (
currentUser?.projectPermissions.map(
(permission) => permission.projectKey,
) ?? []
);
} }
const permissions = await this.prisma.projectMemberPermission.findMany({ const projects = await this.prisma.project.findMany({
where: { select: {
userId: user.id, key: true,
},
include: {
project: true,
}, },
}); });
return permissions.map((permission) => permission.project.key); return projects.map((project) => project.key);
} }
private requireMemoryUser(userId: string): UserRecord { private requireMemoryUser(userId: string): UserRecord {
+5 -5
View File
@@ -1,7 +1,7 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { z } from 'zod'; import { z } from 'zod';
const permissionLevelSchema = z.enum(['none', 'read', 'build']); const permissionLevelSchema = z.enum(['read', 'build']);
export const projectPermissionSchema = z.object({ export const projectPermissionSchema = z.object({
projectKey: z.string().trim().min(1).max(80), projectKey: z.string().trim().min(1).max(80),
@@ -21,10 +21,10 @@ export class ProjectPermissionDto {
projectKey!: string; projectKey!: string;
@ApiProperty({ @ApiProperty({
enum: ['none', 'read', 'build'], enum: ['read', 'build'],
description: '项目权限:none 无权限,read 只读,build 构建', description: '项目权限:read 只读,build 构建',
}) })
level!: 'none' | 'read' | 'build'; level!: 'read' | 'build';
} }
export class CreateMemberDto { export class CreateMemberDto {
@@ -37,7 +37,7 @@ export class CreateMemberDto {
@ApiProperty({ @ApiProperty({
type: [ProjectPermissionDto], type: [ProjectPermissionDto],
required: false, required: false,
description: '成员项目权限,未传项目默认为权限', description: '成员项目权限,未传或漏传的项目默认为只读权限',
}) })
permissions?: ProjectPermissionDto[]; permissions?: ProjectPermissionDto[];
} }
+31 -6
View File
@@ -69,9 +69,12 @@ export class MembersService {
mustChangePassword: true, mustChangePassword: true,
createdById: operator.id, createdById: operator.id,
}); });
const permissions = await this.completeProjectPermissions(
input.permissions ?? [],
);
const updatedUser = await this.userRepository.replaceProjectPermissions( const updatedUser = await this.userRepository.replaceProjectPermissions(
user.id, user.id,
input.permissions ?? [], permissions,
); );
await this.auditService.record({ await this.auditService.record({
@@ -82,7 +85,7 @@ export class MembersService {
actorName: operator.account, actorName: operator.account,
after: { after: {
account: updatedUser.account, account: updatedUser.account,
permissions: this.safePermissionDigest(input.permissions ?? []), permissions: this.safePermissionDigest(permissions),
mustChangePassword: true, mustChangePassword: true,
}, },
}); });
@@ -96,9 +99,10 @@ export class MembersService {
operator: AuthenticatedUser, operator: AuthenticatedUser,
): Promise<MemberSummary> { ): Promise<MemberSummary> {
const user = await this.requireMutableMember(memberId); const user = await this.requireMutableMember(memberId);
const permissions = await this.completeProjectPermissions(input.permissions);
const updatedUser = await this.userRepository.replaceProjectPermissions( const updatedUser = await this.userRepository.replaceProjectPermissions(
user.id, user.id,
input.permissions, permissions,
); );
await this.auditService.record({ await this.auditService.record({
@@ -111,7 +115,7 @@ export class MembersService {
permissions: this.safePermissionDigest(user.projectPermissions), permissions: this.safePermissionDigest(user.projectPermissions),
}, },
after: { after: {
permissions: this.safePermissionDigest(input.permissions), permissions: this.safePermissionDigest(permissions),
}, },
}); });
@@ -253,7 +257,7 @@ export class MembersService {
const level = const level =
user.role === 'super_admin' user.role === 'super_admin'
? 'build' ? 'build'
: (permissionByProject.get(project.key) ?? 'none'); : (permissionByProject.get(project.key) ?? 'read');
return { return {
projectId: project.id, projectId: project.id,
@@ -274,7 +278,28 @@ export class MembersService {
return '只读权限'; return '只读权限';
} }
return '权限'; return '只读权限';
}
private async completeProjectPermissions(
permissions: ProjectPermissionInput[],
): Promise<ProjectPermissionInput[]> {
const projects = await this.projectRepository.findMany();
const defaultLevel: Exclude<ProjectPermissionLevel, 'none'> = 'read';
const levelByProject = new Map<
string,
Exclude<ProjectPermissionLevel, 'none'>
>(
permissions.map((permission) => [
permission.projectKey,
permission.level === 'build' ? 'build' : defaultLevel,
]),
);
return projects.map((project) => ({
projectKey: project.key,
level: levelByProject.get(project.key) ?? defaultLevel,
}));
} }
private safePermissionDigest( private safePermissionDigest(
+4
View File
@@ -1,6 +1,8 @@
/** /**
* 项目模块对前端暴露的安全模型;第三方 token 只在后端使用,不进入响应。 * 项目模块对前端暴露的安全模型;第三方 token 只在后端使用,不进入响应。
*/ */
import { ProjectPermissionLevel } from '../auth/auth.types';
export type ProjectEnvironmentSummary = { export type ProjectEnvironmentSummary = {
name: 'test' | 'production'; name: 'test' | 'production';
displayName: string; displayName: string;
@@ -16,6 +18,8 @@ export type ProjectSummary = {
repositoryUrl: string; repositoryUrl: string;
defaultBranch: string; defaultBranch: string;
status: 'active' | 'archived'; status: 'active' | 'archived';
permissionLevel?: Exclude<ProjectPermissionLevel, 'none'>;
permissionLevelName?: string;
environments: ProjectEnvironmentSummary[]; environments: ProjectEnvironmentSummary[];
}; };
+4 -1
View File
@@ -71,10 +71,13 @@ describe('ProjectsService', () => {
>; >;
const auth = { const auth = {
listAllowedProjectKeys: jest.fn(() => Promise.resolve([project.key])), listAllowedProjectKeys: jest.fn(() => Promise.resolve([project.key])),
getProjectPermissionLevel: jest.fn(() => Promise.resolve('build' as const)),
requireProjectPermission: jest.fn(() => Promise.resolve()), requireProjectPermission: jest.fn(() => Promise.resolve()),
} satisfies Pick< } satisfies Pick<
AuthService, AuthService,
'listAllowedProjectKeys' | 'requireProjectPermission' | 'listAllowedProjectKeys'
| 'getProjectPermissionLevel'
| 'requireProjectPermission'
>; >;
const jenkins = { const jenkins = {
healthSummary: jest.fn(() => ({ status: 'not_configured' as const })), healthSummary: jest.fn(() => ({ status: 'not_configured' as const })),
+40 -3
View File
@@ -4,7 +4,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { AuthService } from '../auth/auth.service'; import { AuthService } from '../auth/auth.service';
import { AuthenticatedUser } from '../auth/auth.types'; import { AuthenticatedUser, ProjectPermissionLevel } from '../auth/auth.types';
import { AppError } from '../common/errors/app-error'; import { AppError } from '../common/errors/app-error';
import { IntegrationRequestError } from '../common/errors/integration-error'; import { IntegrationRequestError } from '../common/errors/integration-error';
import { EnvConfig } from '../config/env.schema'; import { EnvConfig } from '../config/env.schema';
@@ -45,15 +45,32 @@ export class ProjectsService {
async listProjects(user?: AuthenticatedUser): Promise<ProjectSummary[]> { async listProjects(user?: AuthenticatedUser): Promise<ProjectSummary[]> {
const projects = await this.projectRepository.findMany(); const projects = await this.projectRepository.findMany();
if (!user || user.role === 'super_admin') { if (!user) {
return projects; return projects;
} }
if (user.role === 'super_admin') {
return projects.map((project) => this.withProjectPermission(project, 'build'));
}
const allowedProjectKeys = new Set( const allowedProjectKeys = new Set(
await this.authService.listAllowedProjectKeys(user), await this.authService.listAllowedProjectKeys(user),
); );
return projects.filter((project) => allowedProjectKeys.has(project.key)); const visibleProjects = projects.filter((project) =>
allowedProjectKeys.has(project.key),
);
return Promise.all(
visibleProjects.map(async (project) => {
const level = await this.authService.getProjectPermissionLevel(
user,
project.key,
);
return this.withProjectPermission(project, level);
}),
);
} }
async getProject( async getProject(
@@ -70,11 +87,31 @@ export class ProjectsService {
if (user) { if (user) {
await this.authService.requireProjectPermission(user, key, 'read'); await this.authService.requireProjectPermission(user, key, 'read');
const level =
user.role === 'super_admin'
? 'build'
: await this.authService.getProjectPermissionLevel(user, key);
return this.withProjectPermission(project, level);
} }
return project; return project;
} }
private withProjectPermission(
project: ProjectSummary,
level: ProjectPermissionLevel,
): ProjectSummary {
const permissionLevel = level === 'build' ? 'build' : 'read';
return {
...project,
permissionLevel,
permissionLevelName:
permissionLevel === 'build' ? '构建权限' : '只读权限',
};
}
async listProjectRefs( async listProjectRefs(
key: string, key: string,
user?: AuthenticatedUser, user?: AuthenticatedUser,