src/ApplicationBundle/Modules/Api/Controller/EbEventsController.php line 38

Open in your IDE?
  1. <?php
  2. namespace ApplicationBundle\Modules\Api\Controller;
  3. use ApplicationBundle\Controller\GenericController;
  4. use ApplicationBundle\Interfaces\PublicApiInterface;
  5. use ApplicationBundle\Helper\EbBridgeConfig;
  6. use ApplicationBundle\Modules\Api\Support\EbBridgeVerifier;
  7. use CompanyGroupBundle\Entity\EnergyBridgeEvent;
  8. use CompanyGroupBundle\Entity\EnergyBridgeSiteLink;
  9. use Symfony\Component\HttpFoundation\Request;
  10. use Symfony\Component\HttpFoundation\JsonResponse;
  11. /**
  12.  * EB1c — the Energy⇄Business Bridge ingress endpoint (`POST /api/v1/eb/events`). The cloud EMS
  13.  * dispatcher delivers signed, idempotent events here per EB_BRIDGE_CONTRACT_v1.md. This endpoint
  14.  * ONLY lands + dedupes the raw event (no GL, no business posting — that is EB3/EB2, downstream).
  15.  *
  16.  * Design decisions (documented per the track brief):
  17.  *  - The raw event log lives CENTRAL (company_group), keyed by event_id, so dedupe + audit are
  18.  *    global and the endpoint needs no tenant context to accept an event. The resolved tenant
  19.  *    (appId, via the EB0 site-link) is stamped on the row; per-tenant artifacts are created later
  20.  *    by the downstream handlers, which switch to the tenant DB themselves.
  21.  *  - The signing secret being unset is the ingress OFF-switch (503 "not configured") — so the
  22.  *    bridge is inert by default until the owner shares the secret. The per-tenant opt-in
  23.  *    `acc_setting energy_bridge_enabled` (default OFF) gates the downstream BUSINESS action, not
  24.  *    this landing step (we never want to lose an audit event just because a tenant hasn't opted in).
  25.  *  - Fail-safe on an unmapped site_uid: the event is still landed, flagged `unlinked`, and NO
  26.  *    business action is queued — we never post to a guessed tenant.
  27.  *
  28.  * Responses (contract §1): 200 accepted/duplicate; 401 bad signature / stale timestamp;
  29.  * 400 malformed; 503 not configured / transient (producer retries).
  30.  */
  31. class EbEventsController extends GenericController implements PublicApiInterface
  32. {
  33.     const MAX_BYTES 1048576// 1 MB cap (DoS guard); EB events are small JSON envelopes.
  34.     public function receiveAction(Request $request)
  35.     {
  36.         // 1. Read the RAW bytes FIRST — the HMAC signature is over the exact body sent.
  37.         $raw = (string) $request->getContent();
  38.         if (strlen($raw) > self::MAX_BYTES) {
  39.             return new JsonResponse(['status' => 'error''error' => 'payload too large'], 413);
  40.         }
  41.         // 2. Ingress gate: no shared secret configured → we cannot verify anything. Return 503 so
  42.         //    the producer retries (rather than permanently failing the event) once the owner wires
  43.         //    HONEYBEE_EB_SIGNING_SECRET. This is also the bridge's default-OFF switch.
  44.         $secret EbBridgeConfig::signingSecret();
  45.         if ($secret === '') {
  46.             return new JsonResponse(['status' => 'error''error' => 'bridge not configured'], 503);
  47.         }
  48.         // 3. Verify HMAC-SHA256 over the raw body (constant-time). Mismatch/malformed → 401.
  49.         $sigHeader $request->headers->get('X-EB-Signature');
  50.         if (!EbBridgeVerifier::verifySignature($raw$sigHeader$secret)) {
  51.             return new JsonResponse(['status' => 'error''error' => 'bad signature'], 401);
  52.         }
  53.         // 4. Replay window: reject a delivery whose timestamp is outside ±window of server time.
  54.         $now time();
  55.         if (!EbBridgeVerifier::timestampFresh($request->headers->get('X-EB-Timestamp'), $nowEbBridgeConfig::replayWindow())) {
  56.             return new JsonResponse(['status' => 'error''error' => 'stale or missing timestamp'], 401);
  57.         }
  58.         // 5. Decode + validate the envelope (event_id + event_type required).
  59.         $evt EbBridgeVerifier::decodeEvent($raw);
  60.         if ($evt === null) {
  61.             return new JsonResponse(['status' => 'error''error' => 'malformed event'], 400);
  62.         }
  63.         $eventId $evt['event_id'];
  64.         // 6. Everything below is on the CENTRAL manager (dedupe + audit are global).
  65.         try {
  66.             $em $this->getDoctrine()->getManager('company_group');
  67.             // 6a. Dedupe: a redelivery of an already-seen event_id is a successful no-op.
  68.             $existing $em->getRepository('CompanyGroupBundle\\Entity\\EnergyBridgeEvent')
  69.                 ->findOneBy(['eventId' => $eventId]);
  70.             if ($existing !== null) {
  71.                 return new JsonResponse(['status' => 'ok''idempotency_key' => $eventId'duplicate' => true], 200);
  72.             }
  73.             // 6b. Resolve the tenant via the EB0 site-link (fail-safe: 0 → unlinked, never guessed).
  74.             //     Stamp link_type (self/customer) onto the landed row so EB2/EB3 can branch without
  75.             //     re-reading the map (and so the monitor shows Own vs Customer at a glance).
  76.             $appId 0;
  77.             $linkType null;
  78.             if ($evt['site_uid'] !== '') {
  79.                 $link $em->getRepository('CompanyGroupBundle\\Entity\\EnergyBridgeSiteLink')
  80.                     ->findOneBy(['siteUid' => $evt['site_uid']]);
  81.                 if ($link !== null) {
  82.                     $appId = (int) $link->getAppId();
  83.                     $linkType $link->getLinkType() ?: EbBridgeVerifier::linkTypeForCustomer($link->getCustomerId());
  84.                 }
  85.             }
  86.             $status EbBridgeVerifier::statusForAppId($appId);
  87.             // 6c. Land the raw event.
  88.             $row = new EnergyBridgeEvent();
  89.             $row->setEventId($eventId);
  90.             $row->setEventType($evt['event_type']);
  91.             $row->setSiteUid($evt['site_uid'] !== '' $evt['site_uid'] : null);
  92.             $row->setOccurredAt(self::parseOccurredAt($evt['occurred_at']));
  93.             $row->setRawJson($raw);
  94.             $row->setSignatureAlg(EbBridgeVerifier::SIGNATURE_ALG);
  95.             $row->setCorrelationId((string) $request->headers->get('X-Correlation-Id') ?: null);
  96.             $row->setStatus($status);
  97.             $row->setAppId($appId $appId null);
  98.             $row->setLinkType($linkType);
  99.             $row->setReceivedAt(new \DateTime());
  100.             if ($status === 'unlinked') {
  101.                 $row->setNote('no site-link for site_uid=' $evt['site_uid'] . ' — landed, no business action');
  102.                 error_log('[EB1c] unlinked event ' $eventId ' site_uid=' $evt['site_uid'] . ' type=' $evt['event_type']);
  103.             }
  104.             $em->persist($row);
  105.             try {
  106.                 $em->flush();
  107.             } catch (\Throwable $flushEx) {
  108.                 // Race: a concurrent delivery of the same event_id won the UNIQUE(event_id) insert
  109.                 // between our find and flush. That is idempotency working — treat as a duplicate.
  110.                 $dupe $em->getRepository('CompanyGroupBundle\\Entity\\EnergyBridgeEvent')
  111.                     ->findOneBy(['eventId' => $eventId]);
  112.                 if ($dupe !== null) {
  113.                     return new JsonResponse(['status' => 'ok''idempotency_key' => $eventId'duplicate' => true], 200);
  114.                 }
  115.                 throw $flushEx// genuine DB error → 5xx below (producer retries)
  116.             }
  117.             return new JsonResponse([
  118.                 'status'          => 'ok',
  119.                 'idempotency_key' => $eventId,
  120.                 'link_status'     => $status,
  121.             ], 202);
  122.         } catch (\Throwable $e) {
  123.             // Genuine transient server error → 5xx so the producer retries with backoff.
  124.             error_log('[EB1c] ingress error for ' $eventId ': ' $e->getMessage());
  125.             return new JsonResponse(['status' => 'error''error' => 'temporary server error'], 503);
  126.         }
  127.     }
  128.     /** Tolerant parse of the envelope's occurred_at (a date or datetime); null if unparseable. */
  129.     private static function parseOccurredAt(string $occurredAt): ?\DateTime
  130.     {
  131.         if (trim($occurredAt) === '') { return null; }
  132.         try {
  133.             return new \DateTime($occurredAt);
  134.         } catch (\Throwable $e) {
  135.             return null;
  136.         }
  137.     }
  138. }