diff --git a/index.html b/index.html index bad2be9..0a40515 100644 --- a/index.html +++ b/index.html @@ -187,6 +187,10 @@ Users + + +
+
+ +
+
+
+ + +
@@ -807,6 +837,7 @@ SELECT * FROM users LIMIT 10;"> document.getElementById('roleBadge').textContent = this.currentUser.role || 'viewer'; document.getElementById('logsButton').classList.toggle('hidden', !this.getPermissions().canViewLogs); document.getElementById('usersButton').classList.toggle('hidden', !this.getPermissions().canManageUsers); + document.getElementById('auditButton').classList.toggle('hidden', !this.getPermissions().canManageUsers); document.querySelector('button[onclick="app.showSQLPanel()"]').style.display = this.getPermissions().canRunSql ? '' : 'none'; document.querySelector('button[onclick="app.showCreateTableModal()"]').style.display = this.getPermissions().canCreate ? '' : 'none'; @@ -1677,7 +1708,34 @@ SELECT * FROM users LIMIT 10;"> return value.split(',').map(item => item.trim()).filter(Boolean); } + getAvailableFolders() { + return Array.from(new Set(this.tables.map(table => { + const parts = table.name.split('__'); + return parts.length > 1 ? parts[0] : 'default'; + }))).sort(); + } + + renderOptionChecklist(containerId, values, selected = []) { + const container = document.getElementById(containerId); + const selectedSet = new Set(selected || []); + container.innerHTML = values.length + ? values.map(value => ` + + `).join('') + : '
No options available
'; + } + + getCheckedValues(containerId) { + return Array.from(document.querySelectorAll(`#${containerId} input[type="checkbox"]:checked`)).map(input => input.value); + } + async showUsersModal() { + this.renderOptionChecklist('userViewFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default')); + this.renderOptionChecklist('userDeleteFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default')); + this.renderOptionChecklist('userEditTablesList', this.tables.map(table => table.name)); await this.loadUsers(); this.resetUserForm(); document.getElementById('usersModal').classList.remove('hidden'); @@ -1714,9 +1772,9 @@ SELECT * FROM users LIMIT 10;"> document.getElementById('userUsername').disabled = true; document.getElementById('userPassword').value = ''; document.getElementById('userRole').value = user.role; - document.getElementById('userViewFolders').value = (user.access?.view?.folders || []).join(', '); - document.getElementById('userEditTables').value = (user.access?.edit?.tables || []).join(', '); - document.getElementById('userDeleteFolders').value = (user.access?.delete?.folders || []).join(', '); + this.renderOptionChecklist('userViewFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default'), user.access?.view?.folders || []); + this.renderOptionChecklist('userDeleteFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default'), user.access?.delete?.folders || []); + this.renderOptionChecklist('userEditTablesList', this.tables.map(table => table.name), user.access?.edit?.tables || []); document.getElementById('userDisabled').checked = Boolean(user.disabled); } @@ -1726,9 +1784,9 @@ SELECT * FROM users LIMIT 10;"> document.getElementById('userUsername').disabled = false; document.getElementById('userPassword').value = ''; document.getElementById('userRole').value = 'viewer'; - document.getElementById('userViewFolders').value = ''; - document.getElementById('userEditTables').value = ''; - document.getElementById('userDeleteFolders').value = ''; + this.renderOptionChecklist('userViewFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default')); + this.renderOptionChecklist('userDeleteFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default')); + this.renderOptionChecklist('userEditTablesList', this.tables.map(table => table.name)); document.getElementById('userDisabled').checked = false; } @@ -1742,10 +1800,10 @@ SELECT * FROM users LIMIT 10;"> role: document.getElementById('userRole').value, disabled: document.getElementById('userDisabled').checked, access: { - view: { folders: this.parseList(document.getElementById('userViewFolders').value), tables: [] }, + view: { folders: this.getCheckedValues('userViewFoldersList'), tables: [] }, create: { folders: [], tables: [] }, - edit: { folders: [], tables: this.parseList(document.getElementById('userEditTables').value) }, - delete: { folders: this.parseList(document.getElementById('userDeleteFolders').value), tables: [] }, + edit: { folders: [], tables: this.getCheckedValues('userEditTablesList') }, + delete: { folders: this.getCheckedValues('userDeleteFoldersList'), tables: [] }, }, }; @@ -1784,7 +1842,12 @@ SELECT * FROM users LIMIT 10;"> showMoveTableModal() { const parts = (this.currentTable || '').split('__'); - document.getElementById('moveTableFolder').value = parts.length > 1 ? parts[0] : ''; + const currentFolder = parts.length > 1 ? parts[0] : 'default'; + const folders = this.getAvailableFolders(); + document.getElementById('moveTableFolder').innerHTML = [''] + .concat(folders.filter(folder => folder !== 'default').map(folder => ``)) + .join(''); + document.getElementById('moveTableFolder').value = currentFolder; document.getElementById('moveTableName').value = parts.length > 1 ? parts.slice(1).join('__') : this.currentTable || ''; document.getElementById('moveTableModal').classList.remove('hidden'); } @@ -1801,7 +1864,7 @@ SELECT * FROM users LIMIT 10;"> const response = await fetch(`/api/tables/${encodeURIComponent(this.currentTable)}/move`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ folder, name }), + body: JSON.stringify({ folder: folder === 'default' ? '' : folder, name }), }); const data = await response.json(); if (!response.ok) { @@ -1817,6 +1880,36 @@ SELECT * FROM users LIMIT 10;"> } } + async showAuditModal() { + await this.loadAuditLog(); + document.getElementById('auditModal').classList.remove('hidden'); + } + + async loadAuditLog() { + try { + const response = await fetch('/api/audit'); + const entries = await response.json(); + if (!response.ok) { + throw new Error(entries.error || 'Failed to load audit log'); + } + + document.getElementById('auditList').innerHTML = entries.length + ? entries.map(entry => ` +
+
+
${entry.event}
+
${entry.timestamp}
+
+
Actor: ${entry.actor}
+
${JSON.stringify(entry.details, null, 2)}
+
+ `).join('') + : '
Audit log is empty.
'; + } catch (err) { + this.showToast(err.message, 'error'); + } + } + showIndexesModal() { document.getElementById('indexesTableName').textContent = this.currentTable; this.loadIndexes(); diff --git a/server.js b/server.js index 09f7b28..4f346c6 100644 --- a/server.js +++ b/server.js @@ -47,6 +47,7 @@ function canAccessTable(role, tableName) { } const USERS_FILE = path.join(__dirname, 'users.json'); +const AUDIT_LOG_FILE = path.join(__dirname, 'audit.log'); const DOCKER_SOCKET_PATH = process.env.DOCKER_SOCKET_PATH || '/var/run/docker.sock'; const DOCKER_API_PREFIX = process.env.DOCKER_API_PREFIX || '/v1.41'; const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/; @@ -346,6 +347,36 @@ function writeUsersConfig(users) { fs.writeFileSync(USERS_FILE, serialized, 'utf8'); } +function appendAudit(event, actor, details = {}) { + const entry = { + timestamp: new Date().toISOString(), + event, + actor: actor || 'system', + details, + }; + fs.appendFileSync(AUDIT_LOG_FILE, `${JSON.stringify(entry)}\n`, 'utf8'); +} + +function readAuditLog(limit = 200) { + if (!fs.existsSync(AUDIT_LOG_FILE)) { + return []; + } + + return fs.readFileSync(AUDIT_LOG_FILE, 'utf8') + .split(/\r?\n/) + .filter(Boolean) + .map((line) => { + try { + return JSON.parse(line); + } catch (error) { + return null; + } + }) + .filter(Boolean) + .slice(-limit) + .reverse(); +} + function dockerRequest(requestPath, { stream = false } = {}) { return new Promise((resolve, reject) => { const req = http.request({ @@ -535,6 +566,7 @@ app.post('/api/login', async (req, res) => { const sessionUser = createSessionUser(user); req.session.user = sessionUser; + appendAudit('login.success', sessionUser.username, { role: sessionUser.role }); return res.json({ success: true, username: sessionUser.username, @@ -563,6 +595,7 @@ app.post('/api/login', async (req, res) => { const sessionUser = createSessionUser({ username, role: 'superadmin', folders: null }); req.session.user = sessionUser; + appendAudit('login.success', sessionUser.username, { role: sessionUser.role, source: 'env' }); return res.json({ success: true, username: sessionUser.username, @@ -585,6 +618,7 @@ app.post('/api/login', async (req, res) => { } } + appendAudit('login.failed', username, {}); return res.status(401).json({ success: false, error: 'Invalid credentials' @@ -593,7 +627,9 @@ app.post('/api/login', async (req, res) => { // Logout app.post('/api/logout', (req, res) => { + const actor = req.session?.user?.username; req.session.destroy(() => { + appendAudit('logout', actor, {}); res.json({ success: true }); }); }); @@ -646,6 +682,7 @@ app.post('/api/users', requireAuth, requirePermission( disabled: payload.disabled, }); writeUsersConfig(config.users); + appendAudit('user.created', req.currentUser.username, { username: payload.username, role: payload.role }); res.json({ success: true, user: sanitizeUser(config.users[config.users.length - 1]) }); } catch (err) { res.status(400).json({ success: false, error: err.message }); @@ -674,6 +711,7 @@ app.put('/api/users/:username', requireAuth, requirePermission( } writeUsersConfig(config.users); + appendAudit('user.updated', req.currentUser.username, { username: user.username, role: user.role }); res.json({ success: true, user: sanitizeUser(user) }); } catch (err) { res.status(400).json({ success: false, error: err.message }); @@ -691,9 +729,18 @@ app.delete('/api/users/:username', requireAuth, requirePermission( } writeUsersConfig(nextUsers); + appendAudit('user.deleted', req.currentUser.username, { username: req.params.username }); res.json({ success: true }); }); +app.get('/api/audit', requireAuth, requirePermission( + (permissions) => permissions.canManageUsers, + 'Audit access denied' +), (req, res) => { + const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 200, 20), 1000); + res.json(readAuditLog(limit)); +}); + // Get all tables app.get('/api/tables', requireAuth, async (req, res) => { try { @@ -848,6 +895,7 @@ app.post('/api/tables', requireAuth, requirePermission((permissions, req) => { }).join(', '); await pool.query(`CREATE TABLE ${quoteIdentifier(name)} (${columnsSQL})`); + appendAudit('table.created', req.currentUser.username, { table: name }); res.json({ success: true, message: 'Table created' }); } catch (err) { res.status(500).json({ success: false, error: err.message }); @@ -860,6 +908,7 @@ app.delete('/api/tables/:tableName', requireAuth, requireTableAccess, requirePer ), async (req, res) => { try { await pool.query(`DROP TABLE IF EXISTS ${quoteIdentifier(req.params.tableName)}`); + appendAudit('table.deleted', req.currentUser.username, { table: req.params.tableName }); res.json({ success: true, message: 'Table deleted' }); } catch (err) { res.status(500).json({ success: false, error: err.message }); @@ -882,6 +931,7 @@ app.post('/api/tables/:tableName/move', requireAuth, requireTableAccess, require const nextName = cleanFolder ? `${cleanFolder}__${cleanName}` : cleanName; try { await pool.query(`ALTER TABLE ${quoteIdentifier(tableName)} RENAME TO ${quoteIdentifier(nextName)}`); + appendAudit('table.moved', req.currentUser.username, { from: tableName, to: nextName, folder: cleanFolder || 'default' }); res.json({ success: true, name: nextName }); } catch (err) { res.status(500).json({ success: false, error: err.message }); @@ -926,6 +976,7 @@ app.post('/api/tables/:tableName/records', requireAuth, requireTableAccess, requ const placeholders = values.map((_, i) => `$${i + 1}`).join(', '); const sql = `INSERT INTO ${quoteIdentifier(tableName)} (${columns.map(quoteIdentifier).join(', ')}) VALUES (${placeholders}) RETURNING *`; const result = await pool.query(sql, values); + appendAudit('record.created', req.currentUser.username, { table: tableName }); res.json({ success: true, data: result.rows[0] }); } catch (err) { res.status(500).json({ success: false, error: err.message }); @@ -953,6 +1004,7 @@ app.put('/api/tables/:tableName/records/:pk', requireAuth, requireTableAccess, r : 'ctid::text = $' + (values.length + 1); const sql = `UPDATE ${quoteIdentifier(tableName)} SET ${setClause} WHERE ${whereClause} RETURNING *`; const result = await pool.query(sql, [...values, pk]); + appendAudit('record.updated', req.currentUser.username, { table: tableName, key: pk }); res.json({ success: true, data: result.rows[0] }); } catch (err) { res.status(500).json({ success: false, error: err.message }); @@ -971,6 +1023,7 @@ app.delete('/api/tables/:tableName/records/:pk', requireAuth, requireTableAccess ? `${quoteIdentifier(primaryKey)} = $1` : 'ctid::text = $1'; await pool.query(`DELETE FROM ${quoteIdentifier(tableName)} WHERE ${whereClause}`, [pk]); + appendAudit('record.deleted', req.currentUser.username, { table: tableName, key: pk }); res.json({ success: true }); } catch (err) { res.status(500).json({ success: false, error: err.message }); @@ -1071,6 +1124,7 @@ app.post('/api/query', requireAuth, requirePermission( try { const result = await pool.query(sql); + appendAudit('sql.executed', req.currentUser.username, { command: result.command, sql: sql.slice(0, 400) }); res.json({ success: true, rows: result.rows,