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