From 6fec6277f5000598c387a58d66c98189e964868e Mon Sep 17 00:00:00 2001 From: Kundencenter Date: Sun, 27 Sep 2026 21:35:15 +0200 Subject: [PATCH] =?UTF-8?q?KeyHelp:=20Panel-Passwort=20neu=20vergeben=20un?= =?UTF-8?q?d=20verschl=C3=BCsselt=20hinterlegen=20(verblurrt=20wie=20Lizen?= =?UTF-8?q?zschl=C3=BCssel)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/modules/resources/index.ts | 47 ++++++++++++++++++- apps/api/test/keyhelp.test.ts | 36 ++++++++++++++ .../src/app/(app)/ressourcen/[id]/page.tsx | 23 ++++++++- apps/web/src/components/SecretField.tsx | 12 ++--- apps/web/src/components/ui.tsx | 2 +- migrations/019_panel_credentials.sql | 8 ++++ packages/connector-keyhelp/src/index.ts | 3 +- packages/connector-sdk/src/index.ts | 12 ++--- packages/connectors/src/index.ts | 13 +++-- 9 files changed, 136 insertions(+), 20 deletions(-) create mode 100644 migrations/019_panel_credentials.sql diff --git a/apps/api/src/modules/resources/index.ts b/apps/api/src/modules/resources/index.ts index 204dfae..465b759 100644 --- a/apps/api/src/modules/resources/index.ts +++ b/apps/api/src/modules/resources/index.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { randomUUID, createHash } from 'node:crypto'; import { ACTION_CAPABILITY, ACTIONS, loadInstance, type ActionName } from '@kc/connectors'; import { ConnectorError, type ChildKind } from '@kc/connector-sdk'; -import { encrypt } from '../../core/crypto.js'; +import { decrypt, encrypt, randomToken } from '../../core/crypto.js'; import { CHILD_MANAGE, CUSTOMER_ACTIONS } from '../../core/actions.js'; import { rl } from '../../core/config.js'; import { DESTRUCTIVE_ACTIONS } from '@kc/connector-sdk'; @@ -27,6 +27,7 @@ function view(r: any, staff: boolean, a?: AuthContext) { return { id: r.id, type: r.type, name: r.name, state: r.state, orgId: r.org_id, validFrom: r.valid_from, validUntil: r.valid_until, syncedAt: r.synced_at, missing: !!r.missing_since, canReveal: a ? canReveal(r, a) : undefined, + canResetPassword: a ? canResetPassword(r, a) : undefined, stale, staleReason: r.health !== 'ok' ? (r.health_message ?? 'Der Dienst ist derzeit nicht erreichbar.') : stale ? 'Die Daten sind älter als erwartet.' : null, ...(staff ? { instance: r.instance_name, connector: r.connector_key, externalRef: r.external_ref } : {}), }; @@ -48,6 +49,11 @@ function canReveal(r: any, a: AuthContext): boolean { if (!caps.includes('secret.reveal') || !r.enabled) return false; return can(a.principal, 'resources.write') || (!!r.org_id && canInOrg(a.principal, r.org_id, 'resources.manage', 'resources.write')); } +/** Panel-Passwort neu vergeben/einsehen: nur Personal mit Schreibrecht (kein Kunden-Selfservice, ändert ein echtes Zugangsdatum). */ +function canResetPassword(r: any, a: AuthContext): boolean { + const caps = json(r.capabilities_json, []); + return caps.includes('panel.password_reset') && !!r.enabled && r.health === 'ok' && can(a.principal, 'resources.write'); +} function access(a: AuthContext, r: any): boolean { return can(a.principal, 'resources.read') || (!!r.org_id && canInOrg(a.principal, r.org_id, 'resources.read', 'resources.read')); } @@ -81,7 +87,7 @@ export const resourcesModule: KcModule = { const staff = can(a.principal, 'resources.read'); const jobs = await query("SELECT id, status, last_error, attempts, created_at, updated_at, JSON_VALUE(payload, '$.action') AS action FROM jobs WHERE type = 'connector.execute' AND JSON_VALUE(payload, '$.resourceId') = ? ORDER BY created_at DESC LIMIT 10", [id]); const data = json<{ limits?: object; usage?: object; details?: object }>(r.data_json, {}); - return { ...view(r, staff), limits: data.limits ?? {}, usage: data.usage ?? {}, details: data.details ?? {}, allowedActions: allowedActions(r, a), canReveal: canReveal(r, a), hasChildren: childCapsTop(r).includes('children.read'), canLogin: canLoginTop(r, a), customerActions: staff ? json(r.customer_actions, []) : undefined, jobs: jobs.map((j) => ({ id: j.id, action: j.action, status: j.status, error: j.last_error, attempts: j.attempts, createdAt: j.created_at, updatedAt: j.updated_at })) }; + return { ...view(r, staff), limits: data.limits ?? {}, usage: data.usage ?? {}, details: data.details ?? {}, allowedActions: allowedActions(r, a), canReveal: canReveal(r, a), canResetPassword: canResetPassword(r, a), hasChildren: childCapsTop(r).includes('children.read'), canLogin: canLoginTop(r, a), customerActions: staff ? json(r.customer_actions, []) : undefined, jobs: jobs.map((j) => ({ id: j.id, action: j.action, status: j.status, error: j.last_error, attempts: j.attempts, createdAt: j.created_at, updatedAt: j.updated_at })) }; }); app.post('/resources/:id/actions', async (req, reply) => { @@ -137,6 +143,43 @@ export const resourcesModule: KcModule = { return { items, hideAfterSec: 60 }; }); + // ---- Panel-Zugangsdaten: bestehende Passwörter sind bei Anbietern grundsätzlich nicht lesbar; hier wird + // stattdessen ein NEUES Passwort erzeugt, beim Anbieter gesetzt und bei uns verschlüsselt hinterlegt. ---- + app.get('/resources/:id/panel-credentials', async (req) => { + const a = requireAuth(req); const { id } = z.object({ id: z.string().uuid() }).parse(req.params); + const r = await loadResource(id); if (!r || !access(a, r)) throw notFound(); + const c = await one('SELECT set_at FROM panel_credentials WHERE resource_id = ?', [id]); + return { canReset: canResetPassword(r, a), set: !!c, setAt: c?.set_at ?? null }; + }); + app.post('/resources/:id/panel-credentials/reset', async (req, reply) => { + const a = requireAuth(req); const { id } = z.object({ id: z.string().uuid() }).parse(req.params); + const r = await loadResource(id); if (!r || !access(a, r)) throw notFound(); + if (!canResetPassword(r, a)) throw forbidden('Zugangsdaten können für diese Ressource nicht neu vergeben werden', 'RESET_FORBIDDEN'); + const password = randomToken(15); // ~20 Zeichen, base64url – druckbar, keine Sonderzeichen, die Formulare/Shells stören könnten + const secretEnc = encrypt(JSON.stringify({ password })); + const hdr = req.headers['idempotency-key']; + const key = `pwreset:${typeof hdr === 'string' && /^[\w-]{8,100}$/.test(hdr) ? hdr : createHash('sha256').update(`${id}|${Math.floor(Date.now() / 10000)}`).digest('hex').slice(0, 40)}`; + const existing = await one('SELECT id FROM jobs WHERE idempotency_key = ?', [key]); + const jobId = existing?.id ?? randomUUID(); + if (!existing) { + await run('INSERT INTO jobs (id, type, payload, idempotency_key, correlation_id) VALUES (?,?,?,?,?)', + [jobId, 'connector.execute', JSON.stringify({ resourceId: id, action: 'reset_password', secretEnc, actorUserId: a.user.id, destructive: true }), key, req.correlationId]); + await audit({ actorType: 'user', actorId: a.user.id, orgId: r.org_id, action: 'resource.reset_password.request', resourceType: 'resource', resourceId: id, connector: r.connector_key, correlationId: req.correlationId, ip: clientIp(req), after: { jobId } }); + } + return reply.code(202).send({ jobId, duplicate: !!existing }); + }); + app.post('/resources/:id/panel-credentials/reveal', { config: rl(10, '1 minute') }, async (req, reply) => { + const a = requireAuth(req); const { id } = z.object({ id: z.string().uuid() }).parse(req.params); + const r = await loadResource(id); if (!r || !access(a, r)) throw notFound(); + if (!canResetPassword(r, a)) throw forbidden('Zugangsdaten können für diese Ressource nicht eingesehen werden', 'REVEAL_FORBIDDEN'); + const c = await one('SELECT secret_enc FROM panel_credentials WHERE resource_id = ?', [id]); + if (!c) throw notFound('Es wurde noch kein Passwort vergeben.'); + const password = decrypt(c.secret_enc); + await audit({ actorType: 'user', actorId: a.user.id, orgId: r.org_id, action: 'resource.panel_credentials.reveal', resourceType: 'resource', resourceId: id, connector: r.connector_key, correlationId: req.correlationId, ip: clientIp(req) }); + reply.header('cache-control', 'no-store'); + return { items: [{ label: 'Panel-Passwort', value: password }], hideAfterSec: 60 }; + }); + // ---- Hosting: Unterobjekte (Domains, Postfächer, Datenbanken, FTP, SSL) und Panel-Login ---------- const KINDS = ['domain', 'email', 'database', 'ftp', 'certificate'] as const; const kindParam = z.object({ id: z.string().uuid(), kind: z.enum(KINDS) }); diff --git a/apps/api/test/keyhelp.test.ts b/apps/api/test/keyhelp.test.ts index c22bf10..ab5bec7 100644 --- a/apps/api/test/keyhelp.test.ts +++ b/apps/api/test/keyhelp.test.ts @@ -120,3 +120,39 @@ describe('KeyHelp: Verbindung, Kundenübernahme, neue Tarife, Hosting-Funktionen fake.opts.failGet503 = 50; const down = await call(app, owner, 'GET', `/resources/${cres.id}/children/domain`); expect(down.statusCode).toBe(502); fake.opts.failGet503 = 0; }); }); + +describe('KeyHelp: Panel-Passwort neu vergeben', () => { + it('erzeugt ein neues Passwort, setzt es beim Anbieter, speichert es verschlüsselt und lässt es auf Abruf ansehen', async () => { + const admin = await staff('kh-pw-admin@x.test', 'admin'); const support = await staff('kh-pw-support@x.test', 'support'); + await call(app, admin, 'POST', '/admin/connectors', { connector: 'keyhelp', name: 'KeyHelp-PW-Test', values: { baseUrl: 'https://kh.test', apiKey: 'kh-test-key' } }); + await drain(); + const res = (await call(app, admin, 'GET', '/resources')).json(); const alpha = res.find((r: any) => r.name.includes('alpha')); + + // ohne Berechtigung (Support nur lesend) + expect((await call(app, support, 'POST', `/resources/${alpha.id}/panel-credentials/reset`)).statusCode).toBe(403); + const before = (await call(app, admin, 'GET', `/resources/${alpha.id}/panel-credentials`)).json(); + expect(before).toEqual({ canReset: true, set: false, setAt: null }); + + const reset = await call(app, admin, 'POST', `/resources/${alpha.id}/panel-credentials/reset`); expect(reset.statusCode).toBe(202); + await drain(); + const fakeClient = fake.state.clients.find((c) => c.username === 'alpha')!; + const after = (await call(app, admin, 'GET', `/resources/${alpha.id}/panel-credentials`)).json(); + expect(after.set).toBe(true); expect(after.setAt).toBeTruthy(); + + const revealed = (await call(app, admin, 'POST', `/resources/${alpha.id}/panel-credentials/reveal`)).json(); + expect(revealed.items).toEqual([{ label: 'Panel-Passwort', value: (fakeClient as any).password }]); + expect((revealed.items[0].value as string).length).toBeGreaterThan(15); + expect((await call(app, support, 'POST', `/resources/${alpha.id}/panel-credentials/reveal`)).statusCode).toBe(403); + + // in der Datenbank steht das Passwort nur verschlüsselt + const row = await one('SELECT secret_enc FROM panel_credentials WHERE resource_id = ?', [alpha.id]); + expect(row!.secret_enc).toMatch(/^v1:/); expect(row!.secret_enc).not.toContain((fakeClient as any).password); + + // erneutes Vergeben ersetzt das alte Passwort (eigener Idempotenzschlüssel, damit es nicht als Doppelklick gilt) + const oldPw = (fakeClient as any).password; + await app.inject({ method: 'POST', url: `/v1/resources/${alpha.id}/panel-credentials/reset`, headers: { cookie: admin.cookie, 'x-csrf-token': admin.csrf, 'idempotency-key': 'zweiter-reset-test' } }); + await drain(); + const revealed2 = (await call(app, admin, 'POST', `/resources/${alpha.id}/panel-credentials/reveal`)).json(); + expect(revealed2.items[0].value).not.toBe(oldPw); + }); +}); diff --git a/apps/web/src/app/(app)/ressourcen/[id]/page.tsx b/apps/web/src/app/(app)/ressourcen/[id]/page.tsx index 8789299..a66c29d 100644 --- a/apps/web/src/app/(app)/ressourcen/[id]/page.tsx +++ b/apps/web/src/app/(app)/ressourcen/[id]/page.tsx @@ -12,13 +12,23 @@ import { HostingPanel, UsageBars } from '@/components/HostingPanel'; interface Det { id: string; type: string; name: string; state: string; orgId: string | null; validFrom: string | null; validUntil: string | null; syncedAt: string; stale: boolean; staleReason: string | null; missing: boolean; instance?: string; connector?: string; canReveal?: boolean; hasChildren?: boolean; canLogin?: boolean; limits: Record; usage: Record; details: Record; allowedActions: string[]; customerActions?: string[]; jobs: { id: string; action: string; status: string; error: string | null; createdAt: string }[] } const LIMIT_LABEL: Record = { users: 'Benutzer', storageMb: 'Speicher (MB)' }; +interface PanelCreds { canReset: boolean; set: boolean; setAt: string | null } export default function Ressource() { const { id } = useParams<{ id: string }>(); const { can } = useSession(); const staff = can('resources.read'); const w = can('resources.write'); const [d, setD] = useState(null); const [msg, setMsg] = useState<{ k: 'ok' | 'err' | 'warn'; t: string } | null>(null); const [confirm, setConfirm] = useState<{ action: string; days?: number } | null>(null); const key = useRef(uuid()); const [custs, setCusts] = useState<{ id: string; name: string; customerNumber: string }[]>([]); - const load = useCallback(() => api('GET', `/resources/${id}`).then(setD).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), [id]); + const [creds, setCreds] = useState(null); const [credsBusy, setCredsBusy] = useState(false); const [credsConfirm, setCredsConfirm] = useState(false); + const load = useCallback(() => Promise.all([ + api('GET', `/resources/${id}`).then(setD), + api('GET', `/resources/${id}/panel-credentials`).then(setCreds).catch(() => undefined), + ]).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), [id]); useEffect(() => { void load(); }, [load]); + async function resetPanelPassword() { + setCredsBusy(true); setCredsConfirm(false); + try { await api('POST', `/resources/${id}/panel-credentials/reset`); setMsg({ k: 'ok', t: 'Wird gesetzt … das kann einen Moment dauern.' }); void load(); } + catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setCredsBusy(false); } + } useEffect(() => { if (w) api('GET', '/admin/customers').then(setCusts).catch(() => undefined); }, [w]); // Laufende Aufträge regelmäßig aktualisieren const pending = d?.jobs.some((j) => ['scheduled', 'running', 'retrying', 'waiting_external'].includes(j.status)); @@ -61,6 +71,17 @@ export default function Ressource() { {d.type === 'hosting_account' && } {(d.hasChildren || d.canLogin) && } {d.canReveal &&

Lizenzschlüssel

} + {creds?.canReset &&

Panel-Zugangsdaten

+

Das bestehende Passwort ist nicht auslesbar. Hier wird bei Bedarf ein neues, zufälliges Passwort erzeugt und beim Anbieter gesetzt — das alte Passwort wird damit ungültig.

+ {creds.set ? (<> + +

Zuletzt vergeben: {creds.setAt ? fmt(creds.setAt) : '–'}

+ ) :

Es wurde noch kein Passwort über das Kundencenter vergeben.

} + {credsConfirm ? ( +

Neues Passwort setzen? Das {creds.set ? 'bisherige, über das Kundencenter vergebene' : 'aktuelle'} Passwort wird damit ungültig.

+
+ ) : } +
}

Aktionen

{act.length === 0 ?

Für diese Ressource sind aktuell keine Aktionen verfügbar.

: confirm ? (

{ACTION_LABEL[confirm.action]}{confirm.days ? ` um ${confirm.days} Tage` : ''}? Die Änderung wird als Auftrag ausgeführt und protokolliert.

diff --git a/apps/web/src/components/SecretField.tsx b/apps/web/src/components/SecretField.tsx index 3f48488..4b01a8c 100644 --- a/apps/web/src/components/SecretField.tsx +++ b/apps/web/src/components/SecretField.tsx @@ -4,13 +4,13 @@ import { api, errMsg } from '@/lib/api'; import { copyText } from '@/lib/clipboard'; /** - * Lizenzschlüssel: standardmäßig unscharf (im Seiteninhalt steht nur ein Platzhalter), Klick lädt den echten Schlüssel, - * daneben ein Kopieren-Button. Der Abruf erfolgt live beim Anbieter, wird protokolliert und nie gespeichert. + * Geheimwert auf Abruf: standardmäßig unscharf (im Seiteninhalt steht nur ein Platzhalter), Klick lädt den echten Wert, + * daneben ein Kopieren-Button. Für Lizenzschlüssel (live beim Anbieter, nie gespeichert) und Panel-Passwörter (bei uns verschlüsselt gespeichert) genutzt. */ -export function SecretField({ resourceId, compact = false }: { resourceId: string; compact?: boolean }) { +export function SecretField({ resourceId, compact = false, url, label = 'Lizenzschlüssel', placeholder = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' }: { resourceId: string; compact?: boolean; url?: string; label?: string; placeholder?: string }) { const [secret, setSecret] = useState(null); const [left, setLeft] = useState(0); const [copied, setCopied] = useState(false); const [busy, setBusy] = useState(false); const [err, setErr] = useState(''); useEffect(() => { if (!secret) return; if (left <= 0) { setSecret(null); setCopied(false); return; } const t = setTimeout(() => setLeft(left - 1), 1000); return () => clearTimeout(t); }, [secret, left]); - const fetchSecret = () => api<{ items: { label: string; value: string }[]; hideAfterSec: number }>('POST', `/resources/${resourceId}/reveal`); + const fetchSecret = () => api<{ items: { label: string; value: string }[]; hideAfterSec: number }>('POST', url ?? `/resources/${resourceId}/reveal`); async function reveal() { if (secret || busy) return; setBusy(true); setErr(''); try { const r = await fetchSecret(); setSecret(r.items[0]?.value ?? null); setLeft(r.hideAfterSec); setCopied(false); } catch (x) { setErr(errMsg(x)); } finally { setBusy(false); } @@ -29,10 +29,10 @@ export function SecretField({ resourceId, compact = false }: { resourceId: strin return (
- {secret && } diff --git a/apps/web/src/components/ui.tsx b/apps/web/src/components/ui.tsx index 9c6d9dd..075b635 100644 --- a/apps/web/src/components/ui.tsx +++ b/apps/web/src/components/ui.tsx @@ -29,7 +29,7 @@ export function JobStatus({ value }: { value: string }) { const icon = cls === 'ok' ? '✓' : cls === 'err' ? '✕' : cls === 'warn' ? '▲' : '◐'; return {JOB_LABEL[value] ?? value}; } -export const ACTION_LABEL: Record = { suspend: 'Sperren', unsuspend: 'Entsperren', extend: 'Verlängern', 'panel.login': 'Panel-Login', 'domain.manage': 'Domains verwalten', 'email.manage': 'Postfächer verwalten', 'database.manage': 'Datenbanken verwalten', 'ftp.manage': 'FTP-Zugänge verwalten' }; +export const ACTION_LABEL: Record = { suspend: 'Sperren', unsuspend: 'Entsperren', extend: 'Verlängern', reset_password: 'Zugangsdaten neu vergeben', 'panel.login': 'Panel-Login', 'domain.manage': 'Domains verwalten', 'email.manage': 'Postfächer verwalten', 'database.manage': 'Datenbanken verwalten', 'ftp.manage': 'FTP-Zugänge verwalten' }; /** Aktionen, die einem Kunden je Produkt/Ressource freigegeben werden können. */ export const CUSTOMER_ACTIONS = ['suspend', 'unsuspend', 'extend', 'panel.login', 'domain.manage', 'email.manage', 'database.manage', 'ftp.manage'] as const; diff --git a/migrations/019_panel_credentials.sql b/migrations/019_panel_credentials.sql new file mode 100644 index 0000000..2768de0 --- /dev/null +++ b/migrations/019_panel_credentials.sql @@ -0,0 +1,8 @@ +-- Panel-Zugangsdaten (z. B. KeyHelp-Login), die das Kundencenter selbst neu gesetzt hat. +-- Bestehende Passwörter werden NIE übernommen (nicht lesbar), nur neu vergebene werden hier verschlüsselt abgelegt. +CREATE TABLE panel_credentials ( + resource_id CHAR(36) PRIMARY KEY REFERENCES resources(id), + secret_enc TEXT NOT NULL, + set_by CHAR(36) NOT NULL REFERENCES users(id), + set_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/packages/connector-keyhelp/src/index.ts b/packages/connector-keyhelp/src/index.ts index 8cc4bd0..1042777 100644 --- a/packages/connector-keyhelp/src/index.ts +++ b/packages/connector-keyhelp/src/index.ts @@ -185,7 +185,7 @@ export function createKeyHelpConnector(deps: KeyHelpDeps = {}): Connector { }; void NAMEKEY; - const capabilities: Capability[] = ['customers.list', 'catalog.write', 'catalog.list', 'resources.list', 'resources.get', 'status.read', 'usage.read', 'lifecycle.create', 'lifecycle.suspend', 'lifecycle.unsuspend', 'lifecycle.terminate', 'plan.change', 'sso.login', 'children.read', 'children.write']; + const capabilities: Capability[] = ['customers.list', 'catalog.write', 'catalog.list', 'resources.list', 'resources.get', 'status.read', 'usage.read', 'lifecycle.create', 'lifecycle.suspend', 'lifecycle.unsuspend', 'lifecycle.terminate', 'plan.change', 'sso.login', 'children.read', 'children.write', 'panel.password_reset']; return { contractVersion: CONTRACT_VERSION, key: 'keyhelp', displayName: 'KeyHelp (Webhosting)', @@ -285,6 +285,7 @@ export function createKeyHelpConnector(deps: KeyHelpDeps = {}): Connector { if (act === 'suspend' || act === 'unsuspend') { await call(ctx, 'PUT', `/clients/${id}`, { is_suspended: act === 'suspend' }); return { resource: mapClient(await getClient(ctx, req.externalRef), null, plans) }; } if (act === 'change_plan') { const pid = Number(req.params?.planId); if (!Number.isInteger(pid) || pid < 1) throw new ConnectorError('BAD_CONFIG', 'planId fehlt'); await call(ctx, 'PUT', `/clients/${id}`, { id_hosting_plan: pid }); return { resource: mapClient(await getClient(ctx, req.externalRef), null, plans) }; } if (act === 'terminate') { await optional(call(ctx, 'DELETE', `/clients/${id}`)); return {}; } // 404 = schon gelöscht + if (act === 'reset_password') { await call(ctx, 'PUT', `/clients/${id}`, { password: password(req.secrets) }); return { resource: mapClient(await getClient(ctx, req.externalRef), null, plans) }; } throw new ConnectorError('UNSUPPORTED', 'Diese Aktion gibt es bei KeyHelp nicht'); }, /** Offizieller Login-Link (60 Minuten gültig, Brute-Force-Schutz im Panel). */ diff --git a/packages/connector-sdk/src/index.ts b/packages/connector-sdk/src/index.ts index 0c9cc40..e606f7e 100644 --- a/packages/connector-sdk/src/index.ts +++ b/packages/connector-sdk/src/index.ts @@ -8,15 +8,15 @@ export const CONTRACT_VERSION = 1 as const; export type Capability = | 'customers.list' | 'catalog.write' | 'children.read' | 'children.write' | 'secret.reveal' | 'license.customer_info' | 'catalog.list' | 'license.key_prefix' | 'license.expiry' | 'resources.list' | 'resources.get' | 'status.read' | 'usage.read' | 'lifecycle.create' | 'lifecycle.suspend' | 'lifecycle.unsuspend' | 'lifecycle.terminate' | 'lifecycle.extend' - | 'settings.update' | 'plan.change' | 'sso.login' | 'webhooks'; + | 'settings.update' | 'plan.change' | 'sso.login' | 'webhooks' | 'panel.password_reset'; /** Aktionen, die über `execute` laufen (immer als persistenter Auftrag). */ -export type ActionName = 'suspend' | 'unsuspend' | 'extend' | 'terminate' | 'change_plan'; +export type ActionName = 'suspend' | 'unsuspend' | 'extend' | 'terminate' | 'change_plan' | 'reset_password'; export const ACTION_CAPABILITY: Record = { - suspend: 'lifecycle.suspend', unsuspend: 'lifecycle.unsuspend', extend: 'lifecycle.extend', terminate: 'lifecycle.terminate', change_plan: 'plan.change', + suspend: 'lifecycle.suspend', unsuspend: 'lifecycle.unsuspend', extend: 'lifecycle.extend', terminate: 'lifecycle.terminate', change_plan: 'plan.change', reset_password: 'panel.password_reset', }; -/** Aktionen, die nie automatisch wiederholt werden dürfen (destruktiv). */ -export const DESTRUCTIVE_ACTIONS: ReadonlySet = new Set(['terminate']); +/** Aktionen, die nie automatisch wiederholt werden dürfen (destruktiv, oder ein Fehlausgang wäre irreführend statt nur unvollständig). */ +export const DESTRUCTIVE_ACTIONS: ReadonlySet = new Set(['terminate', 'reset_password']); export type ResourceType = 'license' | 'hosting_account' | 'domain' | 'server'; export type ResourceState = 'active' | 'suspended' | 'expired' | 'error' | 'unknown'; @@ -130,7 +130,7 @@ export interface Connector { */ provision?(ctx: ConnectorContext, req: { params: Record; label: string; idempotencyKey: string; context?: ProvisionContext }): Promise<{ resource: NormalizedResource }>; /** Idempotent: derselbe idempotencyKey darf beim Provider nie zu einer Doppelausführung führen. */ - execute?(ctx: ConnectorContext, req: { action: ActionName; externalRef: string; params?: Record; idempotencyKey: string }): Promise<{ resource?: NormalizedResource }>; + execute?(ctx: ConnectorContext, req: { action: ActionName; externalRef: string; params?: Record; secrets?: Record; idempotencyKey: string }): Promise<{ resource?: NormalizedResource }>; } // ---- Normalisierte Fehler -------------------------------------------------- diff --git a/packages/connectors/src/index.ts b/packages/connectors/src/index.ts index 4b068d2..603be07 100644 --- a/packages/connectors/src/index.ts +++ b/packages/connectors/src/index.ts @@ -70,8 +70,10 @@ async function upsert(instanceId: string, r: NormalizedResource): Promise; actorUserId: string | null; destructive?: boolean } -/** Führt eine Aktion aus. Aufgerufen vom Worker im Rahmen eines persistenten Auftrags; jobId dient als Idempotenzschlüssel. */ +export interface ExecutePayload { resourceId: string; action: ActionName; params?: Record; secretEnc?: string; actorUserId: string | null; destructive?: boolean } +/** Führt eine Aktion aus. Aufgerufen vom Worker im Rahmen eines persistenten Auftrags; jobId dient als Idempotenzschlüssel. + * `secretEnc` (z. B. ein neu erzeugtes Panel-Passwort) steht im Auftrag nur verschlüsselt und wird erst hier entschlüsselt; + * nach erfolgreichem "reset_password" wird der Wert erneut verschlüsselt in panel_credentials abgelegt (nie im Klartext, nie im Protokoll). */ export async function executeAction(p: ExecutePayload, jobId: string, correlationId: string): Promise { const r = await one('SELECT * FROM resources WHERE id = ?', [p.resourceId]); if (!r) throw new JobFailure('Ressource nicht gefunden', false, 'failed'); @@ -79,9 +81,14 @@ export async function executeAction(p: ExecutePayload, jobId: string, correlatio const caps = await connector.capabilities(ctx); if (!caps.includes(ACTION_CAPABILITY[p.action] as Capability) || !connector.execute) throw new JobFailure('Aktion wird vom Connector nicht unterstützt', false, 'failed'); const before = { state: r.state, validUntil: r.valid_until }; + const secrets = p.secretEnc ? (JSON.parse(decrypt(p.secretEnc)) as Record) : undefined; try { - const out = await connector.execute(ctx, { action: p.action, externalRef: r.external_ref, params: p.params, idempotencyKey: jobId }); + const out = await connector.execute(ctx, { action: p.action, externalRef: r.external_ref, params: p.params, secrets, idempotencyKey: jobId }); if (out.resource) await upsert(r.instance_id, out.resource); + if (p.action === 'reset_password' && secrets?.password) { + await run('INSERT INTO panel_credentials (resource_id, secret_enc, set_by, set_at) VALUES (?,?,?,UTC_TIMESTAMP(3)) ON DUPLICATE KEY UPDATE secret_enc = VALUES(secret_enc), set_by = VALUES(set_by), set_at = VALUES(set_at)', + [p.resourceId, encrypt(secrets.password), p.actorUserId]); + } const after = await one('SELECT state, valid_until FROM resources WHERE id = ?', [p.resourceId]); await audit({ actorType: p.actorUserId ? 'user' : 'system', actorId: p.actorUserId, orgId: r.org_id, action: `resource.${p.action}`, resourceType: 'resource', resourceId: p.resourceId, connector: inst.connector_key, correlationId, before, after: { state: after?.state, validUntil: after?.valid_until, params: p.params } }); return `${p.action}: ${after?.state ?? 'ok'}`;