An open API service indexing awesome lists of open source software.

https://github.com/topaxi/ng-ability

Access Control for Angular
https://github.com/topaxi/ng-ability

access acl angular can cancan permissions

Last synced: about 1 month ago
JSON representation

Access Control for Angular

Awesome Lists containing this project

README

          

# ng-ability

Define access control lists in Angular.

## Installation

```bash
npm install --save ng-ability
```

## Usage

### 1. Define an ability context

The ability context provides the current user (or other subject) to your ability checks:

```typescript
import { Injectable, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop'
import { AbilityContext } from 'ng-ability';

@Injectable({ providedIn: 'root' })
export class AbilityUserContext implements AbilityContext {
readonly #auth = inject(AuthService);

readonly abilityContext = toSignal(this.#auth.getCurrentUser());
}
```

If your auth service uses signals, you can directly use them:

```typescript
@Injectable({ providedIn: 'root' })
export class AbilityUserContext implements AbilityContext {
readonly #auth = inject(AuthService);

// If auth.currentUser is already a Signal
readonly abilityContext = this.#auth.currentUser;
}
```

### 2. Define abilities

Define abilities for pages, models, and other data:

```typescript
import { AbilityFor, Ability } from 'ng-ability';

// Define ability for Article instance objects, the string 'Article'
// and graphql like objects using a matching function
@AbilityFor(Article, 'Article', article => article.__typename === 'Article')
export class ArticleAbility implements Ability {
can(currentUser: User | null, action: string, article: Article) {
if (currentUser != null && currentUser.admin) {
// Admins can do anything
return true;
}

switch (action) {
case 'view': // Everyone can view articles
return true;
case 'create': // Every user can create new articles
return currentUser != null;
case 'edit': // Users can only edit their own articles
return currentUser != null && currentUser.id === article.authorId;
default:
return false;
}
}
}

@AbilityFor('AdminArea')
export class AdminAreaAbility implements Ability {
can(currentUser: User | null, action: string) {
switch (action) {
case 'view': // Only admins can view the admin area
return currentUser != null && currentUser.admin;
default:
return false;
}
}
}
```

### 3. Register abilities

**Standalone (recommended):**

```typescript
import { bootstrapApplication } from '@angular/platform-browser';
import { provideAbilities } from 'ng-ability';
import { AbilityUserContext } from './ability-user-context';
import { ArticleAbility } from './abilities/article.ability';
import { AdminAreaAbility } from './abilities/admin-area.ability';

bootstrapApplication(AppComponent, {
providers: [
provideAbilities(AbilityUserContext, [ArticleAbility, AdminAreaAbility]),
],
});
```

**NgModule:**

```typescript
import { NgModule } from '@angular/core';
import { NgAbilityModule } from 'ng-ability';

@NgModule({
imports: [
NgAbilityModule.withAbilities(AbilityUserContext, [
ArticleAbility,
AdminAreaAbility,
]),
],
})
export class AppModule {}
```

### 4. Check abilities

**In templates** using the `can` pipe (import `CanPipe` or `NgAbilityModule`):

```html
@if ('Article' | can: 'create') {
I can create new articles!
}

@if (latestArticle | can: 'edit') {
Edit latest article
} @else {

Latest article is not editable :(

}
```

When an entity can be identified on its own — for example via `instanceof` or a
field like `__typename` in GraphQL responses — passing it directly is enough.
If the entity cannot be inferred, you can pass an explicit matcher as the pipe
value and the entity as a third argument:

```html
@if ('Article' | can: 'read' : draftArticle) {
I can read this draft article!
}
```

Alternatively, you can use the `*can` structural directive (import `CanDirective` or `NgAbilityModule`):

```html


I can create new articles!


Edit latest article

Latest article is not editable :(

```

**In code** using the `NgAbilityService`:

```typescript
import { Component, inject } from '@angular/core';
import { NgAbilityService } from 'ng-ability';

@Component({ ... })
export class AppComponent {
readonly #ability = inject(NgAbilityService);

editArticle(article: Article) {
// When the entity can be inferred (e.g., via instanceof)
if (this.#ability.can(article, 'edit')) {
// edit article...
}
}

createNewArticle() {
// When checking a string matcher without an entity
if (this.#ability.can('Article', 'create')) {
// create article...
}
}

editDraftArticle(draftArticle: unknown) {
// When explicit matcher is needed
if (this.#ability.can('Article', 'edit', draftArticle)) {
// edit draft article...
}
}
}
```

### 5. Type-safe action strings (optional)

By default, the `action` parameter accepts any string. You can register known
actions per matcher via declaration merging on the `AbilityActions` interface to
get autocompletion:

```typescript
// e.g. in src/ability-actions.d.ts
declare module 'ng-ability' {
interface AbilityActions {
Article: 'view' | 'create' | 'edit';
AdminArea: 'view';
}
}
```

With this in place, calls using a registered matcher key will suggest the
corresponding actions:

```typescript
ability.can('Article', 'edit'); // autocomplete suggests 'view' | 'create' | 'edit'
ability.can('AdminArea', 'view'); // autocomplete suggests 'view'
ability.can(article, 'edit'); // non-string matcher: suggests all registered actions
```

Arbitrary action strings are still accepted — the type narrows suggestions
without rejecting unknown actions.

#### Using `AbilityActionsOf` with class-based matchers

When using class constructors as matchers, you can implement the `AbilityActionsOf`
interface to narrow the allowed actions based on your `AbilityActions` declaration:

```typescript
import { AbilityActionsOf } from 'ng-ability';

// Declare your actions mapping
declare module 'ng-ability' {
interface AbilityActions {
Article: 'view' | 'create' | 'edit';
}
}

// Link your class to the 'Article' actions
export class Article implements AbilityActionsOf<'Article'> {
constructor(
public id: number,
public title: string,
public authorId: number,
) {}
}
```

Now when you pass the `Article` class constructor to ability checks, TypeScript
will narrow the allowed actions to `'view' | 'create' | 'edit'`:

```typescript
// In components or services
ability.can(Article, 'view'); // ✓ valid
ability.can(Article, 'edit'); // ✓ valid
ability.can(Article, 'delete'); // ✗ TypeScript error: 'delete' is not assignable

// With an instance
const myArticle = new Article(1, 'Hello', 42);
ability.can(Article, 'edit', myArticle); // ✓ valid with narrowed actions
```

**Why use `AbilityActionsOf`?**

Without `AbilityActionsOf`, passing a class constructor accepts any action string,
since TypeScript can't infer which actions apply to that class. By implementing
`AbilityActionsOf`, you create a type-level link between your class and its
registered actions, enabling:

- **Type safety**: Catch typos and invalid actions at compile time
- **Autocompletion**: IDE suggests only valid actions for that class
- **Self-documenting code**: The class declaration shows which actions are supported

This is especially useful when:
- You have many entity classes with different actions
- You want to prevent mistakes like checking `ability.can(User, 'edit')` when `User` only supports `'view'`
- You're passing class constructors instead of string matchers

### 6. Global abilities (optional)

Global abilities are special abilities that act as gatekeepers for your permission
system. They are checked **before** any specific abilities, and **all** global
abilities must return `true` for the permission check to proceed.

**When to use global abilities:**

- Enforce read-only mode across your entire application
- Implement maintenance mode restrictions
- Check license or feature flags
- Verify user status (banned, suspended, etc.)
- Multi-tenant isolation checks

**Example:**

```typescript
import { AbilityFor, Ability, GlobalAbility } from 'ng-ability';

// Global ability to enforce read-only mode
@AbilityFor(GlobalAbility)
export class ReadOnlyModeAbility implements Ability {
can(currentUser: User | null, action: string): boolean {
// Block all write operations when user is in read-only mode
if (currentUser?.readOnly && action !== 'read') {
return false;
}
// Allow the check to continue to specific abilities
return true;
}
}

// Regular ability
@AbilityFor('Article')
export class ArticleAbility implements Ability {
can(currentUser: User | null, action: string): boolean {
// This will only be checked if all global abilities return true
return currentUser != null;
}
}
```

Register global abilities like any other ability:

```typescript
bootstrapApplication(AppComponent, {
providers: [
provideAbilities(AbilityUserContext, [
ReadOnlyModeAbility, // Global ability
ArticleAbility, // Regular abilities
// ... other abilities
]),
],
});
```

**Type safety:**

The type system enforces that abilities are either global OR specific:

```typescript
// ✓ Valid
@AbilityFor(GlobalAbility)
export class GlobalCheck implements Ability { ... }

// ✗ Invalid: Cannot mix GlobalAbility with matchers
@AbilityFor(GlobalAbility, 'Article') // TypeScript error!
```

**How it works:**

1. When you call `can('Article', 'write')`, all global abilities are checked first
2. If any global ability returns `false`, the check fails immediately
3. Only if all global abilities return `true` does the check proceed to `ArticleAbility`

### 7. Route guards

Protect routes using ability-based guards:

```typescript
import { canActivateAbility, canActivateChildAbility, canMatchAbility } from 'ng-ability'

const routes: Routes = [
{ path: 'articles', canActivate: [canActivateAbility('Article', 'read')], component: ArticlesComponent },
{ path: 'admin', canMatch: [canMatchAbility('Admin', 'access')], component: AdminComponent },
]
```

When a `canActivate`/`canActivateChild` check fails, an `AbilityGuardUnauthorizedError` is thrown by default, which propagates to Angular's navigation error handler. Override `ABILITY_UNAUTHORIZED_HANDLER` globally or per route subtree to redirect instead:

```typescript
import { ABILITY_UNAUTHORIZED_HANDLER, redirectAbilityUnauthorizedHandler } from 'ng-ability'

{ provide: ABILITY_UNAUTHORIZED_HANDLER, useValue: redirectAbilityUnauthorizedHandler('/error/403') }
```

See the [Route Guards guide](https://topaxi.github.io/ng-ability/guide/route-guards) for full details.

## Development

### Build

```bash
npm run build
```

### Running unit tests

```bash
npm test
```