Konektory do Postgresu, pristupove udaje sifrovane

Pristupove udaje konektoru se ukladaji do databaze a prezijou restart. Popis
v documentation/14-databaze.md.

Databaze je volitelna a rezimy jsou oddelene:
- postgres kdyz je DATABASE_URL i SECRETS_KEY
- memory jinak, tedy pri nasazenem mockupu a lokalnim vyvoji bez DB

Rozhodnuti je jen na jednom miste (src/data/connectorStore.ts). Nikde jinde se
nezjistuje, jestli databaze je - kdyby se to rozlezlo po kodu, jedno misto by se
zapomnelo a chovalo by se pak jinak nez zbytek.

Chybejici databaze nesmi shodit start: container, ktery nenastartuje, je pro
AppFactory nefunkcni sluzba. Misto toho se do logu napise proc a portal to ukaze
na strance Konektory. Stejne tak kdyz migrace selzou - psat do rozbiteho
schematu je horsi nez neukladat.

Databaze potrebuje oboji. Bez SECRETS_KEY by se udaje ukladaly v plaintextu
a to je horsi nez ztratit je pri restartu: tabulku vidi kazda zaloha a kazdy
dump pri ladeni.

Pridano:
- pool v src/db/pool.ts vcetne transakci a dbFor(tenantId) jako sev pro budouci
  oddelenou databazi jednoho klienta
- migrace ze src/db/migrations/*.sql pod pg_advisory_lock, jinak je pri rolling
  deployi pusti vsechny instance naraz. Jeden soubor je jedna transakce
- sifrovani AES-256-GCM s nahodnym IV a verzi klice. Nerozsifrovatelna hodnota
  nepada, chova se jako nevyplnena a zaloguje se - jeden rozbity konektor nesmi
  shodit seznam ostatnich
- /health/ready s pingem do DB. /health na databazi zamerne nezavisi, kratky
  vypadek by jinak vedl k restartovani containeru
- GET /api/dashboard/storage a hlaska v portalu o tom, ze data jsou jen v pameti
- jediny vychozi konektor na firmu a sluzbu hlida castecny unikatni index, ne jen
  kod. Dva soubezne zapisy by jinak udelaly dva vychozi

Zmeneno: cteni i zapis konektoru je asynchronni, vcetne validace stromu.

Overeno proti Postgresu 16 v kontejneru: migrace, sifrovani v tabulce, preziti
restartu, rozsifrovani spravnym klicem, degradace pri spatnem klici, PATCH bez
tajneho pole, prepnuti a smazani vychoziho konektoru, pametovy rezim bez
DATABASE_URL. Kontejner po overeni smazan.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-12 15:08:25 +02:00
co-authored by Claude Opus 5
parent 3279dd7dac
commit 78e7f99d60
22 changed files with 1761 additions and 298 deletions
+19 -17
View File
@@ -91,14 +91,16 @@ function serviceOrDeny(
* pozna rozdil mezi "napojeno" a "muzete si napojit". Neni to vlastnost sluzby,
* ale te firmy, proto se to pocita tady a ne v katalogu.
*/
connectorsRouter.get('/services', (req, res) => {
connectorsRouter.get('/services', async (req, res) => {
const access = accessFor(req.user!);
const requested = typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined;
const tenantId = requested ?? access.defaultTenantId;
const visible = visibleServices(req.user!, tenantId);
const withScripts = new Map(serviceCatalog().map((service) => [service.id, service]));
const counts = tenantId ? connectorCountsByService([tenantId]) : new Map<string, number>();
const counts = tenantId
? await connectorCountsByService([tenantId])
: new Map<string, number>();
const items = visible.map((service) => {
const merged = withScripts.get(service.id) ?? service;
@@ -118,12 +120,12 @@ connectorsRouter.get('/services', (req, res) => {
// ----------------------------------------------------------------- konektory
connectorsRouter.get('/', (req, res) => {
connectorsRouter.get('/', async (req, res) => {
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
const serviceId = typeof req.query.serviceId === 'string' ? req.query.serviceId : undefined;
const items = listConnectors([tenantId], serviceId ? { serviceId } : {})
const items = (await listConnectors([tenantId], serviceId ? { serviceId } : {}))
// Konektor na sluzbu, kterou uzivatel nevidi, mu taky ukazovat nebudeme.
.filter((connector) => {
const service = findService(connector.serviceId);
@@ -134,11 +136,11 @@ connectorsRouter.get('/', (req, res) => {
return res.json({ items, tenantId });
});
connectorsRouter.get('/:id', (req, res) => {
connectorsRouter.get('/:id', async (req, res) => {
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
const connector = getConnector(req.params.id, [tenantId]);
const connector = await getConnector(req.params.id, [tenantId]);
if (!connector) {
return res.status(404).json({ error: 'not_found', message: 'Konektor neexistuje.' });
}
@@ -154,7 +156,7 @@ const createSchema = z.object({
values: z.record(z.string()).optional(),
});
connectorsRouter.post('/', (req, res) => {
connectorsRouter.post('/', async (req, res) => {
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
@@ -181,7 +183,7 @@ connectorsRouter.post('/', (req, res) => {
return res.status(400).json({ error: 'validation_error', message: issues[0].message, issues });
}
const connector = createConnector({
const connector = await createConnector({
tenantId,
serviceId: service.id,
name: parsed.data.name,
@@ -201,11 +203,11 @@ const updateSchema = z.object({
isDefault: z.literal(true).optional(),
});
connectorsRouter.patch('/:id', (req, res) => {
connectorsRouter.patch('/:id', async (req, res) => {
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
const existing = getConnector(req.params.id, [tenantId]);
const existing = await getConnector(req.params.id, [tenantId]);
if (!existing) {
return res.status(404).json({ error: 'not_found', message: 'Konektor neexistuje.' });
}
@@ -228,18 +230,18 @@ connectorsRouter.patch('/:id', (req, res) => {
}
}
const updated = updateConnector(req.params.id, parsed.data, [tenantId]);
const updated = await updateConnector(req.params.id, parsed.data, [tenantId]);
if (!updated) {
return res.status(404).json({ error: 'not_found', message: 'Konektor neexistuje.' });
}
return res.json(toPublicConnector(updated));
});
connectorsRouter.delete('/:id', (req, res) => {
connectorsRouter.delete('/:id', async (req, res) => {
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
if (!deleteConnector(req.params.id, [tenantId])) {
if (!(await deleteConnector(req.params.id, [tenantId]))) {
return res.status(404).json({ error: 'not_found', message: 'Konektor neexistuje.' });
}
return res.status(204).end();
@@ -258,7 +260,7 @@ connectorsRouter.post('/:id/test', async (req, res) => {
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
const connector = getConnector(req.params.id, [tenantId]);
const connector = await getConnector(req.params.id, [tenantId]);
if (!connector) {
return res.status(404).json({ error: 'not_found', message: 'Konektor neexistuje.' });
}
@@ -267,7 +269,7 @@ connectorsRouter.post('/:id/test', async (req, res) => {
const target = resolveTarget(service.id, connector);
if (!target.ready) {
setConnectorStatus(connector.id, 'error', target.missing.join(', '), [tenantId]);
await setConnectorStatus(connector.id, 'error', target.missing.join(', '), [tenantId]);
return res.json({
ok: false,
checked: 'nic',
@@ -293,7 +295,7 @@ connectorsRouter.post('/:id/test', async (req, res) => {
});
const response = await http.get(path);
setConnectorStatus(connector.id, 'ok', null, [tenantId]);
await setConnectorStatus(connector.id, 'ok', null, [tenantId]);
return res.json({
ok: true,
@@ -314,7 +316,7 @@ connectorsRouter.post('/:id/test', async (req, res) => {
: undefined
: redact(truncate(String(err instanceof Error ? err.stack ?? err.message : err), config.errorDetailBytes));
setConnectorStatus(connector.id, 'error', message, [tenantId]);
await setConnectorStatus(connector.id, 'error', message, [tenantId]);
console.warn(
`[connectors] test ${connector.id} selhal: ${message}` + (detail ? `
${detail}` : ''),
+21 -10
View File
@@ -20,6 +20,7 @@ import { operatorAllowedForType, operatorsByType } from '../data/conditions.js';
import {
defaultConnectorFor,
getConnector,
storageStatus,
} from '../data/connectorStore.js';
import {
findOperation,
@@ -91,6 +92,16 @@ dashboardRouter.get('/summary', (req, res) => {
return res.json(getSummary(scope.tenantIds));
});
/**
* Kam se uklada a jestli to prezije restart.
*
* Portal to musi umet rict nahlas. Bez toho se clovek divi, kam se podely
* jeho konektory, a hleda chybu v aplikaci - presne to se stalo.
*/
dashboardRouter.get('/storage', (_req, res) => {
res.json(storageStatus());
});
dashboardRouter.get('/incidents', (_req, res) => {
res.json({ items: listIncidents() });
});
@@ -486,10 +497,10 @@ function normalizeTriggerFields(flow: z.infer<typeof flowSchema>): z.infer<typeo
* `tenantIds` je potreba kvuli konektorum: cizi konektor se musi chovat jako
* neexistujici, jinak by strom mohl volat cizim jmenem.
*/
function validateFlowReferences(
async function validateFlowReferences(
flow: z.infer<typeof flowSchema>,
tenantIds: string[],
): { problems: string[]; issues: string[] } {
): Promise<{ problems: string[]; issues: string[] }> {
const problems: string[] = [];
/** Nedodelky: strom se ulozi, jen automatizace nepujde zapnout. */
const issues: string[] = [];
@@ -516,7 +527,7 @@ function validateFlowReferences(
// jen na parametry, ktere pred ni uz vznikly.
const scopes = collectScopes(flow);
const walk = (steps: FlowStep[]) => {
const walk = async (steps: FlowStep[]): Promise<void> => {
for (const step of steps) {
if (step.kind === 'condition') {
const field = scopes.all.get(step.fieldId);
@@ -527,8 +538,8 @@ function validateFlowReferences(
`Operátor "${step.operator}" nelze použít na parametr „${field.name}" typu ${field.type}.`,
);
}
walk(step.yes);
walk(step.no);
await walk(step.yes);
await walk(step.no);
continue;
}
const action = findOperation(step.serviceId, step.operationId, 'action');
@@ -574,7 +585,7 @@ function validateFlowReferences(
// Vybrany konektor musi patrit te same firme a te same sluzbe.
// Cizi konektor je rozbity strom, ne nedodelek.
if (step.connectorId) {
const connector = getConnector(step.connectorId, tenantIds);
const connector = await getConnector(step.connectorId, tenantIds);
if (!connector) {
problems.push(`Krok odkazuje na konektor, který neexistuje (${step.connectorId}).`);
} else if (connector.serviceId !== step.serviceId) {
@@ -586,7 +597,7 @@ function validateFlowReferences(
const service = findService(step.serviceId);
// Chybejici napojeni je nedodelek, ne chyba - rozdelana prace se ulozi.
if (service && !service.general && tenantIds.length === 1) {
const fallback = defaultConnectorFor(tenantIds[0], step.serviceId);
const fallback = await defaultConnectorFor(tenantIds[0], step.serviceId);
if (!fallback) {
issues.push(
`Služba ${service.name} nemá v této firmě konektor. Vytvořte ho v Konektorech.`,
@@ -596,7 +607,7 @@ function validateFlowReferences(
}
}
};
walk(flow.steps);
await walk(flow.steps);
return { problems, issues };
}
@@ -638,7 +649,7 @@ dashboardRouter.post('/automations', (req, res) => {
return res.status(201).json(automation);
});
dashboardRouter.put('/automations/:id', (req, res) => {
dashboardRouter.put('/automations/:id', async (req, res) => {
const parsed = updateSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
@@ -653,7 +664,7 @@ dashboardRouter.put('/automations/:id', (req, res) => {
let connectorIssues: string[] = [];
if (flow) {
const { problems, issues } = validateFlowReferences(flow, writableTenants(req));
const { problems, issues } = await validateFlowReferences(flow, writableTenants(req));
if (problems.length > 0) {
console.warn(`[automations] ${req.params.id}: neplatny strom - ${problems.join(' ')}`);
return res.status(400).json({
+9 -10
View File
@@ -50,14 +50,13 @@ scriptsRouter.get('/', async (req, res) => {
access.defaultTenantId;
// Ke kazde sluzbe vychozi konektor firmy, aby portal poznal, jestli je cim volat.
const connectors = Object.fromEntries(
items
.map((item) => {
const connector = tenantId ? defaultConnectorFor(tenantId, item.serviceId) : undefined;
return connector ? [item.serviceId, toPublicConnector(connector)] : null;
})
.filter((entry): entry is [string, ReturnType<typeof toPublicConnector>] => entry !== null),
const pairs = await Promise.all(
items.map(async (item) => {
const connector = tenantId ? await defaultConnectorFor(tenantId, item.serviceId) : undefined;
return connector ? ([item.serviceId, toPublicConnector(connector)] as const) : null;
}),
);
const connectors = Object.fromEntries(pairs.filter((entry) => entry !== null));
res.json({
items,
@@ -93,7 +92,7 @@ scriptsRouter.get('/:id', async (req, res) => {
const tenantId =
(typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined) ??
access.defaultTenantId;
const connector = tenantId ? defaultConnectorFor(tenantId, serviceId) : undefined;
const connector = tenantId ? await defaultConnectorFor(tenantId, serviceId) : undefined;
return res.json({
id,
@@ -182,8 +181,8 @@ scriptsRouter.post('/:id/test', requirePlatformAdmin, async (req, res) => {
// Konektor musi patrit te same firme. Cizi se chova jako neexistujici.
const connector = parsed.data.connectorId
? getConnector(parsed.data.connectorId, [tenantId])
: defaultConnectorFor(tenantId, serviceId);
? await getConnector(parsed.data.connectorId, [tenantId])
: await defaultConnectorFor(tenantId, serviceId);
if (parsed.data.connectorId && !connector) {
return res.status(404).json({ error: 'not_found', message: 'Konektor neexistuje.' });