feat: allow audited project secret reveal
This commit is contained in:
parent
0f1fb4ea65
commit
5be0b308bc
@ -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
|
||||
|
||||
@ -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 } })
|
||||
|
||||
@ -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))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user