Merge pull request 'feat: validate required SSH permissions before creating server' (#56) from feat/dashboard-aggregated-chart into master

Reviewed-on: #56
Reviewed-by: rafaga21 <rafaelminaya20@hotmail.com>
This commit was merged in pull request #56.
This commit is contained in:
2026-06-13 10:44:21 -04:00
10 changed files with 689 additions and 24 deletions

View File

@@ -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

View File

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

View File

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

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

View 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);
}
}

View 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');
}
}

View File

@@ -1,4 +1,10 @@
<?php $groups = $groups ?? []; ?>
<?php
$groups = $groups ?? [];
$old = $oldInput ?? [];
function old(string $key, string $default = ''): string {
return htmlspecialchars($GLOBALS['old'][$key] ?? $default);
}
?>
<div class="page-header">
<h1 class="page-title">Add Server</h1>
@@ -16,13 +22,14 @@
<div class="form-group">
<label for="name">Server Name *</label>
<input type="text" id="name" name="name" class="form-input"
placeholder="e.g., Production Web Server" required>
placeholder="e.g., Production Web Server" required
value="<?= old('name') ?>">
</div>
<div class="form-group">
<label for="group_name">Group / Category</label>
<input type="text" id="group_name" name="group_name" class="form-input"
placeholder="e.g., Production, Staging"
list="groupList">
list="groupList" value="<?= old('group_name') ?>">
<datalist id="groupList">
<?php foreach ($groups as $group): ?>
<option value="<?= htmlspecialchars($group) ?>">
@@ -33,7 +40,7 @@
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" name="description" class="form-input" rows="3"
placeholder="Optional description of the server"></textarea>
placeholder="Optional description of the server"><?= old('description') ?></textarea>
</div>
</div>
@@ -43,17 +50,19 @@
<div class="form-group">
<label for="ip_address">IP Address / Hostname *</label>
<input type="text" id="ip_address" name="ip_address" class="form-input"
placeholder="e.g., 192.168.1.100 or server.example.com" required>
placeholder="e.g., 192.168.1.100 or server.example.com" required
value="<?= old('ip_address') ?>">
</div>
<div class="form-group">
<label for="ssh_port">SSH Port *</label>
<input type="number" id="ssh_port" name="ssh_port" class="form-input"
value="22" min="1" max="65535" required>
value="<?= old('ssh_port', '22') ?>" min="1" max="65535" required>
</div>
<div class="form-group">
<label for="ssh_user">SSH User *</label>
<input type="text" id="ssh_user" name="ssh_user" class="form-input"
placeholder="e.g., root or admin" required>
placeholder="e.g., root or admin" required
value="<?= old('ssh_user') ?>">
</div>
</div>
</div>
@@ -80,13 +89,59 @@
<div class="auth-method-content" id="keyMethod" style="display: none;">
<div class="form-group">
<label for="ssh_key">SSH Private Key</label>
<textarea id="ssh_key" name="ssh_key" class="form-input font-mono" rows="6"
<textarea id="ssh_key" name="ssh_key" class="form-input font-mono" rows="6"
placeholder="Paste the private key content (RSA/Ed25519)"></textarea>
<small class="form-hint">Private key is encrypted before storage. Passphrase-protected keys supported.</small>
</div>
</div>
</div>
<div class="form-section">
<h3><i class="fas fa-shield-alt"></i> Required Permissions</h3>
<p class="form-hint">The system will connect to the remote server and verify each permission before saving.</p>
<div id="permissions-container">
<?php
$savedPerms = $old['permissions'] ?? [];
if (!empty($savedPerms) && is_array($savedPerms)):
foreach ($savedPerms as $i => $perm):
?>
<div class="permission-row" data-index="<?= $i ?>">
<select name="permissions[<?= $i ?>][type]" class="form-input permission-type">
<option value="sudo" <?= ($perm['type'] ?? '') === 'sudo' ? 'selected' : '' ?>>Sudo (passwordless)</option>
<option value="command" <?= ($perm['type'] ?? '') === 'command' ? 'selected' : '' ?>>Command</option>
<option value="file_read" <?= ($perm['type'] ?? '') === 'file_read' ? 'selected' : '' ?>>File Read</option>
<option value="file_write" <?= ($perm['type'] ?? '') === 'file_write' ? 'selected' : '' ?>>File Write</option>
<option value="custom" <?= ($perm['type'] ?? '') === 'custom' ? 'selected' : '' ?>>Custom Command</option>
</select>
<input type="text" name="permissions[<?= $i ?>][value]" class="form-input permission-value"
placeholder="e.g., systemctl" value="<?= htmlspecialchars($perm['value'] ?? '') ?>">
<input type="text" name="permissions[<?= $i ?>][description]" class="form-input permission-desc"
placeholder="Description (optional)" value="<?= htmlspecialchars($perm['description'] ?? '') ?>">
<button type="button" class="btn btn-danger btn-sm" onclick="removePermissionRow(this)"><i class="fas fa-times"></i></button>
</div>
<?php
endforeach;
endif;
?>
</div>
<div class="permission-actions">
<button type="button" id="addPermissionBtn" class="btn btn-secondary">
<i class="fas fa-plus"></i> Add Permission
</button>
</div>
<div class="permission-presets">
<span class="preset-label">Quick add:</span>
<button type="button" class="btn btn-xs btn-outline" onclick="addPresetPermission('sudo', 'sudo -n true', 'Passwordless sudo for system management')">Sudo</button>
<button type="button" class="btn btn-xs btn-outline" onclick="addPresetPermission('command', 'systemctl', 'Service management')">systemctl</button>
<button type="button" class="btn btn-xs btn-outline" onclick="addPresetPermission('command', 'df', 'Disk usage monitoring')">df</button>
<button type="button" class="btn btn-xs btn-outline" onclick="addPresetPermission('command', 'free', 'Memory monitoring')">free</button>
<button type="button" class="btn btn-xs btn-outline" onclick="addPresetPermission('command', 'top', 'CPU monitoring')">top</button>
</div>
</div>
<div class="form-section">
<div class="form-group">
<label class="checkbox-label">
@@ -116,4 +171,47 @@ document.querySelectorAll('.auth-method-tabs .tab').forEach(tab => {
document.getElementById('keyMethod').style.display = method === 'key' ? 'block' : 'none';
});
});
let permIndex = <?= !empty($savedPerms) && is_array($savedPerms) ? count($savedPerms) : 0 ?>;
function createPermissionRow(type, value, description) {
const idx = permIndex++;
const container = document.getElementById('permissions-container');
const div = document.createElement('div');
div.className = 'permission-row';
div.dataset.index = idx;
div.innerHTML = `
<select name="permissions[${idx}][type]" class="form-input permission-type">
<option value="sudo" ${type === 'sudo' ? 'selected' : ''}>Sudo (passwordless)</option>
<option value="command" ${type === 'command' ? 'selected' : ''}>Command</option>
<option value="file_read" ${type === 'file_read' ? 'selected' : ''}>File Read</option>
<option value="file_write" ${type === 'file_write' ? 'selected' : ''}>File Write</option>
<option value="custom" ${type === 'custom' ? 'selected' : ''}>Custom Command</option>
</select>
<input type="text" name="permissions[${idx}][value]" class="form-input permission-value"
placeholder="e.g., systemctl" value="${escapeHtml(value || '')}">
<input type="text" name="permissions[${idx}][description]" class="form-input permission-desc"
placeholder="Description (optional)" value="${escapeHtml(description || '')}">
<button type="button" class="btn btn-danger btn-sm" onclick="removePermissionRow(this)"><i class="fas fa-times"></i></button>
`;
container.appendChild(div);
}
function removePermissionRow(btn) {
btn.closest('.permission-row').remove();
}
function addPresetPermission(type, value, description) {
createPermissionRow(type, value, description);
}
document.getElementById('addPermissionBtn').addEventListener('click', function() {
createPermissionRow('command', '', '');
});
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
</script>

View File

@@ -1,6 +1,7 @@
<?php
$server = $server ?? [];
$groups = $groups ?? [];
$permissions = $permissions ?? [];
$hasPassword = !empty($server['ssh_password']);
$hasKey = !empty($server['ssh_key']);
?>
@@ -92,6 +93,47 @@ $hasKey = !empty($server['ssh_key']);
</div>
</div>
<div class="form-section">
<h3><i class="fas fa-shield-alt"></i> Required Permissions</h3>
<p class="form-hint">The system will verify these permissions on the remote server before saving changes.</p>
<div id="permissions-container">
<?php if (!empty($permissions)): ?>
<?php foreach ($permissions as $i => $perm): ?>
<div class="permission-row" data-index="<?= $i ?>">
<select name="permissions[<?= $i ?>][type]" class="form-input permission-type">
<option value="sudo" <?= ($perm['permission_type'] ?? '') === 'sudo' ? 'selected' : '' ?>>Sudo (passwordless)</option>
<option value="command" <?= ($perm['permission_type'] ?? '') === 'command' ? 'selected' : '' ?>>Command</option>
<option value="file_read" <?= ($perm['permission_type'] ?? '') === 'file_read' ? 'selected' : '' ?>>File Read</option>
<option value="file_write" <?= ($perm['permission_type'] ?? '') === 'file_write' ? 'selected' : '' ?>>File Write</option>
<option value="custom" <?= ($perm['permission_type'] ?? '') === 'custom' ? 'selected' : '' ?>>Custom Command</option>
</select>
<input type="text" name="permissions[<?= $i ?>][value]" class="form-input permission-value"
placeholder="e.g., systemctl" value="<?= htmlspecialchars($perm['permission_value'] ?? '') ?>">
<input type="text" name="permissions[<?= $i ?>][description]" class="form-input permission-desc"
placeholder="Description (optional)" value="<?= htmlspecialchars($perm['description'] ?? '') ?>">
<button type="button" class="btn btn-danger btn-sm" onclick="removePermissionRow(this)"><i class="fas fa-times"></i></button>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
<div class="permission-actions">
<button type="button" id="addPermissionBtn" class="btn btn-secondary">
<i class="fas fa-plus"></i> Add Permission
</button>
</div>
<div class="permission-presets">
<span class="preset-label">Quick add:</span>
<button type="button" class="btn btn-xs btn-outline" onclick="addPresetPermission('sudo', 'sudo -n true', 'Passwordless sudo for system management')">Sudo</button>
<button type="button" class="btn btn-xs btn-outline" onclick="addPresetPermission('command', 'systemctl', 'Service management')">systemctl</button>
<button type="button" class="btn btn-xs btn-outline" onclick="addPresetPermission('command', 'df', 'Disk usage monitoring')">df</button>
<button type="button" class="btn btn-xs btn-outline" onclick="addPresetPermission('command', 'free', 'Memory monitoring')">free</button>
<button type="button" class="btn btn-xs btn-outline" onclick="addPresetPermission('command', 'top', 'CPU monitoring')">top</button>
</div>
</div>
<div class="form-section">
<div class="form-group">
<label class="checkbox-label">
@@ -110,3 +152,48 @@ $hasKey = !empty($server['ssh_key']);
</form>
</div>
</div>
<script>
let permIndex = <?= count($permissions) ?>;
function createPermissionRow(type, value, description) {
const idx = permIndex++;
const container = document.getElementById('permissions-container');
const div = document.createElement('div');
div.className = 'permission-row';
div.dataset.index = idx;
div.innerHTML = `
<select name="permissions[${idx}][type]" class="form-input permission-type">
<option value="sudo" ${type === 'sudo' ? 'selected' : ''}>Sudo (passwordless)</option>
<option value="command" ${type === 'command' ? 'selected' : ''}>Command</option>
<option value="file_read" ${type === 'file_read' ? 'selected' : ''}>File Read</option>
<option value="file_write" ${type === 'file_write' ? 'selected' : ''}>File Write</option>
<option value="custom" ${type === 'custom' ? 'selected' : ''}>Custom Command</option>
</select>
<input type="text" name="permissions[${idx}][value]" class="form-input permission-value"
placeholder="e.g., systemctl" value="${escapeHtml(value || '')}">
<input type="text" name="permissions[${idx}][description]" class="form-input permission-desc"
placeholder="Description (optional)" value="${escapeHtml(description || '')}">
<button type="button" class="btn btn-danger btn-sm" onclick="removePermissionRow(this)"><i class="fas fa-times"></i></button>
`;
container.appendChild(div);
}
function removePermissionRow(btn) {
btn.closest('.permission-row').remove();
}
function addPresetPermission(type, value, description) {
createPermissionRow(type, value, description);
}
document.getElementById('addPermissionBtn').addEventListener('click', function() {
createPermissionRow('command', '', '');
});
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
</script>

View File

@@ -191,6 +191,34 @@ $canManage = $server_role === 'owner' || $server_role === 'manager';
<?php endif; ?>
</div>
</div>
<?php if (!empty($permissions)): ?>
<div class="dashboard-card">
<div class="card-header">
<h2><i class="fas fa-shield-alt"></i> Required Permissions</h2>
</div>
<div class="card-body">
<table class="table table-sm">
<thead>
<tr>
<th>Type</th>
<th>Value</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<?php foreach ($permissions as $perm): ?>
<tr>
<td><span class="badge badge-info"><?= htmlspecialchars($perm['permission_type'] ?? '') ?></span></td>
<td><code><?= htmlspecialchars($perm['permission_value'] ?? '') ?></code></td>
<td><?= htmlspecialchars($perm['description'] ?? '') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
</div>
<div class="card">