Category:Library

Asgardian

A type-safe authorization library for TypeScript with a chainable API, advanced operators, and an optional React integration.

Author

9 min. read

Post image
Asgardian

Introduction

Authorization is one of those parts of an application that often starts simple and becomes complicated over time. While authentication answers the question "Who are you?", authorization tackles the more nuanced question: "What are you allowed to do?"

After implementing authorization systems across multiple projects, I found myself repeatedly solving the same problems with slightly different implementations. Most existing libraries were either too simple for complex permission scenarios or overly complex for straightforward use cases. This led me to create Asgardian, a flexible TypeScript authorization library designed to scale with your application's needs while remaining intuitive to use.

Available now!

Check it out right here: asgardian.oesterkilde.dk.


Or simply install it: npm i @nordic-ui/asgardian

The core model

Asgardian builds every permission check around three things: an action (what you want to do), a resource (what you want to do it to), and an optional condition (when the action is allowed). You define rules upfront, then query them anywhere in your application.

The core API is small by design:

  • can() and cannot() for defining rules
  • isAllowed() and notAllowed() for checking permissions
  • getReason() for retrieving denial explanations
  • throwIfNotAllowed() for enforcing permissions with exceptions

Rules are evaluated in the order they are defined. The last matching rule wins, so defining the least-permissive rules first and the most-permissive rules last gives you a clean override pattern.

Role-based access control

For simpler applications, Asgardian provides a straightforward way to implement role-based access control:

import { createAbility } from '@nordic-ui/asgardian';
 
type User = {
  id: number;
  name: string;
  roles: ('user' | 'admin')[];
};
 
type Post = {
  id: number;
  authorId: number;
  status: 'draft' | 'published';
};
 
export const permission = (user: User) => {
  const ability = createAbility<never, 'Post'>();
 
  // Note: The order in which permissions are defined is important
  // Least permissions should come first
 
  // Strangers can only read published posts
  ability.can('read', 'Post', { status: 'published' });
 
  // Signed-in users can read their draft posts, create posts and update/delete their own posts
  if (user.roles.includes('user')) {
    ability
      .can('read', 'Post', { authorId: user.id, status: 'draft' })
      .can('create', 'Post')
      .can(['update', 'delete'], 'Post', { authorId: user.id });
  }
 
  // Admin users can perform any action no matter what
  if (user.roles.includes('admin')) {
    ability.can('manage', 'all');
  }
 
  return ability;
};

This approach makes it easy to assign permissions based on user roles, which is sufficient for many applications. However, as your application grows, you may need more fine-grained control.

Conditions and operators

For more complex scenarios, Asgardian provides powerful operators for sophisticated permission rules:

import { createAbility } from '@nordic-ui/asgardian';
 
const ability = createAbility();
 
// Define permissions with operators
ability
  .can('read', 'Post', { published: true }) // Simple object condition
  .can('update', 'Post', {
    $or: [{ authorId: userId }, { 'editor.id': userId }],
  })
  .can('delete', 'Post', {
    $and: [
      { authorId: userId },
      { createdAt: { $gt: Date.now() - 86_400_000 } }, // Less than 24 hours old
    ],
  })
  .can('manage', 'Premium', {
    'subscription.level': { $in: ['pro', 'enterprise'] },
  });
 
// Check permissions
const post = {
  id: 1,
  authorId: 123,
  published: true,
  createdAt: Date.now() - 3_600_000, // 1 hour ago
  editor: { id: 456 },
};
 
console.log(ability.isAllowed('read', 'Post', post)); // true (published)
console.log(ability.isAllowed('update', 'Post', { ...post, authorId: userId })); // true (author)
console.log(ability.isAllowed('delete', 'Post', { ...post, authorId: userId })); // true (author and recent)

Available operators include:

  • Logical: $and, $or, $not
  • Comparison: $eq, $ne, $gt, $gte, $lt, $lte, $between
  • Array: $in, $nin
  • String: $contains, $startsWith, $endsWith, $regex

Error handling and denial reasons

Asgardian provides comprehensive error handling features:

import { createAbility } from '@nordic-ui/asgardian';
 
const ability = createAbility();
 
// Define permissions with denial reasons
ability
  .can('read', 'Post', { published: true })
  .cannot('delete', 'Post')
  .reason('Only administrators can delete posts')
  .cannot('update', 'Post', { locked: true })
  .reason('This post is locked and cannot be edited');
 
// Check permissions and get reasons
const lockedPost = { id: 1, locked: true, published: true };
 
if (!ability.isAllowed('update', 'Post', lockedPost)) {
  const reason = ability.getReason('update', 'Post', lockedPost);
  console.log(reason); // "This post is locked and cannot be edited"
}
 
// Throw errors for unauthorized actions
try {
  ability.throwIfNotAllowed('delete', 'Post');
} catch (error) {
  console.log(error.message); // ForbiddenError with the denial reason
}

Type safety

One of Asgardian's key advantages is its strong TypeScript support. You can define your actions and resources as types to ensure compile-time safety:

import { createAbility } from '@nordic-ui/asgardian';
 
// Define your actions and resources as string literals
type Action = 'create' | 'read' | 'update' | 'delete' | 'manage';
type Resource = 'Post' | 'Comment' | 'User' | 'Setting' | 'all';
 
// Create a type-safe ability definition
const ability = createAbility<Action, Resource>();
 
ability.can('read', 'Post').can('update', 'Post').can('publish', 'Post'); // Error: 'publish' is not assignable to type Action
 
// Type-safe permission checks
if (ability.isAllowed('update', 'Post')) {
  // TypeScript knows both arguments are valid
}

This type safety helps catch permission-related errors early in the development process, rather than discovering them at runtime. Conditions remain intentionally flexible, which keeps the API simple but means condition field names should still be covered by tests.

React integration

Asgardian ships a separate @nordic-ui/asgardian-react package with a context provider and hooks. The core library has no React dependency.

import React from 'react';
import { createAbility } from '@nordic-ui/asgardian';
import { AbilityProvider, useAbility, useCan, useCannot } from '@nordic-ui/asgardian-react';
 
// Create ability based on user
const defineAbilitiesFor = user => {
  const ability = createAbility();
 
  if (user?.roles?.includes('admin')) {
    ability.can('manage', 'all');
    return ability;
  }
 
  if (user) {
    ability.can('read', 'Post', { published: true }).can(['update', 'delete'], 'Post', { authorId: user.id });
  } else {
    // Guest users
    ability.can('read', 'Post', { published: true });
  }
 
  return ability;
};
 
// App setup
function App({ user }) {
  const ability = defineAbilitiesFor(user);
 
  return (
    <AbilityProvider ability={ability}>
      <PostList />
    </AbilityProvider>
  );
}
 
// Using hooks in components
function PostActions({ post }) {
  const { can: canEdit, reason: editReason } = useCan('update', 'Post', post);
  const { cannot: cannotDelete } = useCannot('delete', 'Post', post);
  const ability = useAbility();
 
  return (
    <div>
      {canEdit && <button>Edit Post</button>}
      {!cannotDelete && <button>Delete Post</button>}
      {editReason && <p>Cannot edit: {editReason}</p>}
    </div>
  );
}

Build the ability once per user session and pass it through the provider. Avoid rebuilding it inside component renders — permission rules should not recompute on every paint.

Testing authorization logic

Because permissions are plain functions that return an ability, testing them requires no mocking:

import { createAbility } from '@nordic-ui/asgardian';
import { describe, it, expect } from 'vitest';
 
describe('Post permissions', () => {
  it('allows editors to update all posts', () => {
    const ability = createAbility();
 
    // Define different abilities based on role
    const editorAbility = createAbility().can('update', 'Post');
 
    const userAbility = createAbility().can('read', 'Post');
 
    expect(editorAbility.isAllowed('update', 'Post')).toBe(true);
    expect(userAbility.isAllowed('update', 'Post')).toBe(false);
  });
 
  it('allows users to edit their own posts', () => {
    const userId = 3;
    const ability = createAbility().can('update', 'Post', { authorId: userId });
 
    const userPost = { id: 1, authorId: userId };
    const otherPost = { id: 2, authorId: 4 };
 
    expect(ability.isAllowed('update', 'Post', userPost)).toBe(true);
    expect(ability.isAllowed('update', 'Post', otherPost)).toBe(false);
  });
 
  it('allows admins to do anything', () => {
    const ability = createAbility().can('manage', 'all');
 
    expect(ability.isAllowed('create', 'Post')).toBe(true);
    expect(ability.isAllowed('read', 'User')).toBe(true);
    expect(ability.isAllowed('delete', 'Comment')).toBe(true);
  });
});

Performance

Authorization checks are fast enough for typical application-level use cases, but complex rule sets should still be measured in the context of your app. In hot paths, you can cache ability construction, avoid rebuilding permissions per component render, and keep rule sets focused.

Comparison with Other Libraries

There are already several mature authorization libraries in the JavaScript and TypeScript ecosystem. Asgardian is not trying to replace every one of them; it is intentionally focused on a smaller API surface, strong action/resource typing, and straightforward rule definitions for application-level authorization.

LibraryBest fitNotes
AsgardianSmall to medium TypeScript apps that want a compact, chainable APIStrong action/resource typing, condition-based rules, small core package (~1.4KB gzipped), optional React integration
CASLMature apps that need a proven ecosystem and database/framework integrationsVery capable and battle-tested, with support for subject/attribute-based rules and integrations for React, Vue, Angular, Prisma, and Mongoose (~6.9KB gzipped)
AccessControlNode.js apps that want RBAC/ABAC, ownership checks, attribute filtering, and audit-oriented behaviorMore feature-rich than simple RBAC libraries, but also a larger conceptual and package footprint (~18.2KB gzipped)
Casbin / OpenFGA-style systemsLarger systems that need policy models, relationship-based authorization, or cross-service authorizationMore powerful for complex domains, but usually heavier than what small application-level authorization needs

For my own projects, Asgardian fits the space where I want authorization logic to stay close to the application code, remain easy to test, and avoid introducing a larger policy engine before the application actually needs one.

Future Improvements

There are several areas where Asgardian could be enhanced in future versions:

  1. Hierarchical resources: Support for resource hierarchies and inheritance
  2. Permission visualization: Tools to visualize and debug complex permission rules
  3. Persistent storage: Built-in adapters for storing and retrieving permission rules
  4. GraphQL integration: Specialized helpers for GraphQL resolvers

I'm actively working on these improvements and welcome feedback from the community on which features would be most valuable.

Conclusion

I built Asgardian because I kept repeating the same authorization patterns across projects. The library has successfully addressed the complexity of authorization while remaining flexible enough to adapt to different application requirements.

The chainable API makes it easy to define and understand permission rules, and the small core footprint means you can adopt it without committing to a heavy policy engine. Whether you're building a small application with basic authorization needs or a complex system with fine-grained permissions, Asgardian provides the tools you need to implement secure, maintainable access control.

Shout-out to

CASL, AccessControl, and other authorization libraries that inspired aspects of Asgardian's design.