+ 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,