NestboltNestbolt

@nestbolt/permissions

Wildcard Permissions

Use wildcard patterns like "articles.*" and "*" to grant broad permissions without listing every individual permission name.

Wildcard permissions allow you to define broad access rules using pattern matching. Instead of granting every individual permission explicitly, you can grant a wildcard pattern that automatically covers multiple permissions.

Enabling Wildcards

Wildcard permissions are disabled by default. Enable them in your module configuration:

PermissionsModule.forRoot({
  userRepository: TypeOrmPermissionUserRepository,
  enableWildcardPermissions: true,
});

Or with async configuration:

PermissionsModule.forRootAsync({
  imports: [ConfigModule],
  inject: [ConfigService],
  useFactory: (config: ConfigService) => ({
    userRepository: TypeOrmPermissionUserRepository,
    enableWildcardPermissions: config.get<boolean>("ENABLE_WILDCARD_PERMISSIONS", false),
  }),
});

When wildcards are disabled, permission names are compared using exact string matching only.

Wildcard Patterns

Segment Wildcard: resource.*

A permission name ending in .* matches any sub-permission under that resource. The * replaces a single segment in the permission name.

// Create and assign the wildcard permission
await this.permissionService.create("articles.*");
await this.registrar.givePermissionTo(userId, "articles.*");

// These checks all return true:
await this.registrar.userHasPermissionTo(userId, "articles.create");  // true
await this.registrar.userHasPermissionTo(userId, "articles.edit");    // true
await this.registrar.userHasPermissionTo(userId, "articles.delete");  // true
await this.registrar.userHasPermissionTo(userId, "articles.publish"); // true
await this.registrar.userHasPermissionTo(userId, "articles.archive"); // true

Global Wildcard: *

A single * permission matches everything. This is useful for super-admin users who should bypass all permission checks.

await this.permissionService.create("*");
await this.registrar.givePermissionTo(userId, "*");

// Every permission check returns true:
await this.registrar.userHasPermissionTo(userId, "articles.create");   // true
await this.registrar.userHasPermissionTo(userId, "users.delete");      // true
await this.registrar.userHasPermissionTo(userId, "settings.manage");   // true
await this.registrar.userHasPermissionTo(userId, "anything.at.all");   // true

Multi-Level Wildcards

Wildcard matching works at each level of the dot-separated permission name. A wildcard at a given level implies all levels below it.

await this.permissionService.create("cms.*");
await this.registrar.givePermissionTo(userId, "cms.*");

// Matches any permission starting with "cms."
await this.registrar.userHasPermissionTo(userId, "cms.posts");           // true
await this.registrar.userHasPermissionTo(userId, "cms.posts.create");    // true
await this.registrar.userHasPermissionTo(userId, "cms.pages.edit");      // true
await this.registrar.userHasPermissionTo(userId, "cms.media.upload");    // true

// Does NOT match unrelated prefixes
await this.registrar.userHasPermissionTo(userId, "users.create");        // false
await this.registrar.userHasPermissionTo(userId, "analytics.view");      // false

How It Works

When enableWildcardPermissions is true, the permission check follows this process:

  1. Direct permission check -- Exact name match against the user's direct permissions.
  2. Role-inherited permission check -- Exact name match against permissions inherited from assigned roles.
  3. Wildcard check -- If neither exact match succeeds, all of the user's permissions (direct and role-inherited) are compiled into a trie (tree index), and the requested permission is tested against the trie.

The wildcard engine is provided by WildcardPermissionService, which uses a trie-based algorithm for efficient matching:

  • buildIndex(permissionNames) -- Builds a trie from an array of permission names. Permission names are split on . (the part delimiter). Each segment can also contain , (the subpart delimiter) to represent multiple values at the same level.
  • implies(permission, index) -- Tests whether a given permission name is implied by the trie. If a * token is found at any level of the trie, all sub-levels are considered matched.

Step-by-step Example

Given a user with these permissions: ["articles.*", "users.view"]

The trie looks like this:

{
  "articles": {
    "*": {}
  },
  "users": {
    "view": {
      "": true
    }
  }
}

When checking articles.create:

  1. Navigate to articles node -- found.
  2. Navigate to create node -- not found, but * is found at this level.
  3. * implies all sub-keys. Result: true.

When checking users.edit:

  1. Navigate to users node -- found.
  2. Navigate to edit node -- not found, no * at this level.
  3. Result: false.

Wildcards via Roles

Wildcard permissions work the same way whether they are assigned directly to a user or through a role:

// Create the wildcard permission
await this.permissionService.findOrCreate("articles.*");

// Attach it to a role
await this.roleService.givePermissionTo("content-manager", "articles.*");

// Assign the role to a user
await this.registrar.assignRole(userId, "content-manager");

// The wildcard is effective
await this.registrar.userHasPermissionTo(userId, "articles.create"); // true
await this.registrar.userHasPermissionTo(userId, "articles.delete"); // true

Wildcards with Guards

Wildcard permissions work with route guards just like regular permissions. The guard calls userHasAllPermissions() (for PermissionsGuard) or userHasAnyPermission() (for RolesOrPermissionsGuard), which internally calls userHasPermissionTo(), which runs the wildcard check.

@Controller("articles")
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class ArticlesController {
  // A user with "articles.*" can access this route
  @RequirePermissions("articles.create")
  @Post()
  create() {
    return { created: true };
  }

  // A user with "articles.*" can access this too
  @RequirePermissions("articles.edit")
  @Put(":id")
  edit(@Param("id") id: string) {
    return { updated: true };
  }

  // A user with "*" can access any route
  @RequirePermissions("articles.delete")
  @Delete(":id")
  delete(@Param("id") id: string) {
    return { deleted: true };
  }
}

Practical Example: Super Admin Setup

A common pattern is to give a super-admin the global wildcard permission so they bypass all permission checks:

@Injectable()
export class SuperAdminSeeder implements OnModuleInit {
  constructor(
    private readonly permissionService: PermissionService,
    private readonly roleService: RoleService,
    private readonly registrar: PermissionRegistrarService,
  ) {}

  async onModuleInit() {
    // Create the global wildcard
    await this.permissionService.findOrCreate("*");

    // Create the super-admin role
    await this.roleService.findOrCreate("super-admin");
    await this.roleService.givePermissionTo("super-admin", "*");

    // Optionally create scoped wildcards for other roles
    await this.permissionService.findOrCreate("posts.*");
    await this.permissionService.findOrCreate("users.*");

    await this.roleService.findOrCreate("content-admin");
    await this.roleService.givePermissionTo("content-admin", "posts.*");

    await this.roleService.findOrCreate("user-admin");
    await this.roleService.givePermissionTo("user-admin", "users.*");
  }
}

Important Considerations

Wildcard permissions are only checked when exact matches fail

The wildcard check is the third and final step in the permission resolution chain. If an exact match is found through direct or role-inherited permissions, the wildcard engine is not invoked. This means wildcard matching has no performance impact when the user has the exact permission.

Wildcards do not create permissions

A wildcard like articles.* does not automatically create articles.create, articles.edit, etc. in the database. The wildcard is a pattern that is evaluated at check time. The individual permissions do not need to exist as records -- the check will still pass.

Performance

The trie is built from the user's permission names on each wildcard check. If you have a large number of permissions and frequent authorization checks, consider whether caching meets your performance requirements. The built-in cache helps because the permission list itself is cached, but the trie is rebuilt on each request.

Disabling wildcards

If you enable wildcards and later disable them, users who were granted only wildcard permissions (e.g., articles.*) will lose access to the specific permissions (e.g., articles.create) that the wildcard previously covered. Make sure to assign explicit permissions before disabling wildcards if continuity is needed.