From 2d9b9f9fad1c407ed6a8139c4ac461b7230b7c4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Thu, 17 Sep 2026 21:34:22 +0800 Subject: [PATCH] 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 --- services/api/jest.config.ts | 11 +- services/api/src/albums/albums.module.ts | 12 + .../api/src/albums/permissions.service.ts | 100 ++++++++ services/api/src/app-cn.module.ts | 3 + services/api/test/permissions.spec.ts | 225 ++++++++++++++++++ 5 files changed, 349 insertions(+), 2 deletions(-) create mode 100644 services/api/src/albums/albums.module.ts create mode 100644 services/api/src/albums/permissions.service.ts create mode 100644 services/api/test/permissions.spec.ts diff --git a/services/api/jest.config.ts b/services/api/jest.config.ts index 52ced56..8318705 100644 --- a/services/api/jest.config.ts +++ b/services/api/jest.config.ts @@ -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$': '/../../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: ['/src/**/*.spec.ts', '/test/**/*.spec.ts'], testPathIgnorePatterns: ['/node_modules/', '/dist/'], }; diff --git a/services/api/src/albums/albums.module.ts b/services/api/src/albums/albums.module.ts new file mode 100644 index 0000000..3058fd9 --- /dev/null +++ b/services/api/src/albums/albums.module.ts @@ -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 {} diff --git a/services/api/src/albums/permissions.service.ts b/services/api/src/albums/permissions.service.ts new file mode 100644 index 0000000..f65d756 --- /dev/null +++ b/services/api/src/albums/permissions.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/services/api/src/app-cn.module.ts b/services/api/src/app-cn.module.ts index 0710729..e3600c6 100644 --- a/services/api/src/app-cn.module.ts +++ b/services/api/src/app-cn.module.ts @@ -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, diff --git a/services/api/test/permissions.spec.ts b/services/api/test/permissions.spec.ts new file mode 100644 index 0000000..e1e8234 --- /dev/null +++ b/services/api/test/permissions.spec.ts @@ -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); + }); +});