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]; }); } }