feat: validate required SSH permissions before creating server
- New server_permissions table (migration 007) stores required SSH permissions per server - PermissionValidator service connects via SSH and validates each permission before save - ServerPermission model for CRUD on server_permissions table - Web flow: create/edit forms include dynamic permission rows with quick-add presets - API flow: serverAdd/serverUpdate accept permissions array, return 400 on failure - Show view displays required permissions on server detail page - Form input preserved on validation failure (credentials excluded) - AGENTS.md updated with new conventions
This commit is contained in:
@@ -7,10 +7,12 @@ namespace ServerManager\Controllers;
|
||||
use ServerManager\Core\App;
|
||||
use ServerManager\Core\Session;
|
||||
use ServerManager\Models\Server;
|
||||
use ServerManager\Models\ServerPermission;
|
||||
use ServerManager\Models\User;
|
||||
use ServerManager\Services\MonitoringService;
|
||||
use ServerManager\Services\SSHService;
|
||||
use ServerManager\Services\AuditService;
|
||||
use ServerManager\Services\PermissionValidator;
|
||||
use ServerManager\Models\CommandHistory;
|
||||
use ServerManager\Core\Validator;
|
||||
|
||||
@@ -143,9 +145,43 @@ class ApiController
|
||||
$this->view->json(['error' => $validator->getFirstError()], 400);
|
||||
}
|
||||
|
||||
$permissions = $this->extractPermissionsFromInput($input);
|
||||
|
||||
if (!empty($permissions)) {
|
||||
$serverConfig = [
|
||||
'ip_address' => $input['ip_address'],
|
||||
'ssh_port' => (int) $input['ssh_port'],
|
||||
'ssh_user' => $input['ssh_user'],
|
||||
'ssh_password' => $input['ssh_password'] ?? null,
|
||||
'ssh_key' => $input['ssh_key'] ?? null,
|
||||
];
|
||||
|
||||
$permValidator = new PermissionValidator();
|
||||
$permResult = $permValidator->validate($serverConfig, $permissions);
|
||||
|
||||
if (!$permResult['passed']) {
|
||||
$failedPermissions = array_filter($permResult['results'], fn($r) => !$r['passed']);
|
||||
$errors = [];
|
||||
foreach ($failedPermissions as $fp) {
|
||||
$label = $fp['description'] ?: ($fp['type'] . ': ' . $fp['value']);
|
||||
$errors[] = $label . ' — ' . $fp['message'];
|
||||
}
|
||||
|
||||
$this->view->json([
|
||||
'error' => 'Permission validation failed',
|
||||
'permission_errors' => $errors,
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
$serverModel = new Server();
|
||||
$id = $serverModel->create($input);
|
||||
|
||||
if (!empty($permissions)) {
|
||||
$permModel = new ServerPermission();
|
||||
$permModel->save($id, $permissions);
|
||||
}
|
||||
|
||||
$this->view->json([
|
||||
'success' => true,
|
||||
'message' => 'Server created successfully.',
|
||||
@@ -170,14 +206,70 @@ class ApiController
|
||||
$this->view->json(['error' => 'Forbidden'], 403);
|
||||
}
|
||||
|
||||
$permissions = $this->extractPermissionsFromInput($input);
|
||||
|
||||
if (!empty($permissions)) {
|
||||
$serverConfig = [
|
||||
'ip_address' => $input['ip_address'] ?? $server['ip_address'],
|
||||
'ssh_port' => (int) ($input['ssh_port'] ?? $server['ssh_port']),
|
||||
'ssh_user' => $input['ssh_user'] ?? $server['ssh_user'],
|
||||
'ssh_password' => $input['ssh_password'] ?? $server['ssh_password'],
|
||||
'ssh_key' => $input['ssh_key'] ?? $server['ssh_key'],
|
||||
];
|
||||
|
||||
$permValidator = new PermissionValidator();
|
||||
$permResult = $permValidator->validate($serverConfig, $permissions);
|
||||
|
||||
if (!$permResult['passed']) {
|
||||
$failedPermissions = array_filter($permResult['results'], fn($r) => !$r['passed']);
|
||||
$errors = [];
|
||||
foreach ($failedPermissions as $fp) {
|
||||
$label = $fp['description'] ?: ($fp['type'] . ': ' . $fp['value']);
|
||||
$errors[] = $label . ' — ' . $fp['message'];
|
||||
}
|
||||
|
||||
$this->view->json([
|
||||
'error' => 'Permission validation failed',
|
||||
'permission_errors' => $errors,
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
$serverModel->update($id, $input);
|
||||
|
||||
if (!empty($permissions)) {
|
||||
$permModel = new ServerPermission();
|
||||
$permModel->save($id, $permissions);
|
||||
}
|
||||
|
||||
$this->view->json([
|
||||
'success' => true,
|
||||
'message' => 'Server updated successfully.',
|
||||
]);
|
||||
}
|
||||
|
||||
private function extractPermissionsFromInput(array $input): array
|
||||
{
|
||||
$permissions = $input['permissions'] ?? [];
|
||||
|
||||
if (!is_array($permissions)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$filtered = [];
|
||||
foreach ($permissions as $perm) {
|
||||
if (!empty($perm['type']) && isset($perm['value']) && $perm['value'] !== '') {
|
||||
$filtered[] = [
|
||||
'type' => $perm['type'],
|
||||
'value' => trim($perm['value']),
|
||||
'description' => trim($perm['description'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
public function serverDelete(int $id): void
|
||||
{
|
||||
$user = $this->authenticateRequest();
|
||||
|
||||
@@ -7,11 +7,14 @@ namespace ServerManager\Controllers;
|
||||
use ServerManager\Core\App;
|
||||
use ServerManager\Core\Session;
|
||||
use ServerManager\Core\Validator;
|
||||
use ServerManager\Core\View;
|
||||
use ServerManager\Models\Server;
|
||||
use ServerManager\Models\User;
|
||||
use ServerManager\Models\ServerPermission;
|
||||
use ServerManager\Services\SSHService;
|
||||
use ServerManager\Services\MonitoringService;
|
||||
use ServerManager\Services\AuditService;
|
||||
use ServerManager\Services\AgentService;
|
||||
use ServerManager\Services\MonitoringService;
|
||||
use ServerManager\Services\PermissionValidator;
|
||||
use ServerManager\Models\CommandHistory;
|
||||
|
||||
class ServerController
|
||||
@@ -83,9 +86,13 @@ class ServerController
|
||||
{
|
||||
$groups = $this->serverModel->getGroups();
|
||||
|
||||
$oldInput = $_SESSION['_form_input'] ?? [];
|
||||
unset($_SESSION['_form_input']);
|
||||
|
||||
$this->view->display('servers.create', [
|
||||
'title' => 'Add Server - ServerManager',
|
||||
'groups' => $groups,
|
||||
'oldInput' => $oldInput,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -101,15 +108,46 @@ class ServerController
|
||||
];
|
||||
|
||||
if (!$validator->validate($_POST, $rules)) {
|
||||
$this->preserveFormInput();
|
||||
Session::setFlash('error', $validator->getFirstError());
|
||||
$this->view->redirect('/servers/create');
|
||||
}
|
||||
|
||||
if (empty($_POST['ssh_password']) && empty($_POST['ssh_key'])) {
|
||||
$this->preserveFormInput();
|
||||
Session::setFlash('error', 'You must provide either an SSH password or a private key.');
|
||||
$this->view->redirect('/servers/create');
|
||||
}
|
||||
|
||||
$permissions = $this->extractPermissionsFromInput();
|
||||
|
||||
if (!empty($permissions)) {
|
||||
$serverConfig = [
|
||||
'ip_address' => $_POST['ip_address'],
|
||||
'ssh_port' => (int) $_POST['ssh_port'],
|
||||
'ssh_user' => $_POST['ssh_user'],
|
||||
'ssh_password' => $_POST['ssh_password'] ?? null,
|
||||
'ssh_key' => $_POST['ssh_key'] ?? null,
|
||||
];
|
||||
|
||||
$permValidator = new PermissionValidator();
|
||||
$permResult = $permValidator->validate($serverConfig, $permissions);
|
||||
|
||||
if (!$permResult['passed']) {
|
||||
$this->preserveFormInput();
|
||||
|
||||
$failedPermissions = array_filter($permResult['results'], fn($r) => !$r['passed']);
|
||||
$messages = [];
|
||||
foreach ($failedPermissions as $fp) {
|
||||
$label = $fp['description'] ?: ($fp['type'] . ': ' . $fp['value']);
|
||||
$messages[] = htmlspecialchars($label) . ' — ' . htmlspecialchars($fp['message']);
|
||||
}
|
||||
|
||||
Session::setFlash('error', 'Permission validation failed: ' . implode(' | ', $messages));
|
||||
$this->view->redirect('/servers/create');
|
||||
}
|
||||
}
|
||||
|
||||
$data = [
|
||||
'name' => $_POST['name'],
|
||||
'description' => $_POST['description'] ?? '',
|
||||
@@ -124,6 +162,15 @@ class ServerController
|
||||
|
||||
$id = $this->serverModel->create($data);
|
||||
|
||||
if (!empty($permissions)) {
|
||||
$permModel = new ServerPermission();
|
||||
$permModel->save($id, $permissions);
|
||||
|
||||
$this->auditService->log('server_permissions_set', 'server', $id, [
|
||||
'count' => count($permissions),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->auditService->log('server_created', 'server', $id, [
|
||||
'name' => $data['name'],
|
||||
'ip_address' => $data['ip_address'],
|
||||
@@ -133,23 +180,33 @@ class ServerController
|
||||
$this->view->redirect('/servers');
|
||||
}
|
||||
|
||||
public function show(int $id): void
|
||||
private function extractPermissionsFromInput(): array
|
||||
{
|
||||
$server = $this->requireServerAccess($id, 'viewer');
|
||||
$permissions = $_POST['permissions'] ?? [];
|
||||
|
||||
$role = $this->serverModel->getUserRole($id, (int) Session::get('user_id'));
|
||||
if (!is_array($permissions)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$monitoringService = new MonitoringService();
|
||||
$latestMetrics = $monitoringService->getLatestMetrics($id);
|
||||
$historicalMetrics = $monitoringService->getHistoricalMetrics($id, 24);
|
||||
$filtered = [];
|
||||
foreach ($permissions as $perm) {
|
||||
if (!empty($perm['type']) && isset($perm['value']) && $perm['value'] !== '') {
|
||||
$filtered[] = [
|
||||
'type' => $perm['type'],
|
||||
'value' => trim($perm['value']),
|
||||
'description' => trim($perm['description'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$this->view->display('servers.show', [
|
||||
'title' => $server['name'] . ' - ServerManager',
|
||||
'server' => $server,
|
||||
'metrics' => $latestMetrics,
|
||||
'historicalMetrics' => $historicalMetrics,
|
||||
'server_role' => $role,
|
||||
]);
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
private function preserveFormInput(): void
|
||||
{
|
||||
$input = $_POST;
|
||||
unset($input['ssh_password'], $input['ssh_key'], $input['_csrf_token']);
|
||||
$_SESSION['_form_input'] = $input;
|
||||
}
|
||||
|
||||
public function edit(int $id): void
|
||||
@@ -158,10 +215,14 @@ class ServerController
|
||||
|
||||
$groups = $this->serverModel->getGroups();
|
||||
|
||||
$permModel = new ServerPermission();
|
||||
$permissions = $permModel->getByServerId($id);
|
||||
|
||||
$this->view->display('servers.edit', [
|
||||
'title' => 'Edit ' . $server['name'] . ' - ServerManager',
|
||||
'server' => $server,
|
||||
'groups' => $groups,
|
||||
'permissions' => $permissions,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -183,6 +244,33 @@ class ServerController
|
||||
$this->view->redirect("/servers/{$id}/edit");
|
||||
}
|
||||
|
||||
$permissions = $this->extractPermissionsFromInput();
|
||||
|
||||
if (!empty($permissions)) {
|
||||
$serverConfig = [
|
||||
'ip_address' => $_POST['ip_address'],
|
||||
'ssh_port' => (int) $_POST['ssh_port'],
|
||||
'ssh_user' => $_POST['ssh_user'],
|
||||
'ssh_password' => $_POST['ssh_password'] ?: ($server['ssh_password'] ?? null),
|
||||
'ssh_key' => $_POST['ssh_key'] ?: ($server['ssh_key'] ?? null),
|
||||
];
|
||||
|
||||
$permValidator = new PermissionValidator();
|
||||
$permResult = $permValidator->validate($serverConfig, $permissions);
|
||||
|
||||
if (!$permResult['passed']) {
|
||||
$failedPermissions = array_filter($permResult['results'], fn($r) => !$r['passed']);
|
||||
$messages = [];
|
||||
foreach ($failedPermissions as $fp) {
|
||||
$label = $fp['description'] ?: ($fp['type'] . ': ' . $fp['value']);
|
||||
$messages[] = htmlspecialchars($label) . ' — ' . htmlspecialchars($fp['message']);
|
||||
}
|
||||
|
||||
Session::setFlash('error', 'Permission validation failed: ' . implode(' | ', $messages));
|
||||
$this->view->redirect("/servers/{$id}/edit");
|
||||
}
|
||||
}
|
||||
|
||||
$data = [
|
||||
'name' => $_POST['name'],
|
||||
'description' => $_POST['description'] ?? '',
|
||||
@@ -203,6 +291,13 @@ class ServerController
|
||||
|
||||
$this->serverModel->update($id, $data);
|
||||
|
||||
$permModel = new ServerPermission();
|
||||
$permModel->save($id, $permissions);
|
||||
|
||||
$this->auditService->log('server_permissions_set', 'server', $id, [
|
||||
'count' => count($permissions),
|
||||
]);
|
||||
|
||||
$this->auditService->log('server_updated', 'server', $id, [
|
||||
'name' => $data['name'],
|
||||
]);
|
||||
@@ -211,6 +306,29 @@ class ServerController
|
||||
$this->view->redirect("/servers/{$id}");
|
||||
}
|
||||
|
||||
public function show(int $id): void
|
||||
{
|
||||
$server = $this->requireServerAccess($id, 'viewer');
|
||||
|
||||
$role = $this->serverModel->getUserRole($id, (int) Session::get('user_id'));
|
||||
|
||||
$monitoringService = new MonitoringService();
|
||||
$latestMetrics = $monitoringService->getLatestMetrics($id);
|
||||
$historicalMetrics = $monitoringService->getHistoricalMetrics($id, 24);
|
||||
|
||||
$permModel = new ServerPermission();
|
||||
$permissions = $permModel->getByServerId($id);
|
||||
|
||||
$this->view->display('servers.show', [
|
||||
'title' => $server['name'] . ' - ServerManager',
|
||||
'server' => $server,
|
||||
'metrics' => $latestMetrics,
|
||||
'historicalMetrics' => $historicalMetrics,
|
||||
'server_role' => $role,
|
||||
'permissions' => $permissions,
|
||||
]);
|
||||
}
|
||||
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$server = $this->requireServerAccess($id, 'manager');
|
||||
|
||||
58
src/Models/ServerPermission.php
Normal file
58
src/Models/ServerPermission.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ServerManager\Models;
|
||||
|
||||
use ServerManager\Core\Database;
|
||||
|
||||
class ServerPermission
|
||||
{
|
||||
private Database $db;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->db = Database::getInstance();
|
||||
}
|
||||
|
||||
public function save(int $serverId, array $permissions): void
|
||||
{
|
||||
$this->deleteByServerId($serverId);
|
||||
|
||||
foreach ($permissions as $perm) {
|
||||
if (empty($perm['type']) || !isset($perm['value'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->db->insert('server_permissions', [
|
||||
'server_id' => $serverId,
|
||||
'permission_type' => $perm['type'],
|
||||
'permission_value' => $perm['value'],
|
||||
'description' => $perm['description'] ?? null,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function getByServerId(int $serverId): array
|
||||
{
|
||||
return $this->db->fetchAll(
|
||||
'SELECT * FROM server_permissions WHERE server_id = ? ORDER BY id ASC',
|
||||
[$serverId]
|
||||
);
|
||||
}
|
||||
|
||||
public function deleteByServerId(int $serverId): void
|
||||
{
|
||||
$this->db->delete('server_permissions', 'server_id = ?', [$serverId]);
|
||||
}
|
||||
|
||||
public static function formatForValidation(array $permissions): array
|
||||
{
|
||||
return array_map(fn($p) => [
|
||||
'type' => $p['permission_type'],
|
||||
'value' => $p['permission_value'],
|
||||
'description' => $p['description'] ?? '',
|
||||
], $permissions);
|
||||
}
|
||||
}
|
||||
102
src/Services/PermissionValidator.php
Normal file
102
src/Services/PermissionValidator.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ServerManager\Services;
|
||||
|
||||
class PermissionValidator
|
||||
{
|
||||
private SSHService $ssh;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->ssh = new SSHService();
|
||||
}
|
||||
|
||||
public function validate(array $serverConfig, array $permissions): array
|
||||
{
|
||||
try {
|
||||
if (!$this->ssh->connect($serverConfig, true)) {
|
||||
return [
|
||||
'passed' => false,
|
||||
'results' => [],
|
||||
'error' => 'Could not connect to the remote server. Please check the connection details.',
|
||||
];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return [
|
||||
'passed' => false,
|
||||
'results' => [],
|
||||
'error' => 'SSH connection failed: ' . $e->getMessage(),
|
||||
];
|
||||
}
|
||||
|
||||
$results = [];
|
||||
|
||||
foreach ($permissions as $index => $perm) {
|
||||
if (empty($perm['type']) || !isset($perm['value'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$type = $perm['type'];
|
||||
$value = $perm['value'];
|
||||
$description = $perm['description'] ?? '';
|
||||
|
||||
$command = $this->buildCheckCommand($type, $value);
|
||||
|
||||
try {
|
||||
$output = $this->ssh->exec($command);
|
||||
$passed = $this->evaluateResult($type, $output);
|
||||
$results[] = [
|
||||
'type' => $type,
|
||||
'value' => $value,
|
||||
'description' => $description,
|
||||
'passed' => $passed,
|
||||
'message' => $passed ? 'Permission verified' : ($type === 'sudo' ? 'Passwordless sudo is not available' : 'Permission not granted: ' . trim($output['output'] ?? '')),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
$results[] = [
|
||||
'type' => $type,
|
||||
'value' => $value,
|
||||
'description' => $description,
|
||||
'passed' => false,
|
||||
'message' => 'Check failed: ' . $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$this->ssh->disconnect();
|
||||
|
||||
$allPassed = !empty($results) && empty(array_filter($results, fn($r) => !$r['passed']));
|
||||
|
||||
return [
|
||||
'passed' => $allPassed,
|
||||
'results' => $results,
|
||||
'error' => null,
|
||||
];
|
||||
}
|
||||
|
||||
private function buildCheckCommand(string $type, string $value): string
|
||||
{
|
||||
return match ($type) {
|
||||
'sudo' => 'sudo -n true 2>&1 && echo "OK" || echo "FAIL"',
|
||||
'command' => sprintf('command -v %s 2>/dev/null && echo "OK" || echo "FAIL"', escapeshellarg($value)),
|
||||
'file_read' => sprintf('test -r %s 2>/dev/null && echo "OK" || echo "FAIL"', escapeshellarg($value)),
|
||||
'file_write' => sprintf('test -w %s 2>/dev/null && echo "OK" || echo "FAIL"', escapeshellarg($value)),
|
||||
'custom' => sprintf('%s 2>&1; echo "EXIT:$?"', $value),
|
||||
default => 'echo "FAIL"',
|
||||
};
|
||||
}
|
||||
|
||||
private function evaluateResult(string $type, array $output): bool
|
||||
{
|
||||
$exitCode = $output['exit_status'] ?? 1;
|
||||
$stdout = trim($output['output'] ?? '');
|
||||
|
||||
if ($type === 'custom') {
|
||||
return $exitCode === 0;
|
||||
}
|
||||
|
||||
return $exitCode === 0 && str_contains($stdout, 'OK');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user