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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user