@aegis-runtime/aegisauth
v0.2.0
Published
Type-safe, explainable policy-as-code authorization engine with static route analysis for Node/TypeScript.
Downloads
69
Maintainers
Readme
AegisAuth
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.
- ✅ 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
Table of Contents
- 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
Architecture
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) │
└─────────────────────────────────────────────────────────────┘Key Architectural Principles
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 behaviorsPerformance: 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
Module Structure
@aegis-runtime/aegisauth
├── core (default export)
│ └── PolicyEngine, createPolicyEngine, Decision, RuleMeta
├── /express
│ └── authorize, authorizeWithShadow, AuthorizeOptions
├── /shadow
│ └── createShadowEngine, ShadowEngine, ShadowDivergenceInfo
└── /otel
└── createPolicyEngineWithOtel, AegisAuthOtelOptionsTech Stack
Core Technologies
- 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
Build & Testing
- TypeScript Compiler (tsc): Type checking and compilation
- Vitest: Fast, modern testing framework
- ESM (ES Modules): Native module system support
Runtime Dependencies
- 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
Developer Experience
- 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
Observability & Monitoring
- OpenTelemetry: Standard observability protocol
- Metrics: Decision counts, latency histograms
- Traces: Spans for each authorization decision
- Attributes: Resource type, action, outcome, reasons
Motivation
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.
Core Concepts
AegisAuth revolves around four core ideas:
1. Context (Ctx)
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.
2. Resources (Resources)
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').
3. Policies
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.
4. Rule Metadata & Policy Intelligence
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?"
Installation
Install from npm:
npm install @aegis-runtime/aegisauthIf you use the Express adapter, also install Express:
npm install expressIf you use OpenTelemetry integration, also install:
npm install @opentelemetry/apiAegisAuth is ESM-only and targets Node 18+.
expressand@opentelemetry/apiare peer dependencies; the library does not bundle them.
Defining Policies
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',
});API Overview
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).
Policy Intelligence & Introspection
AegisAuth exposes introspection APIs so you can treat authorization as data, not just behavior.
Rule Metadata Type
interface RuleMeta {
roles?: string[];
tags?: string[];
severity?: 'low' | 'medium' | 'high';
descriptionId?: string;
[key: string]: any;
}Listing All Rules
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'] }
},
...
]
*/Finding Rules by Predicate
const highRiskInvoiceRules = engine.findRules(
(r) =>
r.resourceType === 'invoice' &&
r.meta?.severity === 'high'
);Summarizing by Role
const adminSummary = engine.summarizeByRole('admin');
/*
[
{
role: 'admin',
resourceType: 'invoice',
action: 'delete',
effect: 'allow',
description: 'Admins can delete invoices in their org'
},
...
]
*/Capability Snapshots
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.
Making Decisions Manually
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.
Using the Express Adapter
AegisAuth ships an Express middleware adapter that wires the engine into HTTP routes.
1. Import the adapter
import { authorize } from '@aegis-runtime/aegisauth/express';
import { engine } from '../auth/policies';2. Protect a route
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();
}
);3. Middleware signature
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
4. Customizing behavior
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,
}
)
);Advanced Features
OpenTelemetry Integration
AegisAuth provides built-in OpenTelemetry integration for production observability.
Setup
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.allowed
- Attributes:
aegisauth_decision_duration_ms(Histogram): Latency of authorization decisions- Unit: milliseconds
- Attributes: Same as counter
Traces Emitted (if tracer provided)
- 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
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
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
}
);How It Works
- 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
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:
const engine = createPolicyEngine({
onDecision: (info) => {
console.log(`Decision took ${info.elapsedMs}ms`);
// Custom metrics, logging, etc.
},
});CLI: Route Authorization Report
The CLI scans your TypeScript source files for Express routes and reports which ones use authorize(...).
1. Human-readable report
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 src2. JSON mode for tooling / dashboards
You 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
}
}3. Diff Reports
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
4. What the CLI recognizes
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.
Examples & Project Structure
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 bootstrapauth/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
Design Notes
1. Deny-overrides semantics
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).
2. Type-safe DSL
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
3. No runtime dependencies in the core
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.
4. Express-first, framework-agnostic
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.
5. Performance Characteristics
- 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.
Roadmap
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.
License
MIT. Use it freely in commercial and open-source projects.
