Publishing ACL Contracts
@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.
In a distributed system, one service often owns the authorization rules for a specific resource, but other services need to know those rules to provide a consistent user experience. For example, an orders service owns the rules for issuing refunds, but the storefront needs to know those rules to decide whether to show a “Request Refund” button.
If the storefront were to maintain its own copy of these rules, it would inevitably diverge from the source of truth. To prevent this, @evanion/acl allows a service to publish a “contract”—a reduced version of its policy matrix—that other services can adopt and evaluate locally.
Creating a Public Contract with serialize
A service defines which parts of its policy are intended for public consumption using the .visibility('public') mark. Any permission not marked as public is considered internal and is stripped away during serialization.
The serialize function produces the contract. A “reduced” serialization keeps only the public permissions and their associated rules, while a “full” serialization returns the entire document.
import { , , } from '@evanion/acl';
type = { : string; : string[]; : string };
type = { : string };
type = { : string };
const = <
,
{ 'orders:order': ; 'orders:ledger': },
{ 'orders:order': 'refund'; 'orders:ledger': 'reconcile' }
>({ : 'orders@7' })
.('orders:order', () =>
.('refund', .('subject.roles', 'bookseller'))
.('refund', .('subject.tier', 'probation'))
.('public'),
)
.('orders:ledger', () =>
.('reconcile', .('subject.roles', 'accountant')),
)
.();
const = (, 'reduced');
..(() => .); // -> ['orders:order.refund']
const = <, { 'orders:order': }>();
const = { : 'u1', : ['bookseller'], : 'permanent' };
.(, 'orders:order', 'refund').; // -> trueThe resulting reduced document is the formal contract. It contains only the permissions the owner intended to share (e.g., orders:order.refund) and excludes internal-only permissions (e.g., orders:ledger.read). The consumer adopts this contract using parseMatrix, allowing them to make local decisions without making a network call to the owner for every check.
The Integrity of Published Permissions
When a permission is published, it is exported in its entirety. This includes all allow rules and all deny rules.
If a permission’s logic depends on a deny rule (e.g., “Allow refund unless the user is on probation”), that deny rule must be part of the published contract. If it were stripped, a consumer might incorrectly grant a permission that the owner would refuse.
import { , , } from '@evanion/acl';
import type { Matrix } from '@evanion/acl';
const : Matrix = {
: 'orders@7',
: 300_000,
: {
: {
'orders:order': { : { : 'string' } },
'orders:ledger': { : { : 'string' } },
},
},
: [
{
: 'orders:order.refund',
: 'orders:order',
: 'refund',
: 'public',
: [
{
: [
{ : 'subject.roles', : 'contains', : 'bookseller' },
],
},
],
: [
{ : [{ : 'subject.tier', : 'eq', : 'probation' }] },
],
},
{
: 'orders:ledger.reconcile',
: 'orders:ledger',
: 'reconcile',
: 'internal',
: [{ : [] }],
},
],
};
const = ();
const = (, 'reduced');
..(() => .); // -> ['orders:order.refund']
.(.?. ?? {}); // -> ['orders:order']
'visibility' in (.[0] ?? {}); // -> false
// The consumer adopts it the way it adopts any foreign document, and reports
// when it last checked the contract was current.
const = (, { : .() });
const = { : 'u1', : ['bookseller'], : 'permanent' };
.(, 'orders:order', 'refund').; // -> true
.(, 'orders:ledger', 'reconcile').; // -> 'unknown-action'Because the contract includes the full set of rules for the published permission, the consumer’s local decision will exactly match the owner’s decision for that specific key.
Contract Terminality
A published contract is “terminal.” The visibility marks are stripped
during serialization, meaning a consumer cannot re-serialize a contract to
publish it again to a third party.
Managing Contract Freshness
Since consumers evaluate a local copy of the contract, there is a risk that the copy becomes stale. @evanion/acl manages this through a “freshness budget.”
The owner defines a maxStale value (in milliseconds) in the document, which sets the maximum allowed age of a copy. The consumer tracks when the document was last updated using fetchedAt.
| Bound | Source | Purpose |
|---|---|---|
maxStale (Document) | The Owner | Sets the absolute ceiling for how long a copy may be considered fresh. |
maxStale (Consumer) | The Consumer | Allows the consumer to tighten the owner’s ceiling (e.g., for higher-security areas). |
fetchedAt | The Consumer | The timestamp of the last successful synchronization. |
If the current time exceeds fetchedAt + min(ownerMaxStale, consumerMaxStale), any attempt to use the contract results in a stale-contract response. This forces the consumer to refetch the document to repair the budget.
Enabling Third-Party Vetoes
In some organizations, a separate team (such as Compliance or Fraud) must be able to override the permissions of a service. This is handled via Deny Overlays.
To allow another team to veto specific permissions, the owner must explicitly list those keys as vetoable during serialization. If a key is not published in the contract, a third party cannot write a valid overlay for it, as they would have no schema to validate their rules against.
serialize(access, 'reduced', { vetoable }) will throw an UnpublishedVetoableError if the vetoable list includes a key that would otherwise be dropped from the reduced contract.
Where to go next
- Adopting Rules: Learn how to use
parseMatrixand manage freshness budgets when consuming a contract. - Many services: Explore how to combine multiple published contracts into a single federated view.
- What can throw?: See the list of errors that occur during contract serialization and adoption.