src/ApplicationBundle/Modules/CentralControl/Controller/CentralControlController.php line 41

Open in your IDE?
  1. <?php
  2. namespace ApplicationBundle\Modules\CentralControl\Controller;
  3. use ApplicationBundle\Controller\GenericController;
  4. use ApplicationBundle\Interfaces\SessionCheckInterface;
  5. use ApplicationBundle\Modules\Authentication\Constants\UserConstants;
  6. use ApplicationBundle\Modules\CentralControl\Service\PlatformAuditLogger;
  7. use ApplicationBundle\Modules\CentralControl\Service\PlatformFlagFleetService;
  8. use ApplicationBundle\Modules\CentralControl\Service\PlatformFlagSettingsService;
  9. use ApplicationBundle\Modules\CentralControl\Service\PlatformOpsService;
  10. use ApplicationBundle\Modules\CentralControl\Service\SupportAccessService;
  11. use ApplicationBundle\Modules\CentralControl\Service\TenantCommandService;
  12. use ApplicationBundle\Modules\CentralControl\Support\CentralControlCore;
  13. use ApplicationBundle\Modules\CentralControl\Support\PlatformFlagRegistry;
  14. use ApplicationBundle\Modules\CentralControl\Support\PlatformOpsCore;
  15. use Symfony\Component\HttpFoundation\JsonResponse;
  16. use Symfony\Component\HttpFoundation\Request;
  17. /**
  18.  * CC1+CC2 — Tenant Command Console (/admin/control): the central control plane's cockpit.
  19.  * cp-shell surface (owner decision for NEW console pages), hard-gated to the CENTRAL box +
  20.  * super-admin session, linked from the hb-admin sidebar. Every mutation goes through the central
  21.  * services (TenantCommandService / PlatformFlagSettingsService), which audit into
  22.  * platform_admin_audit. CC7 LAW: the console opens NO tenant DB connection — desired state lives in
  23.  * central platform_setting; actual state is read from the collector's fleet_health_snapshot.
  24.  */
  25. class CentralControlController extends GenericController implements SessionCheckInterface
  26. {
  27.     // ── Guards (same access set as SuperAdminDashboardController) ───────────
  28.     private function isCentral()
  29.     {
  30.         $sys $this->container->hasParameter('system_type') ? $this->container->getParameter('system_type') : '_ERP_';
  31.         return $sys === '_CENTRAL_';
  32.     }
  33.     private function canAccessSuperAdmin(Request $request)
  34.     {
  35.         $session $request->getSession();
  36.         if ((int) $session->get(UserConstants::USER_ID0) <= 0) {
  37.             return false;
  38.         }
  39.         $userType = (int) $session->get(UserConstants::USER_TYPE0);
  40.         $allowedTypes = array(
  41.             UserConstants::USER_TYPE_SYSTEM,
  42.             UserConstants::USER_TYPE_MANAGEMENT_USER,
  43.             UserConstants::USER_TYPE_GENERAL,
  44.         );
  45.         return (int) $session->get(UserConstants::IS_BUDDYBEE_ADMIN0) === 1
  46.             || (int) $session->get(UserConstants::ALL_MODULE_ACCESS_FLAG0) === 1
  47.             || in_array($userType$allowedTypestrue);
  48.     }
  49.     /** @return \Symfony\Component\HttpFoundation\RedirectResponse|null null when allowed */
  50.     private function guard(Request $request)
  51.     {
  52.         if (!$this->isCentral() || !$this->canAccessSuperAdmin($request)) {
  53.             return $this->redirectToRoute('dashboard');
  54.         }
  55.         return null;
  56.     }
  57.     private function centralEm()
  58.     {
  59.         return $this->getDoctrine()->getManager('company_group');
  60.     }
  61.     private function adminCtx(Request $request)
  62.     {
  63.         $session $request->getSession();
  64.         return array(
  65.             'user_id' => (int) $session->get(UserConstants::USER_ID0),
  66.             'user_name' => (string) $session->get(UserConstants::USER_NAME''),
  67.             'ip' => (string) $request->getClientIp(),
  68.         );
  69.     }
  70.     private function commandService()
  71.     {
  72.         $em $this->centralEm();
  73.         return new TenantCommandService(
  74.             $em,
  75.             new PlatformAuditLogger($em),
  76.             $this->container->getParameter('kernel.root_dir')
  77.         );
  78.     }
  79.     private function opsService()
  80.     {
  81.         $projectDir $this->container->hasParameter('kernel.project_dir')
  82.             ? $this->container->getParameter('kernel.project_dir')
  83.             : dirname($this->container->getParameter('kernel.root_dir'));
  84.         $env $this->container->hasParameter('kernel.environment')
  85.             ? $this->container->getParameter('kernel.environment') : 'prod';
  86.         return new PlatformOpsService($this->centralEm(), $projectDir$env);
  87.     }
  88.     // ── CC1: console ─────────────────────────────────────────────────────────
  89.     public function consoleAction(Request $request)
  90.     {
  91.         if ($redirect $this->guard($request)) {
  92.             return $redirect;
  93.         }
  94.         $search trim((string) $request->query->get('q'''));
  95.         $status trim((string) $request->query->get('status'''));
  96.         $em $this->centralEm();
  97.         $logger = new PlatformAuditLogger($em);
  98.         $qb $em->createQueryBuilder()
  99.             ->select('g')
  100.             ->from('CompanyGroupBundle\\Entity\\CompanyGroup''g')
  101.             ->orderBy('g.lastActivityAt''DESC')
  102.             ->addOrderBy('g.id''DESC')
  103.             ->setMaxResults(300);
  104.         if ($search !== '') {
  105.             $qb->andWhere('g.name LIKE :s OR g.email LIKE :s OR g.appId LIKE :s')
  106.                 ->setParameter('s''%' $search '%');
  107.         }
  108.         if ($status !== '') {
  109.             if ($status === 'readonly') {
  110.                 $qb->andWhere('g.readOnlyMode = 1');
  111.             } elseif ($status === 'disabled') {
  112.                 $qb->andWhere('g.active = 0');
  113.             } else {
  114.                 $qb->andWhere('g.companyStatus = :st')->setParameter('st'$status);
  115.             }
  116.         }
  117.         $tenants = array();
  118.         $summary = array('all' => 0'active' => 0'trial' => 0'suspended' => 0'expired' => 0'readonly' => 0'disabled' => 0);
  119.         try {
  120.             $tenants $qb->getQuery()->getResult();
  121.             $conn $em->getConnection();
  122.             $summary['all'] = (int) $conn->fetchOne('SELECT COUNT(*) FROM company_group');
  123.             foreach (array('active''trial''suspended''expired') as $st) {
  124.                 $summary[$st] = (int) $conn->fetchOne('SELECT COUNT(*) FROM company_group WHERE company_status = ?', array($st));
  125.             }
  126.             // readOnlyMode has no explicit column mapping — let DQL resolve it, never raw SQL.
  127.             $summary['readonly'] = (int) $em->createQuery(
  128.                 'SELECT COUNT(g.id) FROM CompanyGroupBundle\\Entity\\CompanyGroup g WHERE g.readOnlyMode = 1'
  129.             )->getSingleScalarResult();
  130.             $summary['disabled'] = (int) $conn->fetchOne('SELECT COUNT(*) FROM company_group WHERE active = 0');
  131.         } catch (\Throwable $e) {
  132.             $this->addFlash('error''Central registry unreachable: ' $e->getMessage());
  133.         }
  134.         // CC5 — usage facts for the visible tenants, read from the nightly fleet_health_snapshot
  135.         // (NEVER live cross-DB here). Fail-soft: a pre-CC5 central (no usage columns) → empty map,
  136.         // the console simply shows dashes. Keyed by appId for O(1) lookup in the row loop.
  137.         $usage = array();
  138.         try {
  139.             $conn $em->getConnection();
  140.             $hasUsage = (int) $conn->fetchOne(
  141.                 "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE()
  142.                  AND table_name = 'fleet_health_snapshot' AND column_name = 'sessions_7d'") > 0;
  143.             if ($hasUsage && !empty($tenants)) {
  144.                 $ids = array();
  145.                 foreach ($tenants as $t) { $ids[] = (int) $t->getAppId(); }
  146.                 foreach ($conn->fetchAllAssociative(
  147.                     'SELECT app_id, sessions_7d, sessions_30d, active_users_7d, active_users_30d,
  148.                             top_modules_json, collected_at
  149.                      FROM fleet_health_snapshot WHERE app_id IN (' implode(','array_fill(0count($ids), '?')) . ')',
  150.                     $ids) as $r) {
  151.                     $usage[(int) $r['app_id']] = array(
  152.                         'sessions7d' => $r['sessions_7d'],
  153.                         'sessions30d' => $r['sessions_30d'],
  154.                         'activeUsers7d' => $r['active_users_7d'],
  155.                         'activeUsers30d' => $r['active_users_30d'],
  156.                         'topModules' => \ApplicationBundle\Modules\CentralControl\Support\UsageFactsCore::summarize($r['top_modules_json'], 3),
  157.                         'collectedAt' => $r['collected_at'],
  158.                     );
  159.                 }
  160.             }
  161.         } catch (\Throwable $e) { /* pre-CC5 central or unreachable — dashes */ }
  162.         return $this->render('@CentralControl/pages/control_console.html.twig', array(
  163.             'page_title' => 'Tenant Command Console',
  164.             'sidebar_partial' => '@CentralControl/pages/_control_sidebar.html.twig',
  165.             'cp_active' => 'console',
  166.             'tenants' => $tenants,
  167.             'summary' => $summary,
  168.             'usage' => $usage,
  169.             'search' => $search,
  170.             'status_filter' => $status,
  171.             'audit_ready' => $logger->tableExists(),
  172.             'recent_audit' => $logger->recent(12),
  173.             'flag_defs' => PlatformFlagRegistry::flags(),
  174.             'ops_defs' => PlatformOpsCore::ops(),
  175.             'ops_ready' => $this->opsService()->ready(),
  176.             'ops_local_topology' => $this->opsService()->localTopologyEnabled(),
  177.             'today' => date('Y-m-d'),
  178.         ));
  179.     }
  180.     /** One-click lifecycle action. POST only; audited; fail-closed without the audit table. */
  181.     public function tenantActionAction(Request $request$appId)
  182.     {
  183.         if ($redirect $this->guard($request)) {
  184.             return $redirect;
  185.         }
  186.         $action = (string) $request->request->get('control_action''');
  187.         if (!in_array($actionCentralControlCore::ACTIONStrue)) {
  188.             $this->addFlash('error''Unknown control action.');
  189.             return $this->redirectToRoute('admin_control');
  190.         }
  191.         $result $this->commandService()->apply(
  192.             (int) $appId,
  193.             $action,
  194.             $request->request->get('expiry_date'),
  195.             $request->request->get('reason'),
  196.             $this->adminCtx($request)
  197.         );
  198.         $this->addFlash($result['success'] ? (strpos($result['message'], 'AUDIT ROW FAILED') !== false || ($result['push_ok'] === false) ? 'warning' 'success') : 'error'$result['message']);
  199.         return $this->redirectToRoute('admin_control', array(
  200.             'q' => $request->request->get('back_q'''),
  201.             'status' => $request->request->get('back_status'''),
  202.         ));
  203.     }
  204.     // ── AUT1: Founder Sovereignty Council register (§5) ────────────────────────
  205.     private function governanceService()
  206.     {
  207.         return new \ApplicationBundle\Modules\CentralControl\Service\GovernanceService($this->centralEm());
  208.     }
  209.     /**
  210.      * The governance register: decisions with their EXACT voted text + hash, the live evaluation
  211.      * (unanimity / silence≠consent / text-change-voids-votes), and the expiring delegation register.
  212.      * A RECORD-KEEPER — nothing here grants authority (AUT0's capability_registry stays the gate).
  213.      */
  214.     public function governanceAction(Request $request)
  215.     {
  216.         if ($redirect $this->guard($request)) {
  217.             return $redirect;
  218.         }
  219.         $em $this->centralEm();
  220.         $svc $this->governanceService();
  221.         $ready $svc->tablesReady();
  222.         $rows = array();
  223.         $delegations = array();
  224.         if ($ready) {
  225.             foreach ($svc->decisions(null200) as $d) {
  226.                 $rows[] = array(
  227.                     'd' => $d,
  228.                     'eval' => $svc->evaluate($d),
  229.                     'votes' => $svc->votesFor($d),
  230.                 );
  231.             }
  232.             foreach ($svc->delegations(200) as $g) {
  233.                 $delegations[] = array('g' => $g'valid' => $svc->delegationStatus($g));
  234.             }
  235.         }
  236.         return $this->render('@CentralControl/pages/control_governance.html.twig', array(
  237.             'page_title' => 'Founder Sovereignty Council — governance register',
  238.             'sidebar_partial' => '@CentralControl/pages/_control_sidebar.html.twig',
  239.             'cp_active' => 'governance',
  240.             'tables_ready' => $ready,
  241.             'audit_ready' => (new PlatformAuditLogger($em))->tableExists(),
  242.             'founders' => $svc->founders(),
  243.             'rows' => $rows,
  244.             'delegations' => $delegations,
  245.             'types' => \ApplicationBundle\Modules\CentralControl\Support\GovernanceCore::TYPES,
  246.         ));
  247.     }
  248.     /** Record a founder's vote on a decision's CURRENT text. Audited; fail-closed on the audit table. */
  249.     public function governanceVoteAction(Request $request$id)
  250.     {
  251.         if ($redirect $this->guard($request)) {
  252.             return $redirect;
  253.         }
  254.         $em $this->centralEm();
  255.         $logger = new PlatformAuditLogger($em);
  256.         if (!$logger->tableExists()) {
  257.             $this->addFlash('error''Refused: platform_admin_audit is missing — governance actions must be auditable.');
  258.             return $this->redirectToRoute('admin_control_governance');
  259.         }
  260.         $svc $this->governanceService();
  261.         $d $svc->decision($id);
  262.         if (!$d) {
  263.             $this->addFlash('error''Decision not found.');
  264.             return $this->redirectToRoute('admin_control_governance');
  265.         }
  266.         $founderId = (int) $request->request->get('founder_id'0);
  267.         $vote = (string) $request->request->get('vote''');
  268.         $r $svc->recordVote($d$founderId$vote, array(
  269.             'user_name' => (string) $request->getSession()->get(UserConstants::USER_NAME''),
  270.             'note' => $request->request->get('note'),
  271.         ));
  272.         if (!empty($r['success'])) {
  273.             $logger->log(null'governance_vote', array(), array('decision' => (int) $d->getId(), 'founder' => $founderId'vote' => $vote'status' => $r['status'], 'hash' => $d->getContentHash()), $request->request->get('note'), nullnull$this->adminCtx($request));
  274.         }
  275.         $this->addFlash(!empty($r['success']) ? 'success' 'error'$r['message']);
  276.         return $this->redirectToRoute('admin_control_governance');
  277.     }
  278.     /** Propose a new decision (the exact text is what gets voted + hashed). Audited. */
  279.     public function governanceProposeAction(Request $request)
  280.     {
  281.         if ($redirect $this->guard($request)) {
  282.             return $redirect;
  283.         }
  284.         $em $this->centralEm();
  285.         $logger = new PlatformAuditLogger($em);
  286.         if (!$logger->tableExists()) {
  287.             $this->addFlash('error''Refused: platform_admin_audit is missing.');
  288.             return $this->redirectToRoute('admin_control_governance');
  289.         }
  290.         $r $this->governanceService()->propose(
  291.             (string) $request->request->get('type'''),
  292.             (string) $request->request->get('subject'''),
  293.             (string) $request->request->get('body'''),
  294.             array('user_name' => (string) $request->getSession()->get(UserConstants::USER_NAME''))
  295.         );
  296.         if (!empty($r['success'])) {
  297.             $logger->log(null'governance_propose', array(), array('decision' => (int) $r['decision']->getId(), 'type' => $r['decision']->getType(), 'hash' => $r['decision']->getContentHash()), nullnullnull$this->adminCtx($request));
  298.         }
  299.         $this->addFlash(!empty($r['success']) ? 'success' 'error'$r['message']);
  300.         return $this->redirectToRoute('admin_control_governance');
  301.     }
  302.     /**
  303.      * Amend a decision's text. §5: this VOIDS every prior vote (the hash moves) and drops it back to
  304.      * proposed — structural, not a convention. Audited loudly.
  305.      */
  306.     public function governanceAmendAction(Request $request$id)
  307.     {
  308.         if ($redirect $this->guard($request)) {
  309.             return $redirect;
  310.         }
  311.         $em $this->centralEm();
  312.         $logger = new PlatformAuditLogger($em);
  313.         if (!$logger->tableExists()) {
  314.             $this->addFlash('error''Refused: platform_admin_audit is missing.');
  315.             return $this->redirectToRoute('admin_control_governance');
  316.         }
  317.         $svc $this->governanceService();
  318.         $d $svc->decision($id);
  319.         if (!$d) {
  320.             $this->addFlash('error''Decision not found.');
  321.             return $this->redirectToRoute('admin_control_governance');
  322.         }
  323.         $before = array('hash' => $d->getContentHash(), 'status' => $d->getStatus());
  324.         $r $svc->amend($d, (string) $request->request->get('body'''), array('user_name' => (string) $request->getSession()->get(UserConstants::USER_NAME'')));
  325.         $logger->log(null'governance_amend'$before, array('hash' => $d->getContentHash(), 'status' => $d->getStatus(), 'votes_voided' => $r['voided']), nullnullnull$this->adminCtx($request));
  326.         $this->addFlash('warning'$r['message']);
  327.         return $this->redirectToRoute('admin_control_governance');
  328.     }
  329.     /** Record a scoped, EXPIRING delegation (§5 forbids indefinite/unrestricted). Audited. */
  330.     public function governanceDelegateAction(Request $request)
  331.     {
  332.         if ($redirect $this->guard($request)) {
  333.             return $redirect;
  334.         }
  335.         $em $this->centralEm();
  336.         $logger = new PlatformAuditLogger($em);
  337.         if (!$logger->tableExists()) {
  338.             $this->addFlash('error''Refused: platform_admin_audit is missing.');
  339.             return $this->redirectToRoute('admin_control_governance');
  340.         }
  341.         $scopeRaw = (string) $request->request->get('scope''');
  342.         $scope json_decode($scopeRawtrue);
  343.         if (!is_array($scope)) { $scope $scopeRaw !== '' ? array('scope' => $scopeRaw) : array(); }
  344.         $r $this->governanceService()->addDelegation(
  345.             (string) $request->request->get('subject'''),
  346.             $scope,
  347.             $request->request->get('delegate_id'),
  348.             $request->request->get('delegate_name'),
  349.             (string) $request->request->get('expires_at'''),
  350.             $request->request->get('decision_id') ?: null,
  351.             array('user_name' => (string) $request->getSession()->get(UserConstants::USER_NAME''))
  352.         );
  353.         if (!empty($r['success'])) {
  354.             $logger->log(null'governance_delegate', array(), array('subject' => $request->request->get('subject'), 'expires_at' => $request->request->get('expires_at')), nullnullnull$this->adminCtx($request));
  355.         }
  356.         $this->addFlash(!empty($r['success']) ? 'success' 'error'$r['message']);
  357.         return $this->redirectToRoute('admin_control_governance');
  358.     }
  359.     // ── CC4: ops buttons ───────────────────────────────────────────────────────
  360.     /**
  361.      * Launch a whitelisted ops command in an isolated process. POST only; fail-closed on the audit
  362.      * + ops-run tables; the command name never comes from the form — only the op KEY selects a
  363.      * PlatformOpsCore whitelist entry. Destructive ops are dry-run unless armed by PlatformOpsCore.
  364.      */
  365.     public function opsRunAction(Request $request$appId)
  366.     {
  367.         if ($redirect $this->guard($request)) {
  368.             return $redirect;
  369.         }
  370.         $opKey = (string) $request->request->get('op_key''');
  371.         if (!PlatformOpsCore::isOp($opKey)) {
  372.             $this->addFlash('error''Unknown ops action.');
  373.             return $this->redirectToRoute('admin_control');
  374.         }
  375.         $tenant $this->tenantMeta((int) $appId);
  376.         if ($tenant === null) {
  377.             $this->addFlash('error''Tenant not found in the central registry.');
  378.             return $this->redirectToRoute('admin_control');
  379.         }
  380.         $params = array(
  381.             'apply' => $request->request->get('apply'),
  382.             'confirm' => $request->request->get('confirm'),
  383.             'days' => $request->request->get('days'),
  384.             'keepOpening' => $request->request->get('keep_opening'),
  385.             'reason' => $request->request->get('reason'),
  386.         );
  387.         $r $this->opsService()->run($opKey$tenant$params$this->adminCtx($request));
  388.         if (empty($r['ok'])) {
  389.             $this->addFlash('error'$r['error']);
  390.         } else {
  391.             $head trim((string) $r['snippet']);
  392.             if (strlen($head) > 500) {
  393.                 $head substr($head0500) . '…';
  394.             }
  395.             $label PlatformOpsCore::def($opKey)['label'];
  396.             $msg $label ' — ' . ($r['applied'] ? 'APPLIED' 'dry-run') . ': ' strtoupper((string) $r['status'])
  397.                 . ' (exit ' . ($r['exit'] === null '?' $r['exit']) . ')';
  398.             if ($head !== '') {
  399.                 $msg .= ' · ' $head;
  400.             }
  401.             $this->addFlash($r['status'] === 'ok' 'success' 'warning'$msg);
  402.         }
  403.         return $this->redirectToRoute('admin_control', array(
  404.             'q' => $request->request->get('back_q'''),
  405.             'status' => $request->request->get('back_status'''),
  406.         ));
  407.     }
  408.     /**
  409.      * CC8 — JSON tenant user directory for the selectize pickers (applicant / accountable owner /
  410.      * support-access user). Read from the CENTRAL fleet_health_snapshot (`user_directory_json`,
  411.      * collected ON the tenant box) — never a live cross-DB read. Missing snapshot → empty options
  412.      * (the picker keeps its manual-id create fallback, so an action is never blocked).
  413.      */
  414.     public function userDirectoryAction(Request $request$appId)
  415.     {
  416.         if ($this->guard($request)) {
  417.             return new JsonResponse(array('error' => 'forbidden'), 403);
  418.         }
  419.         try {
  420.             $conn $this->centralEm()->getConnection();
  421.             $hasCol = (int) $conn->fetchOne(
  422.                 "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE()
  423.                  AND table_name = 'fleet_health_snapshot' AND column_name = 'user_directory_json'") > 0;
  424.             if (!$hasCol) {
  425.                 return new JsonResponse(array('options' => array(), 'hasSnapshot' => false'note' => 'user_directory_json not migrated on central'));
  426.             }
  427.             $r $conn->fetchAssociative(
  428.                 'SELECT user_directory_json, collected_at FROM fleet_health_snapshot WHERE app_id = ? LIMIT 1',
  429.                 array((int) $appId));
  430.             $json $r ? (string) $r['user_directory_json'] : '';
  431.             return new JsonResponse(array(
  432.                 'options' => \ApplicationBundle\Modules\CentralControl\Support\UserDirectoryCore::options($json),
  433.                 'hasSnapshot' => $r && $json !== '',
  434.                 'snapshotAt' => $r $r['collected_at'] : null,
  435.             ));
  436.         } catch (\Throwable $e) {
  437.             return new JsonResponse(array('options' => array(), 'hasSnapshot' => false'error' => $e->getMessage()));
  438.         }
  439.     }
  440.     /** JSON: recent ops runs for one tenant (loaded lazily when the drawer opens). */
  441.     public function opsHistoryAction(Request $request$appId)
  442.     {
  443.         if ($this->guard($request)) {
  444.             return new JsonResponse(array('error' => 'forbidden'), 403);
  445.         }
  446.         $out = array();
  447.         foreach ($this->opsService()->recent((int) $appId6) as $run) {
  448.             $out[] = array(
  449.                 'op' => $run->getOpKey(),
  450.                 'applied' => (bool) $run->getApplied(),
  451.                 'status' => $run->getStatus(),
  452.                 'exit' => $run->getExitCode(),
  453.                 'when' => $run->getStartedAt() ? $run->getStartedAt()->format('Y-m-d H:i') : '',
  454.                 'by' => $run->getAdminUserName() ?: ('#' $run->getAdminUserId()),
  455.                 'ms' => $run->getDurationMs(),
  456.             );
  457.         }
  458.         return new JsonResponse(array('runs' => $out));
  459.     }
  460.     /** Minimal tenant meta (appId + dbName) for an ops target; null when the tenant is unknown. */
  461.     private function tenantMeta($appId)
  462.     {
  463.         try {
  464.             $g $this->centralEm()->getRepository('CompanyGroupBundle\\Entity\\CompanyGroup')
  465.                 ->findOneBy(array('appId' => (int) $appId));
  466.             if (!$g) {
  467.                 return null;
  468.             }
  469.             return array('appId' => (int) $g->getAppId(), 'dbName' => (string) $g->getDbName());
  470.         } catch (\Throwable $e) {
  471.             return null;
  472.         }
  473.     }
  474.     // ── CC2: flags ───────────────────────────────────────────────────────────
  475.     /** Fleet-wide flag matrix (live cross-DB reads, one try/catch per tenant). */
  476.     public function flagsFleetAction(Request $request)
  477.     {
  478.         if ($redirect $this->guard($request)) {
  479.             return $redirect;
  480.         }
  481.         $em $this->centralEm();
  482.         $companies = array();
  483.         try {
  484.             $companies $em->createQueryBuilder()
  485.                 ->select('g')->from('CompanyGroupBundle\\Entity\\CompanyGroup''g')
  486.                 ->andWhere('g.active = 1')
  487.                 ->andWhere("g.dbName IS NOT NULL AND g.dbName <> ''")
  488.                 ->orderBy('g.appId''ASC')
  489.                 ->getQuery()->getResult();
  490.         } catch (\Throwable $e) {
  491.             $this->addFlash('error''Central registry unreachable: ' $e->getMessage());
  492.         }
  493.         // CC7 — desired (central platform_setting) vs actual (collector snapshot), central-local only.
  494.         // NO live cross-DB read: the console never opens a tenant DB connection anymore.
  495.         $matrix = (new PlatformFlagFleetService($em))->matrix($companies);
  496.         return $this->render('@CentralControl/pages/control_flags.html.twig', array(
  497.             'page_title' => 'Platform Flags',
  498.             'sidebar_partial' => '@CentralControl/pages/_control_sidebar.html.twig',
  499.             'cp_active' => 'flags',
  500.             'flag_defs' => PlatformFlagRegistry::flags(),
  501.             'companies' => $companies,
  502.             'matrix' => $matrix,
  503.             'audit_ready' => (new PlatformAuditLogger($em))->tableExists(),
  504.             'settings_ready' => (new PlatformFlagSettingsService($em))->tableExists(),
  505.         ));
  506.     }
  507.     /** Per-tenant flag states for the console drawer (JSON). */
  508.     public function tenantFlagsAction(Request $request$appId)
  509.     {
  510.         if (!$this->isCentral() || !$this->canAccessSuperAdmin($request)) {
  511.             return new JsonResponse(array('success' => false'message' => 'Central super-admin only.'), 403);
  512.         }
  513.         try {
  514.             $company $this->centralEm()->getRepository('CompanyGroupBundle\\Entity\\CompanyGroup')
  515.                 ->findOneBy(array('appId' => (int) $appId));
  516.             if (!$company) {
  517.                 return new JsonResponse(array('success' => false'message' => 'Tenant not found.'), 404);
  518.             }
  519.             // CC7 — desired (central) vs actual (snapshot), central-local only. No cross-DB.
  520.             $m = (new PlatformFlagFleetService($this->centralEm()))->matrix(array($company));
  521.             $row = isset($m[(int) $appId]) ? $m[(int) $appId] : array('flags' => array(), 'hasSnapshot' => false'snapshotAt' => null'ageHours' => null);
  522.             return new JsonResponse(array(
  523.                 'success' => true,
  524.                 'flags' => $row['flags'],
  525.                 'hasSnapshot' => $row['hasSnapshot'],
  526.                 'snapshotAt' => $row['snapshotAt'],
  527.                 'ageHours' => $row['ageHours'],
  528.                 'defs' => PlatformFlagRegistry::flags(),
  529.             ));
  530.         } catch (\Throwable $e) {
  531.             return new JsonResponse(array('success' => false'message' => $e->getMessage()), 500);
  532.         }
  533.     }
  534.     /**
  535.      * AI-QUOTA — per-tenant AI allocation/usage panel for the console drawer (JSON).
  536.      * CC7: read the panel from the CENTRAL fleet_health_snapshot (`ai_usage_json`, written by the
  537.      * tenant's own collector run ON the tenant box) — never a live cross-DB read. Reports the
  538.      * snapshot age so the drawer can say how fresh it is; missing snapshot → an honest empty state.
  539.      */
  540.     public function tenantAiUsageAction(Request $request$appId)
  541.     {
  542.         if (!$this->isCentral() || !$this->canAccessSuperAdmin($request)) {
  543.             return new JsonResponse(array('success' => false'message' => 'Central super-admin only.'), 403);
  544.         }
  545.         try {
  546.             $conn $this->centralEm()->getConnection();
  547.             $hasCol = (int) $conn->fetchOne(
  548.                 "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE()
  549.                  AND table_name = 'fleet_health_snapshot' AND column_name = 'ai_usage_json'") > 0;
  550.             if (!$hasCol) {
  551.                 return new JsonResponse(array('success' => false'message' => 'AI usage snapshot not migrated on this central box yet (update_database_schema).''stale' => true));
  552.             }
  553.             $r $conn->fetchAssociative(
  554.                 'SELECT ai_usage_json, collected_at FROM fleet_health_snapshot WHERE app_id = ? LIMIT 1',
  555.                 array((int) $appId));
  556.             if (!$r || $r['ai_usage_json'] === null || $r['ai_usage_json'] === '') {
  557.                 return new JsonResponse(array('success' => false'message' => 'No snapshot yet — the tenant collector has not reported AI usage.''stale' => true));
  558.             }
  559.             $panel json_decode((string) $r['ai_usage_json'], true);
  560.             return new JsonResponse(array(
  561.                 'success' => is_array($panel),
  562.                 'panel' => is_array($panel) ? $panel null,
  563.                 'snapshotAt' => $r['collected_at'],
  564.             ));
  565.         } catch (\Throwable $e) {
  566.             return new JsonResponse(array('success' => false'message' => $e->getMessage()), 500);
  567.         }
  568.     }
  569.     /** Write one flag on one tenant. Audited; fail-closed without the audit table. */
  570.     public function flagSetAction(Request $request$appId)
  571.     {
  572.         if (!$this->isCentral() || !$this->canAccessSuperAdmin($request)) {
  573.             return new JsonResponse(array('success' => false'message' => 'Central super-admin only.'), 403);
  574.         }
  575.         $key = (string) $request->request->get('flag_key''');
  576.         $flagValue = (string) $request->request->get('flag_value''');
  577.         $clear $flagValue === 'clear';
  578.         $on $flagValue === '1';
  579.         $em $this->centralEm();
  580.         $logger = new PlatformAuditLogger($em);
  581.         if (!$logger->tableExists()) {
  582.             return new JsonResponse(array('success' => false'message' => 'Refused: platform_admin_audit table is missing — run update_database_schema on CENTRAL first.'), 409);
  583.         }
  584.         $settings = new PlatformFlagSettingsService($em);
  585.         if (!$settings->tableExists()) {
  586.             return new JsonResponse(array('success' => false'message' => 'Refused: platform_setting table is missing — run update_database_schema on CENTRAL first.'), 409);
  587.         }
  588.         if (!PlatformFlagRegistry::has($key)) {
  589.             return new JsonResponse(array('success' => false'message' => 'Unknown platform flag.'), 400);
  590.         }
  591.         try {
  592.             $company $em->getRepository('CompanyGroupBundle\\Entity\\CompanyGroup')
  593.                 ->findOneBy(array('appId' => (int) $appId));
  594.             if (!$company) {
  595.                 return new JsonResponse(array('success' => false'message' => 'Tenant not found.'), 404);
  596.             }
  597.             // CC7 — write the central DESIRED state (per-app override). NO tenant DB write: the
  598.             // tenant reads this centrally with a fail-safe local fallback. 'clear' removes the
  599.             // override so the tenant falls back to its own local acc_setting.
  600.             $write $settings->setOverride((int) $appId$key$clear null $on$this->adminCtx($request));
  601.             if (empty($write['success'])) {
  602.                 return new JsonResponse(array('success' => false'message' => $write['message']), 400);
  603.             }
  604.             $logger->log(
  605.                 (int) $appId,
  606.                 'flag:' $key,
  607.                 array($key => $write['before']),
  608.                 array($key => $write['after']),
  609.                 $request->request->get('reason'),
  610.                 null,
  611.                 'central desired-state (platform_setting) — tenant honours it, no cross-DB write',
  612.                 $this->adminCtx($request)
  613.             );
  614.             $label PlatformFlagRegistry::flags()[$key]['label'];
  615.             return new JsonResponse(array(
  616.                 'success' => true,
  617.                 'message' => $clear
  618.                     $label ' override cleared for tenant #' . (int) $appId ' — reverts to the tenant\'s local setting.'
  619.                     $label ' desired → ' . ($on 'ON' 'OFF') . ' for tenant #' . (int) $appId ' (applies on the tenant\'s next read).',
  620.                 'desired' => $clear null PlatformFlagRegistry::effective($key$write['after']),
  621.                 'raw' => $write['after'],
  622.             ));
  623.         } catch (\Throwable $e) {
  624.             return new JsonResponse(array('success' => false'message' => 'Flag write failed: ' $e->getMessage()), 500);
  625.         }
  626.     }
  627.     // ── CC6: support access (impersonation) — GO per 2026-07-10 rulings ─────
  628.     /** Tenant user list for the support picker (cross-DB, JSON). */
  629.     public function supportUsersAction(Request $request$appId)
  630.     {
  631.         if (!$this->isCentral() || !$this->canAccessSuperAdmin($request)) {
  632.             return new JsonResponse(array('success' => false'message' => 'Central super-admin only.'), 403);
  633.         }
  634.         try {
  635.             $company $this->centralEm()->getRepository('CompanyGroupBundle\\Entity\\CompanyGroup')
  636.                 ->findOneBy(array('appId' => (int) $appId));
  637.             if (!$company) {
  638.                 return new JsonResponse(array('success' => false'message' => 'Tenant not found.'), 404);
  639.             }
  640.             $res = (new SupportAccessService($this->centralEm()))->listTenantUsers($company);
  641.             return new JsonResponse(array('success' => $res['error'] === null'users' => $res['users'], 'message' => $res['error']));
  642.         } catch (\Throwable $e) {
  643.             return new JsonResponse(array('success' => false'message' => $e->getMessage()), 500);
  644.         }
  645.     }
  646.     /**
  647.      * Mint a support token (ruling: mandatory reason; single-use; 60 min; audited). Returns
  648.      * the tenant-box entry URL — the admin opens it; the secret is never a password.
  649.      */
  650.     public function supportTokenAction(Request $request$appId)
  651.     {
  652.         if (!$this->isCentral() || !$this->canAccessSuperAdmin($request)) {
  653.             return new JsonResponse(array('success' => false'message' => 'Central super-admin only.'), 403);
  654.         }
  655.         $em $this->centralEm();
  656.         $logger = new PlatformAuditLogger($em);
  657.         if (!$logger->tableExists()) {
  658.             return new JsonResponse(array('success' => false'message' => 'Refused: platform_admin_audit table missing — run update_database_schema on CENTRAL first.'), 409);
  659.         }
  660.         $tenantUserId = (int) $request->request->get('tenant_user_id'0);
  661.         $reason = (string) $request->request->get('reason''');
  662.         if ($tenantUserId <= 0) {
  663.             return new JsonResponse(array('success' => false'message' => 'Pick a tenant user.'), 400);
  664.         }
  665.         try {
  666.             $company $em->getRepository('CompanyGroupBundle\\Entity\\CompanyGroup')
  667.                 ->findOneBy(array('appId' => (int) $appId));
  668.             if (!$company) {
  669.                 return new JsonResponse(array('success' => false'message' => 'Tenant not found.'), 404);
  670.             }
  671.             $adminCtx $this->adminCtx($request);
  672.             $res = (new SupportAccessService($em))->mint($company$tenantUserId$reason$adminCtx);
  673.             if (!$res['success']) {
  674.                 return new JsonResponse(array('success' => false'message' => $res['message']), 400);
  675.             }
  676.             $logger->log((int) $appId'impersonate_token_minted', array(), array(
  677.                 'tenant_user_id' => $tenantUserId,
  678.                 'token_id' => $res['token_id'],
  679.                 'ttl_min' => 60,
  680.             ), $reasonnull'single-use entry link issued'$adminCtx);
  681.             return new JsonResponse(array('success' => true'message' => $res['message'], 'entry_url' => $res['entry_url']));
  682.         } catch (\Throwable $e) {
  683.             return new JsonResponse(array('success' => false'message' => 'Mint failed: ' $e->getMessage()), 500);
  684.         }
  685.     }
  686.     // ── CC3: enforcement arming (central-readable switches, no %param%) ────────
  687.     public function enforcementAction(Request $request)
  688.     {
  689.         if ($redirect $this->guard($request)) {
  690.             return $redirect;
  691.         }
  692.         $em $this->centralEm();
  693.         $svc = new \ApplicationBundle\Modules\CentralControl\Service\EnforcementSettingsService($em);
  694.         $logger = new PlatformAuditLogger($em);
  695.         $keys = array();
  696.         foreach (\ApplicationBundle\Modules\CentralControl\Service\EnforcementSettingsService::KEYS as $k) {
  697.             $eff $svc->effectiveGlobal($k);
  698.             $keys[$k] = array(
  699.                 'global' => $svc->global($k),          // raw '1'/'0'/null
  700.                 'effective' => $eff['value'],
  701.                 'source' => $eff['source'],
  702.                 'overrides' => $svc->overrides($k),
  703.             );
  704.         }
  705.         // CC8 — tenant options for the app-id selectize picker (central-local read; label "name — #id").
  706.         $tenants = array();
  707.         try {
  708.             foreach ($em->createQueryBuilder()
  709.                          ->select('g')->from('CompanyGroupBundle\\Entity\\CompanyGroup''g')
  710.                          ->orderBy('g.appId''ASC')->getQuery()->getResult() as $g) {
  711.                 $tenants[] = array('appId' => (int) $g->getAppId(), 'name' => (string) $g->getName());
  712.             }
  713.         } catch (\Throwable $e) { /* registry unreachable — picker falls back to manual id entry */ }
  714.         return $this->render('@CentralControl/pages/control_enforcement.html.twig', array(
  715.             'page_title' => 'Enforcement Arming',
  716.             'sidebar_partial' => '@CentralControl/pages/_control_sidebar.html.twig',
  717.             'cp_active' => 'enforcement',
  718.             'keys' => $keys,
  719.             'tenants' => $tenants,
  720.             'schema_ready' => $svc->tableExists(),
  721.             'audit_ready' => $logger->tableExists(),
  722.         ));
  723.     }
  724.     /** Set/clear a global or per-app enforcement switch. Audited; fail-closed on the audit table. */
  725.     public function enforcementSetAction(Request $request)
  726.     {
  727.         if ($redirect $this->guard($request)) {
  728.             return $redirect;
  729.         }
  730.         $em $this->centralEm();
  731.         $svc = new \ApplicationBundle\Modules\CentralControl\Service\EnforcementSettingsService($em);
  732.         $logger = new PlatformAuditLogger($em);
  733.         if (!$logger->tableExists() || !$svc->tableExists()) {
  734.             $this->addFlash('error''Refused: run update_database_schema on CENTRAL (platform_setting + platform_admin_audit) first.');
  735.             return $this->redirectToRoute('admin_control_enforcement');
  736.         }
  737.         $key = (string) $request->request->get('key''');
  738.         if (!\ApplicationBundle\Modules\CentralControl\Service\EnforcementSettingsService::isKey($key)) {
  739.             $this->addFlash('error''Unknown enforcement key.');
  740.             return $this->redirectToRoute('admin_control_enforcement');
  741.         }
  742.         $appId = (int) $request->request->get('appId'0);
  743.         $state = (string) $request->request->get('state'''); // 'on' | 'off' | 'clear'
  744.         $on $state === 'on' true : ($state === 'off' false null);
  745.         $ctx $this->adminCtx($request);
  746.         $res $appId $svc->setOverride($appId$key$on$ctx) : $svc->setGlobal($key$on$ctx);
  747.         if ($res['success']) {
  748.             try {
  749.                 $logger->log($appId $appId null'enforcement_set',
  750.                     array($key => $res['before']), array($key => $res['after'], 'scope' => $appId ? ('app#' $appId) : 'global'),
  751.                     $request->request->get('reason'), nullnull$ctx);
  752.             } catch (\Throwable $e) {
  753.                 $this->addFlash('warning''Saved BUT audit write failed: ' $e->getMessage());
  754.                 return $this->redirectToRoute('admin_control_enforcement');
  755.             }
  756.             $this->addFlash('success'$res['message']);
  757.         } else {
  758.             $this->addFlash('error'$res['message']);
  759.         }
  760.         return $this->redirectToRoute('admin_control_enforcement');
  761.     }
  762.     // ── Audit trail ──────────────────────────────────────────────────────────
  763.     public function auditAction(Request $request)
  764.     {
  765.         if ($redirect $this->guard($request)) {
  766.             return $redirect;
  767.         }
  768.         $appId $request->query->get('appId');
  769.         $logger = new PlatformAuditLogger($this->centralEm());
  770.         return $this->render('@CentralControl/pages/control_audit.html.twig', array(
  771.             'page_title' => 'Platform Admin Audit Trail',
  772.             'sidebar_partial' => '@CentralControl/pages/_control_sidebar.html.twig',
  773.             'cp_active' => 'audit',
  774.             'audit_ready' => $logger->tableExists(),
  775.             'rows' => $logger->recent(200$appId !== null && $appId !== '' ? (int) $appId null),
  776.             'app_id_filter' => $appId !== null && $appId !== '' ? (int) $appId null,
  777.         ));
  778.     }
  779. }