thatcher 1.0.69 → 1.0.70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
package/src/server/server.js
CHANGED
|
@@ -405,39 +405,63 @@ async function handleGenericCrud(req, res, entity, id, action, thatcher, configE
|
|
|
405
405
|
}
|
|
406
406
|
break;
|
|
407
407
|
|
|
408
|
-
case 'POST':
|
|
408
|
+
case 'POST': {
|
|
409
409
|
// enforceEditPermissions is the authoritative field-level write check --
|
|
410
410
|
// it throws if the payload touches a field the caller's role isn't
|
|
411
411
|
// editable_by, so a hand-crafted request bypassing the rendered form
|
|
412
412
|
// cannot smuggle a restricted field in.
|
|
413
413
|
permissionService.enforceEditPermissions(user, spec, body);
|
|
414
414
|
result = await thatcher.create(entity, body, user);
|
|
415
|
+
const { logAction } = await import('../lib/busybase/audit.js');
|
|
416
|
+
logAction(entity, result.id, 'create', user.id, null, result);
|
|
415
417
|
result = permissionService.filterFields(user, spec, result);
|
|
416
418
|
status = 201;
|
|
417
419
|
break;
|
|
420
|
+
}
|
|
418
421
|
|
|
419
422
|
case 'PUT':
|
|
420
|
-
case 'PATCH':
|
|
423
|
+
case 'PATCH': {
|
|
421
424
|
if (!id) {
|
|
422
425
|
res.writeHead(400);
|
|
423
426
|
res.end(JSON.stringify({ error: 'ID required' }));
|
|
424
427
|
return;
|
|
425
428
|
}
|
|
429
|
+
// Same row/org-access gap the GET path had: an update by id must be
|
|
430
|
+
// scoped the same way a read is, or a user could edit a record
|
|
431
|
+
// outside their org purely because PUT was never checked.
|
|
432
|
+
const existingForUpdate = await thatcher.get(entity, id, { user });
|
|
433
|
+
if (!existingForUpdate) {
|
|
434
|
+
res.writeHead(404);
|
|
435
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
426
438
|
permissionService.enforceEditPermissions(user, spec, body);
|
|
427
439
|
result = await thatcher.update(entity, id, body, user);
|
|
440
|
+
const { logAction: logUpdate } = await import('../lib/busybase/audit.js');
|
|
441
|
+
logUpdate(entity, id, 'update', user.id, existingForUpdate, result);
|
|
428
442
|
result = permissionService.filterFields(user, spec, result);
|
|
429
443
|
break;
|
|
444
|
+
}
|
|
430
445
|
|
|
431
|
-
case 'DELETE':
|
|
446
|
+
case 'DELETE': {
|
|
432
447
|
if (!id) {
|
|
433
448
|
res.writeHead(400);
|
|
434
449
|
res.end(JSON.stringify({ error: 'ID required' }));
|
|
435
450
|
return;
|
|
436
451
|
}
|
|
452
|
+
const existingForDelete = await thatcher.get(entity, id, { user });
|
|
453
|
+
if (!existingForDelete) {
|
|
454
|
+
res.writeHead(404);
|
|
455
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
437
458
|
await thatcher.delete(entity, id);
|
|
459
|
+
const { logAction: logDelete } = await import('../lib/busybase/audit.js');
|
|
460
|
+
logDelete(entity, id, 'delete', user.id, existingForDelete, null);
|
|
438
461
|
res.writeHead(204);
|
|
439
462
|
res.end();
|
|
440
463
|
return;
|
|
464
|
+
}
|
|
441
465
|
|
|
442
466
|
default:
|
|
443
467
|
res.writeHead(405);
|
|
@@ -100,12 +100,20 @@ export function renderAuditDashboard(user, auditData = {}) {
|
|
|
100
100
|
|
|
101
101
|
const statsHtml = `<div class="stats-row">${[
|
|
102
102
|
{ label: 'Total Actions (30d)', value: summary.total_actions || 0 },
|
|
103
|
-
{ label: '
|
|
104
|
-
{ label: '
|
|
105
|
-
{ label: '
|
|
103
|
+
{ label: 'Creates', value: summary.creates || 0 },
|
|
104
|
+
{ label: 'Updates', value: summary.updates || 0 },
|
|
105
|
+
{ label: 'Deletes', value: summary.deletes || 0 },
|
|
106
106
|
].map(s => `<div class="stat-card"><div class="stat-card-value">${s.value}</div><div class="stat-card-label">${s.label}</div></div>`).join('')}</div>`;
|
|
107
107
|
|
|
108
108
|
const content = `<div class="page-header"><h1 class="page-title">Audit Dashboard</h1><a href="/permission_audit" class="btn-ghost-clean">View All Records</a></div>
|
|
109
|
+
<form method="GET" style="display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap">
|
|
110
|
+
<input type="text" name="entity_type" placeholder="Entity type" class="form-input" style="width:160px">
|
|
111
|
+
<input type="text" name="user_id" placeholder="User ID" class="form-input" style="width:160px">
|
|
112
|
+
<input type="date" name="from_date" class="form-input">
|
|
113
|
+
<input type="date" name="to_date" class="form-input">
|
|
114
|
+
<button type="submit" class="btn-primary-clean">Filter</button>
|
|
115
|
+
<a href="/admin/audit" class="btn-ghost-clean">Clear</a>
|
|
116
|
+
</form>
|
|
109
117
|
${statsHtml}
|
|
110
118
|
<div class="table-wrap">
|
|
111
119
|
<div class="table-toolbar">
|
|
@@ -84,7 +84,34 @@ function formatFieldValue(k, v, entityName, f) {
|
|
|
84
84
|
return fmtVal(v, k)
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
|
|
87
|
+
function formatHistoryDiff(before, after) {
|
|
88
|
+
const keys = new Set([...Object.keys(before || {}), ...Object.keys(after || {})])
|
|
89
|
+
const changed = [...keys].filter(k => JSON.stringify(before?.[k]) !== JSON.stringify(after?.[k]) && !['updated_at', '_version'].includes(k))
|
|
90
|
+
if (!changed.length) return ''
|
|
91
|
+
return changed.map(k => {
|
|
92
|
+
const from = before?.[k] !== undefined ? esc(String(before[k])) : '<em>none</em>'
|
|
93
|
+
const to = after?.[k] !== undefined ? esc(String(after[k])) : '<em>none</em>'
|
|
94
|
+
return `<div class="history-field-change"><strong>${esc(k)}</strong>: ${from} → ${to}</div>`
|
|
95
|
+
}).join('')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function renderHistorySection(history) {
|
|
99
|
+
if (!history || !history.length) return ''
|
|
100
|
+
const rows = history.map(h => {
|
|
101
|
+
const when = esc(new Date((h.createdAt || 0) * 1000).toLocaleString('en-ZA'))
|
|
102
|
+
const diff = h.action === 'update' ? formatHistoryDiff(h.beforeState, h.afterState) : ''
|
|
103
|
+
return `<div class="history-entry">
|
|
104
|
+
<div class="history-entry-header"><span class="pill pill-info">${esc(h.action || '-')}</span><span class="history-entry-time">${when}</span><span class="history-entry-user">${esc(h.userId || '-')}</span></div>
|
|
105
|
+
${diff ? `<div class="history-entry-diff">${diff}</div>` : ''}
|
|
106
|
+
</div>`
|
|
107
|
+
}).join('')
|
|
108
|
+
return `<div class="card-clean" style="margin-top:1.5rem"><div class="card-clean-body">
|
|
109
|
+
<h3 style="margin-bottom:12px">History</h3>
|
|
110
|
+
<div class="history-list">${rows}</div>
|
|
111
|
+
</div></div>`
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function renderEntityDetail(entityName, item, spec, user, history = []) {
|
|
88
115
|
const label = spec?.label || entityName
|
|
89
116
|
// A field the caller's role isn't visible_to must never reach rendered HTML,
|
|
90
117
|
// not just be CSS-hidden -- filterFields drops it from `fields` entirely so
|
|
@@ -135,6 +162,7 @@ export function renderEntityDetail(entityName, item, spec, user) {
|
|
|
135
162
|
<div class="card-clean">
|
|
136
163
|
<div class="card-clean-body"><div class="detail-grid">${fieldRows || '<p style="color:var(--color-text-muted);font-size:0.875rem;grid-column:1/-1">No details available</p>'}</div></div>
|
|
137
164
|
</div>
|
|
165
|
+
${renderHistorySection(history)}
|
|
138
166
|
`
|
|
139
167
|
|
|
140
168
|
// Canonical gmConfirm (session-13): showDeleteConfirm runs the styled confirm then DELETEs; no bespoke dialog markup/show-hide.
|
|
@@ -21,10 +21,16 @@ async function lazyRenderer(name) {
|
|
|
21
21
|
return import(`file://${__dirname_adm}${name}?t=${t}`);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
export async function handleAdminPage(normalized, segments, user) {
|
|
24
|
+
export async function handleAdminPage(normalized, segments, user, req) {
|
|
25
25
|
if (normalized === '/admin/audit') {
|
|
26
26
|
if (!isPartner(user) && !isManager(user)) return renderAccessDenied(user, 'admin', 'view');
|
|
27
|
-
const
|
|
27
|
+
const params = req ? new URL(req.url, `http://${req.headers.host}`).searchParams : new URLSearchParams();
|
|
28
|
+
const filters = {};
|
|
29
|
+
if (params.get('entity_type')) filters.entityType = params.get('entity_type');
|
|
30
|
+
if (params.get('user_id')) filters.userId = params.get('user_id');
|
|
31
|
+
if (params.get('from_date')) filters.fromDate = Math.floor(new Date(params.get('from_date')).getTime() / 1000);
|
|
32
|
+
if (params.get('to_date')) filters.toDate = Math.floor(new Date(params.get('to_date')).getTime() / 1000) + 86400;
|
|
33
|
+
const auditData = await getAuditData(filters);
|
|
28
34
|
return renderAuditDashboard(user, auditData);
|
|
29
35
|
}
|
|
30
36
|
if (!isPartner(user)) return renderAccessDenied(user, 'admin', 'view');
|
|
@@ -76,12 +76,32 @@ export async function getSystemConfig() {
|
|
|
76
76
|
} catch { return {}; }
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
export async function getAuditData() {
|
|
79
|
+
export async function getAuditData(filters = {}) {
|
|
80
80
|
try {
|
|
81
|
-
|
|
81
|
+
// audit_logs is the real record-change trail logAction() writes (create/
|
|
82
|
+
// update/delete/transition on every entity); permission_audit is a
|
|
83
|
+
// separate, narrower RBAC grant/revoke/role_change log. This dashboard's
|
|
84
|
+
// own column headers (Entity Type, Entity ID) describe the former, so it
|
|
85
|
+
// reads audit_logs -- the same table entity-renderer.js's per-record
|
|
86
|
+
// History section now reads, giving one consistent audit surface.
|
|
87
|
+
const where = {};
|
|
88
|
+
if (filters.entityType) where.entity_type = filters.entityType;
|
|
89
|
+
if (filters.userId) where.user_id = filters.userId;
|
|
90
|
+
let audits = await list('audit_logs', where);
|
|
91
|
+
if (filters.fromDate) audits = audits.filter(a => (a.timestamp || a.created_at) >= filters.fromDate);
|
|
92
|
+
if (filters.toDate) audits = audits.filter(a => (a.timestamp || a.created_at) <= filters.toDate);
|
|
93
|
+
audits.sort((a, b) => (b.timestamp || b.created_at || 0) - (a.timestamp || a.created_at || 0));
|
|
82
94
|
const cutoff = Math.floor(Date.now() / 1000) - 30 * 86400;
|
|
83
95
|
const recent = audits.filter(a => (a.timestamp || a.created_at) > cutoff);
|
|
84
|
-
return {
|
|
96
|
+
return {
|
|
97
|
+
summary: {
|
|
98
|
+
total_actions: recent.length,
|
|
99
|
+
creates: recent.filter(a => a.action === 'create').length,
|
|
100
|
+
updates: recent.filter(a => a.action === 'update').length,
|
|
101
|
+
deletes: recent.filter(a => a.action === 'delete').length,
|
|
102
|
+
},
|
|
103
|
+
recentActivity: audits.slice(0, 200),
|
|
104
|
+
};
|
|
85
105
|
} catch { return { summary: {}, recentActivity: [] }; }
|
|
86
106
|
}
|
|
87
107
|
|
package/src/ui/page-handler.js
CHANGED
|
@@ -115,8 +115,26 @@ async function handleGenericEntityView(user, entityName, id, req) {
|
|
|
115
115
|
if (item.team_id && user.team_id && item.team_id !== user.team_id && !isPartner(user)) return renderAccessDenied(user, entityName, 'view');
|
|
116
116
|
if (isClientUser(user) && user.client_id && item.client_id && item.client_id !== user.client_id) return renderAccessDenied(user, entityName, 'view');
|
|
117
117
|
const [resolvedItem] = resolveRefFields([item], spec);
|
|
118
|
+
// get(...,{user}) above already enforced row/org access for this exact
|
|
119
|
+
// record (a denied/absent record returns null before this point), so
|
|
120
|
+
// fetching its audit trail here is scoped by construction -- there is no
|
|
121
|
+
// separate access check to duplicate for the history view.
|
|
122
|
+
let history = [];
|
|
123
|
+
try {
|
|
124
|
+
const { getEntityAuditTrail } = await import('@/lib/busybase/audit-reads.js');
|
|
125
|
+
const { permissionService } = await import('@/services/permission.service.js');
|
|
126
|
+
const rawHistory = await getEntityAuditTrail(entityName, id);
|
|
127
|
+
// Field-level RBAC must hold for audit diffs too -- a viewer who can't see
|
|
128
|
+
// a restricted field on the live record must not see it in a before/after
|
|
129
|
+
// snapshot either, or field-level RBAC would be trivially bypassed via history.
|
|
130
|
+
history = rawHistory.map(h => ({
|
|
131
|
+
...h,
|
|
132
|
+
beforeState: h.beforeState ? permissionService.filterFields(user, spec, h.beforeState) : null,
|
|
133
|
+
afterState: h.afterState ? permissionService.filterFields(user, spec, h.afterState) : null,
|
|
134
|
+
}));
|
|
135
|
+
} catch {}
|
|
118
136
|
const { renderEntityDetail: lazyEntityDetail } = await lazyRenderer('entity-renderer.js');
|
|
119
|
-
return lazyEntityDetail(entityName, resolvedItem, spec, user);
|
|
137
|
+
return lazyEntityDetail(entityName, resolvedItem, spec, user, history);
|
|
120
138
|
}
|
|
121
139
|
async function handleClientSubRoute(user, clientId, subRoute) {
|
|
122
140
|
if (!canView(user, 'client')) return renderAccessDenied(user, 'client', 'view');
|
|
@@ -162,7 +180,7 @@ export async function handlePage(pathname, req, res) {
|
|
|
162
180
|
if (normalized === '/unauthorized') return renderAccessDenied(user, 'system', 'access');
|
|
163
181
|
if (normalized === '/notifications') { let notifs=[]; try{notifs=await list('notification',{user_id:user.id},{sort:{field:'created_at',dir:'DESC'},limit:100,user})}catch{} const{renderNotificationsPage}=await lazyRenderer('notifications-renderer.js'); return renderNotificationsPage(user,notifs); }
|
|
164
182
|
if (normalized === '/' || normalized === '/dashboard') return renderDashboard(user, await getDashboardStats(user));
|
|
165
|
-
if (normalized.startsWith('/admin/') || normalized === '/admin/jobs') return handleAdminPage(normalized, segments, user);
|
|
183
|
+
if (normalized.startsWith('/admin/') || normalized === '/admin/jobs') return handleAdminPage(normalized, segments, user, req);
|
|
166
184
|
if (segments[0] === 'client' && segments.length === 3 && ['dashboard', 'users', 'progress'].includes(segments[2])) return handleClientSubRoute(user, segments[1], segments[2]);
|
|
167
185
|
if (isClerk(user) && segments.length >= 1 && ['user', 'team'].includes(segments[0])) return renderAccessDenied(user, segments[0], 'list');
|
|
168
186
|
if (normalized === '/mwr' || normalized === '/mwr/home') {
|