moduleRegistry->getPermissions() as $permissionDefinition) { $total++; $key = trim((string) $permissionDefinition['key']); if ($key === '') { throw new \RuntimeException('Module permission definition has empty key.'); } $desired = [ 'key' => $key, 'description' => trim((string) $permissionDefinition['description']), 'active' => (int) $permissionDefinition['active'], 'is_system' => (int) $permissionDefinition['is_system'], ]; $existing = $this->permissionRepository->findByKey($key); if (!is_array($existing)) { $createdId = $this->permissionRepository->create($desired); if ($createdId === null) { throw new \RuntimeException("Failed to create module permission '{$key}'."); } $created++; continue; } $existingId = (int) ($existing['id'] ?? 0); if ($existingId <= 0) { throw new \RuntimeException("Permission '{$key}' exists without a valid id."); } if ($this->isPermissionEqual($existing, $desired)) { $unchanged++; continue; } $wasUpdated = $this->permissionRepository->update($existingId, $desired); if (!$wasUpdated) { throw new \RuntimeException("Failed to update module permission '{$key}' (id {$existingId})."); } $updated++; } $deactivated = $this->deactivateOrphaned(); return [ 'created' => $created, 'updated' => $updated, 'unchanged' => $unchanged, 'deactivated' => $deactivated, 'total' => $total, ]; } /** * Deactivate module-owned permissions that no active module claims. * * Only touches permissions whose key is declared in at least one module * manifest on disk (enabled or not). Core seed permissions are never touched. * Setting active=0 is reversible — re-enabling the module and running sync * restores active=1. */ private function deactivateOrphaned(): int { $activeKeys = $this->moduleRegistry->getPermissionKeys(); $allModuleKeys = $this->collectAllModulePermissionKeys(); // Nothing to deactivate if we can't determine module-owned keys if ($allModuleKeys === []) { return 0; } $systemPermissions = $this->permissionRepository->listSystem(); $deactivated = 0; foreach ($systemPermissions as $permission) { $key = (string) $permission['key']; $id = (int) $permission['id']; $isActive = (int) $permission['active']; if ($key === '' || $id <= 0) { continue; } // Already inactive — nothing to do if ($isActive === 0) { continue; } // Still claimed by an active module — keep it if (in_array($key, $activeKeys, true)) { continue; } // Not a module-owned permission — never touch core seed permissions if (!in_array($key, $allModuleKeys, true)) { continue; } // Orphaned module permission: declared by a module on disk but not by any active module $this->permissionRepository->update($id, [ 'key' => $key, 'description' => (string) $permission['description'], 'active' => 0, 'is_system' => 1, ]); $deactivated++; } return $deactivated; } /** * Scan all module manifests on disk (not just enabled) and collect their permission keys. * This determines which permissions are module-owned vs core seed data. * * @return list */ private function collectAllModulePermissionKeys(): array { if ($this->modulesDir === '' || !is_dir($this->modulesDir)) { return []; } $keys = []; $entries = scandir($this->modulesDir); if ($entries === false) { return []; } foreach ($entries as $entry) { if ($entry === '.' || $entry === '..') { continue; } $manifestFile = $this->modulesDir . '/' . $entry . '/module.php'; if (!is_file($manifestFile)) { continue; } try { $raw = require $manifestFile; if (!is_array($raw)) { continue; } $permissions = $raw['permissions'] ?? []; if (!is_array($permissions)) { continue; } foreach ($permissions as $permission) { if (is_array($permission)) { $key = trim((string) ($permission['key'] ?? '')); if ($key !== '') { $keys[] = $key; } } } } catch (\Throwable) { // Skip broken manifests — don't break the sync continue; } } return array_values(array_unique($keys)); } /** * @param array $existing * @param array $desired */ private function isPermissionEqual(array $existing, array $desired): bool { $existingDescription = trim((string) ($existing['description'] ?? '')); $existingActive = (int) ($existing['active'] ?? 0); $existingSystem = (int) ($existing['is_system'] ?? 0); return $existingDescription === (string) ($desired['description'] ?? '') && $existingActive === (int) ($desired['active'] ?? 0) && $existingSystem === (int) ($desired['is_system'] ?? 0); } }