Skip to Content

One policy for server and browser. Answers that explain themselves.

Write your authorization rules in TypeScript. The library builds them into a JSON document, called the matrix, that your server and browser both read.

Every check is a synchronous function call with no network request.

Get startednpmGitHub

npm install @evanion/acl
Mika Persson

Brass: Birminghamdraft

Network building on the canals and railways of the Midlands, two to four players, about two hours. Complexity 3.9 of 5. A copy is on the shelf and another sits on table three most evenings.

  • Jo VainioPlayed it twice at the back tables before buying. Worth the shelf space.
Signed in as Mika Persson, bookseller. Review, Edit.

access

const access = policy<Shopper, ShopObjects, ShopVerbs>()
  .for('listing', (p) =>
    p.allow('review', p.always)
      .allow('edit',
        p.in('subject.role', [, , ]))
      .allow('publish',
        p.in('subject.role', [, , ]))
      .deny('edit', p.eq('object.status', 'published'))
      .id('published-is-locked'),
  )
  .build();

Your API, same policy

The browser only hides the button. The server asks again.

  • PUT /listings/42 → 200
  • POST /listings/42/publish → 403

Answers that explain themselves

Turn a refusal into clear user feedback.

A simple yes or no does not tell the user why they are blocked.

The library returns a Decision object with a reason and the rule that decided. If data is missing, the status is unevaluable and the decision lists exactly which fields to fetch.

Who is asking
What they want to do
The listing is

Allowed

The bookseller who listed it wants to edit this listing. The rule seller-edits-own says yes.

access.can(mika, 'listing', 'edit', listing)
// {
//   allowed: true,
//   reason: 'allow',
//   rule: 'seller-edits-own',
// }
See which rule decides the access and which fields the library needs for a final answer.
How decisions work →

Review access changes before you ship

Identify who gains or loses access in your pull request.

A one-line rule edit can widen or narrow access for a whole group, and a line-by-line diff of the JSON does not show who is affected.

The diffMatrix function compares two policy documents. It reports granted and withdrawn permissions and which checks now read different fields of the row.

Change one rule

What this change does

  • GainedBooksellers can now reprice listings that they sell.listing.reprice · allow-branch-added
  • Needs the rowChecks for reprice now read object.sellerId of the row. Asked before the row is fetched, they answer cannot tell.listing.reprice
const diff = diffMatrix(before, after)
diff.version   // { before: 'listings@7', after: 'listings@8' }
diff.unchanged // false
// { kind: 'granted', key: 'listing.reprice', cause: 'allow-branch-added' }
// { kind: 'reads-object', key: 'listing.reprice' }
Select a rule change to see how it shifts access for different subjects.
Comparing policy versions →

A security contract you can read

Know exactly what the library prevents and what your app must handle.

Developers need to know where the library ends and their responsibility begins.

The library maps 37 attack classes to CWE and OWASP entries. Some are prevented by the engine, some use provided primitives, and some are listed as your application's job.

Entries from the security register
IDAttackCWE / OWASPHow it is handled
SEC-004Unevaluable deny: a deny rule reading a path the projection lacksCWE-863, A01:2021A deny side that cannot be read outranks a matching allow; the decision is unevaluable and names the paths to fetch
SEC-005Prototype pollution reaching a decisionCWE-1321Every path read and every rule lookup is own-property guarded; every decision map is written with defineProperty
SEC-010Incorrect default permissionsCWE-276, A01:2021Nothing is allowed without an allow rule that matched: no rules, an empty rule list and an unknown action all refuse
Read the security contract →View the attack register →

Field-level control

Control exactly which fields a user can read or write.

Users may have access to a row but should not be able to change every column.

Use canFields to determine allowed fields for a subject. Use pickAllowedFields to strip unauthorized fields from a submitted write.

type Question = { askedBy: string; body: string; status: string };

const access = policy<{ id: string }, { question: Question }>()
  .for('question', (p) =>
    p
      .allow('update', p.eq('object.askedBy', 'subject.id'))
      .fields(['*', '!status']),
  )
  .build();

const current = { askedBy: 'customer-41', body: 'In stock?', status: 'open' };
const proposed = { body: 'Wingspan in stock?', status: 'locked' };

const decision = access.canFields(
  { id: 'customer-41' },
  'question',
  'update',
  current,
  'write',
  proposed,
);

// The asker may change the body but not the status, so only the body is kept.
const safe = pickAllowedFields(decision, proposed);
Managing field access →

Try it with your own policy

Paste your policy and see what it decides.

You can verify your logic without writing and deploying a full update.

The matrix explorer lets you paste your JSON policy and test decisions for any subject. The document is read in your browser and never uploaded.

Open the matrix explorer →

Limits

Integrated with React

Use a provider and hooks to manage access in your components.

npm install @evanion/acl @evanion/react-acl
import { policy } from '@evanion/acl';
import { PolicyProvider, useCan } from '@evanion/react-acl';

type Shopper = { id: string; roles: string[] };
type Listing = { sellerId: string };

const access = policy<Shopper, { listing: Listing }, { listing: 'edit' }>()
  .for('listing', (p) => p.allow('edit', p.eq('object.sellerId', 'subject.id')))
  .build();

function EditButton({ listing }: { listing: Listing }) {
  const decision = useCan('listing', 'edit', listing);
  return decision.allowed ? <button type="button">Edit listing</button> : null;
}

function App({ shopper }: { shopper: Shopper }) {
  return (
    <PolicyProvider access={access} subject={shopper}>
      <EditButton listing={{ sellerId: 'mika' }} />
    </PolicyProvider>
  );
}
Read the guide →

Questions

What is this library?
It is a TypeScript authorization library that builds a policy into a JSON document. This allows the same synchronous checks to run on both your server and in the browser.
Does it need a service of its own?
No. It is a library with zero dependencies. You install it via npm and run it within your own application process.
Can I trust the browser check?
No. Browser checks are advisory and used to update the UI. You must re-decide every request on your server or gateway to ensure security.
What happens when the row is not loaded?
The library returns an unevaluable decision. The result is allowed: false, and the missing field lists the data needed to make a final decision.
Does it work with React?
Yes. The @evanion/react-acl package provides a PolicyProvider and a useCan hook to check permissions inside your components.
How do I notice changes when another service publishes new rules?
Use contractDrift from @evanion/acl/testing. It replays your own questions against the contract you pinned and the one CI fetched, then reports what moved between them.

Unified authorization logic

Run the same rules across every layer of your app. Every decision provides the reason and the rule used to reach it.

Get startedAPI referenceGitHub