feat(api): server-side album role permission checks

Task 14 (A02 foundation): PermissionsService with fresh-DB-per-call
canViewAlbum/canManageAlbum/canUploadToAlbum/canManagePhoto over the
album_members @@unique([albumId,userId]) compound key; admin bypass,
owner/collaborator matrix per requirements doc section 2, removed
members lose access on the very next call (no caching). Boolean-only
returns; callers map 404/403. AlbumsModule shell wired into app module
(controllers arrive in Task 15). jest maxWorkers=1: DB-backed specs
share one MySQL database and reset it via TRUNCATE, so parallel spec
files wipe each other's seed data.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-17 21:34:22 +08:00
parent 39cae26106
commit 2d9b9f9fad
5 changed files with 349 additions and 2 deletions
+9 -2
View File
@@ -12,6 +12,12 @@
import type { Config } from 'jest';
const config: Config = {
// 单进程串行:test/ 下的 DB 型 spec(seed.spec.ts、permissions.spec.ts)
// 共享同一 MySQL 库且在 beforeAll 以 reset.ts TRUNCATE 全库重置,jest 默认
// 按文件并行会在 spec 间互相清掉对方刚种的数据(seed.spec 断言全库用户数);
// 串行后纯函数 spec 一并排队,代价可忽略。e2e 配置继承本文件同样受益
// (此前多个 e2e spec 并行 reset 仅靠启动时序侥幸不冲突)。
maxWorkers: 1,
testEnvironment: 'node',
rootDir: '.',
setupFiles: ['./test/load-env.ts'],
@@ -23,8 +29,9 @@ const config: Config = {
'^@photo/db-cn$': '<rootDir>/../../packages/db-cn/src/index.ts',
'^(\\.{1,2}/.*)\\.js$': '$1',
},
// test/ 下的 *.spec.ts 为直连真实 DB 的集成型单测(seed.spec.ts,经 reset.ts
// 隔离);纯 HTTP e2e 仍由 jest.e2e.config.ts 的 testMatch 接管。
// test/ 下的 *.spec.ts 为直连真实 DB 的集成型单测(seed.spec.ts、
// permissions.spec.ts,各自经 reset.ts 隔离);纯 HTTP e2e 仍由
// jest.e2e.config.ts 的 testMatch 接管。
testMatch: ['<rootDir>/src/**/*.spec.ts', '<rootDir>/test/**/*.spec.ts'],
testPathIgnorePatterns: ['/node_modules/', '/dist/'],
};
+12
View File
@@ -0,0 +1,12 @@
// services/api/src/albums/albums.module.ts —— 相册模块壳(Task 14)。
// 本任务只落 PermissionsService(A02 权限基石,依赖 @Global 的 PrismaService),
// 控制器与相册 CRUD 自 Task 15 起加入本模块。export 供 uploads/photos 等后续
// 模块经模块间 DI 复用同一份判定逻辑。
import { Module } from '@nestjs/common';
import { PermissionsService } from './permissions.service';
@Module({
providers: [PermissionsService],
exports: [PermissionsService],
})
export class AlbumsModule {}
@@ -0,0 +1,100 @@
// services/api/src/albums/permissions.service.ts —— 相册角色权限判定(Task 14,
// A02 基石)。权限矩阵来源:docs/01-需求说明书.md §2 角色与权限表:
// canViewAlbum 浏览(成员/管理员可见);canManageAlbum 配置相册+管理成员;
// canUploadToAlbum 上传(创建者自己相册/协作者已加入相册);
// canManagePhoto 管理照片(管理员全部;创建者相册全部;协作者仅自己上传)。
// 原则(§2「服务端必须逐次校验权限」):每个方法每次调用都直查数据库,
// 不做任何跨调用缓存/记忆化——成员被移除或账号状态变化后的下一次请求即刻
// 生效;上传凭证等既有签发物的拦截由各端点自行补充(A20)。
// 本服务只返回 boolean,不抛 ApiException:404(资源不存在/不可见)与 403
// (可见但无权)的映射是控制器层(Task 15)的职责——保持纯判定便于复用与测试。
// 查询一律走 schema 的复合唯一/索引:album_members @@unique([albumId,userId])
// 的 findUnique(albumId_userId),photos 按 @id 主键。
// canDownloadOriginal 属 P4a(原图下载策略),P1a 不实现。
import { Injectable } from '@nestjs/common';
import type { AuthUser } from '../auth/session.guard';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class PermissionsService {
constructor(private readonly prisma: PrismaService) {}
/** 浏览相册:公司管理员恒真;否则需 album_members 有效行(status=active)。 */
async canViewAlbum(user: AuthUser, albumId: string): Promise<boolean> {
if (user.role === 'admin') {
return true;
}
const member = await this.findActiveMember(user.id, albumId);
return member !== null;
}
/**
* 配置相册 / 管理成员(邀请、移除协作者):公司管理员恒真;
* 否则需 album_members 行 role='owner' 且 status='active'。
*/
async canManageAlbum(user: AuthUser, albumId: string): Promise<boolean> {
if (user.role === 'admin') {
return true;
}
const member = await this.findActiveMember(user.id, albumId);
return member !== null && member.role === 'owner';
}
/**
* 上传照片:公司管理员恒真;否则任一有效成员行(owner 或 collaborator,
* 「已加入的相册」)。注意:这里不检查 albums.uploadEnabled 开关——
* 上传开关是相册级运营状态而非角色权限,Task 18 的上传端点在权限通过后
* 单独校验开关(关注点分离:本服务只回答“角色能不能”,不回答“相册收不收”)。
*/
async canUploadToAlbum(user: AuthUser, albumId: string): Promise<boolean> {
if (user.role === 'admin') {
return true;
}
const member = await this.findActiveMember(user.id, albumId);
return member !== null;
}
/**
* 管理照片(修改分类/隐藏/软删除——“管理自己照片”的边界,§2):
* 公司管理员全部;相册创建者管本相册全部;协作者仅自己上传的照片
* (uploaderId=user.id);照片不存在返回 false(由调用方映射 404)。
*/
async canManagePhoto(user: AuthUser, photoId: string): Promise<boolean> {
if (user.role === 'admin') {
return true;
}
const photo = await this.prisma.photos.findUnique({
where: { id: photoId },
select: { albumId: true, uploaderId: true },
});
if (photo === null) {
return false;
}
if (photo.uploaderId === user.id) {
// 上传者本人:仍须是(当时的)有效成员——被移除后不能再管理历史上传。
const member = await this.findActiveMember(user.id, photo.albumId);
return member !== null;
}
// 他人照片:仅相册创建者可管理(协作者对他人上传无权,A02)。
const member = await this.findActiveMember(user.id, photo.albumId);
return member !== null && member.role === 'owner';
}
/**
* album_members 复合唯一查找(albumId_userId)。每次调用直查——
* A02 逐次校验:不缓存,成员行删除/状态变化下一次调用即刻生效。
*/
private async findActiveMember(
userId: string,
albumId: string,
): Promise<{ role: string } | null> {
const member = await this.prisma.album_members.findUnique({
where: { albumId_userId: { albumId, userId } },
select: { role: true, status: true },
});
if (member === null || member.status !== 'active') {
return null;
}
return member;
}
}
+3
View File
@@ -15,6 +15,7 @@ import {
import { APP_FILTER, APP_PIPE } from '@nestjs/core';
import { LoggerModule } from 'nestjs-pino';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { AlbumsModule } from './albums/albums.module';
import { AllExceptionsFilter } from './all-exceptions.filter';
import { AuthModule } from './auth/auth.module';
import { HealthController } from './health.controller';
@@ -28,6 +29,8 @@ import type { ServerRequestWithId } from './shared/request-with-id';
// @Global:功能模块(Task 12+)直接注入 PrismaService,无需 re-import。
PrismaModule,
AuthModule,
// 相册域(Task 14 起步:PermissionsService;控制器自 Task 15 加入)。
AlbumsModule,
LoggerModule.forRoot({
pinoHttp: {
// 与 RequestIdMiddleware 共用同一解析函数:优先透传 x-request-id,
+225
View File
@@ -0,0 +1,225 @@
// services/api/test/permissions.spec.ts —— 相册角色权限服务集成测试(Task 14,
// 真实 MySQL,经 reset.ts 隔离,同 seed.spec.ts 的直连 harness 模式)。
// 权限矩阵来源:docs/01-需求说明书.md §2 角色与权限表(A02:协作者不能修改
// 他人照片,创建者不能越权其他相册)。
// 种子矩阵:admin(公司管理员,无任何成员行)、ownerA(albumA1 创建者)、
// collabB(albumA1 协作者,同时是 albumB1 创建者——验证同一人跨相册角色独立)、
// outsiderC(无任何成员关系)、removedD(曾是 albumA1 协作者,测试中删除成员行)。
// 关键断言组:
// 1) canViewAlbum / canManageAlbum / canUploadToAlbum 的角色矩阵;
// 2) canManagePhoto:创建者管全册、协作者只管自己上传的、跨相册不越权;
// 3) 逐次查库(A02):删除成员行后,同一服务实例的下一次调用即刻变 false
// ——不重新实例化服务,证明无跨调用缓存。
// 服务返回纯 boolean;404/403 映射是调用方(Task 15 控制器)的职责,此处只测
// true/false 语义。
import { PrismaService } from '../src/prisma/prisma.service';
import { PermissionsService } from '../src/albums/permissions.service';
import type { AuthUser } from '../src/auth/session.guard';
import { resetDb } from './reset';
/** 把 users 行映射成 SessionGuard 挂载的 req.user 形状(AuthUser)。 */
function asAuthUser(row: {
id: string;
account: string;
role: string;
locale: string | null;
}): AuthUser {
return { id: row.id, account: row.account, role: row.role, locale: row.locale };
}
describe('PermissionsService (real DB, album role matrix)', () => {
let prisma: PrismaService;
let service: PermissionsService;
let admin: AuthUser;
let ownerA: AuthUser;
let collabB: AuthUser;
let outsiderC: AuthUser;
let removedD: AuthUser;
let albumA1: { id: string };
let albumB1: { id: string };
let photoA1ByOwner: { id: string };
let photoA1ByCollab: { id: string };
let photoA1ByRemoved: { id: string };
let photoB1ByCollab: { id: string };
beforeAll(async () => {
await resetDb();
prisma = new PrismaService();
await prisma.$connect();
service = new PermissionsService(prisma);
const mkUser = (account: string, role = 'staff') =>
prisma.users.create({
data: { account, role, passwordHash: 'x', displayName: account },
select: { id: true, account: true, role: true, locale: true },
});
admin = asAuthUser(await mkUser('perm-admin', 'admin'));
ownerA = asAuthUser(await mkUser('perm-owner-a'));
collabB = asAuthUser(await mkUser('perm-collab-b'));
outsiderC = asAuthUser(await mkUser('perm-outsider-c'));
removedD = asAuthUser(await mkUser('perm-removed-d'));
const mkAlbum = (token: string, ownerId: string) =>
prisma.albums.create({
data: {
publicToken: token,
ownerId,
startAt: new Date('2026-09-01T00:00:00Z'),
defaultLocale: 'zh-CN',
},
select: { id: true },
});
albumA1 = await mkAlbum('perm-token-a1', ownerA.id);
albumB1 = await mkAlbum('perm-token-b1', collabB.id);
await prisma.album_members.createMany({
data: [
{ albumId: albumA1.id, userId: ownerA.id, role: 'owner' },
{ albumId: albumA1.id, userId: collabB.id, role: 'collaborator' },
// removedD 的行在「逐次查库」用例中删除——先种上以证明“曾是成员”。
{ albumId: albumA1.id, userId: removedD.id, role: 'collaborator' },
{ albumId: albumB1.id, userId: collabB.id, role: 'owner' },
],
});
const mkPhoto = (albumId: string, uploaderId: string, key: string) =>
prisma.photos.create({
data: { albumId, uploaderId, objectKey: key },
select: { id: true },
});
photoA1ByOwner = await mkPhoto(albumA1.id, ownerA.id, 'perm/a1-by-owner.jpg');
photoA1ByCollab = await mkPhoto(albumA1.id, collabB.id, 'perm/a1-by-collab.jpg');
photoA1ByRemoved = await mkPhoto(albumA1.id, removedD.id, 'perm/a1-by-removed.jpg');
photoB1ByCollab = await mkPhoto(albumB1.id, collabB.id, 'perm/b1-by-collab.jpg');
});
afterAll(async () => {
await prisma.$disconnect();
});
it('admin passes every check with no membership row at all', async () => {
await expect(
service.canViewAlbum(admin, albumA1.id),
).resolves.toBe(true);
await expect(
service.canManageAlbum(admin, albumA1.id),
).resolves.toBe(true);
await expect(
service.canUploadToAlbum(admin, albumA1.id),
).resolves.toBe(true);
await expect(
service.canManagePhoto(admin, photoA1ByOwner.id),
).resolves.toBe(true);
});
it('album owner can view, manage, and upload to own album', async () => {
await expect(service.canViewAlbum(ownerA, albumA1.id)).resolves.toBe(true);
await expect(service.canManageAlbum(ownerA, albumA1.id)).resolves.toBe(true);
await expect(service.canUploadToAlbum(ownerA, albumA1.id)).resolves.toBe(true);
});
it('collaborator can view and upload but not manage (配置相册/管理成员)', async () => {
await expect(service.canViewAlbum(collabB, albumA1.id)).resolves.toBe(true);
await expect(service.canUploadToAlbum(collabB, albumA1.id)).resolves.toBe(true);
await expect(service.canManageAlbum(collabB, albumA1.id)).resolves.toBe(false);
});
it('outsider gets false everywhere (caller maps to 404-able denial)', async () => {
await expect(service.canViewAlbum(outsiderC, albumA1.id)).resolves.toBe(false);
await expect(service.canManageAlbum(outsiderC, albumA1.id)).resolves.toBe(false);
await expect(service.canUploadToAlbum(outsiderC, albumA1.id)).resolves.toBe(false);
await expect(
service.canManagePhoto(outsiderC, photoA1ByOwner.id),
).resolves.toBe(false);
});
it('album roles do not leak across albums (A02): ownerA has no rights in albumB1', async () => {
await expect(service.canViewAlbum(ownerA, albumB1.id)).resolves.toBe(false);
await expect(service.canManageAlbum(ownerA, albumB1.id)).resolves.toBe(false);
await expect(service.canUploadToAlbum(ownerA, albumB1.id)).resolves.toBe(false);
// 同一人在相册 A 是协作者、在相册 B 是创建者——B 中具备管理权。
await expect(service.canManageAlbum(collabB, albumB1.id)).resolves.toBe(true);
});
it('canManagePhoto: owner manages every photo in own album', async () => {
await expect(
service.canManagePhoto(ownerA, photoA1ByOwner.id),
).resolves.toBe(true);
await expect(
service.canManagePhoto(ownerA, photoA1ByCollab.id),
).resolves.toBe(true);
});
it('canManagePhoto: collaborator manages only photos they uploaded', async () => {
await expect(
service.canManagePhoto(collabB, photoA1ByCollab.id),
).resolves.toBe(true);
// 他人上传的照片(即便是同一相册)不可管理。
await expect(
service.canManagePhoto(collabB, photoA1ByOwner.id),
).resolves.toBe(false);
});
it('canManagePhoto: membership in one album grants nothing in another', async () => {
// ownerA 是 albumA1 创建者,但 albumB1 的照片与其无关。
await expect(
service.canManagePhoto(ownerA, photoB1ByCollab.id),
).resolves.toBe(false);
// collabB 在 albumB1 是创建者 → 可管理(既是 owner 也是上传者)。
await expect(
service.canManagePhoto(collabB, photoB1ByCollab.id),
).resolves.toBe(true);
});
it('canManagePhoto: nonexistent photo yields false, not a throw', async () => {
await expect(
service.canManagePhoto(collabB, 'no-such-photo-id'),
).resolves.toBe(false);
});
it('reflects member removal on the very next call, same service instance (逐次查库)', async () => {
// 删除前:removedD 是 albumA1 的有效协作者。
await expect(service.canViewAlbum(removedD, albumA1.id)).resolves.toBe(true);
await expect(service.canUploadToAlbum(removedD, albumA1.id)).resolves.toBe(true);
// 连续两次调用都应查库(第二次仍 true,不能因“刚查过”而走缓存)。
await expect(service.canUploadToAlbum(removedD, albumA1.id)).resolves.toBe(true);
// 有效协作者可管理自己上传的照片(§2「管理照片:自己上传的」)。
await expect(
service.canManagePhoto(removedD, photoA1ByRemoved.id),
).resolves.toBe(true);
// 移除成员行(需求 §2:协作者被移除后不得继续操作——行删除即移除)。
await prisma.album_members.deleteMany({
where: { albumId: albumA1.id, userId: removedD.id },
});
// 不重新实例化 service:下一次调用必须即刻反映移除。
await expect(service.canViewAlbum(removedD, albumA1.id)).resolves.toBe(false);
await expect(service.canUploadToAlbum(removedD, albumA1.id)).resolves.toBe(false);
await expect(service.canManageAlbum(removedD, albumA1.id)).resolves.toBe(false);
// 被移除的协作者不能再管理自己上传的照片(“当时是成员”不构成既得权限)。
await expect(
service.canManagePhoto(removedD, photoA1ByRemoved.id),
).resolves.toBe(false);
await expect(
service.canManagePhoto(removedD, photoA1ByCollab.id),
).resolves.toBe(false);
// 管理员与不相干用户不受影响。
await expect(service.canViewAlbum(admin, albumA1.id)).resolves.toBe(true);
await expect(service.canViewAlbum(outsiderC, albumA1.id)).resolves.toBe(false);
});
it('collaborator membership row with non-active status denies (status is checked)', async () => {
// 软移除预留语义:status != active 的行视同无权限(当前移除=删行,行为一致)。
const { id } = await prisma.album_members.create({
data: { albumId: albumB1.id, userId: outsiderC.id, role: 'collaborator', status: 'revoked' },
select: { id: true },
});
await expect(service.canViewAlbum(outsiderC, albumB1.id)).resolves.toBe(false);
await expect(service.canUploadToAlbum(outsiderC, albumB1.id)).resolves.toBe(false);
await expect(service.canManageAlbum(outsiderC, albumB1.id)).resolves.toBe(false);
await prisma.album_members.delete({ where: { id } }).catch(() => undefined);
});
});