aegis-guard
v0.3.0
Published
Dynamic, database driven RBAC/ABAC for Next.js App Router. Shield your Server Components at runtime.
Maintainers
Readme
aegis-guard
Dynamic, database driven RBAC/ABAC for Next.js App Router. Shield your Server Components at runtime.
- Server first: gates content in React Server Components before it reaches the client
- Database agnostic: bring your own adapter (Prisma, Drizzle, raw SQL, anything)
- Auto discovery:
<Shield>registers resources in your database during development - Admin dashboard: built in UI for managing permissions (headless, unstyled, or styled)
Install
npm install aegis-guardPeer dependencies: next >= 16, react >= 18, react-dom >= 18
Quick Start
1. Implement an adapter
// lib/aegis-adapter.ts
import type { AegisAdapter } from "aegis-guard/core";
export const adapter: AegisAdapter = {
getUserRoles: async (ctx) => {
// Query your database for the user's roles
return db.getUserRoles(ctx.userId);
},
getAllowedRoles: async (resourceKey) => {
return db.getResourceRoles(resourceKey);
},
getAllResources: async () => {
return db.getAllResources();
},
setResourcePermissions: async (resourceKey, roles) => {
await db.updateResourceRoles(resourceKey, roles);
},
upsertResource: async (resourceKey, description) => {
await db.upsertResource(resourceKey, description);
},
};2. Initialize Aegis
Use Next.js instrumentation to initialize once at server startup:
// instrumentation.ts
export async function register() {
const { initAegis } = await import("aegis-guard/core");
const { adapter } = await import("./lib/aegis-adapter");
initAegis({
adapter,
getSecurityContext: async () => {
const { auth } = await import("./lib/auth"); // your auth library
const session = await auth();
return { userId: session?.userId ?? null };
},
});
}3. Protect pages with Shield
// app/admin/page.tsx
import { Shield } from "aegis-guard/server";
export default function AdminPage() {
return (
<Shield
resource="admin.page"
description="Main admin page"
fallback={<p>Access denied.</p>}
>
<h1>Welcome, admin</h1>
</Shield>
);
}In development, <Shield> automatically calls adapter.upsertResource() to register the resource in your database.
4. Protect API routes and middleware
// app/api/secret/route.ts
import { checkPermission } from "aegis-guard/server";
export async function GET() {
const allowed = await checkPermission("api.secret");
if (!allowed) {
return Response.json({ error: "Forbidden" }, { status: 403 });
}
return Response.json({ data: "secret" });
}5. Add the admin API route
// app/api/aegis/route.ts
import { createAegisHandler } from "aegis-guard/server";
export const { GET, POST } = createAegisHandler();6. Add the permissions dashboard
// app/admin/permissions/page.tsx
import { AegisDashboardStyled } from "aegis-guard/client";
export default function PermissionsPage() {
return <AegisDashboardStyled roles={["admin", "editor", "viewer"]} />;
}7. Gate routes with middleware (optional)
createAegisMiddleware maps path patterns to resources and gates them before the request reaches your pages.
// middleware.ts
import { createAegisMiddleware } from "aegis-guard/server";
export const middleware = createAegisMiddleware({
rules: [
{ matcher: "/admin/*", resource: "admin.area" },
{ matcher: "/billing", resource: "billing.view" },
{ matcher: /^\/reports\/\d+$/, resource: "reports.view" },
],
redirectTo: "/login", // omit to return 403, or pass `onDenied` for full control
});
export const config = {
matcher: ["/admin/:path*", "/billing", "/reports/:id"],
};Matchers can be an exact path ("/billing"), a prefix with a trailing /* ("/admin/*"), or a RegExp. The first matching rule wins; unmatched paths pass through.
Runtime note: Next.js middleware runs on the Edge runtime by default, where the config set by
initAegis()ininstrumentation.ts(Node runtime) is not visible. To use this helper, either opt your middleware into the Node.js runtime, or callinitAegis()with an Edge compatible adapter inside the middleware module.
API Reference
aegis-guard/core
| Export | Description |
| --------------------------------- | ----------------------------------------------------------------- |
| initAegis(config) | Initialize the library with your adapter and auth context |
| createGuard(config) | Build a portable AegisGuard. The base for any framework binding |
| evaluatePermission(r,c,d) | Pure decision engine. Explicit context and deps, no globals |
| AegisAdapter | Interface your database adapter must implement |
| AegisConfig | Configuration object type |
| AegisContext | User context type ({ userId, ...custom }) |
| AegisResource | Resource type ({ key, description? }) |
| AegisGuard / BoundGuard | The universal guard surface and its per-request binder |
| AegisDecision | Explainable result (allowed, reason, matched/missing roles) |
| GuardInput | Context input union (omit / { request } / AegisContext) |
| Validator<T> | (data: unknown) => T. Runtime validator type (Zod, Valibot, ...) |
| createValidatedAdapter | Wrap an adapter so its returned data is validated |
| AegisError / AegisDeniedError | Typed config error and denial error (.decision) |
| AegisValidationError | Thrown when a validator rejects (.source, .cause) |
aegis-guard/server
| Export | Description |
| -------------------------------- | ------------------------------------------------------------ |
| <Shield> | Async Server Component that gates content by permission |
| checkPermission(resource) | Returns boolean. Use in API routes, middleware, tRPC |
| decidePermission(resource) | Returns the full AegisDecision (allowed, reason, roles) |
| requirePermission(resource) | Returns the decision, or throws AegisDeniedError on deny |
| createGuard(config) | Re-exported from core for request-scoped or custom guards |
| createAegisHandler() | Returns { GET, POST } handlers for the admin API |
| createAegisMiddleware(options) | Returns a Next.js middleware that gates routes by permission |
aegis-guard/client
| Export | Description |
| -------------------------------- | --------------------------------------------------------- |
| useAegisResources(options?) | Hook to fetch resources with their roles |
| useUpdatePermissions(options?) | Hook to update resource permissions |
| <AegisDashboard> | Unstyled dashboard with semantic HTML. Bring your own CSS |
| <AegisDashboardStyled> | Tailwind styled dashboard, ready to use |
Adapter Interface
interface AegisAdapter {
getUserRoles(ctx: AegisContext): Promise<string[]>;
getAllowedRoles(resourceKey: string): Promise<string[]>;
getAllResources(): Promise<AegisResource[]>;
setResourcePermissions(resourceKey: string, roles: string[]): Promise<void>;
upsertResource(resourceKey: string, description?: string): Promise<void>;
}Universal guard (framework agnostic)
checkPermission, decidePermission, and requirePermission are convenience wrappers over a global guard built from initAegis. They suit ambient frameworks like Next.js, where the request is available through cookies() and headers() with no argument.
For request scoped frameworks (Astro, SvelteKit, Express, Hono, Cloudflare Workers, tRPC), build a guard with createGuard and pass the request per call. The same AegisAdapter works unchanged across every framework.
import { createGuard } from "aegis-guard/core";
const guard = createGuard({
adapter,
// A source aware resolver: derive the context from the passed request.
resolve: async (request) => ({ userId: await getUserId(request) }),
});
// Three ways to supply context to any guard method:
await guard.check("admin.page"); // ambient: uses resolve()
await guard.check("admin.page", { request }); // request scoped
await guard.check("admin.page", { userId: "u_123" }); // pre resolved context
// Bind a request once, then call with no extra argument:
const bound = guard.forRequest(request);
await bound.check("admin.page");
// Throw on denial (idiomatic for middleware and loaders):
import { AegisDeniedError } from "aegis-guard/core";
try {
await guard.require("admin.page", { request });
} catch (error) {
if (error instanceof AegisDeniedError) {
// error.decision.reason: "no-matching-role" | "closed-by-default" | ...
}
}Every method can return an explainable AegisDecision (via decide / decideMany):
const decision = await guard.decide("admin.page", { request });
// {
// allowed: false,
// resource: "admin.page",
// reason: "no-matching-role",
// userId: "u_123",
// allowedRoles: ["admin"],
// userRoles: ["editor"],
// matchedRoles: [], // why allowed
// missingRoles: ["admin"], // why denied
// }The pure engine (evaluatePermission(resource, context, { adapter })) takes the context and adapter explicitly, touches no globals, and runs on any runtime. Framework bindings are thin layers over createGuard and this engine.
Runtime validation (optional)
The adapter returns data from a database that aegis-guard does not control. You can validate that data at runtime with any validation library, or none. A validator is just a function (data: unknown) => T that returns the value or throws. Register validators on initAegis (or createGuard):
import { z } from "zod"; // your dependency, not aegis-guard's
initAegis({
adapter,
getSecurityContext,
validators: {
context: (d) => z.object({ userId: z.string().nullable() }).strict().parse(d),
roles: (d) => z.array(z.string()).parse(d), // getUserRoles + getAllowedRoles
resource: (d) => z.object({ key: z.string(), description: z.string().optional() }).parse(d),
body: (d) => z.object({ resource: z.string().min(1), roles: z.array(z.string()) }).parse(d),
},
});The same shape works with other libraries, with no aegis-guard dependency on them:
// Valibot
roles: (d) => v.parse(v.array(v.string()), d),
// ArkType
roles: type("string[]").assert,
// Hand written
roles: (d) => {
if (!Array.isArray(d) || d.some((x) => typeof x !== "string")) {
throw new TypeError("roles must be string[]");
}
return d as string[];
},Validation fails closed. If a validator throws, the call rejects with AegisValidationError (extends AegisError, code VALIDATION_FAILED) before any decision is scored, so a check can never silently grant access on bad data. A denied request still throws AegisDeniedError, so you can tell a real denial (map to 403) apart from corrupt data (map to 500):
import { AegisDeniedError, AegisValidationError } from "aegis-guard/server";
try {
await requirePermission("admin.page");
} catch (error) {
if (error instanceof AegisDeniedError) return new Response("Forbidden", { status: 403 });
if (error instanceof AegisValidationError) return new Response("Server error", { status: 500 });
throw error;
}Notes:
- Validators are opt in. With none configured, behavior is identical to before and adds no overhead.
- Adapter validators must only assert, never transform. A validator that adds roles or claims would widen access. Use strict schemas for
contextso unexpected fields are rejected. - Validators are synchronous.
Dashboard Props
Both <AegisDashboard> and <AegisDashboardStyled> accept:
| Prop | Type | Default | Description |
| ----------- | ---------- | --------------------- | ------------------------------ |
| roles | string[] | required | All possible roles in your app |
| apiBase | string | "/api/aegis" | Base path for the admin API |
| className | string | | CSS class for the root element |
| title | string | "Aegis Permissions" | Dashboard heading |
Hook Options
Both hooks accept { apiBase?: string } (default "/api/aegis").
Tailwind Setup
If using <AegisDashboardStyled>, ensure Tailwind scans the library's classes:
Tailwind v4 add to your CSS:
@source "../../node_modules/aegis-guard/dist";Tailwind v3 add to tailwind.config.js:
content: [
"./node_modules/aegis-guard/dist/**/*.{js,mjs}",
// ...your other content paths
];Examples
See the apps/playground/ directory for a complete working demo with an in memory adapter, protected pages, role switching, and the permissions dashboard.
See examples/ for adapter implementations:
prisma-adapter.tsPrisma ORM adapterdrizzle-adapter.tsDrizzle ORM adapter (PostgreSQL)
License
MIT
