Role names alone do not establish who can do what to which record. I wrote a small policy module that combines role, company scope, assignment and document state.

This is neither a login service nor a deployed ERP security layer. It tests decisions under the assumption of an already trusted server context. The demo’s role selector must not be used to grant real permissions.

Establish the trust boundary before calling the function

decideAccess receives a principal, an action and a record. In a real service, the principal must come from server-verified authentication. Do not pass through a browser selection, request-body role or arbitrary header. An allowed result cannot establish authority if the caller supplied a forged identity. The local demo cannot enforce that trust boundary.

The example requires an active principal, a known role and action, valid company and user identifiers, and a known draft or sent document state. Unknown conditions or a different company are denied first. Denial has a generic shape without record contents. The server integration must obtain company and assignment from current trusted records, rather than accepting a caller’s description of their scope.

Describe actions rather than an automatic role hierarchy

Staff can read assigned records and edit draft quantity. Coordinators can read company records and edit or approve drafts. Owners can read and export company records, without edit or approval permission. An owner title does not automatically include every operational action. This is a synthetic decision for discussing company-specific policy, not a universal organization model.

Restore is denied to all example roles because separate recovery authority and approval have not been implemented. Unknown actions such as delete also remain denied. Adding a familiar action name must not accidentally open a permission. In production, these checks need to be connected to the execution point of every relevant API operation.

Use explicit response and edit-field allowlists

After authorizing a read, projectOrder creates a new object containing identifier, status and quantity. The owner projection adds amount only when it is a nonnegative safe integer. Internal notes and additional input fields are excluded. It does not spread the original object into a response and rely on the interface to hide unwanted values.

editableOrderFields returns quantity as the only permitted edit field. It does not enforce a request body or perform a write. A real server must apply that list and recheck values and document state. Bulk export needs a properly scoped query and record rules as well. Permission for one example record must not be expanded into permission for all records.

Explore the implementation

Complete independent synthetic implementation

// Teaching policy only. Principal must come from a trusted server session, never request-body roles.
const idOK=v=>typeof v==='string'&&/^[A-Za-z0-9_-]{1,40}$/.test(v);
const roles=['staff','coordinator','owner'],actions=['read','edit','approve','export','restore'];
export function decideAccess(principal,action,record) {
  const deny=()=>({allowed:false,reason:'denied'});
  if(!principal||!record||principal.active!==true||!roles.includes(principal.role)||!actions.includes(action)||!idOK(principal.companyId)||!idOK(principal.userId)||!idOK(record.companyId)||!idOK(record.assignedTo)||!idOK(record.id)||!['draft','sent'].includes(record.status))return deny();
  if(principal.companyId!==record.companyId)return deny();
  const own=principal.userId===record.assignedTo,draft=record.status==='draft';
  const allowed=principal.role==='staff'?(own&&(action==='read'||(action==='edit'&&draft))):principal.role==='coordinator'?(action==='read'||(draft&&['edit','approve'].includes(action))):['read','export'].includes(action);
  return allowed?{allowed:true,reason:'allowed'}:deny();
}
export function projectOrder(principal,record) {
  if(!decideAccess(principal,'read',record).allowed)return null;
  if(!Number.isSafeInteger(record.quantity)||record.quantity<1||record.quantity>99)return null;
  const result={id:record.id,status:record.status,quantity:record.quantity};
  if(principal.role==='owner'){
    if(!Number.isSafeInteger(record.amount)||record.amount<0)return null;
    result.amount=record.amount;
  }
  return result;
}
export function editableOrderFields(principal,record){return decideAccess(principal,'edit',record).allowed?['quantity']:[];}
export function syntheticAccess(){return {principal:{active:true,companyId:'ORG_A',userId:'USER_A',role:'staff'},record:{companyId:'ORG_A',id:'S01',assignedTo:'USER_A',status:'draft',quantity:6,amount:600000,internalNote:'not projected'}};}

The demo changes a synthetic decision, not an account

Choose a role, company equality, assignment, document state and requested action below. Allowed reads expose the limited projection; edit actions show the editable fields. A different company is denied even for an owner. The page creates no actual login, cookie or server permission. Being able to change the selector freely demonstrates why a client role choice cannot be a trusted authority.

All values are fixed synthetic records without real companies or accounts. Both language demos evaluate identical code and retain the same machine-readable result keys. An operational interface needs understandable denial messages without leaking another company’s sensitive contents or record existence. Deciding what to return belongs to policy design, not merely visual presentation.

Independent synthetic example; no client records or operational server

Check the package and plan the missing server responsibilities

Extract the ZIP and run node test.mjs. The package includes implementation, bilingual standalone HTML demos, scope and explicit reuse permission. Modification, redistribution and commercial reuse are permitted without warranty. Nine test groups and the complete 120-case policy matrix passed, including inactive principals, field limits and unknown actions. This is newly written synthetic code, not extracted client software.

Demo event wiring was evaluated in code, not through a real identity service, rendered-browser test or production security audit. Adoption still requires identity verification, permission revocation, server-scoped queries, checks on every action, transaction boundaries, request-field enforcement and an appropriate logging policy. Sharing readable decision rules and completing a protected service remain different accomplishments.

Download code, tests and bilingual demos
Includes reuse permission and boundaries