Skip to Content
AuthorizationFiltering what a user sees

Filtering what a user sees

@evanion/acl 0.1.0 and its patch releases up to 0.1.2, the version on npm. Values on this page were produced by running the source at 40b8f84, a later commit than the 0.1.0 tag, and are not re-executed.

@evanion/acl filters the rows an endpoint returns with canMany, and strips each surviving row’s denied fields with canFields and pickAllowedFields.

Filtering a list endpoint with @evanion/acl

To protect a list endpoint like GET /orders, you must decide which rows a customer may read and which fields within those rows are visible. You perform this by using canMany to filter the rows and canFields combined with pickAllowedFields to strip the denied fields.

To ensure consistency across these steps, you use access.authorize(subject) to bind the subject and a single instant to a handle. Because both the row filter and the field strip use that same instant, a rule relying on p.before or p.after cannot flip its decision between the two operations.

import { , } from '@evanion/acl'; type = { : string; : string[] }; type = { : string; : string; : number; : string; }; const = <, { : }>() .('order', () => .('read', .('object.customerId', 'subject.id')) .(['*', '!internalNote']), ) .(); const : [] = [ { : 'o1', : 'c1', : 40, : 'gift wrap' }, { : 'o2', : 'c2', : 15, : 'late payer' }, { : 'o3', : 'c1', : 22, : 'repeat buyer' }, ]; // One handle holds the subject and one instant for the whole response. const = .({ : 'c1', : [] }); const = .('order', 'read', ); const = .((, ) => []?.) .(() => (.('order', 'read', , 'read'), ), ); ; // -> [{ id: 'o1', customerId: 'c1', total: 40 }, { id: 'o3', customerId: 'c1', total: 22 }]

In this example, order o2 belongs to another customer; its decision is no-rule-matched, so the filter drops it. Because internalNote is denied on the read axis, pickAllowedFields excludes it from o1 and o3. The resulting response contains only the rows and fields allowed by the rules.

The pickAllowedFields function throws ActionNotAllowedError if the action itself is refused. Since the row filter runs first, the strip only processes rows where read is already allowed. If canMany marks a row as unevaluable, it is treated as allowed: false and dropped. If your list query selected only some columns, Can this user do this? explains the second fetch required by missing.

For an example of a filter that incorrectly implements the field step by passing a projection, see Caveats and pitfalls.

Nested objects in @evanion/acl field rules

To define which parts of a data row are visible, @evanion/acl evaluates top-level keys via Object.keys. Because field rules name top-level keys only, nested paths are not supported; for example, address is a field, but address.street is not.

If you attempt to use a dotted key—whether in a field list like ['*', '!address.street'] or within targets or transitions rules—the build will refuse it. This results in an InvalidPermissionError that names the specific permission key and the invalid entry. The build also refuses a name containing a backslash, which is reserved as the path escape. A flat data key that literally contains a dot cannot be named by a rule.

The example below demonstrates a refused build followed by the correct way to hide a nested field.

import { , , , } from '@evanion/acl'; import { } from 'vitest'; type = { : string; : string }; type = { : string; : }; // Field rules name top-level keys, and a dotted entry is refused. const = () => <{ : string }, { : }>() .('order', () => .('read', .).(['*', '!address.street']), ) .(); ().(); // The nested shape gets its own object kind, decided with its own call. const = <{ : string }, { : ; : }>() .('order', () => .('read', .)) .('address', () => .('read', .).(['*', '!street'])) .(); const = { : 'c1' }; const : = { : 'o1', : { : 'Malmo', : 'Storgatan 1' }, }; const = .(, 'order', 'read', , 'read'); .(.); // -> ['id', 'address'] .['address']; // -> 'allowed' const = (, ). ?? { : '', : '', }; const = .(, 'address', 'read', , 'read'); (, ); // -> { city: 'Malmo' }

Conditions share this same limitation, as a path can only read one field of a scope. As detailed in What can throw?, a path like object.address.city will cause the policy to be refused during the build process.

To hide a specific part of a nested object, you must decide its visibility separately. You can achieve this by giving the nested shape its own object kind with its own field rules, then calling canFields on the nested value after the parent has been picked. In this pattern, the row-level gate remains on the parent kind, while the nested kind permits read unconditionally.

Because @evanion/acl does not automatically walk into values, a response containing three nested objects requires three additional canFields calls per row. To reduce this overhead, you can flatten sensitive nested data into its own top-level field on the row.

Paging a filtered list with @evanion/acl

Because @evanion/acl decides on rows after they are fetched rather than producing a database filter, a query with LIMIT 20 may return fewer than 20 rows if some are filtered out. This can leave the client with a short page and no indication that more readable rows exist further down the table.

You can handle this using one of two strategies.

Over-fetch and refill

To ensure a full page, fetch a batch, filter it with canMany, and continue fetching until the page is full or the table is exhausted. In this scenario, the cursor returned to the client is the position after the last row examined, rather than a page number.

import { } from '@evanion/acl'; type = { : string; : string }; const = <{ : string }, { : }>() .('order', () => .('read', .('object.customerId', 'subject.id'))) .(); const : [] = [ { : 'o1', : 'c2' }, { : 'o2', : 'c1' }, { : 'o3', : 'c2' }, { : 'o4', : 'c2' }, { : 'o5', : 'c1' }, { : 'o6', : 'c1' }, { : 'o7', : 'c2' }, ]; // Stands in for `SELECT ... ORDER BY id LIMIT :limit OFFSET :cursor`. const = (: number, : number): [] => .(, + ); function (: { : string }, : number, : number) { const = .(); const : [] = []; let = ; while (. < ) { const = (, ); if (. === 0) break; const = .('order', 'read', ); for (const [, ] of .()) { if (. === ) break; += 1; if ([]?.) .(); } } return { , }; } // Batches of two: o1 and o2 hold one readable row, o3 and o4 none, o5 and o6 one. const = ({ : 'c1' }, 0, 2); ..(() => .); // -> ['o2', 'o5'] .; // -> 5 ({ : 'c1' }, ., 2)..(() => .); // -> ['o6']

When asked for two rows, c1 retrieves one readable row from the first batch (o1 and o2), none from the second, and the second readable row from the third. The returned next value is 5 (the position after o5), so the subsequent call resumes there to return o6.

This approach increases the number of queries and can result in unbounded work for subjects who can read very few rows. To prevent this, cap the number of rounds or total rows examined and return the current results with the cursor.

Put the coarse part of the rule in the query

To optimize performance, identify the coarse part of your read rules that the database can express—such as owner or tenant—and include it in the query. You then let @evanion/acl handle the fine-grained state-dependent decisions. This keeps the page mostly full because the database has already removed the majority of ineligible rows.

import { } from '@evanion/acl'; type = { : string; : string; : string }; const = <{ : string; : string[] }, { : }>() .('order', () => .('read', .('object.customerId', 'subject.id')) .('read', .('object.status', 'withdrawn')), ) .(); const : [] = [ { : 'o1', : 'c2', : 'paid' }, { : 'o2', : 'c1', : 'paid' }, { : 'o3', : 'c1', : 'withdrawn' }, { : 'o4', : 'c1', : 'paid' }, ]; // Stands in for `WHERE customer_id = :customerId ORDER BY id LIMIT :limit`. const = (: string, : number): [] => .(() => . === ).(0, ); const = { : 'c1', : [] }; const = (., 2); const = .(, 'order', 'read', ); .((, ) => []?.).(() => .); // -> ['o2']

Here, the query restricts customer_id to the subject, while @evanion/acl applies the deny rule on status, dropping o3 from the page. If the remaining fine rules still leave the page too short, you can pair this method with the refill loop.

The query acts as a performance filter; @evanion/acl still decides every row. A query that is too broad leaks nothing, while one that is too narrow may hide rows the rules would have allowed. Ensure the query’s condition stays in step with the rule, for instance by testing both against the same fixtures.

When the decision does not depend on the row

To avoid unnecessary filtering, use access.readsObject(kind, action) to determine if a permission actually reads the row. If it returns false, the decision is identical for every row. A single can call without an object decides the entire page, meaning the page is either fully returned or entirely refused without running a filter.

import { } from '@evanion/acl'; type = { : string }; const = <{ : string; : string[] }, { : }>() .('order', () => .('read', .('subject.roles', 'bookseller')), ) .(); .('order', 'read'); // -> false .({ : 'b1', : ['bookseller'] }, 'order', 'read').; // -> true .({ : 'c1', : [] }, 'order', 'read').; // -> false

Perform this check once and then page normally. For more on readsObject, see Can this user do this?.

Last updated on