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:
2026-06-13 10:41:18 -04:00
parent 8f3b5bd7ce
commit 0cfed5bf27
10 changed files with 689 additions and 24 deletions

View File

@@ -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();

View File

@@ -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');