thatcher 1.0.68 → 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 +1 -1
- package/src/index.js +2 -2
- package/src/lib/config-generator-engine.js +15 -0
- package/src/server/server.js +45 -4
- package/src/ui/dashboard-renderer.js +11 -3
- package/src/ui/entity-renderer.js +43 -3
- package/src/ui/page-handler-admin.js +8 -2
- package/src/ui/page-handler-helpers.js +23 -3
- package/src/ui/page-handler.js +20 -2
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -305,9 +305,9 @@ export class Thatcher {
|
|
|
305
305
|
return list(entity, where, opts);
|
|
306
306
|
}
|
|
307
307
|
|
|
308
|
-
async get(entity, id) {
|
|
308
|
+
async get(entity, id, opts = {}) {
|
|
309
309
|
const { get } = await import(resolveModule('./lib/busybase/store.js'));
|
|
310
|
-
return get(entity, id);
|
|
310
|
+
return get(entity, id, opts);
|
|
311
311
|
}
|
|
312
312
|
|
|
313
313
|
async create(entity, data, user) {
|
|
@@ -513,6 +513,21 @@ export class ConfigGeneratorEngine {
|
|
|
513
513
|
};
|
|
514
514
|
}
|
|
515
515
|
|
|
516
|
+
// Field-level RBAC: a field's visible_to/editable_by (already present on
|
|
517
|
+
// spec.fields[key] via the `...field` spread above) is compiled into
|
|
518
|
+
// spec.fieldPermissions, the shape permission.service.js's checkFieldAccess
|
|
519
|
+
// already reads. A field with neither key contributes no entry here, so
|
|
520
|
+
// checkFieldAccess's `if (!perm) return true` keeps every role's access
|
|
521
|
+
// exactly as it was before this feature existed -- no regression for the
|
|
522
|
+
// overwhelming majority of fields that never set either key.
|
|
523
|
+
for (const [key, field] of Object.entries(spec.fields)) {
|
|
524
|
+
if (!field.visible_to && !field.editable_by) continue;
|
|
525
|
+
spec.fieldPermissions = spec.fieldPermissions || {};
|
|
526
|
+
spec.fieldPermissions[key] = {};
|
|
527
|
+
if (field.visible_to) spec.fieldPermissions[key].view = field.visible_to;
|
|
528
|
+
if (field.editable_by) spec.fieldPermissions[key].edit = field.editable_by;
|
|
529
|
+
}
|
|
530
|
+
|
|
516
531
|
if (entityDef.permission_template) {
|
|
517
532
|
const matrix = this.getPermissionTemplate(entityDef.permission_template);
|
|
518
533
|
const access = {};
|
package/src/server/server.js
CHANGED
|
@@ -382,45 +382,86 @@ async function handleGenericCrud(req, res, entity, id, action, thatcher, configE
|
|
|
382
382
|
let result;
|
|
383
383
|
let status = 200;
|
|
384
384
|
|
|
385
|
+
const { permissionService } = await import('../services/permission.service.js');
|
|
386
|
+
|
|
385
387
|
switch (req.method) {
|
|
386
388
|
case 'GET':
|
|
387
389
|
if (id) {
|
|
388
|
-
|
|
390
|
+
// get(...,{user}) applies the same row/org-access scoping every other
|
|
391
|
+
// read path in the codebase uses -- this generic-CRUD GET-by-id had
|
|
392
|
+
// none at all, so any authenticated user could read any record by id
|
|
393
|
+
// across organizations. filterFields then strips any field the
|
|
394
|
+
// caller's role isn't visible_to, the field-level half of this pass.
|
|
395
|
+
result = await thatcher.get(entity, id, { user });
|
|
389
396
|
if (!result) {
|
|
390
397
|
res.writeHead(404);
|
|
391
398
|
res.end(JSON.stringify({ error: 'Not found' }));
|
|
392
399
|
return;
|
|
393
400
|
}
|
|
401
|
+
result = permissionService.filterFields(user, spec, result);
|
|
394
402
|
} else {
|
|
395
403
|
result = await thatcher.list(entity, {}, { user });
|
|
404
|
+
result = result.map(r => permissionService.filterFields(user, spec, r));
|
|
396
405
|
}
|
|
397
406
|
break;
|
|
398
407
|
|
|
399
|
-
case 'POST':
|
|
408
|
+
case 'POST': {
|
|
409
|
+
// enforceEditPermissions is the authoritative field-level write check --
|
|
410
|
+
// it throws if the payload touches a field the caller's role isn't
|
|
411
|
+
// editable_by, so a hand-crafted request bypassing the rendered form
|
|
412
|
+
// cannot smuggle a restricted field in.
|
|
413
|
+
permissionService.enforceEditPermissions(user, spec, body);
|
|
400
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);
|
|
417
|
+
result = permissionService.filterFields(user, spec, result);
|
|
401
418
|
status = 201;
|
|
402
419
|
break;
|
|
420
|
+
}
|
|
403
421
|
|
|
404
422
|
case 'PUT':
|
|
405
|
-
case 'PATCH':
|
|
423
|
+
case 'PATCH': {
|
|
406
424
|
if (!id) {
|
|
407
425
|
res.writeHead(400);
|
|
408
426
|
res.end(JSON.stringify({ error: 'ID required' }));
|
|
409
427
|
return;
|
|
410
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
|
+
}
|
|
438
|
+
permissionService.enforceEditPermissions(user, spec, body);
|
|
411
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);
|
|
442
|
+
result = permissionService.filterFields(user, spec, result);
|
|
412
443
|
break;
|
|
444
|
+
}
|
|
413
445
|
|
|
414
|
-
case 'DELETE':
|
|
446
|
+
case 'DELETE': {
|
|
415
447
|
if (!id) {
|
|
416
448
|
res.writeHead(400);
|
|
417
449
|
res.end(JSON.stringify({ error: 'ID required' }));
|
|
418
450
|
return;
|
|
419
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
|
+
}
|
|
420
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);
|
|
421
461
|
res.writeHead(204);
|
|
422
462
|
res.end();
|
|
423
463
|
return;
|
|
464
|
+
}
|
|
424
465
|
|
|
425
466
|
default:
|
|
426
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">
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { page, dataTable } from '@/ui/layout.js'
|
|
2
2
|
import { fmtVal, TOAST_SCRIPT, esc } from '@/ui/render-helpers.js'
|
|
3
3
|
import { canCreate, canEdit, canDelete } from '@/ui/permissions-ui.js'
|
|
4
|
+
import { permissionService } from '@/services/permission.service.js'
|
|
4
5
|
|
|
5
6
|
export function renderEntityList(entityName, items, spec, user, options = {}) {
|
|
6
7
|
const label = spec?.labelPlural || spec?.label || entityName
|
|
@@ -83,9 +84,39 @@ function formatFieldValue(k, v, entityName, f) {
|
|
|
83
84
|
return fmtVal(v, k)
|
|
84
85
|
}
|
|
85
86
|
|
|
86
|
-
|
|
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 = []) {
|
|
87
115
|
const label = spec?.label || entityName
|
|
88
|
-
|
|
116
|
+
// A field the caller's role isn't visible_to must never reach rendered HTML,
|
|
117
|
+
// not just be CSS-hidden -- filterFields drops it from `fields` entirely so
|
|
118
|
+
// it can't appear in visibleFields below however this function evolves.
|
|
119
|
+
const fields = permissionService.filterFields(user, spec || {}, spec?.fields || {})
|
|
89
120
|
const userCanEdit = canEdit(user, entityName)
|
|
90
121
|
const userCanDelete = canDelete(user, entityName)
|
|
91
122
|
|
|
@@ -131,6 +162,7 @@ export function renderEntityDetail(entityName, item, spec, user) {
|
|
|
131
162
|
<div class="card-clean">
|
|
132
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>
|
|
133
164
|
</div>
|
|
165
|
+
${renderHistorySection(history)}
|
|
134
166
|
`
|
|
135
167
|
|
|
136
168
|
// Canonical gmConfirm (session-13): showDeleteConfirm runs the styled confirm then DELETEs; no bespoke dialog markup/show-hide.
|
|
@@ -141,7 +173,15 @@ export function renderEntityDetail(entityName, item, spec, user) {
|
|
|
141
173
|
|
|
142
174
|
export function renderEntityForm(entityName, item, spec, user, isNew = false, refOptions = {}, templates = []) {
|
|
143
175
|
const label = spec?.label || entityName
|
|
144
|
-
|
|
176
|
+
// A field the caller's role isn't editable_by must not be offered in the
|
|
177
|
+
// form at all -- omitting it here is UX (a clean form), the real
|
|
178
|
+
// enforcement is enforceEditPermissions rejecting the field server-side if
|
|
179
|
+
// a raw request includes it anyway.
|
|
180
|
+
const allFields = spec?.fields || {}
|
|
181
|
+
const fields = {}
|
|
182
|
+
for (const [k, f] of Object.entries(allFields)) {
|
|
183
|
+
if (permissionService.checkFieldAccess(user, spec || {}, k, 'edit')) fields[k] = f
|
|
184
|
+
}
|
|
145
185
|
const lbl = (k, f, req) => `<label class="form-label" for="field-${k}">${esc(f.label||k)}${req ? '<span class="req">*</span>' : ''}</label>`
|
|
146
186
|
const formFields = Object.entries(fields).filter(([k, f]) => k !== 'id' && !f.auto && !f.readOnly && !f.auto_generate && k !== 'password_hash').map(([k, f]) => {
|
|
147
187
|
let val = item?.[k] ?? f.default ?? ''
|
|
@@ -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') {
|