thatcher 1.0.68 → 1.0.69

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.69",
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,22 +382,37 @@ 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
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
+ result = permissionService.filterFields(user, spec, result);
401
416
  status = 201;
402
417
  break;
403
418
 
@@ -408,7 +423,9 @@ async function handleGenericCrud(req, res, entity, id, action, thatcher, configE
408
423
  res.end(JSON.stringify({ error: 'ID required' }));
409
424
  return;
410
425
  }
426
+ permissionService.enforceEditPermissions(user, spec, body);
411
427
  result = await thatcher.update(entity, id, body, user);
428
+ result = permissionService.filterFields(user, spec, result);
412
429
  break;
413
430
 
414
431
  case 'DELETE':
@@ -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
@@ -85,7 +86,10 @@ function formatFieldValue(k, v, entityName, f) {
85
86
 
86
87
  export function renderEntityDetail(entityName, item, spec, user) {
87
88
  const label = spec?.label || entityName
88
- const fields = spec?.fields || {}
89
+ // A field the caller's role isn't visible_to must never reach rendered HTML,
90
+ // not just be CSS-hidden -- filterFields drops it from `fields` entirely so
91
+ // it can't appear in visibleFields below however this function evolves.
92
+ const fields = permissionService.filterFields(user, spec || {}, spec?.fields || {})
89
93
  const userCanEdit = canEdit(user, entityName)
90
94
  const userCanDelete = canDelete(user, entityName)
91
95
 
@@ -141,7 +145,15 @@ export function renderEntityDetail(entityName, item, spec, user) {
141
145
 
142
146
  export function renderEntityForm(entityName, item, spec, user, isNew = false, refOptions = {}, templates = []) {
143
147
  const label = spec?.label || entityName
144
- const fields = spec?.fields || {}
148
+ // A field the caller's role isn't editable_by must not be offered in the
149
+ // form at all -- omitting it here is UX (a clean form), the real
150
+ // enforcement is enforceEditPermissions rejecting the field server-side if
151
+ // a raw request includes it anyway.
152
+ const allFields = spec?.fields || {}
153
+ const fields = {}
154
+ for (const [k, f] of Object.entries(allFields)) {
155
+ if (permissionService.checkFieldAccess(user, spec || {}, k, 'edit')) fields[k] = f
156
+ }
145
157
  const lbl = (k, f, req) => `<label class="form-label" for="field-${k}">${esc(f.label||k)}${req ? '<span class="req">*</span>' : ''}</label>`
146
158
  const formFields = Object.entries(fields).filter(([k, f]) => k !== 'id' && !f.auto && !f.readOnly && !f.auto_generate && k !== 'password_hash').map(([k, f]) => {
147
159
  let val = item?.[k] ?? f.default ?? ''