Files
2026-11-06 13:37:12 +08:00

82 lines
5.9 KiB
PHP
Executable File

<?php
declare(strict_types=1);
namespace app\adminapi\service\iam\v2;
use RuntimeException;
use think\facade\Db;
final class IamV2Events
{
public static function accept(string $raw, string $signature, string $eventId, string $secret, ?callable $revoke = null, ?callable $refresh = null, ?callable $locate = null, ?callable $generation = null): array
{
if (strlen($raw) > 65536 || strlen($secret) < 32 || !hash_equals('sha256=' . hash_hmac('sha256', $raw, $secret), $signature)) { throw new RuntimeException('FORBIDDEN'); }
$event = json_decode($raw, true, 32, JSON_THROW_ON_ERROR);
if (empty($event['employeeId']) || $eventId === '' || strlen($eventId) > 128 || $eventId !== ($event['id'] ?? '') || !in_array('zyt', (array) ($event['applicationIds'] ?? []), true)) { throw new RuntimeException('INVALID_EVENT'); }
$occurredAt = (string) ($event['occurredAt'] ?? '');
if (!preg_match('/^\d{4}-\d{2}-\d{2}T.*(Z|[+-][0-9]{2}:[0-9]{2})$/D', $occurredAt)) { throw new RuntimeException('INVALID_EVENT'); }
$stamp = strtotime($occurredAt);
if (!$stamp || $stamp > time() + 60) { throw new RuntimeException('TRANSACTION_EXPIRED'); }
$customRevoke = $revoke !== null;
$revoke ??= fn(int $id) => IamV2Manager::revoke($id);
$refresh ??= fn(int $id) => (new IamV2Manager())->binding($id, true);
$generation ??= $customRevoke ? fn(int $id, int $upper) => $revoke($id) : fn(int $id, int $upper) => IamV2Source::revokeGeneration($id, $upper, (string) $event['employeeId']);
$locate ??= static function (array $event): array {
if (($event['eventType'] ?? '') === 'binding_revoked' && isset($event['externalAccountId'])) {
if (!is_string($event['externalAccountId']) || !preg_match('/^[1-9][0-9]{0,9}$/D', $event['externalAccountId'])) { throw new RuntimeException('INVALID_EVENT'); }
return [(int) $event['externalAccountId']];
}
$reply = (new IamV2Client())->call('GET', '/bindings?' . http_build_query(['employeeId' => $event['employeeId'], 'page' => 1, 'pageSize' => 100], '', '&', PHP_QUERY_RFC3986));
if (($reply['total'] ?? 0) > 100) { throw new RuntimeException('SERVICE_UNAVAILABLE'); }
$ids = [];
foreach ($reply['items'] ?? [] as $item) {
if (($item['binding']['employeeId'] ?? '') !== $event['employeeId'] || ($item['binding']['applicationId'] ?? '') !== 'zyt') { throw new RuntimeException('INVALID_RESPONSE'); }
$external = (string) ($item['binding']['externalAccountId'] ?? '');
if (!preg_match('/^[1-9][0-9]{0,9}$/D', $external)) { throw new RuntimeException('INVALID_RESPONSE'); }
$ids[] = (int) $external;
}
return $ids;
};
return Db::transaction(function () use ($event, $eventId, $raw, $revoke, $refresh, $locate, $generation) {
$existing = Db::name('iam_v2_event')->where('event_id', $eventId)->lock(true)->find();
if ($existing) {
if (!hash_equals($existing['digest'], hash('sha256', $raw))) { throw new RuntimeException('INVALID_EVENT'); }
return ['accepted' => true, 'duplicate' => true];
}
Db::name('iam_v2_event')->insert(['event_id' => $eventId, 'digest' => hash('sha256', $raw), 'received_at' => time()]);
$rows = Db::name('iam_v2_binding')->where('employee_id', (string) $event['employeeId'])->lock(true)->select()->toArray();
if (!$rows && ($event['eventType'] ?? '') !== 'source_binding_revoked') {
foreach ($locate($event) as $id) {
$row = Db::name('iam_v2_binding')->where('admin_id', $id)->find();
if (!$row) { $refresh($id); $row = Db::name('iam_v2_binding')->where('admin_id', $id)->find(); }
if ($row) { $rows[] = $row; }
}
}
foreach ($rows as $row) {
$bindingEvent = ($event['eventType'] ?? '') === 'binding_revoked'
|| in_array($event['reason'] ?? '', ['iam.v2.unbind', 'iam.v2.rebind'], true);
// ZYT is source-only in this rollout; target-derived revocation belongs to ERP.
if (($event['eventType'] ?? '') === 'source_binding_revoked') { continue; }
$upper = isset($event['bindingVersion']) ? (int) $event['bindingVersion'] : null;
if ($bindingEvent && $upper !== null && ($upper < (int) $row['version'] || $row['employee_id'] !== $event['employeeId'])) { $generation((int) $row['admin_id'], $upper); continue; }
if (!$bindingEvent || $upper === null) {
// Durable outbox delivery may be old. Current central state wins over a stale generic event.
// A failed lookup rolls back event receipt, so the durable outbox can retry.
$current = $refresh((int) $row['admin_id']);
if (($current['binding']['state'] ?? '') === 'verified' && ($current['employee']['status'] ?? '') === 'active'
&& ($current['access']['allowed'] ?? false)) { continue; }
}
$updates = ['checked_at' => 0];
// Personnel disable/entitlement events invalidate sessions, not the binding itself.
// A versioned binding event carries the OLD generation as its revocation upper bound.
if ($bindingEvent && $upper !== null && $upper > 0) {
$updates['state'] = 'revoked'; $updates['version'] = max((int) $row['version'], $upper);
}
Db::name('iam_v2_binding')->where('admin_id', $row['admin_id'])->update($updates);
if ($bindingEvent && $upper !== null) { $generation((int) $row['admin_id'], $upper); }
else { $revoke((int) $row['admin_id']); }
}
return ['accepted' => true];
});
}
}