feat: 优化项目权限与发布鉴权
This commit is contained in:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user