diff --git a/AGENTS.md b/AGENTS.md index 755249d..5a0f6b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,11 +39,20 @@ Repo-specific guidance for OpenCode sessions. Verified against code. - Rate limiting on `POST /login` only (via `RateLimitMiddleware`) - Every credential access must be logged at `warning` level via `AuditService` +## Permission validation on server create/edit +- When creating or editing a server, required SSH permissions can be specified and are validated via SSH before saving. +- `PermissionValidator::validate(array $serverConfig, array $permissions)` connects to the remote server and runs check commands for each permission. +- Permission types: `sudo` (checks passwordless sudo), `command` (checks `command -v`), `file_read` (`test -r`), `file_write` (`test -w`), `custom` (arbitrary command, exit code 0 = pass). +- `ServerPermission` model handles CRUD for the `server_permissions` table. +- Both web (`ServerController::store/update`) and API (`ApiController::serverAdd/serverUpdate`) flows include permission validation. +- On validation failure, the web flow preserves form input via `$_SESSION['_form_input']` and redirects back with error details. + ## Database - Migrations run manually: `mysql servermanager < database/migrations/NNN_name.sql` -- 5 migrations: `001_initial_schema`, `002_server_access`, `003_teams`, `004_soft_deletes`, `005_app_config_and_notifications` +- 7 migrations: `001_initial_schema`, `002_server_access`, `003_teams`, `004_soft_deletes`, `005_app_config_and_notifications`, `006_agent_tokens`, `007_server_permission_checks` - Users table has `role` ENUM: `super_admin`, `admin`, `operator` - `api_token` column on users for Bearer token auth +- `server_permissions` table stores required SSH permissions per server (FK to `servers.id` CASCADE) ## Testing / CI - **No tests, no CI, no linter, no formatter, no typechecker** exist in this repo diff --git a/database/migrations/007_server_permission_checks.sql b/database/migrations/007_server_permission_checks.sql new file mode 100644 index 0000000..fc3f4d2 --- /dev/null +++ b/database/migrations/007_server_permission_checks.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS `server_permissions` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `server_id` INT UNSIGNED NOT NULL, + `permission_type` ENUM('sudo', 'command', 'file_read', 'file_write', 'custom') NOT NULL, + `permission_value` VARCHAR(255) NOT NULL, + `description` VARCHAR(255) DEFAULT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_server_id` (`server_id`), + CONSTRAINT `fk_permissions_server` FOREIGN KEY (`server_id`) REFERENCES `servers` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/public/assets/css/style.css b/public/assets/css/style.css index bd74508..45b68ce 100755 --- a/public/assets/css/style.css +++ b/public/assets/css/style.css @@ -3071,3 +3071,65 @@ textarea.form-input { font-weight: 500; color: var(--text-secondary); } + +/* --- Server permission rows --- */ +.permission-row { + display: flex; + gap: 0.5rem; + align-items: center; + margin-bottom: 0.5rem; + padding: 0.5rem; + background: var(--bg-secondary, #f8f9fa); + border-radius: 6px; + border: 1px solid var(--border-color, #e0e0e0); +} + +.permission-row .permission-type { + flex: 0 0 160px; +} + +.permission-row .permission-value { + flex: 1 1 auto; +} + +.permission-row .permission-desc { + flex: 0 0 200px; +} + +.permission-row .btn-danger { + flex: 0 0 auto; +} + +.permission-actions { + margin-top: 0.5rem; +} + +.permission-presets { + margin-top: 0.75rem; + display: flex; + align-items: center; + gap: 0.4rem; + flex-wrap: wrap; +} + +.permission-presets .preset-label { + font-size: 0.8rem; + color: var(--text-muted, #888); + margin-right: 0.25rem; +} + +.btn-outline { + background: transparent; + border: 1px solid var(--border-color, #ccc); + color: var(--text-primary, #333); + padding: 0.25rem 0.6rem; + font-size: 0.8rem; + border-radius: 4px; + cursor: pointer; + transition: all 0.15s; +} + +.btn-outline:hover { + background: var(--bg-hover, #e9ecef); + border-color: var(--text-muted, #888); +} diff --git a/src/Controllers/ApiController.php b/src/Controllers/ApiController.php index f7bd03b..0b5ffca 100755 --- a/src/Controllers/ApiController.php +++ b/src/Controllers/ApiController.php @@ -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(); diff --git a/src/Controllers/ServerController.php b/src/Controllers/ServerController.php index 4bf3970..ecfaeb6 100755 --- a/src/Controllers/ServerController.php +++ b/src/Controllers/ServerController.php @@ -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'); diff --git a/src/Models/ServerPermission.php b/src/Models/ServerPermission.php new file mode 100644 index 0000000..254e61f --- /dev/null +++ b/src/Models/ServerPermission.php @@ -0,0 +1,58 @@ +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); + } +} diff --git a/src/Services/PermissionValidator.php b/src/Services/PermissionValidator.php new file mode 100644 index 0000000..d89c724 --- /dev/null +++ b/src/Services/PermissionValidator.php @@ -0,0 +1,102 @@ +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'); + } +} diff --git a/views/servers/create.php b/views/servers/create.php index a5fde91..616ceba 100755 --- a/views/servers/create.php +++ b/views/servers/create.php @@ -1,4 +1,10 @@ - + @@ -80,13 +89,59 @@ +
+

Required Permissions

+

The system will connect to the remote server and verify each permission before saving.

+ +
+ $perm): + ?> +
+ + + + +
+ +
+ +
+ +
+ +
+ Quick add: + + + + + +
+
+
+
+

Required Permissions

+

The system will verify these permissions on the remote server before saving changes.

+ +
+ + $perm): ?> +
+ + + + +
+ + +
+ +
+ +
+ +
+ Quick add: + + + + + +
+
+
+ + diff --git a/views/servers/show.php b/views/servers/show.php index 0deab5e..de413bd 100755 --- a/views/servers/show.php +++ b/views/servers/show.php @@ -191,6 +191,34 @@ $canManage = $server_role === 'owner' || $server_role === 'manager'; + + +
+
+

Required Permissions

+
+
+ + + + + + + + + + + + + + + + + +
TypeValueDescription
+
+
+