feat(ext-api): gerenciador de satélites — auth por integration_key + gestão
Torna o registry de satélites (PluginPair) o gatekeeper do ext-api, em vez de uma única EXT_MASTER_KEY global: - satellites.ts: serviço + router /api/ext/v1/satellites (listar/provisionar/ revogar/rotacionar), montado fora do authMiddleware com gate de chave mestra. - apikey-auth: resolvePairKey aceita a chave do satélite (nwk_) no REST e no WS, resolvendo o tenant do par; checa revoke no banco a cada request (revoke instantâneo) + throttle de lastSeenAt. - Chave mestra e ApiKey por-tenant seguem funcionando (não-quebra). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Gerenciador de plugins/satélites do motor.
|
||||
*
|
||||
* Um "satélite" é um sistema cliente (ex.: scoreodonto) registrado como PluginPair,
|
||||
* com integration_key própria (nwk_...), clientUrl e clientSystem. Este módulo:
|
||||
* (a) resolve a chave do satélite → tenant no auth do ext-api (honra revoke,
|
||||
* atualiza lastSeenAt) — tornando o REGISTRY o gatekeeper, em vez de uma
|
||||
* única EXT_MASTER_KEY global;
|
||||
* (b) expõe gestão (listar / provisionar / revogar / rotacionar) para superadmin
|
||||
* (autenticado pela chave mestra).
|
||||
*
|
||||
* A chave mestra (EXT_MASTER_KEY) permanece para bootstrap/superadmin.
|
||||
*/
|
||||
import type { PrismaClient } from '@prisma/client'
|
||||
import { Router, type Request, type Response } from 'express'
|
||||
import crypto from 'crypto'
|
||||
import { isMasterKey } from './apikey-auth'
|
||||
|
||||
const genKey = () => `nwk_${crypto.randomUUID().replace(/-/g, '')}`
|
||||
|
||||
// A resolução da chave de satélite para o auth vive em apikey-auth.resolvePairKey
|
||||
// (co-localizada com os demais resolvers + cache). Aqui ficam só as operações de gestão.
|
||||
|
||||
// ─── Operações de gestão ──────────────────────────────────────────────────────
|
||||
|
||||
export async function provisionSatellite(
|
||||
prisma: PrismaClient,
|
||||
input: { email: string; clientSystem: string; clientName: string; clientUrl?: string | null },
|
||||
) {
|
||||
const email = String(input.email ?? '').toLowerCase().trim()
|
||||
if (!email || !input.clientSystem || !input.clientName) {
|
||||
throw Object.assign(new Error('email, clientSystem e clientName obrigatórios'), { statusCode: 400 })
|
||||
}
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email }, select: { id: true, isActive: true, name: true, email: true },
|
||||
})
|
||||
if (!user || !user.isActive) throw Object.assign(new Error(`Conta não encontrada/inativa: ${email}`), { statusCode: 404 })
|
||||
|
||||
// Revoga par anterior do mesmo sistema (evita duplicatas ativas).
|
||||
await prisma.pluginPair.updateMany({
|
||||
where: { userId: user.id, clientSystem: input.clientSystem, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
})
|
||||
const integrationKey = genKey()
|
||||
const pair = await prisma.pluginPair.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
clientSystem: input.clientSystem,
|
||||
clientName: input.clientName,
|
||||
clientUrl: input.clientUrl || null,
|
||||
integrationKey,
|
||||
},
|
||||
})
|
||||
return {
|
||||
id: pair.id,
|
||||
integration_key: integrationKey,
|
||||
account: { name: user.name, email: user.email },
|
||||
clientSystem: input.clientSystem,
|
||||
clientName: input.clientName,
|
||||
}
|
||||
}
|
||||
|
||||
export async function listSatellites(prisma: PrismaClient) {
|
||||
const pairs = await prisma.pluginPair.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true, clientSystem: true, clientName: true, clientUrl: true,
|
||||
lastSeenAt: true, revokedAt: true, createdAt: true,
|
||||
user: { select: { email: true, isActive: true } },
|
||||
},
|
||||
})
|
||||
return pairs.map((p) => ({
|
||||
id: p.id,
|
||||
clientSystem: p.clientSystem,
|
||||
clientName: p.clientName,
|
||||
clientUrl: p.clientUrl,
|
||||
account: p.user.email,
|
||||
active: !p.revokedAt && p.user.isActive,
|
||||
revokedAt: p.revokedAt,
|
||||
lastSeenAt: p.lastSeenAt,
|
||||
createdAt: p.createdAt,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function revokeSatellite(prisma: PrismaClient, id: string) {
|
||||
const r = await prisma.pluginPair.updateMany({ where: { id, revokedAt: null }, data: { revokedAt: new Date() } })
|
||||
if (!r.count) throw Object.assign(new Error('Satélite não encontrado ou já revogado'), { statusCode: 404 })
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
export async function rotateSatelliteKey(prisma: PrismaClient, id: string) {
|
||||
const pair = await prisma.pluginPair.findFirst({ where: { id, revokedAt: null }, select: { id: true } })
|
||||
if (!pair) throw Object.assign(new Error('Satélite não encontrado'), { statusCode: 404 })
|
||||
const integrationKey = genKey()
|
||||
await prisma.pluginPair.update({ where: { id }, data: { integrationKey } })
|
||||
return { id, integration_key: integrationKey }
|
||||
}
|
||||
|
||||
// ─── Router de gestão (superadmin via chave mestra) ───────────────────────────
|
||||
// Montado FORA do authMiddleware do ext-api: tem gate próprio (chave mestra,
|
||||
// sem exigir x-nw-email como o fluxo de operação).
|
||||
export function buildSatelliteRoutes(prisma: PrismaClient): Router {
|
||||
const router = Router()
|
||||
|
||||
router.use((req: Request, res: Response, next) => {
|
||||
const key = (req.headers['x-nw-key'] as string | undefined)?.trim() ?? ''
|
||||
if (!isMasterKey(key)) { res.status(403).json({ error: 'Apenas a chave mestra gerencia satélites.' }); return }
|
||||
next()
|
||||
})
|
||||
|
||||
router.get('/', async (_req, res) => {
|
||||
try { res.json({ satellites: await listSatellites(prisma) }) }
|
||||
catch (e: any) { res.status(500).json({ error: e.message }) }
|
||||
})
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
try { res.json(await provisionSatellite(prisma, req.body ?? {})) }
|
||||
catch (e: any) { res.status(e.statusCode ?? 500).json({ error: e.message }) }
|
||||
})
|
||||
|
||||
router.post('/:id/revoke', async (req, res) => {
|
||||
try { res.json(await revokeSatellite(prisma, req.params['id']!)) }
|
||||
catch (e: any) { res.status(e.statusCode ?? 500).json({ error: e.message }) }
|
||||
})
|
||||
|
||||
router.post('/:id/rotate', async (req, res) => {
|
||||
try { res.json(await rotateSatelliteKey(prisma, req.params['id']!)) }
|
||||
catch (e: any) { res.status(e.statusCode ?? 500).json({ error: e.message }) }
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
Reference in New Issue
Block a user