feat: allow audited project secret reveal

This commit is contained in:
WuYongJie 2026-08-29 11:57:01 +08:00
parent 0f1fb4ea65
commit 5be0b308bc
3 changed files with 260 additions and 0 deletions

View File

@ -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<void>((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<void>((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

View File

@ -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 } })

View File

@ -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))