KeyHelp: Panel-Passwort neu vergeben und verschlüsselt hinterlegen (verblurrt wie Lizenzschlüssel)
This commit is contained in:
parent
3a2864ee76
commit
6fec6277f5
9 changed files with 136 additions and 20 deletions
|
|
@ -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<string[]>(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) });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>; usage: Record<string, unknown>; details: Record<string, any>; allowedActions: string[]; customerActions?: string[]; jobs: { id: string; action: string; status: string; error: string | null; createdAt: string }[] }
|
||||
const LIMIT_LABEL: Record<string, string> = { 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<Det | null>(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<Det>('GET', `/resources/${id}`).then(setD).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), [id]);
|
||||
const [creds, setCreds] = useState<PanelCreds | null>(null); const [credsBusy, setCredsBusy] = useState(false); const [credsConfirm, setCredsConfirm] = useState(false);
|
||||
const load = useCallback(() => Promise.all([
|
||||
api<Det>('GET', `/resources/${id}`).then(setD),
|
||||
api<PanelCreds>('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<typeof custs>('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' && <UsageBars usage={d.usage} limits={d.limits} />}
|
||||
{(d.hasChildren || d.canLogin) && <HostingPanel resourceId={d.id} canLogin={!!d.canLogin} />}
|
||||
{d.canReveal && <div className="card"><h2>Lizenzschlüssel</h2><SecretField resourceId={d.id} /></div>}
|
||||
{creds?.canReset && <div className="card"><h2>Panel-Zugangsdaten</h2>
|
||||
<p className="muted small">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.</p>
|
||||
{creds.set ? (<>
|
||||
<SecretField resourceId={d.id} url={`/resources/${d.id}/panel-credentials/reveal`} label="Panel-Passwort" placeholder="••••••••••••••••••" />
|
||||
<p className="muted small" style={{ marginTop: 8 }}>Zuletzt vergeben: {creds.setAt ? fmt(creds.setAt) : '–'}</p>
|
||||
</>) : <p className="muted small">Es wurde noch kein Passwort über das Kundencenter vergeben.</p>}
|
||||
{credsConfirm ? (
|
||||
<div role="alertdialog" aria-labelledby="cd-pw" style={{ marginTop: 12 }}><p id="cd-pw"><strong>Neues Passwort setzen?</strong> Das {creds.set ? 'bisherige, über das Kundencenter vergebene' : 'aktuelle'} Passwort wird damit ungültig.</p>
|
||||
<div className="row"><button className="btn primary" disabled={credsBusy} onClick={resetPanelPassword}>Ja, neu vergeben</button><button className="btn" onClick={() => setCredsConfirm(false)}>Abbrechen</button></div></div>
|
||||
) : <button className="btn" style={{ marginTop: 12 }} onClick={() => setCredsConfirm(true)}>{creds.set ? 'Neues Passwort vergeben' : 'Passwort vergeben'}</button>}
|
||||
</div>}
|
||||
<div className="card"><h2>Aktionen</h2>
|
||||
{act.length === 0 ? <p className="muted">Für diese Ressource sind aktuell keine Aktionen verfügbar.</p> : confirm ? (
|
||||
<div role="alertdialog" aria-labelledby="cd"><p id="cd"><strong>{ACTION_LABEL[confirm.action]}{confirm.days ? ` um ${confirm.days} Tage` : ''}?</strong> Die Änderung wird als Auftrag ausgeführt und protokolliert.</p>
|
||||
|
|
|
|||
|
|
@ -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<string | null>(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 (
|
||||
<div>
|
||||
<div className="row" style={{ alignItems: 'stretch', gap: 8, flexWrap: compact ? 'nowrap' : 'wrap' }}>
|
||||
<button type="button" onClick={reveal} disabled={busy && !secret} aria-label={secret ? 'Lizenzschlüssel (sichtbar)' : 'Lizenzschlüssel anzeigen'} className="mono"
|
||||
<button type="button" onClick={reveal} disabled={busy && !secret} aria-label={secret ? `${label} (sichtbar)` : `${label} anzeigen`} className="mono"
|
||||
style={{ flex: '1 1 auto', minWidth: compact ? 260 : 280, textAlign: 'left', padding: compact ? '6px 10px' : '10px 14px', minHeight: compact ? 36 : 44, border: '1px solid var(--border)', borderRadius: 8, background: 'var(--surface-2)', color: 'var(--text)',
|
||||
cursor: secret ? 'text' : 'pointer', fontSize: compact ? '.85rem' : '1.05rem', userSelect: secret ? 'all' : 'none', filter: secret ? 'none' : 'blur(6px)', transition: 'filter .15s' }}>
|
||||
{secret ?? 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'}
|
||||
{secret ?? placeholder}
|
||||
</button>
|
||||
<button type="button" className={`btn${compact ? ' small' : ''}`} onClick={copy} disabled={busy}>{busy ? '…' : copied ? 'Kopiert ✓' : 'Kopieren'}</button>
|
||||
{secret && <button type="button" className={`btn${compact ? ' small' : ''}`} onClick={() => { setSecret(null); setCopied(false); }}>Verbergen</button>}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export function JobStatus({ value }: { value: string }) {
|
|||
const icon = cls === 'ok' ? '✓' : cls === 'err' ? '✕' : cls === 'warn' ? '▲' : '◐';
|
||||
return <span className={`badge ${cls}`}><span aria-hidden="true">{icon}</span>{JOB_LABEL[value] ?? value}</span>;
|
||||
}
|
||||
export const ACTION_LABEL: Record<string, string> = { 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<string, string> = { 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;
|
||||
|
||||
|
|
|
|||
8
migrations/019_panel_credentials.sql
Normal file
8
migrations/019_panel_credentials.sql
Normal file
|
|
@ -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;
|
||||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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<ActionName, Capability> = {
|
||||
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<ActionName> = 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<ActionName> = 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<string, unknown>; 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<string, unknown>; idempotencyKey: string }): Promise<{ resource?: NormalizedResource }>;
|
||||
execute?(ctx: ConnectorContext, req: { action: ActionName; externalRef: string; params?: Record<string, unknown>; secrets?: Record<string, string>; idempotencyKey: string }): Promise<{ resource?: NormalizedResource }>;
|
||||
}
|
||||
|
||||
// ---- Normalisierte Fehler --------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -70,8 +70,10 @@ async function upsert(instanceId: string, r: NormalizedResource): Promise<string
|
|||
return (await one('SELECT id FROM resources WHERE instance_id = ? AND external_ref = ?', [instanceId, r.externalRef]))!.id as string;
|
||||
}
|
||||
|
||||
export interface ExecutePayload { resourceId: string; action: ActionName; params?: Record<string, unknown>; 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<string, unknown>; 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<string> {
|
||||
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<string, string>) : 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'}`;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue