Composable authorization with Effect
Composing RBAC and ABAC checks across frontend and backend with a single Policy typeAuthorization logic tends to be scattered across modules and entangled across different levels of abstractions. Permission checks leak into business logic, duplicate between frontend and backend, and resist refactoring. This post builds a composable Policy abstraction with Effect and layered architecture. We’ll use RBAC/ABAC terminology from Three axes of authorization.
The Policy type
A Policy is an Effect that succeeds with void (access granted) or fails with PolicyError (access denied). It includes AuthUtilRepo as a requirement, so the auth context is injected automatically rather than passed by the caller.
/**
* The union in the error and requirement slots (`PolicyError | E`, `AuthUtilRepo | R`) lets policies compose:
* - You can add custom errors (`E`) or dependencies (`R`) for more specific checks.
* - The base always includes the core authorization error plus injected context
*/
type Policy<E = never, R = never> = Effect.Effect<
void,
PolicyError | E,
AuthUtilRepo | R
>;
export type AuthContext = {
user: {
id: string;
};
org: {
id: string;
};
};
export class AuthUtilRepo extends Context.Tag("AuthUtilRepo")<
AuthUtilRepo,
{
get: () => Effect.Effect<AuthContext, AuthContextError>;
}
>() {}
Policy.make takes a predicate function that receives the AuthContext and returns a boolean, resolving the auth context from dependency injection. Callers never pass context manually. This is similar to moving from prop drilling to using a ContextProvider in React. It has the additional benefit of ensuring that sensitive IDs can never be passed from the client, e.g. accidentally grabbing orgId off of a URL param instead of the user’s actual org ID.
const Policy = {
make: <E, R>(
predicate: (ctx: AuthContext) => Effect.Effect<boolean, E, R>
): Policy<E | AuthError, R> =>
Effect.gen(function* () {
const auth = yield* AuthAccessRepo;
const ctx = yield* auth.get();
const hasAccess = yield* predicate(ctx);
return yield* hasAccess
? Effect.void
: Effect.fail(new AuthorizationError({ reason: "forbidden" }));
}),
};
Here’s an ownership check that compares the resource creator against the authenticated user:
const isOwner = (id: string) =>
Policy.make((authCtx) =>
Effect.gen(function* () {
const crewAccess = yield* CrewAccessRepo;
const crew = yield* crewAccess.get(id);
return crew.id === authCtx.user.id;
})
);
Since policies are just Effects, we can compose them with standard Effect combinators. These three utilities cover the common patterns:
const Policy = {
/**
* Applies a policy as a pre-check to an effect.
* If the policy fails, the effect will fail with Forbidden.
*/
guard: (policy: Policy) => (self: Effect.Effect) =>
Effect.zipRight(policy, self),
/**
* Combines multiple policies into a single policy that requires all policies to pass.
* Policies are evaluated sequentially and will short-circuit on the first failure.
*/
all: (...policies: NonEmptyReadonlyArray<Policy>): Policy =>
Effect.all(policies, { concurrency: 1, discard: true }),
/**
* Combines multiple policies into a single policy that requires any policy to pass.
* Policies are evaluated sequentially until one succeeds.
*/
any: (...policies: NonEmptyReadonlyArray<Policy>): Policy =>
Effect.firstSuccessOf(policies),
};
Policy engines
In the layered architecture, Engines encapsulate business rules. A policy Engine combines RBAC permission checks with ABAC predicates (like ownership) into reusable methods.
// Engine
Effect.gen(function* () {
const crewAccess = yield* CrewAccessRepo;
const isOwner = (id: string) =>
Policy.make((authCtx) =>
Effect.gen(function* () {
const crew = yield* crewRepo.get(id);
return crew.id === authCtx.user.id;
})
);
return {
// use Policy.any to do logical OR on ACL permission check + custom `isOwner` defined above
canEdit: (id: string) =>
Policy.any(
Policy.permission({ resource: "crew", action: "edit" }),
isOwner(id)
),
canView: () => Policy.permission({ resource: "crew", action: "view" }),
};
}),
The Manager orchestrates authorization without knowing the underlying data access implementation. Policy.guard gates the operation, while Policy.toBool converts the result into a boolean for downstream use.
// Manager
Effect.gen(function* () {
const crew = yield* CrewAccessRepo;
const crewPolicy = yield* CrewPolicyEngineRepo;
const baseList = yield* crew.list().pipe(
// throws an error if no permission
Policy.guard(crewPolicy.canView())
);
const crewList = yield* Effect.forEach(baseList, (member) =>
Effect.gen(function* () {
const canEdit = yield* crewPolicy.canEdit(member.id).pipe(Policy.toBool);
return { ...member, canEdit };
})
);
return { crewList };
});
Sharing policies with the frontend
The same Engine methods drive both server-side enforcement and frontend rendering decisions. Policy.toBool converts a policy into a boolean Effect, catching AuthorizationError as false while letting other errors propagate.
const Policy = {
/**
* Converts a policy into a boolean Effect.
* Returns true if the policy succeeds, false if it fails with PolicyError and will still throw for any other Error type
*/
toBool: <E, R>(
policy: PolicyT<E, R>
): Effect.Effect<boolean, E, AuthAccessRepo | R> =>
policy.pipe(
Effect.as(true),
Effect.catchTag("AuthorizationError", () => Effect.succeed(false))
),
};
In the loader (see React Router docs), you can then evaluate policies per record and attach the booleans to the response:
// use this in loader
listMembers: () =>
Effect.gen(function* () {
const { members } = yield* orgAccess.listAuthOrgMembers(ctx.org.authId)
// Enhance each object with policy evaluation to determine whether to render UI for actions
const membersWithPermissions = yield* Effect.all(
members.map((member) =>
Effect.gen(function* () {
const canDelete = yield* rolePolicy
.canDeleteMember({
authId: member.user.authId,
role: member.orgRole,
})
.pipe(Policy.toBool);
return {
...member,
canDelete,
};
}),
),
);
return membersWithPermissions;
}).pipe(Policy.guard(rolePolicy.canViewSettings())),
export default function MembersPage() {
const { members } = useLoaderData<typeof loader>();
// …
return (
<TableBody>
{members.map(
({
user: { authId, email, firstName, lastName },
status,
orgRole,
canDelete,
}) => {
return (
<TableRow>
{canDelete && ( // determine whether to render based on boolean
<DeleteButton
onClick={handleDelete}
/>
)}
</TableRow>
);
},
</TableBody>
)
}
The server-side action reuses the same Engine method. If the authorization logic changes, both frontend rendering and backend enforcement update together.
deleteMember: (targetAuthId: string) =>
Effect.gen(function* () {
// …
yield* OrgResource.deactivateOrgMember(ctx.org.authId, targetAuthId).pipe(
Policy.guard(
rolePolicy.canDeleteMember({ // reuse same Policy
authId: targetAuthId,
role: targetMember.orgRole,
}),
),
);
return {
deleted: true,
authId: targetAuthId,
email: targetMember.user.email,
};
}).pipe(Policy.guard(rolePolicy.canInviteMembers())),
Scope resolution
Boolean policy checks answer “can this user do X?” but not “which records can this user see?” This is the third axis of authorization. For collection-level filtering, we need scope resolution.
A Scope determines the subset of records a user can access. Each scope variant returns the identifiers needed to filter the query:
export type PermissionScopeResult =
| {
type: "all";
}
| {
type: "own";
userId: string;
}
| {
type: "team";
teamId: string[];
}
| {
type: "ids";
ids: string[];
};
An Engine exposes scope resolution through Scope.resolve, declaring which scopes are valid for each operation:
export const BillingPolicyEngine = {
resolveViewScope: () =>
Scope.resolve({
target: "billing",
action: "view",
allowedScope: ["all", "own"],
}),
};
Scope.match pattern-matches on the resolved scope, routing each variant to the appropriate query:
Effect.gen(function* () {
const billingList = yield* Scope.match(
billingPolicy.resolveViewScope(),
{
all: () => billing.list(),
own: ({ userId }) => billing.listByOwner(userId),
}
);
return billingList;
}),
Scope.match is type-safe: it requires a handler for every scope listed in allowedScope. Omit one and the compiler catches it.
Takeaways
This Policy abstraction provides three things:
- Composability:
Policy.all,Policy.any, andPolicy.guardcombine simple checks into complex authorization rules without nesting conditionals. - Frontend/backend reuse:
Policy.toBoolconverts the same Engine methods into rendering decisions for the frontend and enforcement gates for the backend. One change propagates to both. - Decoupling via DI:
AuthUtilRepoas an Effect requirement means authorization logic never imports or passes context directly. It stays testable and layer-compliant.
For enforcing that authorization checks actually run before sensitive operations, see Enforcing audit logging + authz at compile-time.
Inspired by Lucas Barake’s Building a composable Policy system in TypeScript with Effect.