Composable authorization with Effect

Composing RBAC and ABAC checks across frontend and backend with a single Policy type

Authorization 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, and Policy.guard combine simple checks into complex authorization rules without nesting conditionals.
  • Frontend/backend reuse: Policy.toBool converts the same Engine methods into rendering decisions for the frontend and enforcement gates for the backend. One change propagates to both.
  • Decoupling via DI: AuthUtilRepo as 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.