82 lines
5.9 KiB
PHP
Executable File
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];
|
|
});
|
|
}
|
|
}
|