Skip to Content
AuthorizationMany Services

Unreleased changes

These pages document main, which has changes 0.1.2 does not. npm install @evanion/acl gives you 0.1.2, documented here. Values on these pages are produced by running the current source on every build.

Policy Federation

In a microservices architecture, authorization logic is often fragmented across different services. If a single request needs to be validated against rules owned by multiple services, you need a way to compose these separate policies without merging them into a single, monolithic document.

federatedPolicies allows you to create a unified view over multiple ACL evaluators. It routes requests to the appropriate service-specific evaluator based on the object kind, ensuring that each service remains the sole authority over its own rules.

Namespacing Object Kinds by Origin

When multiple services manage similar types of data, they may use the same names for their object kinds. For example, both a storefront service and a stock service might have a listing object.

To avoid collisions in a federated view, object kinds should be namespaced by their origin using a colon separator (e.g., storefront:listing and stock:listing). This tells the federated evaluator exactly which service owns the permission.

import { } from '@evanion/acl'; type = { : string; : string }; type = { : string; : string }; const = <, { 'storefront:listing': }>() .('storefront:listing', () => .('read', .('object.shop', 'subject.shop')), ) .(); const = { : 'staff:ines', : 'stockholm' }; const = { : 'wingspan', : 'stockholm' }; ..[0]?.; // -> 'storefront:listing.read' .(, 'storefront:listing', 'read', ).; // -> true /** The error a call raises, by name, or `undefined` when it raises none. */ function (: () => unknown): string | undefined { try { (); } catch () { return ( as Error).; } return ; } const = () => <, { 'storefront.listing': }>() .('storefront.listing', () => .('read', .)) .(); (); // -> 'InvalidPermissionError'

The key for these permissions remains ${object}.${action}. If a . is used inside the object or action name itself, the library throws an InvalidPermissionError during construction, as the dot is reserved as the key delimiter.

Composing a Federated View

federatedPolicies takes a map of Access evaluators (each created via .build(), hydratePolicy, or parseMatrix) and returns a unified view. This view preserves the integrity of each original document—nothing is merged or altered.

import { , } from '@evanion/acl'; import type { Decision, Matrix } from '@evanion/acl'; const : Matrix = { : [ { : 'storefront:listing.read', : 'storefront:listing', : 'read', : [ { : [{ : 'subject.roles', : 'contains', : 'owner' }] }, ], }, ], }; const : Matrix = { : [ { : 'stock:listing.read', : 'stock:listing', : 'read', : [ { : [ { : 'subject.roles', : 'contains', : 'bookseller' }, ], }, ], }, ], }; // One Access per origin. Each document belongs to the service that emitted it, // so each arrives through `parseMatrix` and fails closed. Two origins claiming // one key throw `OriginCollisionError` on this line, naming both. const = ({ : (), : (), }); const = { : 'u1', : ['bookseller'] }; // One advisory view for a UI, over one instant every origin reads. const : <string, Decision> = .(); .().(); // -> ['stock:listing.read', 'storefront:listing.read'] ['stock:listing.read']?.; // -> true ['storefront:listing.read']?.; // -> false // The origin holding the key answers it. A key nobody holds reaches no origin. .(, 'stock:listing', 'read').; // -> true .(, 'shipping:parcel', 'read').; // -> 'unknown-action' // A request touching both origins asks both, and the caller writes the AND. const = .(, 'storefront:listing', 'read').; const = .(, 'stock:listing', 'read').; && ; // -> false // The member itself, with `canMany`, `canFields`, `readsObject` and `authorize` // on it, plus the document that origin published. .('storefront')?...; // -> 1

The unified view provides three primary members:

MemberResponsibility
can(subject, kind, action)Routes the request to the single origin whose key set contains the target key. If no origin holds the key, it returns unknown-action.
capabilities(subject, now?)Captures a single point in time and evaluates capabilities across all origins, ensuring a consistent “snapshot” for the entire request.
get(origin)Returns the original Access evaluator for a specific origin, allowing access to advanced methods like canFields or authorize.
The routing logic of a federated view. A request for a permission is routed to the specific service evaluator that owns that key. If the key is not found in any registered evaluator, the view returns a refusal.

If a request requires permissions from multiple origins, the caller simply performs multiple checks and combines the results (e.g., can(storefront_key) && can(stock_key)).

Handling Origin Collisions

To prevent ambiguous authorization, federatedPolicies throws an OriginCollisionError if two different origins declare the same permission key. This check happens at the time the federated view is constructed.

A “deny overlay” (see below) does not cause a collision because it appends rules to an existing permission rather than declaring a new one.

The Federated View as an Advisory Layer

It is critical to understand that a federated view at a gateway or edge level is advisory. It is functionally equivalent to a browser-side ACL check: it provides a hint to the system, but it is not the final trust boundary.

Every downstream service must still evaluate the request against its own matrix. A gateway that allows a request does not excuse the service behind it, which may be reached directly by a caller.

The trust boundary in a federated topology. The edge view is advisory and routes requests for efficiency; the actual enforcement must happen within each service using its own independent matrix.

Managing Unreachable Upstreams

If a service is offline or unreachable, simply omit it from the object passed to federatedPolicies. The view will treat any keys owned by that missing service as unknown-action.

Build each member from a fetched document with parseMatrix: in “closed mode,” a permission the owner dropped from its document answers unknown-action where hydratePolicy would throw.

Cross-Cutting Denials via Overlays

Sometimes a specialized team (e.g., Compliance or Fraud) needs to enforce global restrictions that override the rules of a specific service. Instead of asking the service owner to change their rules, the “owner” service can apply a Deny Overlay.

An overlay is a set of deny rules for permissions the service already owns. The service applies this overlay to its own matrix before building the final evaluator:

import { , } from '@evanion/acl'; import type { , Matrix } from '@evanion/acl'; // The owner's document. `schema.objects.refund` is what opening `refund.issue` // to a veto obliges it to declare. const : Matrix = { : 'refunds@7', : { : { : { : { : 'string', : 'number' } } }, }, : [ { : 'refund.issue', : 'refund', : 'issue', : [ { : [{ : 'subject.roles', : 'contains', : 'owner' }] }, ], }, ], }; // What compliance publishes. A contribution is a `Rule[]`, so it can state a // deny and nothing else -- no allow, no field rule. const : = { 'refund.issue': [ { : 'sanctions-hold', : [{ : 'object.region', : 'eq', : 'XX' }], }, ], }; const = ( (, , { : ['refund.issue'] }), { : 'refunds@7+veto@41' }, ); const = { : 'u1', : ['owner'] }; .(, 'refund', 'issue', { : 'SE', : 10 }).; // -> true .(, 'refund', 'issue', { : 'XX', : 10 }).; // -> 'denied'

applyDenyOverlay appends these rules to the denyRules of the target permissions. Because only deny rules are added, an overlay can only subtract permissions—it can never grant a permission that the original author denied.

Validation of Overlays

applyDenyOverlay validates the overlay against the target matrix’s schema. It throws errors if:

  • The overlay refers to a permission key that doesn’t exist in the target.
  • The object kind of a vetoable key lacks a corresponding entry in the target’s schema.objects.
  • The overlay attempts to modify a permission not marked as vetoable.

Publishing and Consuming Policies

To share a policy with another service or a frontend, use serialize(access, 'reduced'). This emits only the permissions marked as public, allowing consumers to adopt the result via parseMatrix.

Security Considerations

@evanion/acl does not provide built-in signing or verification for policy documents. You must transport your matrices over an authenticated and encrypted channel (e.g., HTTPS with mutual TLS). A document received over an unauthenticated channel should be considered untrusted.

Where to go next

  • Publishing Rules: Learn how to use serialize and visibility to share your policy.
  • Adopting Rules: Explore how to use parseMatrix and “closed mode” when consuming a remote policy.
  • Caveats and Pitfalls: Review common traps when implementing federated ACLs.
  • Security Contract: Understand why every layer must decide for itself.
Last updated on