<?php
namespace ApplicationBundle\Modules\CentralControl\Controller;
use ApplicationBundle\Controller\GenericController;
use ApplicationBundle\Interfaces\SessionCheckInterface;
use ApplicationBundle\Modules\Authentication\Constants\UserConstants;
use ApplicationBundle\Modules\CentralControl\Service\PlatformAuditLogger;
use ApplicationBundle\Modules\CentralControl\Service\PlatformFlagFleetService;
use ApplicationBundle\Modules\CentralControl\Service\PlatformFlagSettingsService;
use ApplicationBundle\Modules\CentralControl\Service\PlatformOpsService;
use ApplicationBundle\Modules\CentralControl\Service\SupportAccessService;
use ApplicationBundle\Modules\CentralControl\Service\TenantCommandService;
use ApplicationBundle\Modules\CentralControl\Support\CentralControlCore;
use ApplicationBundle\Modules\CentralControl\Support\PlatformFlagRegistry;
use ApplicationBundle\Modules\CentralControl\Support\PlatformOpsCore;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
/**
* CC1+CC2 — Tenant Command Console (/admin/control): the central control plane's cockpit.
* cp-shell surface (owner decision for NEW console pages), hard-gated to the CENTRAL box +
* super-admin session, linked from the hb-admin sidebar. Every mutation goes through the central
* services (TenantCommandService / PlatformFlagSettingsService), which audit into
* platform_admin_audit. CC7 LAW: the console opens NO tenant DB connection — desired state lives in
* central platform_setting; actual state is read from the collector's fleet_health_snapshot.
*/
class CentralControlController extends GenericController implements SessionCheckInterface
{
// ── Guards (same access set as SuperAdminDashboardController) ───────────
private function isCentral()
{
$sys = $this->container->hasParameter('system_type') ? $this->container->getParameter('system_type') : '_ERP_';
return $sys === '_CENTRAL_';
}
private function canAccessSuperAdmin(Request $request)
{
$session = $request->getSession();
if ((int) $session->get(UserConstants::USER_ID, 0) <= 0) {
return false;
}
$userType = (int) $session->get(UserConstants::USER_TYPE, 0);
$allowedTypes = array(
UserConstants::USER_TYPE_SYSTEM,
UserConstants::USER_TYPE_MANAGEMENT_USER,
UserConstants::USER_TYPE_GENERAL,
);
return (int) $session->get(UserConstants::IS_BUDDYBEE_ADMIN, 0) === 1
|| (int) $session->get(UserConstants::ALL_MODULE_ACCESS_FLAG, 0) === 1
|| in_array($userType, $allowedTypes, true);
}
/** @return \Symfony\Component\HttpFoundation\RedirectResponse|null null when allowed */
private function guard(Request $request)
{
if (!$this->isCentral() || !$this->canAccessSuperAdmin($request)) {
return $this->redirectToRoute('dashboard');
}
return null;
}
private function centralEm()
{
return $this->getDoctrine()->getManager('company_group');
}
private function adminCtx(Request $request)
{
$session = $request->getSession();
return array(
'user_id' => (int) $session->get(UserConstants::USER_ID, 0),
'user_name' => (string) $session->get(UserConstants::USER_NAME, ''),
'ip' => (string) $request->getClientIp(),
);
}
private function commandService()
{
$em = $this->centralEm();
return new TenantCommandService(
$em,
new PlatformAuditLogger($em),
$this->container->getParameter('kernel.root_dir')
);
}
private function opsService()
{
$projectDir = $this->container->hasParameter('kernel.project_dir')
? $this->container->getParameter('kernel.project_dir')
: dirname($this->container->getParameter('kernel.root_dir'));
$env = $this->container->hasParameter('kernel.environment')
? $this->container->getParameter('kernel.environment') : 'prod';
return new PlatformOpsService($this->centralEm(), $projectDir, $env);
}
// ── CC1: console ─────────────────────────────────────────────────────────
public function consoleAction(Request $request)
{
if ($redirect = $this->guard($request)) {
return $redirect;
}
$search = trim((string) $request->query->get('q', ''));
$status = trim((string) $request->query->get('status', ''));
$em = $this->centralEm();
$logger = new PlatformAuditLogger($em);
$qb = $em->createQueryBuilder()
->select('g')
->from('CompanyGroupBundle\\Entity\\CompanyGroup', 'g')
->orderBy('g.lastActivityAt', 'DESC')
->addOrderBy('g.id', 'DESC')
->setMaxResults(300);
if ($search !== '') {
$qb->andWhere('g.name LIKE :s OR g.email LIKE :s OR g.appId LIKE :s')
->setParameter('s', '%' . $search . '%');
}
if ($status !== '') {
if ($status === 'readonly') {
$qb->andWhere('g.readOnlyMode = 1');
} elseif ($status === 'disabled') {
$qb->andWhere('g.active = 0');
} else {
$qb->andWhere('g.companyStatus = :st')->setParameter('st', $status);
}
}
$tenants = array();
$summary = array('all' => 0, 'active' => 0, 'trial' => 0, 'suspended' => 0, 'expired' => 0, 'readonly' => 0, 'disabled' => 0);
try {
$tenants = $qb->getQuery()->getResult();
$conn = $em->getConnection();
$summary['all'] = (int) $conn->fetchOne('SELECT COUNT(*) FROM company_group');
foreach (array('active', 'trial', 'suspended', 'expired') as $st) {
$summary[$st] = (int) $conn->fetchOne('SELECT COUNT(*) FROM company_group WHERE company_status = ?', array($st));
}
// readOnlyMode has no explicit column mapping — let DQL resolve it, never raw SQL.
$summary['readonly'] = (int) $em->createQuery(
'SELECT COUNT(g.id) FROM CompanyGroupBundle\\Entity\\CompanyGroup g WHERE g.readOnlyMode = 1'
)->getSingleScalarResult();
$summary['disabled'] = (int) $conn->fetchOne('SELECT COUNT(*) FROM company_group WHERE active = 0');
} catch (\Throwable $e) {
$this->addFlash('error', 'Central registry unreachable: ' . $e->getMessage());
}
// CC5 — usage facts for the visible tenants, read from the nightly fleet_health_snapshot
// (NEVER live cross-DB here). Fail-soft: a pre-CC5 central (no usage columns) → empty map,
// the console simply shows dashes. Keyed by appId for O(1) lookup in the row loop.
$usage = array();
try {
$conn = $em->getConnection();
$hasUsage = (int) $conn->fetchOne(
"SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE()
AND table_name = 'fleet_health_snapshot' AND column_name = 'sessions_7d'") > 0;
if ($hasUsage && !empty($tenants)) {
$ids = array();
foreach ($tenants as $t) { $ids[] = (int) $t->getAppId(); }
foreach ($conn->fetchAllAssociative(
'SELECT app_id, sessions_7d, sessions_30d, active_users_7d, active_users_30d,
top_modules_json, collected_at
FROM fleet_health_snapshot WHERE app_id IN (' . implode(',', array_fill(0, count($ids), '?')) . ')',
$ids) as $r) {
$usage[(int) $r['app_id']] = array(
'sessions7d' => $r['sessions_7d'],
'sessions30d' => $r['sessions_30d'],
'activeUsers7d' => $r['active_users_7d'],
'activeUsers30d' => $r['active_users_30d'],
'topModules' => \ApplicationBundle\Modules\CentralControl\Support\UsageFactsCore::summarize($r['top_modules_json'], 3),
'collectedAt' => $r['collected_at'],
);
}
}
} catch (\Throwable $e) { /* pre-CC5 central or unreachable — dashes */ }
return $this->render('@CentralControl/pages/control_console.html.twig', array(
'page_title' => 'Tenant Command Console',
'sidebar_partial' => '@CentralControl/pages/_control_sidebar.html.twig',
'cp_active' => 'console',
'tenants' => $tenants,
'summary' => $summary,
'usage' => $usage,
'search' => $search,
'status_filter' => $status,
'audit_ready' => $logger->tableExists(),
'recent_audit' => $logger->recent(12),
'flag_defs' => PlatformFlagRegistry::flags(),
'ops_defs' => PlatformOpsCore::ops(),
'ops_ready' => $this->opsService()->ready(),
'ops_local_topology' => $this->opsService()->localTopologyEnabled(),
'today' => date('Y-m-d'),
));
}
/** One-click lifecycle action. POST only; audited; fail-closed without the audit table. */
public function tenantActionAction(Request $request, $appId)
{
if ($redirect = $this->guard($request)) {
return $redirect;
}
$action = (string) $request->request->get('control_action', '');
if (!in_array($action, CentralControlCore::ACTIONS, true)) {
$this->addFlash('error', 'Unknown control action.');
return $this->redirectToRoute('admin_control');
}
$result = $this->commandService()->apply(
(int) $appId,
$action,
$request->request->get('expiry_date'),
$request->request->get('reason'),
$this->adminCtx($request)
);
$this->addFlash($result['success'] ? (strpos($result['message'], 'AUDIT ROW FAILED') !== false || ($result['push_ok'] === false) ? 'warning' : 'success') : 'error', $result['message']);
return $this->redirectToRoute('admin_control', array(
'q' => $request->request->get('back_q', ''),
'status' => $request->request->get('back_status', ''),
));
}
// ── AUT1: Founder Sovereignty Council register (§5) ────────────────────────
private function governanceService()
{
return new \ApplicationBundle\Modules\CentralControl\Service\GovernanceService($this->centralEm());
}
/**
* The governance register: decisions with their EXACT voted text + hash, the live evaluation
* (unanimity / silence≠consent / text-change-voids-votes), and the expiring delegation register.
* A RECORD-KEEPER — nothing here grants authority (AUT0's capability_registry stays the gate).
*/
public function governanceAction(Request $request)
{
if ($redirect = $this->guard($request)) {
return $redirect;
}
$em = $this->centralEm();
$svc = $this->governanceService();
$ready = $svc->tablesReady();
$rows = array();
$delegations = array();
if ($ready) {
foreach ($svc->decisions(null, 200) as $d) {
$rows[] = array(
'd' => $d,
'eval' => $svc->evaluate($d),
'votes' => $svc->votesFor($d),
);
}
foreach ($svc->delegations(200) as $g) {
$delegations[] = array('g' => $g, 'valid' => $svc->delegationStatus($g));
}
}
return $this->render('@CentralControl/pages/control_governance.html.twig', array(
'page_title' => 'Founder Sovereignty Council — governance register',
'sidebar_partial' => '@CentralControl/pages/_control_sidebar.html.twig',
'cp_active' => 'governance',
'tables_ready' => $ready,
'audit_ready' => (new PlatformAuditLogger($em))->tableExists(),
'founders' => $svc->founders(),
'rows' => $rows,
'delegations' => $delegations,
'types' => \ApplicationBundle\Modules\CentralControl\Support\GovernanceCore::TYPES,
));
}
/** Record a founder's vote on a decision's CURRENT text. Audited; fail-closed on the audit table. */
public function governanceVoteAction(Request $request, $id)
{
if ($redirect = $this->guard($request)) {
return $redirect;
}
$em = $this->centralEm();
$logger = new PlatformAuditLogger($em);
if (!$logger->tableExists()) {
$this->addFlash('error', 'Refused: platform_admin_audit is missing — governance actions must be auditable.');
return $this->redirectToRoute('admin_control_governance');
}
$svc = $this->governanceService();
$d = $svc->decision($id);
if (!$d) {
$this->addFlash('error', 'Decision not found.');
return $this->redirectToRoute('admin_control_governance');
}
$founderId = (int) $request->request->get('founder_id', 0);
$vote = (string) $request->request->get('vote', '');
$r = $svc->recordVote($d, $founderId, $vote, array(
'user_name' => (string) $request->getSession()->get(UserConstants::USER_NAME, ''),
'note' => $request->request->get('note'),
));
if (!empty($r['success'])) {
$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'), null, null, $this->adminCtx($request));
}
$this->addFlash(!empty($r['success']) ? 'success' : 'error', $r['message']);
return $this->redirectToRoute('admin_control_governance');
}
/** Propose a new decision (the exact text is what gets voted + hashed). Audited. */
public function governanceProposeAction(Request $request)
{
if ($redirect = $this->guard($request)) {
return $redirect;
}
$em = $this->centralEm();
$logger = new PlatformAuditLogger($em);
if (!$logger->tableExists()) {
$this->addFlash('error', 'Refused: platform_admin_audit is missing.');
return $this->redirectToRoute('admin_control_governance');
}
$r = $this->governanceService()->propose(
(string) $request->request->get('type', ''),
(string) $request->request->get('subject', ''),
(string) $request->request->get('body', ''),
array('user_name' => (string) $request->getSession()->get(UserConstants::USER_NAME, ''))
);
if (!empty($r['success'])) {
$logger->log(null, 'governance_propose', array(), array('decision' => (int) $r['decision']->getId(), 'type' => $r['decision']->getType(), 'hash' => $r['decision']->getContentHash()), null, null, null, $this->adminCtx($request));
}
$this->addFlash(!empty($r['success']) ? 'success' : 'error', $r['message']);
return $this->redirectToRoute('admin_control_governance');
}
/**
* Amend a decision's text. §5: this VOIDS every prior vote (the hash moves) and drops it back to
* proposed — structural, not a convention. Audited loudly.
*/
public function governanceAmendAction(Request $request, $id)
{
if ($redirect = $this->guard($request)) {
return $redirect;
}
$em = $this->centralEm();
$logger = new PlatformAuditLogger($em);
if (!$logger->tableExists()) {
$this->addFlash('error', 'Refused: platform_admin_audit is missing.');
return $this->redirectToRoute('admin_control_governance');
}
$svc = $this->governanceService();
$d = $svc->decision($id);
if (!$d) {
$this->addFlash('error', 'Decision not found.');
return $this->redirectToRoute('admin_control_governance');
}
$before = array('hash' => $d->getContentHash(), 'status' => $d->getStatus());
$r = $svc->amend($d, (string) $request->request->get('body', ''), array('user_name' => (string) $request->getSession()->get(UserConstants::USER_NAME, '')));
$logger->log(null, 'governance_amend', $before, array('hash' => $d->getContentHash(), 'status' => $d->getStatus(), 'votes_voided' => $r['voided']), null, null, null, $this->adminCtx($request));
$this->addFlash('warning', $r['message']);
return $this->redirectToRoute('admin_control_governance');
}
/** Record a scoped, EXPIRING delegation (§5 forbids indefinite/unrestricted). Audited. */
public function governanceDelegateAction(Request $request)
{
if ($redirect = $this->guard($request)) {
return $redirect;
}
$em = $this->centralEm();
$logger = new PlatformAuditLogger($em);
if (!$logger->tableExists()) {
$this->addFlash('error', 'Refused: platform_admin_audit is missing.');
return $this->redirectToRoute('admin_control_governance');
}
$scopeRaw = (string) $request->request->get('scope', '');
$scope = json_decode($scopeRaw, true);
if (!is_array($scope)) { $scope = $scopeRaw !== '' ? array('scope' => $scopeRaw) : array(); }
$r = $this->governanceService()->addDelegation(
(string) $request->request->get('subject', ''),
$scope,
$request->request->get('delegate_id'),
$request->request->get('delegate_name'),
(string) $request->request->get('expires_at', ''),
$request->request->get('decision_id') ?: null,
array('user_name' => (string) $request->getSession()->get(UserConstants::USER_NAME, ''))
);
if (!empty($r['success'])) {
$logger->log(null, 'governance_delegate', array(), array('subject' => $request->request->get('subject'), 'expires_at' => $request->request->get('expires_at')), null, null, null, $this->adminCtx($request));
}
$this->addFlash(!empty($r['success']) ? 'success' : 'error', $r['message']);
return $this->redirectToRoute('admin_control_governance');
}
// ── CC4: ops buttons ───────────────────────────────────────────────────────
/**
* Launch a whitelisted ops command in an isolated process. POST only; fail-closed on the audit
* + ops-run tables; the command name never comes from the form — only the op KEY selects a
* PlatformOpsCore whitelist entry. Destructive ops are dry-run unless armed by PlatformOpsCore.
*/
public function opsRunAction(Request $request, $appId)
{
if ($redirect = $this->guard($request)) {
return $redirect;
}
$opKey = (string) $request->request->get('op_key', '');
if (!PlatformOpsCore::isOp($opKey)) {
$this->addFlash('error', 'Unknown ops action.');
return $this->redirectToRoute('admin_control');
}
$tenant = $this->tenantMeta((int) $appId);
if ($tenant === null) {
$this->addFlash('error', 'Tenant not found in the central registry.');
return $this->redirectToRoute('admin_control');
}
$params = array(
'apply' => $request->request->get('apply'),
'confirm' => $request->request->get('confirm'),
'days' => $request->request->get('days'),
'keepOpening' => $request->request->get('keep_opening'),
'reason' => $request->request->get('reason'),
);
$r = $this->opsService()->run($opKey, $tenant, $params, $this->adminCtx($request));
if (empty($r['ok'])) {
$this->addFlash('error', $r['error']);
} else {
$head = trim((string) $r['snippet']);
if (strlen($head) > 500) {
$head = substr($head, 0, 500) . '…';
}
$label = PlatformOpsCore::def($opKey)['label'];
$msg = $label . ' — ' . ($r['applied'] ? 'APPLIED' : 'dry-run') . ': ' . strtoupper((string) $r['status'])
. ' (exit ' . ($r['exit'] === null ? '?' : $r['exit']) . ')';
if ($head !== '') {
$msg .= ' · ' . $head;
}
$this->addFlash($r['status'] === 'ok' ? 'success' : 'warning', $msg);
}
return $this->redirectToRoute('admin_control', array(
'q' => $request->request->get('back_q', ''),
'status' => $request->request->get('back_status', ''),
));
}
/**
* CC8 — JSON tenant user directory for the selectize pickers (applicant / accountable owner /
* support-access user). Read from the CENTRAL fleet_health_snapshot (`user_directory_json`,
* collected ON the tenant box) — never a live cross-DB read. Missing snapshot → empty options
* (the picker keeps its manual-id create fallback, so an action is never blocked).
*/
public function userDirectoryAction(Request $request, $appId)
{
if ($this->guard($request)) {
return new JsonResponse(array('error' => 'forbidden'), 403);
}
try {
$conn = $this->centralEm()->getConnection();
$hasCol = (int) $conn->fetchOne(
"SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE()
AND table_name = 'fleet_health_snapshot' AND column_name = 'user_directory_json'") > 0;
if (!$hasCol) {
return new JsonResponse(array('options' => array(), 'hasSnapshot' => false, 'note' => 'user_directory_json not migrated on central'));
}
$r = $conn->fetchAssociative(
'SELECT user_directory_json, collected_at FROM fleet_health_snapshot WHERE app_id = ? LIMIT 1',
array((int) $appId));
$json = $r ? (string) $r['user_directory_json'] : '';
return new JsonResponse(array(
'options' => \ApplicationBundle\Modules\CentralControl\Support\UserDirectoryCore::options($json),
'hasSnapshot' => $r && $json !== '',
'snapshotAt' => $r ? $r['collected_at'] : null,
));
} catch (\Throwable $e) {
return new JsonResponse(array('options' => array(), 'hasSnapshot' => false, 'error' => $e->getMessage()));
}
}
/** JSON: recent ops runs for one tenant (loaded lazily when the drawer opens). */
public function opsHistoryAction(Request $request, $appId)
{
if ($this->guard($request)) {
return new JsonResponse(array('error' => 'forbidden'), 403);
}
$out = array();
foreach ($this->opsService()->recent((int) $appId, 6) as $run) {
$out[] = array(
'op' => $run->getOpKey(),
'applied' => (bool) $run->getApplied(),
'status' => $run->getStatus(),
'exit' => $run->getExitCode(),
'when' => $run->getStartedAt() ? $run->getStartedAt()->format('Y-m-d H:i') : '',
'by' => $run->getAdminUserName() ?: ('#' . $run->getAdminUserId()),
'ms' => $run->getDurationMs(),
);
}
return new JsonResponse(array('runs' => $out));
}
/** Minimal tenant meta (appId + dbName) for an ops target; null when the tenant is unknown. */
private function tenantMeta($appId)
{
try {
$g = $this->centralEm()->getRepository('CompanyGroupBundle\\Entity\\CompanyGroup')
->findOneBy(array('appId' => (int) $appId));
if (!$g) {
return null;
}
return array('appId' => (int) $g->getAppId(), 'dbName' => (string) $g->getDbName());
} catch (\Throwable $e) {
return null;
}
}
// ── CC2: flags ───────────────────────────────────────────────────────────
/** Fleet-wide flag matrix (live cross-DB reads, one try/catch per tenant). */
public function flagsFleetAction(Request $request)
{
if ($redirect = $this->guard($request)) {
return $redirect;
}
$em = $this->centralEm();
$companies = array();
try {
$companies = $em->createQueryBuilder()
->select('g')->from('CompanyGroupBundle\\Entity\\CompanyGroup', 'g')
->andWhere('g.active = 1')
->andWhere("g.dbName IS NOT NULL AND g.dbName <> ''")
->orderBy('g.appId', 'ASC')
->getQuery()->getResult();
} catch (\Throwable $e) {
$this->addFlash('error', 'Central registry unreachable: ' . $e->getMessage());
}
// CC7 — desired (central platform_setting) vs actual (collector snapshot), central-local only.
// NO live cross-DB read: the console never opens a tenant DB connection anymore.
$matrix = (new PlatformFlagFleetService($em))->matrix($companies);
return $this->render('@CentralControl/pages/control_flags.html.twig', array(
'page_title' => 'Platform Flags',
'sidebar_partial' => '@CentralControl/pages/_control_sidebar.html.twig',
'cp_active' => 'flags',
'flag_defs' => PlatformFlagRegistry::flags(),
'companies' => $companies,
'matrix' => $matrix,
'audit_ready' => (new PlatformAuditLogger($em))->tableExists(),
'settings_ready' => (new PlatformFlagSettingsService($em))->tableExists(),
));
}
/** Per-tenant flag states for the console drawer (JSON). */
public function tenantFlagsAction(Request $request, $appId)
{
if (!$this->isCentral() || !$this->canAccessSuperAdmin($request)) {
return new JsonResponse(array('success' => false, 'message' => 'Central super-admin only.'), 403);
}
try {
$company = $this->centralEm()->getRepository('CompanyGroupBundle\\Entity\\CompanyGroup')
->findOneBy(array('appId' => (int) $appId));
if (!$company) {
return new JsonResponse(array('success' => false, 'message' => 'Tenant not found.'), 404);
}
// CC7 — desired (central) vs actual (snapshot), central-local only. No cross-DB.
$m = (new PlatformFlagFleetService($this->centralEm()))->matrix(array($company));
$row = isset($m[(int) $appId]) ? $m[(int) $appId] : array('flags' => array(), 'hasSnapshot' => false, 'snapshotAt' => null, 'ageHours' => null);
return new JsonResponse(array(
'success' => true,
'flags' => $row['flags'],
'hasSnapshot' => $row['hasSnapshot'],
'snapshotAt' => $row['snapshotAt'],
'ageHours' => $row['ageHours'],
'defs' => PlatformFlagRegistry::flags(),
));
} catch (\Throwable $e) {
return new JsonResponse(array('success' => false, 'message' => $e->getMessage()), 500);
}
}
/**
* AI-QUOTA — per-tenant AI allocation/usage panel for the console drawer (JSON).
* CC7: read the panel from the CENTRAL fleet_health_snapshot (`ai_usage_json`, written by the
* tenant's own collector run ON the tenant box) — never a live cross-DB read. Reports the
* snapshot age so the drawer can say how fresh it is; missing snapshot → an honest empty state.
*/
public function tenantAiUsageAction(Request $request, $appId)
{
if (!$this->isCentral() || !$this->canAccessSuperAdmin($request)) {
return new JsonResponse(array('success' => false, 'message' => 'Central super-admin only.'), 403);
}
try {
$conn = $this->centralEm()->getConnection();
$hasCol = (int) $conn->fetchOne(
"SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE()
AND table_name = 'fleet_health_snapshot' AND column_name = 'ai_usage_json'") > 0;
if (!$hasCol) {
return new JsonResponse(array('success' => false, 'message' => 'AI usage snapshot not migrated on this central box yet (update_database_schema).', 'stale' => true));
}
$r = $conn->fetchAssociative(
'SELECT ai_usage_json, collected_at FROM fleet_health_snapshot WHERE app_id = ? LIMIT 1',
array((int) $appId));
if (!$r || $r['ai_usage_json'] === null || $r['ai_usage_json'] === '') {
return new JsonResponse(array('success' => false, 'message' => 'No snapshot yet — the tenant collector has not reported AI usage.', 'stale' => true));
}
$panel = json_decode((string) $r['ai_usage_json'], true);
return new JsonResponse(array(
'success' => is_array($panel),
'panel' => is_array($panel) ? $panel : null,
'snapshotAt' => $r['collected_at'],
));
} catch (\Throwable $e) {
return new JsonResponse(array('success' => false, 'message' => $e->getMessage()), 500);
}
}
/** Write one flag on one tenant. Audited; fail-closed without the audit table. */
public function flagSetAction(Request $request, $appId)
{
if (!$this->isCentral() || !$this->canAccessSuperAdmin($request)) {
return new JsonResponse(array('success' => false, 'message' => 'Central super-admin only.'), 403);
}
$key = (string) $request->request->get('flag_key', '');
$flagValue = (string) $request->request->get('flag_value', '');
$clear = $flagValue === 'clear';
$on = $flagValue === '1';
$em = $this->centralEm();
$logger = new PlatformAuditLogger($em);
if (!$logger->tableExists()) {
return new JsonResponse(array('success' => false, 'message' => 'Refused: platform_admin_audit table is missing — run update_database_schema on CENTRAL first.'), 409);
}
$settings = new PlatformFlagSettingsService($em);
if (!$settings->tableExists()) {
return new JsonResponse(array('success' => false, 'message' => 'Refused: platform_setting table is missing — run update_database_schema on CENTRAL first.'), 409);
}
if (!PlatformFlagRegistry::has($key)) {
return new JsonResponse(array('success' => false, 'message' => 'Unknown platform flag.'), 400);
}
try {
$company = $em->getRepository('CompanyGroupBundle\\Entity\\CompanyGroup')
->findOneBy(array('appId' => (int) $appId));
if (!$company) {
return new JsonResponse(array('success' => false, 'message' => 'Tenant not found.'), 404);
}
// CC7 — write the central DESIRED state (per-app override). NO tenant DB write: the
// tenant reads this centrally with a fail-safe local fallback. 'clear' removes the
// override so the tenant falls back to its own local acc_setting.
$write = $settings->setOverride((int) $appId, $key, $clear ? null : $on, $this->adminCtx($request));
if (empty($write['success'])) {
return new JsonResponse(array('success' => false, 'message' => $write['message']), 400);
}
$logger->log(
(int) $appId,
'flag:' . $key,
array($key => $write['before']),
array($key => $write['after']),
$request->request->get('reason'),
null,
'central desired-state (platform_setting) — tenant honours it, no cross-DB write',
$this->adminCtx($request)
);
$label = PlatformFlagRegistry::flags()[$key]['label'];
return new JsonResponse(array(
'success' => true,
'message' => $clear
? $label . ' override cleared for tenant #' . (int) $appId . ' — reverts to the tenant\'s local setting.'
: $label . ' desired → ' . ($on ? 'ON' : 'OFF') . ' for tenant #' . (int) $appId . ' (applies on the tenant\'s next read).',
'desired' => $clear ? null : PlatformFlagRegistry::effective($key, $write['after']),
'raw' => $write['after'],
));
} catch (\Throwable $e) {
return new JsonResponse(array('success' => false, 'message' => 'Flag write failed: ' . $e->getMessage()), 500);
}
}
// ── CC6: support access (impersonation) — GO per 2026-07-10 rulings ─────
/** Tenant user list for the support picker (cross-DB, JSON). */
public function supportUsersAction(Request $request, $appId)
{
if (!$this->isCentral() || !$this->canAccessSuperAdmin($request)) {
return new JsonResponse(array('success' => false, 'message' => 'Central super-admin only.'), 403);
}
try {
$company = $this->centralEm()->getRepository('CompanyGroupBundle\\Entity\\CompanyGroup')
->findOneBy(array('appId' => (int) $appId));
if (!$company) {
return new JsonResponse(array('success' => false, 'message' => 'Tenant not found.'), 404);
}
$res = (new SupportAccessService($this->centralEm()))->listTenantUsers($company);
return new JsonResponse(array('success' => $res['error'] === null, 'users' => $res['users'], 'message' => $res['error']));
} catch (\Throwable $e) {
return new JsonResponse(array('success' => false, 'message' => $e->getMessage()), 500);
}
}
/**
* Mint a support token (ruling: mandatory reason; single-use; 60 min; audited). Returns
* the tenant-box entry URL — the admin opens it; the secret is never a password.
*/
public function supportTokenAction(Request $request, $appId)
{
if (!$this->isCentral() || !$this->canAccessSuperAdmin($request)) {
return new JsonResponse(array('success' => false, 'message' => 'Central super-admin only.'), 403);
}
$em = $this->centralEm();
$logger = new PlatformAuditLogger($em);
if (!$logger->tableExists()) {
return new JsonResponse(array('success' => false, 'message' => 'Refused: platform_admin_audit table missing — run update_database_schema on CENTRAL first.'), 409);
}
$tenantUserId = (int) $request->request->get('tenant_user_id', 0);
$reason = (string) $request->request->get('reason', '');
if ($tenantUserId <= 0) {
return new JsonResponse(array('success' => false, 'message' => 'Pick a tenant user.'), 400);
}
try {
$company = $em->getRepository('CompanyGroupBundle\\Entity\\CompanyGroup')
->findOneBy(array('appId' => (int) $appId));
if (!$company) {
return new JsonResponse(array('success' => false, 'message' => 'Tenant not found.'), 404);
}
$adminCtx = $this->adminCtx($request);
$res = (new SupportAccessService($em))->mint($company, $tenantUserId, $reason, $adminCtx);
if (!$res['success']) {
return new JsonResponse(array('success' => false, 'message' => $res['message']), 400);
}
$logger->log((int) $appId, 'impersonate_token_minted', array(), array(
'tenant_user_id' => $tenantUserId,
'token_id' => $res['token_id'],
'ttl_min' => 60,
), $reason, null, 'single-use entry link issued', $adminCtx);
return new JsonResponse(array('success' => true, 'message' => $res['message'], 'entry_url' => $res['entry_url']));
} catch (\Throwable $e) {
return new JsonResponse(array('success' => false, 'message' => 'Mint failed: ' . $e->getMessage()), 500);
}
}
// ── CC3: enforcement arming (central-readable switches, no %param%) ────────
public function enforcementAction(Request $request)
{
if ($redirect = $this->guard($request)) {
return $redirect;
}
$em = $this->centralEm();
$svc = new \ApplicationBundle\Modules\CentralControl\Service\EnforcementSettingsService($em);
$logger = new PlatformAuditLogger($em);
$keys = array();
foreach (\ApplicationBundle\Modules\CentralControl\Service\EnforcementSettingsService::KEYS as $k) {
$eff = $svc->effectiveGlobal($k);
$keys[$k] = array(
'global' => $svc->global($k), // raw '1'/'0'/null
'effective' => $eff['value'],
'source' => $eff['source'],
'overrides' => $svc->overrides($k),
);
}
// CC8 — tenant options for the app-id selectize picker (central-local read; label "name — #id").
$tenants = array();
try {
foreach ($em->createQueryBuilder()
->select('g')->from('CompanyGroupBundle\\Entity\\CompanyGroup', 'g')
->orderBy('g.appId', 'ASC')->getQuery()->getResult() as $g) {
$tenants[] = array('appId' => (int) $g->getAppId(), 'name' => (string) $g->getName());
}
} catch (\Throwable $e) { /* registry unreachable — picker falls back to manual id entry */ }
return $this->render('@CentralControl/pages/control_enforcement.html.twig', array(
'page_title' => 'Enforcement Arming',
'sidebar_partial' => '@CentralControl/pages/_control_sidebar.html.twig',
'cp_active' => 'enforcement',
'keys' => $keys,
'tenants' => $tenants,
'schema_ready' => $svc->tableExists(),
'audit_ready' => $logger->tableExists(),
));
}
/** Set/clear a global or per-app enforcement switch. Audited; fail-closed on the audit table. */
public function enforcementSetAction(Request $request)
{
if ($redirect = $this->guard($request)) {
return $redirect;
}
$em = $this->centralEm();
$svc = new \ApplicationBundle\Modules\CentralControl\Service\EnforcementSettingsService($em);
$logger = new PlatformAuditLogger($em);
if (!$logger->tableExists() || !$svc->tableExists()) {
$this->addFlash('error', 'Refused: run update_database_schema on CENTRAL (platform_setting + platform_admin_audit) first.');
return $this->redirectToRoute('admin_control_enforcement');
}
$key = (string) $request->request->get('key', '');
if (!\ApplicationBundle\Modules\CentralControl\Service\EnforcementSettingsService::isKey($key)) {
$this->addFlash('error', 'Unknown enforcement key.');
return $this->redirectToRoute('admin_control_enforcement');
}
$appId = (int) $request->request->get('appId', 0);
$state = (string) $request->request->get('state', ''); // 'on' | 'off' | 'clear'
$on = $state === 'on' ? true : ($state === 'off' ? false : null);
$ctx = $this->adminCtx($request);
$res = $appId > 0 ? $svc->setOverride($appId, $key, $on, $ctx) : $svc->setGlobal($key, $on, $ctx);
if ($res['success']) {
try {
$logger->log($appId > 0 ? $appId : null, 'enforcement_set',
array($key => $res['before']), array($key => $res['after'], 'scope' => $appId > 0 ? ('app#' . $appId) : 'global'),
$request->request->get('reason'), null, null, $ctx);
} catch (\Throwable $e) {
$this->addFlash('warning', 'Saved BUT audit write failed: ' . $e->getMessage());
return $this->redirectToRoute('admin_control_enforcement');
}
$this->addFlash('success', $res['message']);
} else {
$this->addFlash('error', $res['message']);
}
return $this->redirectToRoute('admin_control_enforcement');
}
// ── Audit trail ──────────────────────────────────────────────────────────
public function auditAction(Request $request)
{
if ($redirect = $this->guard($request)) {
return $redirect;
}
$appId = $request->query->get('appId');
$logger = new PlatformAuditLogger($this->centralEm());
return $this->render('@CentralControl/pages/control_audit.html.twig', array(
'page_title' => 'Platform Admin Audit Trail',
'sidebar_partial' => '@CentralControl/pages/_control_sidebar.html.twig',
'cp_active' => 'audit',
'audit_ready' => $logger->tableExists(),
'rows' => $logger->recent(200, $appId !== null && $appId !== '' ? (int) $appId : null),
'app_id_filter' => $appId !== null && $appId !== '' ? (int) $appId : null,
));
}
}