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
|
|
@ -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