Type-safe, explainable policy-as-code authorization engine with static route analysis for Node/TypeScript.
npm install @aegis-runtime/aegisauthType-safe, explainable policy-as-code authorization for TypeScript/Node, with a CLI that scans your routes and tells you which ones are missing authorization.
- ✅ Centralized policies instead of scattered if (user.role === 'admin') checks
- ✅ Explainable decisions: every allow/deny comes with human-readable reasons
- ✅ Type-safe DSL for ctx and resource objects
- ✅ 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 without authorize()
- ✅ 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
---
1. Architecture
2. Tech Stack
3. Motivation
4. Core Concepts
5. Installation
6. Defining Policies
7. Policy Intelligence & Introspection
8. Making Decisions Manually
9. Using the Express Adapter
10. Advanced Features
- OpenTelemetry Integration
- Shadow Testing
11. CLI: Route Authorization Report
12. Examples & Project Structure
13. Design Notes
14. Roadmap
15. 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
│ │ Decision Logic (deny-overrides semantics) │ │
│ │ Introspection APIs (listRules, findRules, etc.) │ │
│ │ Capability Snapshots │ │
│ └──────────────────────────────────────────────────────┘ │
└───────────────────────┬─────────────────────────────────────┘
│
│ Type-safe DSL
│
┌───────────────────────▼─────────────────────────────────────┐
│ Policy Definitions │
│ (Your domain-specific Ctx, Resources, and Rules) │
└─────────────────────────────────────────────────────────────┘
1. Separation of Concerns: The core engine is framework-agnostic; adapters bridge to specific frameworks (Express, OpenTelemetry, etc.)
2. Type Safety: Full TypeScript generics ensure compile-time type checking for contexts, resources, and actions
3. Extensibility: Hook-based architecture (onDecision, onDivergence) enables observability and custom behaviors
4. Performance: In-memory rule evaluation with O(1) lookups by resource type and action
5. 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:
1. A central policy engine where you define all your rules as code
2. A type-safe DSL for expressing permissions with context and resources
3. Explainable decisions, with reasons returned for each allow/deny
4. An Express adapter to protect routes via middleware
5. A CLI that statically scans your code for routes that lack authorization
6. A policy intelligence layer to introspect and summarize your rules
7. OpenTelemetry integration for production observability
8. 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.
`ts`
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.
`ts
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:
`ts`
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:
`ts`
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:
`ts`
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 admin do in this system?"
* "Which rules are high-severity and related to invoices?"
---
Install from npm:
`bash`
npm install @aegis-runtime/aegisauth
If you use the Express adapter, also install Express:
`bash`
npm install express
If you use OpenTelemetry integration, also install:
`bash`
npm install @opentelemetry/api
> AegisAuth is ESM-only and targets Node 18+.
> express and @opentelemetry/api are peer dependencies; the library does not bundle them.
---
You start by creating a policy engine with your own Ctx and Resources types.
`ts
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
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 – 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.
`ts`
interface RuleMeta {
roles?: string[];
tags?: string[];
severity?: 'low' | 'medium' | 'high';
descriptionId?: string;
[key: string]: any;
}
`ts`
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'] }
},
...
]
*/
`ts`
const highRiskInvoiceRules = engine.findRules(
(r) =>
r.resourceType === 'invoice' &&
r.meta?.severity === 'high'
);
`ts
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:
`ts
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 delete
`
You 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):
`ts
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 cannot rule matches, the decision is denied.
* Else if any can rule 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.
`ts`
import { authorize } from '@aegis-runtime/aegisauth/express';
import { engine } from '../auth/policies';
`ts
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();
}
);
`
`ts`
function authorize<
Ctx,
Resources extends Record
K extends keyof Resources & string
>(
engine: PolicyEngine
resourceType: K,
action: string,
resolve: (req: Request) =>
| { ctx: Ctx; resource: Resources[K] }
| Promise<{ ctx: Ctx; resource: Resources[K] }>,
options?: AuthorizeOptions
): RequestHandler;
At runtime:
1. resolve(req) is called to build { ctx, resource }.engine.decide({ resourceType, action, ctx, resource })
2. is executed.
3. If denied:
* Default: responds with 403 and { error, reasons } JSONoptions.onDeny
* Or, if is provided, your custom handler is called
4. If allowed:
* Decision is attached to res.locals[attachKey] (default: 'authDecision')res.locals.resource
* Resource is attached to (optional)next()
* is called
`ts
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.
#### Setup
`ts
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');
`
#### Metrics Emitted
- aegisauth_decisions_total (Counter): Total number of authorization decisions
- Attributes: aegisauth.resource_type, aegisauth.action, aegisauth.allowedaegisauth_decision_duration_ms
- (Histogram): Latency of authorization decisions
- Unit: milliseconds
- Attributes: Same as counter
#### Traces Emitted (if tracer provided)
- Span name: aegisauth.decisionaegisauth.resource_type
- Attributes:
- aegisauth.action
- aegisauth.allowed
- aegisauth.reasons
- (joined with | )aegisauth.matched_rule_ids
- (joined with ,)defaultAttributes
- Plus any you 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
#### Setup
`ts
import { createShadowEngine } from '@aegis-runtime/aegisauth/shadow';
import { authorizeWithShadow } from '@aegis-runtime/aegisauth/express';
// Your current production engine
const currentEngine = createPolicyEngine
// ... define current policies
// Your candidate engine with new/changed policies
const candidateEngine = createPolicyEngine
// ... 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
}
);
`
#### How It Works
1. Current engine's decision is enforced: Users experience the behavior of your current policies
2. Candidate engine is evaluated in parallel: The candidate engine's decision is computed but not used
3. Divergences are reported: If decisions differ, onDivergence is called with both decisions
4. Zero user impact: Even if the candidate engine would deny access, the user's request proceeds based on the current engine
#### Divergence Detection
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.
#### Decision Hooks
You can also use the onDecision hook on individual engines to capture timing and metrics:
`tsDecision took ${info.elapsedMs}ms
const engine = createPolicyEngine({
onDecision: (info) => {
console.log();`
// 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):
`bash`
npx @aegis-runtime/aegisauth report src
Example output:
`text
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 using authorize(...)[ !! ]
* – route has no authorize(...) handler detected
If any route is missing authorization, the CLI returns exit code 1.
This is ideal for CI:
`yaml`GitHub Actions example
- name: AegisAuth route report
run: npx @aegis-runtime/aegisauth report src
You can also get machine-readable JSON:
`bash`
npx @aegis-runtime/aegisauth report src --json > aegisauth-report.json
Example JSON shape:
`json`
{
"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:
`bash`
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.json
Output 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:
`ts`
app.get('/path', handler);
router.post('/path', handler1, handler2);
It looks for imports like:
`ts`
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.
---
A typical usage structure might look like:
`text`
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
routes/.ts
* Import authorize from @aegis-runtime/aegisauth/express
* Wire policies into HTTP handlers via middleware
* CI config
* Runs npx @aegis-runtime/aegisauth report src (optionally with --json) to ensure all routes are protected
---
AegisAuth adopts a simple yet robust model:
* If any deny rule (cannot) matches, the final decision is deny.can
* Otherwise, if any allow rule () 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) => ...)resource
* Mistyped fields on your or ctx are 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
* 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.