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