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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thatcher",
3
- "version": "1.0.68",
3
+ "version": "1.0.70",
4
4
  "description": "A config-driven application framework for building data-intensive web apps without code.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
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 = {};
@@ -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
- result = await thatcher.get(entity, id);
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: 'Permission Grants', value: summary.grants || 0 },
104
- { label: 'Permission Revokes', value: summary.revokes || 0 },
105
- { label: 'Role Changes', value: summary.role_changes || 0 },
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
- export function renderEntityDetail(entityName, item, spec, user) {
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} &rarr; ${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
- const fields = spec?.fields || {}
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
- const fields = spec?.fields || {}
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 auditData = await getAuditData();
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
- const audits = await list('permission_audit', {});
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 { summary: { total_actions: recent.length, grants: recent.filter(a => a.action === 'grant').length, revokes: recent.filter(a => a.action === 'revoke').length, role_changes: recent.filter(a => a.action === 'role_change').length }, recentActivity: audits.slice(0, 50) };
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
 
@@ -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') {