From 5be0b308bc393e0155f3dec5f8b606026ae940df Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Sat, 29 Aug 2026 11:57:01 +0800 Subject: [PATCH] feat: allow audited project secret reveal --- src/__tests__/admin.test.ts | 201 ++++++++++++++++++++++++++++++++++ src/admin-project-registry.ts | 39 +++++++ src/server.ts | 20 ++++ 3 files changed, 260 insertions(+) diff --git a/src/__tests__/admin.test.ts b/src/__tests__/admin.test.ts index a1cfef8..44446c1 100644 --- a/src/__tests__/admin.test.ts +++ b/src/__tests__/admin.test.ts @@ -289,6 +289,207 @@ test('缺少 admin 配置时启动安全回退到 legacy env source,旧 /v1 } }) +test('admin reveal-secret 可重复查看当前密钥,不轮换且审计不泄露,异常与未授权返回明确', async () => { + const oldEncryptionKey = process.env.YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY + const oldAdminApiSecret = process.env.YYD_NOTIFICATION_ADMIN_API_SECRET + const oldAdminToken = process.env.YYD_NOTIFICATION_ADMIN_TOKEN + const oldProjectsJson = process.env.YYD_NOTIFICATION_PROJECTS_JSON + const oldPublicBaseUrl = process.env.YYD_NOTIFICATION_PUBLIC_BASE_URL + process.env.YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY = encryptionKey + process.env.YYD_NOTIFICATION_ADMIN_API_SECRET = adminApiSecret + process.env.YYD_NOTIFICATION_ADMIN_TOKEN = '' + process.env.YYD_NOTIFICATION_PROJECTS_JSON = '' + process.env.YYD_NOTIFICATION_PUBLIC_BASE_URL = 'https://sc.example.com/notification-center' + + await resetDatabase() + const registry = new AdminProjectRegistry() + await registry.refresh() + const server = createServer(registry).listen(0) + await new Promise((resolve) => server.once('listening', resolve)) + const port = (server.address() as AddressInfo).port + + try { + const createResponse = await fetch(`http://127.0.0.1:${port}/admin/projects`, { + method: 'POST', + headers: withAdminAuth(), + body: JSON.stringify({ + projectCode: 'script_monitor', + name: '脚本监控', + allowedHosts: ['cp.example.com'], + recipients: [{ alias: '海棠', userId: 'ding-script-monitor-user' }], + }), + }) + assert.equal(createResponse.status, 201) + const created = await createResponse.json() as any + const rotateAtBeforeReveal = await prisma.notificationProject.findUniqueOrThrow({ + where: { code: 'script_monitor' }, + select: { lastSecretRotatedAt: true }, + }) + + const publicPath = '/v1/notifications' + const beforeRevealBody = JSON.stringify({ + idempotencyKey: 'script-monitor:before-reveal', + recipientAlias: '海棠', + title: '首次验签', + content: '正文', + actionTitle: '查看详情', + actionUrl: 'https://cp.example.com/tasks/1', + }) + const beforeReveal = await fetch(`http://127.0.0.1:${port}${publicPath}`, { + method: 'POST', + headers: signedHeaders({ + projectCode: 'script_monitor', + secret: created.secret, + method: 'POST', + path: publicPath, + body: beforeRevealBody, + }), + body: beforeRevealBody, + }) + assert.equal(beforeReveal.status, 202) + + const revealPath = '/admin/projects/script_monitor/reveal-secret' + const revealOneResponse = await fetch(`http://127.0.0.1:${port}${revealPath}`, { + method: 'POST', + headers: withAdminAuth(), + }) + assert.equal(revealOneResponse.status, 200) + assert.equal(revealOneResponse.headers.get('cache-control'), 'no-store, no-cache, must-revalidate, max-age=0') + assert.equal(revealOneResponse.headers.get('pragma'), 'no-cache') + assert.equal(revealOneResponse.headers.get('expires'), '0') + const revealOne = await revealOneResponse.json() as any + assert.equal(revealOne.projectCode, 'script_monitor') + assert.equal(revealOne.secret, created.secret) + assert.equal(revealOne.projectSecret, created.secret) + assert.equal(revealOne.project.projectCode, 'script_monitor') + assert.deepEqual(revealOne.env, { + YYD_NOTIFICATION_CENTER_URL: 'https://sc.example.com/notification-center', + YYD_NOTIFICATION_CENTER_PROJECT_CODE: 'script_monitor', + YYD_NOTIFICATION_CENTER_PROJECT_SECRET: created.secret, + }) + + const revealTwoResponse = await fetch(`http://127.0.0.1:${port}${revealPath}`, { + method: 'POST', + headers: withAdminAuth(), + }) + assert.equal(revealTwoResponse.status, 200) + const revealTwo = await revealTwoResponse.json() as any + assert.equal(revealTwo.projectCode, 'script_monitor') + assert.equal(revealTwo.secret, created.secret) + assert.equal(revealTwo.projectSecret, created.secret) + assert.deepEqual(revealTwo.env, revealOne.env) + + const rotateAtAfterReveal = await prisma.notificationProject.findUniqueOrThrow({ + where: { code: 'script_monitor' }, + select: { lastSecretRotatedAt: true }, + }) + assert.equal( + rotateAtAfterReveal.lastSecretRotatedAt?.toISOString(), + rotateAtBeforeReveal.lastSecretRotatedAt?.toISOString(), + ) + + const afterRevealBody = JSON.stringify({ + idempotencyKey: 'script-monitor:after-reveal', + recipientAlias: '海棠', + title: '查看后旧密钥继续有效', + content: '正文', + actionTitle: '查看详情', + actionUrl: 'https://cp.example.com/tasks/2', + }) + const afterReveal = await fetch(`http://127.0.0.1:${port}${publicPath}`, { + method: 'POST', + headers: signedHeaders({ + projectCode: 'script_monitor', + secret: created.secret, + method: 'POST', + path: publicPath, + body: afterRevealBody, + }), + body: afterRevealBody, + }) + assert.equal(afterReveal.status, 202) + assert.equal((await afterReveal.json() as any).title, '【脚本监控】查看后旧密钥继续有效') + + const missingAuth = await fetch(`http://127.0.0.1:${port}${revealPath}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }) + assert.equal(missingAuth.status, 401) + assert.equal(missingAuth.headers.get('cache-control'), 'no-store, no-cache, must-revalidate, max-age=0') + assert.equal(missingAuth.headers.get('pragma'), 'no-cache') + assert.equal(missingAuth.headers.get('expires'), '0') + assert.deepEqual(await missingAuth.json(), { + error: { + code: 'ADMIN_AUTH_REQUIRED', + message: '缺少管理员 Bearer Token', + }, + }) + + const missingProject = await fetch(`http://127.0.0.1:${port}/admin/projects/not_found/reveal-secret`, { + method: 'POST', + headers: withAdminAuth(), + }) + assert.equal(missingProject.status, 404) + assert.deepEqual(await missingProject.json(), { + error: { + code: 'PROJECT_NOT_FOUND', + message: '项目 not_found 不存在', + }, + }) + + const brokenCreateResponse = await fetch(`http://127.0.0.1:${port}/admin/projects`, { + method: 'POST', + headers: withAdminAuth(), + body: JSON.stringify({ + projectCode: 'broken_secret', + name: '损坏密钥项目', + allowedHosts: ['broken.example.com'], + }), + }) + assert.equal(brokenCreateResponse.status, 201) + await prisma.notificationProject.update({ + where: { code: 'broken_secret' }, + data: { + secretCiphertext: 'broken', + secretNonce: 'broken', + secretAuthTag: 'broken', + }, + }) + const brokenReveal = await fetch(`http://127.0.0.1:${port}/admin/projects/broken_secret/reveal-secret`, { + method: 'POST', + headers: withAdminAuth(), + }) + assert.equal(brokenReveal.status, 503) + assert.deepEqual(await brokenReveal.json(), { + error: { + code: 'PROJECT_SECRET_UNAVAILABLE', + message: '项目 broken_secret 的密钥暂时不可读取,请检查加密配置', + }, + }) + + const revealAudits = await prisma.notificationAudit.findMany({ + where: { + targetProjectCode: 'script_monitor', + action: 'project.reveal_secret', + }, + orderBy: { createdAt: 'asc' }, + }) + assert.equal(revealAudits.length, 2) + const revealAuditJson = JSON.stringify(revealAudits) + assert.doesNotMatch(revealAuditJson, new RegExp(created.secret)) + assert.doesNotMatch(revealAuditJson, /YYD_NOTIFICATION_CENTER_PROJECT_SECRET/) + assert.doesNotMatch(revealAuditJson, /ding-script-monitor-user/) + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())) + process.env.YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY = oldEncryptionKey + process.env.YYD_NOTIFICATION_ADMIN_API_SECRET = oldAdminApiSecret + process.env.YYD_NOTIFICATION_ADMIN_TOKEN = oldAdminToken + process.env.YYD_NOTIFICATION_PROJECTS_JSON = oldProjectsJson + process.env.YYD_NOTIFICATION_PUBLIC_BASE_URL = oldPublicBaseUrl + await prisma.$disconnect() + } +}) + test('admin API 提供稳定 contract,rotate-secret/test/deliveries/audits 可用且测试通知走 mock', async () => { const oldEncryptionKey = process.env.YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY const oldAdminApiSecret = process.env.YYD_NOTIFICATION_ADMIN_API_SECRET diff --git a/src/admin-project-registry.ts b/src/admin-project-registry.ts index 27d86fd..c66db74 100644 --- a/src/admin-project-registry.ts +++ b/src/admin-project-registry.ts @@ -753,6 +753,45 @@ export class AdminProjectRegistry { } } + async revealSecret(projectCode: string, actor: AdminAuditActor) { + await this.getProjectByCode(projectCode) + let secret = '' + try { + const row = await prisma.notificationProject.findUnique({ + where: { code: projectCode }, + select: { + code: true, + secretCiphertext: true, + secretNonce: true, + secretAuthTag: true, + }, + }) + if (!row) throw new AdminApiError(404, 'PROJECT_NOT_FOUND', `项目 ${projectCode} 不存在`) + secret = this.decryptSecret(row) + } catch (error) { + if (error instanceof AdminApiError) throw error + throw new AdminApiError(503, 'PROJECT_SECRET_UNAVAILABLE', `项目 ${projectCode} 的密钥暂时不可读取,请检查加密配置`) + } + + await this.recordAudit({ + actor, + action: 'project.reveal_secret', + targetProjectCode: projectCode, + summary: `查看项目 ${projectCode} 当前密钥`, + details: { + revealedAt: new Date().toISOString(), + }, + }) + + return { + projectCode, + project: (await this.getProjectView(projectCode, true)).project, + secret, + projectSecret: secret, + env: buildProjectEnv(projectCode, secret), + } + } + async deleteProject(projectCode: string, actor: AdminAuditActor) { const current = await this.loadExistingProjectForMutation(projectCode) const deliveryCount = await prisma.notificationDelivery.count({ where: { projectCode } }) diff --git a/src/server.ts b/src/server.ts index 9630cc2..36a61c3 100644 --- a/src/server.ts +++ b/src/server.ts @@ -725,6 +725,26 @@ export const createServer = (projectSource: ProjectSource, dependencies: CreateS } }) + app.post( + '/admin/projects/:projectCode/reveal-secret', + (_req, res, next) => { + res.set({ + 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', + Pragma: 'no-cache', + Expires: '0', + }) + next() + }, + authenticateAdmin, + async (req: AdminRequest, res, next) => { + try { + res.json(await projectSource.revealSecret(clean(req.params.projectCode), req.adminActor!)) + } catch (error) { + next(error) + } + }, + ) + app.post('/admin/projects/:projectCode/test', authenticateAdmin, async (req: AdminRequest, res, next) => { try { const project = await projectSource.resolveProjectConfigForCreate(clean(req.params.projectCode))