Type-safe, explainable policy-as-code authorization for TypeScript/Node, with a CLI that scans your routes and tells you which ones are missing authorization.
π¦ Install from npm | π Documentation | π Report an issue
- β
Centralized policies instead of scattered
if (user.role === 'admin')checks - β Explainable decisions: every allow/deny comes with human-readable reasons
- β
Type-safe DSL for
ctxandresourceobjects - β Policy intelligence: introspection APIs, role-based summaries, rule metadata
- β
Express adapter:
authorize(engine, 'invoice', 'read', resolve) - β
Static analysis CLI:
@aegis-runtime/aegisauth report src(+--json) to flag routes withoutauthorize() - β OpenTelemetry integration: built-in observability for authorization decisions
- β Shadow testing: safely test new policies alongside production policies
- β No DB, queues, or external services required β pure TypeScript library
- Architecture
- Tech Stack
- Motivation
- Core Concepts
- Installation
- Defining Policies
- Policy Intelligence & Introspection
- Making Decisions Manually
- Using the Express Adapter
- Advanced Features
- CLI: Route Authorization Report
- Examples & Project Structure
- Design Notes
- Roadmap
- License
AegisAuth follows a layered architecture designed for flexibility, type safety, and observability:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Application Layer β
β (Express Routes, RPC Handlers, Background Jobs, etc.) β
βββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββ
β
β authorize() middleware / engine.decide()
β
βββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββ
β Framework Adapters β
β ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ β
β β Express β β OpenTelemetryβ β Shadow β β
β β Adapter β β Integration β β Testing β β
β ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ β
βββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββ
β
β PolicyEngine API
β
βββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββ
β Policy Engine Core β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Rule Storage (Map<resourceType, Map<action, Rule[]>>)β β
β β Decision Logic (deny-overrides semantics) β β
β β Introspection APIs (listRules, findRules, etc.) β β
β β Capability Snapshots β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββ
β
β Type-safe DSL
β
βββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββ
β Policy Definitions β
β (Your domain-specific Ctx, Resources, and Rules) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
Separation of Concerns: The core engine is framework-agnostic; adapters bridge to specific frameworks (Express, OpenTelemetry, etc.)
-
Type Safety: Full TypeScript generics ensure compile-time type checking for contexts, resources, and actions
-
Extensibility: Hook-based architecture (
onDecision,onDivergence) enables observability and custom behaviors -
Performance: In-memory rule evaluation with O(1) lookups by resource type and action
-
Testability: Pure functions, no side effects, and deterministic decision logic make unit testing straightforward
@aegis-runtime/aegisauth
βββ core (default export)
β βββ PolicyEngine, createPolicyEngine, Decision, RuleMeta
βββ /express
β βββ authorize, authorizeWithShadow, AuthorizeOptions
βββ /shadow
β βββ createShadowEngine, ShadowEngine, ShadowDivergenceInfo
βββ /otel
βββ createPolicyEngineWithOtel, AegisAuthOtelOptions
- TypeScript 5.5+: Full type safety, modern language features, and excellent IDE support
- Node.js 18+: ESM support, modern JavaScript runtime
- TypeScript Compiler API: Used by CLI for static analysis of route definitions
- TypeScript Compiler (tsc): Type checking and compilation
- Vitest: Fast, modern testing framework
- ESM (ES Modules): Native module system support
- Zero runtime dependencies for the core engine
- Peer dependencies:
express(^4.17.0 || ^5.0.0): For Express adapter@opentelemetry/api(^1.9.0): For OpenTelemetry integration
- TypeScript strict mode: Maximum type safety
- Declaration files (.d.ts): Full type information for consumers
- Tree-shakeable exports: Modern bundlers can eliminate unused code
- Side-effect free: Safe for tree-shaking and module optimization
- OpenTelemetry: Standard observability protocol
- Metrics: Decision counts, latency histograms
- Traces: Spans for each authorization decision
- Attributes: Resource type, action, outcome, reasons
Authorization (authZ) answers the question: "What is this user allowed to do?"
In most Node/TypeScript backends, authorization logic:
- Is scattered across controllers and services
- Is written as ad-hoc conditionals like
if (user.role === 'admin')everywhere - Has no single source of truth for "who can do what"
- Is hard to test, hard to audit, and hard to change safely
AegisAuth aims to fix this by providing:
- A central policy engine where you define all your rules as code
- A type-safe DSL for expressing permissions with context and resources
- Explainable decisions, with reasons returned for each allow/deny
- An Express adapter to protect routes via middleware
- A CLI that statically scans your code for routes that lack authorization
- A policy intelligence layer to introspect and summarize your rules
- OpenTelemetry integration for production observability
- Shadow testing capabilities for safe policy migrations
The goal is to treat authorization as first-class, testable, auditable code rather than scattered if statements.
AegisAuth revolves around four core ideas:
Represents who is making the request and global request context.
type Ctx = {
user: {
id: string;
orgId: string;
roles: string[];
} | null;
};You decide the shape of Ctx for your application.
Represents the domain objects you protect, such as Invoice, Project, Organization, etc.
interface Invoice {
id: string;
orgId: string;
status: 'draft' | 'paid';
}
interface Resources {
invoice: Invoice;
}Each resource type is referenced by a string key (e.g. 'invoice').
Policies answer "Who can perform which action on which resource, under which conditions?"
You express policies with a fluent, type-safe DSL:
engine
.forResource('invoice')
.can('read')
.when((ctx, invoice) => !!ctx.user && ctx.user.orgId === invoice.orgId)
.because('User belongs to same organization as the invoice')
.can('delete')
.when((ctx, invoice) => !!ctx.user && ctx.user.roles.includes('admin'))
.because('Admins can delete invoices in their org')
.cannot('delete')
.when((_ctx, invoice) => invoice.status === 'paid')
.because('Paid invoices cannot be deleted for compliance');At runtime, AegisAuth evaluates policies to produce a Decision:
interface Decision {
allowed: boolean;
reasons: string[];
matchedRuleIds: string[];
}allowedβ final yes/no answerreasonsβ human-readable explanationsmatchedRuleIdsβ internal rule IDs (helpful for debugging and audits)
AegisAuth uses deny-overrides semantics: any matching cannot rule will deny access, even if a can rule also matches.
Each rule can carry structured metadata for analysis:
engine
.forResource('invoice')
.can('delete')
.when((ctx, invoice) =>
!!ctx.user &&
ctx.user.roles.includes('admin') &&
ctx.user.orgId === invoice.orgId
)
.because('Admins can delete invoices in their org', {
roles: ['admin'],
tags: ['invoice', 'delete'],
severity: 'high',
descriptionId: 'INV-DEL-001',
});This metadata drives the introspection APIs and lets you answer questions like:
- "What can
admindo in this system?" - "Which rules are high-severity and related to invoices?"
- Package name:
@aegis-runtime/aegisauth - Current version:
0.2.0 - npm registry: View on npm
- License: MIT
- Node.js: >=18.0.0
Install the package from npm:
npm install @aegis-runtime/aegisauthOr with yarn:
yarn add @aegis-runtime/aegisauthOr with pnpm:
pnpm add @aegis-runtime/aegisauthIf you use the Express adapter, also install Express:
npm install expressIf you use OpenTelemetry integration, also install:
npm install @opentelemetry/apiNote: AegisAuth is ESM-only and targets Node 18+.
expressand@opentelemetry/apiare peer dependencies; the library does not bundle them.
You start by creating a policy engine with your own Ctx and Resources types.
import { createPolicyEngine } from '@aegis-runtime/aegisauth';
interface User {
id: string;
orgId: string;
roles: string[];
}
interface Invoice {
id: string;
orgId: string;
status: 'draft' | 'paid';
}
type Ctx = { user: User | null };
interface Resources {
invoice: Invoice;
}
export const engine = createPolicyEngine<Ctx, Resources>();
engine
.forResource('invoice')
.can('read')
.when((ctx, invoice) => !!ctx.user && ctx.user.orgId === invoice.orgId)
.because('User belongs to same organization as the invoice', {
roles: ['user'],
tags: ['invoice', 'read'],
})
.can('delete')
.when((ctx, invoice) =>
!!ctx.user &&
ctx.user.roles.includes('admin') &&
ctx.user.orgId === invoice.orgId
)
.because('Admins can delete invoices in their org', {
roles: ['admin'],
tags: ['invoice', 'delete'],
severity: 'high',
})
.cannot('delete')
.when((_ctx, invoice) => invoice.status === 'paid')
.because('Paid invoices cannot be deleted for compliance', {
tags: ['invoice', 'delete', 'compliance'],
severity: 'high',
});createPolicyEngine<Ctx, Resources>()β create an engineengine.forResource('invoice')β start defining rules for a resource type.can(action)/.cannot(action)β define allow/deny rules for an action.when((ctx, resource) => boolean)β attach a condition (optional).because(description, meta?)β finalize the rule with a human-readable explanation and optional metadata
If you omit .when(...), the rule is treated as unconditional (always true).
AegisAuth exposes introspection APIs so you can treat authorization as data, not just behavior.
interface RuleMeta {
roles?: string[];
tags?: string[];
severity?: 'low' | 'medium' | 'high';
descriptionId?: string;
[key: string]: any;
}const rules = engine.listRules();
/*
[
{
id: 'invoice:read:1',
resourceType: 'invoice',
action: 'read',
effect: 'allow',
description: 'User belongs to same organization as the invoice',
meta: { roles: ['user'], tags: ['invoice', 'read'] }
},
...
]
*/const highRiskInvoiceRules = engine.findRules(
(r) =>
r.resourceType === 'invoice' &&
r.meta?.severity === 'high'
);const adminSummary = engine.summarizeByRole('admin');
/*
[
{
role: 'admin',
resourceType: 'invoice',
action: 'delete',
effect: 'allow',
description: 'Admins can delete invoices in their org'
},
...
]
*/Generate capability matrices for batches of resources:
const snapshot = engine.snapshotCapabilities({
ctx: { user: adminUser },
resources: {
invoice: [invoice1, invoice2, invoice3],
},
version: '1.0.0',
});
// snapshot.capabilities['invoice']['delete'] = [true, false, true]
// β invoice1: can delete, invoice2: cannot, invoice3: can deleteYou can expose this internally as a JSON endpoint, generate Markdown docs, or feed it into a dashboard.
You can call the engine directly (e.g. in services, background jobs, or tests):
import type { Decision } from '@aegis-runtime/aegisauth';
import { engine } from './policies';
const ctx: Ctx = {
user: { id: 'u1', orgId: 'org1', roles: ['admin'] },
};
const invoice: Invoice = {
id: 'inv1',
orgId: 'org1',
status: 'draft',
};
const decision: Decision = engine.decide({
resourceType: 'invoice',
action: 'delete',
ctx,
resource: invoice,
});
if (decision.allowed) {
// proceed
} else {
console.log('Denied because:', decision.reasons);
}Behavior:
- If any
cannotrule matches, the decision is denied. - Else if any
canrule matches, the decision is allowed. - If no rule matches, the decision is an implicit deny.
- If no rules exist for the given resource/action, AegisAuth returns a helpful reason message.
AegisAuth ships an Express middleware adapter that wires the engine into HTTP routes.
import { authorize } from '@aegis-runtime/aegisauth/express';
import { engine } from '../auth/policies';import express from 'express';
const router = express.Router();
router.delete(
'/:id',
authorize(engine, 'invoice', 'delete', async (req) => {
const user = req.user as User | null; // from your auth middleware
const invoice = await loadInvoiceFromDb(req.params.id);
return { ctx: { user }, resource: invoice };
}),
async (req, res) => {
const invoice = res.locals.resource as Invoice;
await deleteInvoice(invoice.id);
res.status(204).send();
}
);function authorize<
Ctx,
Resources extends Record<string, any>,
K extends keyof Resources & string
>(
engine: PolicyEngine<Ctx, Resources>,
resourceType: K,
action: string,
resolve: (req: Request) =>
| { ctx: Ctx; resource: Resources[K] }
| Promise<{ ctx: Ctx; resource: Resources[K] }>,
options?: AuthorizeOptions<Ctx, Resources, K>
): RequestHandler;At runtime:
-
resolve(req)is called to build{ ctx, resource }. -
engine.decide({ resourceType, action, ctx, resource })is executed. -
If denied:
- Default: responds with
403and{ error, reasons }JSON - Or, if
options.onDenyis provided, your custom handler is called
- Default: responds with
-
If allowed:
- Decision is attached to
res.locals[attachKey](default:'authDecision') - Resource is attached to
res.locals.resource(optional) next()is called
- Decision is attached to
import { authorize } from '@aegis-runtime/aegisauth/express';
router.post(
'/',
authorize(
engine,
'invoice',
'create',
async (req) => ({
ctx: { user: req.user as User | null },
resource: req.body as Invoice,
}),
{
onDeny: (req, res, decision) => {
res.status(401).json({
error: 'Not allowed to create invoices',
reasons: decision.reasons,
});
},
attachDecisionTo: 'locals', // or 'request'
attachKey: 'invoiceDecision', // res.locals.invoiceDecision
attachResource: true,
}
)
);AegisAuth provides built-in OpenTelemetry integration for production observability.
import { createPolicyEngineWithOtel } from '@aegis-runtime/aegisauth/otel';
import { Meter, Tracer } from '@opentelemetry/api';
// In your OpenTelemetry setup
const meter = /* your OpenTelemetry Meter */;
const tracer = /* your OpenTelemetry Tracer */;
const engine = createPolicyEngineWithOtel({
meter,
tracer, // optional
defaultAttributes: {
'service.name': 'invoice-service',
'service.version': '1.0.0',
},
});
// Use the engine normally - metrics and traces are emitted automatically
engine.forResource('invoice')
.can('read')
.because('User can read invoices');aegisauth_decisions_total(Counter): Total number of authorization decisions- Attributes:
aegisauth.resource_type,aegisauth.action,aegisauth.allowed
- Attributes:
aegisauth_decision_duration_ms(Histogram): Latency of authorization decisions- Unit: milliseconds
- Attributes: Same as counter
- Span name:
aegisauth.decision - Attributes:
aegisauth.resource_typeaegisauth.actionaegisauth.allowedaegisauth.reasons(joined with|)aegisauth.matched_rule_ids(joined with,)- Plus any
defaultAttributesyou provided
This enables monitoring authorization decisions in production, alerting on policy violations, and debugging authorization issues.
Shadow testing allows you to evaluate a candidate policy engine alongside your production engine without affecting user requests. This is invaluable for:
- Testing new policies before deployment
- Validating policy migrations
- A/B testing authorization rules
- Detecting policy regressions
import { createShadowEngine } from '@aegis-runtime/aegisauth/shadow';
import { authorizeWithShadow } from '@aegis-runtime/aegisauth/express';
// Your current production engine
const currentEngine = createPolicyEngine<Ctx, Resources>();
// ... define current policies
// Your candidate engine with new/changed policies
const candidateEngine = createPolicyEngine<Ctx, Resources>();
// ... define candidate policies
const shadowEngine = createShadowEngine({
current: currentEngine,
candidate: candidateEngine,
onDivergence: (info) => {
// Log or alert when decisions diverge
console.warn('Policy divergence detected:', {
resourceType: info.resourceType,
action: info.action,
current: info.current.allowed,
candidate: info.candidate.allowed,
currentReasons: info.current.reasons,
candidateReasons: info.candidate.reasons,
});
// Send to monitoring/alerting system
// metrics.recordDivergence(info);
},
});
// Use shadow engine in routes - current engine's decision is enforced,
// but candidate is evaluated in parallel
router.delete(
'/:id',
authorizeWithShadow(shadowEngine, 'invoice', 'delete', async (req) => {
const user = req.user as User | null;
const invoice = await loadInvoiceFromDb(req.params.id);
return { ctx: { user }, resource: invoice };
}),
async (req, res) => {
// ... handler
}
);- Current engine's decision is enforced: Users experience the behavior of your current policies
- Candidate engine is evaluated in parallel: The candidate engine's decision is computed but not used
- Divergences are reported: If decisions differ,
onDivergenceis called with both decisions - Zero user impact: Even if the candidate engine would deny access, the user's request proceeds based on the current engine
A divergence is detected when:
currentDecision.allowed !== candidateDecision.allowed, OR- The matched rule IDs differ, OR
- The reasons differ
This allows you to catch subtle policy changes that might affect authorization behavior.
You can also use the onDecision hook on individual engines to capture timing and metrics:
const engine = createPolicyEngine({
onDecision: (info) => {
console.log(`Decision took ${info.elapsedMs}ms`);
// Custom metrics, logging, etc.
},
});The CLI scans your TypeScript source files for Express routes and reports which ones use authorize(...).
From your project root (where your routes live):
npx @aegis-runtime/aegisauth report srcExample output:
Scanning routes under: /path/to/project/src
AegisAuth route authorization report
------------------------------------
[ OK ] GET /invoices/:id src/routes/invoices.ts:10
[ OK ] DELETE /invoices/:id src/routes/invoices.ts:30
[ !! ] POST /invoices src/routes/invoices.ts:45
Summary:
Total routes: 3
Protected (authorize): 2
Missing authorize: 1
[ OK ]β route has at least one handler usingauthorize(...)[ !! ]β route has noauthorize(...)handler detected
If any route is missing authorization, the CLI returns exit code 1.
This is ideal for CI:
# GitHub Actions example
- name: AegisAuth route report
run: npx @aegis-runtime/aegisauth report srcYou can also get machine-readable JSON:
npx @aegis-runtime/aegisauth report src --json > aegisauth-report.jsonExample JSON shape:
{
"root": "/absolute/path/to/src",
"routes": [
{
"method": "GET",
"path": "/invoices/:id",
"file": "/absolute/path/to/src/routes/invoices.ts",
"line": 10,
"authorized": true
},
{
"method": "POST",
"path": "/invoices",
"file": "/absolute/path/to/src/routes/invoices.ts",
"line": 45,
"authorized": false
}
],
"summary": {
"total": 3,
"protected": 2,
"missing": 1
}
}Compare two reports to detect regressions:
npx @aegis-runtime/aegisauth report src --json > report.old.json
# ... make changes ...
npx @aegis-runtime/aegisauth report src --json > report.new.json
npx @aegis-runtime/aegisauth diff report.old.json report.new.jsonOutput shows routes that gained or lost authorization protection.
You can:
- Upload this as a CI artifact
- Feed it into a dashboard
- Diff it between branches to see how coverage changes
The CLI currently supports Express-style route definitions:
app.get('/path', handler);
router.post('/path', handler1, handler2);It looks for imports like:
import { authorize } from '@aegis-runtime/aegisauth/express';
import { authorize as aegisAuthorize } from '@aegis-runtime/aegisauth/express';If any handler argument in the route call uses one of these authorize identifiers,
that route is considered protected.
The CLI does not execute your code; it performs static analysis using the TypeScript compiler API.
Get started with AegisAuth in minutes:
# 1. Install the package
npm install @aegis-runtime/aegisauth express
# 2. Create your policy engine// auth/policies.ts
import { createPolicyEngine } from '@aegis-runtime/aegisauth';
type Ctx = { user: { id: string; orgId: string; roles: string[] } | null };
interface Resources {
invoice: { id: string; orgId: string; status: string };
}
export const engine = createPolicyEngine<Ctx, Resources>();
engine
.forResource('invoice')
.can('read')
.when((ctx, invoice) => !!ctx.user && ctx.user.orgId === invoice.orgId)
.because('User belongs to same organization');// routes/invoices.ts
import { authorize } from '@aegis-runtime/aegisauth/express';
import { engine } from '../auth/policies';
router.get(
'/:id',
authorize(engine, 'invoice', 'read', async (req) => ({
ctx: { user: req.user },
resource: await loadInvoice(req.params.id),
})),
(req, res) => res.json(res.locals.resource)
);For more detailed examples, see the Defining Policies and Using the Express Adapter sections.
A typical usage structure might look like:
src/
auth/
policies.ts # all AegisAuth policies live here
routes/
invoices.ts # Express routes using authorize(engine, ...)
db/
invoices.ts # DB accessors
server.ts # app bootstrap
-
auth/policies.ts- Defines
Ctx,Resources, and all rules - Is the single source of truth for authorization
- Defines
-
routes/*.ts- Import
authorizefrom@aegis-runtime/aegisauth/express - Wire policies into HTTP handlers via middleware
- Import
-
CI config
- Runs
npx @aegis-runtime/aegisauth report src(optionally with--json) to ensure all routes are protected
- Runs
AegisAuth adopts a simple yet robust model:
- If any deny rule (
cannot) matches, the final decision is deny. - Otherwise, if any allow rule (
can) matches, the final decision is allow. - If no rule matches, the result is an implicit deny with a clear reason.
This mirrors common practices in secure systems (deny is sticky and safer).
The engine is generic over Ctx and Resources, so:
- You get full TypeScript type-checking inside
when((ctx, resource) => ...) - Mistyped fields on your
resourceorctxare caught at compile time - You define policies in terms of your actual domain types
The core engine:
- Has no external runtime dependencies
- Does not require a database, cache, or message queue
- Is pure, deterministic logic β easy to test and reason about
The CLI is a separate concern, built on top of the TypeScript compiler API.
The included adapter targets Express because it's widely used, but the engine itself is framework-agnostic:
- You can use
engine.decide(...)in Nest, Fastify, RPC handlers, cron jobs, etc. - Thin adapters for other frameworks can be built easily.
- Rule lookup: O(1) by resource type and action (Map-based storage)
- Decision evaluation: O(n) where n is the number of rules for the resource/action pair
- Memory: O(r) where r is the total number of rules
- No I/O: All decisions are synchronous and in-memory
Typical authorization decisions complete in microseconds, making AegisAuth suitable for high-throughput applications.
Planned and potential enhancements:
-
Policy testing helpers Utilities for writing focused unit tests and fixtures for complex policies.
-
More framework adapters Fastify, NestJS decorators, RPC middleware, etc.
-
Richer analysis & reporting
- Mapping routes to specific
(resource, action)pairs - HTML/Markdown report generation
- Mapping routes to specific
-
Policy Explorer UI A small React app that ingests
@aegis-runtime/aegisauth report --json+engine.listRules()and renders a role/action matrix. -
IDE integration VS Code extension to visualize which policies apply to a given route or role.
-
Policy versioning Built-in support for policy versioning and migration strategies.
-
Rule composition Higher-level abstractions for common patterns (RBAC, ABAC, etc.).
If you have use cases or ideas, feel free to open an issue or PR.
MIT. Use it freely in commercial and open-source projects.
- π¦ npm package
- π Issue tracker
- π GitHub repository
- π Changelog (coming soon)
Published and maintained by @aegis-runtime