Permit.io Testing Guide: Testing Fine-Grained Authorization in Your App

Permit.io Testing Guide: Testing Fine-Grained Authorization in Your App

Permit.io is a cloud-based authorization service that lets you define RBAC (Role-Based Access Control), ABAC (Attribute-Based Access Control), and ReBAC (Relationship-Based Access Control) policies in a UI and enforce them via a local PDP (Policy Decision Point) sidecar or hosted API. It uses Open Policy Agent (OPA) under the hood.

Testing Permit.io in your application means verifying two distinct things: (1) your application correctly calls permit.check() at the right places, and (2) your policy definitions produce the expected allow/deny outcomes. This guide covers both.

Understanding Permit.io's Architecture

A Permit.io integration has three components:

  • Policy Store: Your RBAC/ABAC/ReBAC rules defined in the Permit.io dashboard (synced to OPA)
  • PDP (Policy Decision Point): A local Docker sidecar (permitio/pdp-v2) or cloud endpoint that evaluates policies
  • SDK: permit.check(user, action, resource) calls in your application code

When testing, you have two approaches:

  1. Mock the SDK: Bypass the PDP entirely in unit/integration tests
  2. Use a local PDP: Run the Docker sidecar with test policies for integration tests

Setting Up the Test Environment

npm install permitio
npm install --save-dev jest ts-jest @types/jest

# For integration tests with a real PDP:
docker run -p 7766:7000 --env PDP_API_KEY=your-key permitio/pdp-v2

jest.config.ts:

export default {
  preset: 'ts-jest',
  testEnvironment: 'node',
  testMatch: ['**/*.test.ts', '**/*.spec.ts'],
};

Unit Testing Permission Checks

Mocking the Permit SDK

The cleanest unit testing approach: mock permit.check() to return deterministic values and test that your application logic behaves correctly for both allowed and denied decisions.

// src/routes/documents.test.ts
import request from 'supertest';
import { app } from '../app';
import { permit } from '../lib/permit';

jest.mock('../lib/permit', () => ({
  permit: {
    check: jest.fn(),
  },
}));

const mockCheck = permit.check as jest.Mock;

describe('GET /documents/:id', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('returns document when user is permitted', async () => {
    mockCheck.mockResolvedValue(true);

    const response = await request(app)
      .get('/documents/doc-123')
      .set('Authorization', 'Bearer user-token');

    expect(response.status).toBe(200);
    expect(response.body.id).toBe('doc-123');
    expect(mockCheck).toHaveBeenCalledWith('user-123', 'read', {
      type: 'document',
      id: 'doc-123',
    });
  });

  it('returns 403 when user is denied', async () => {
    mockCheck.mockResolvedValue(false);

    const response = await request(app)
      .get('/documents/doc-123')
      .set('Authorization', 'Bearer user-token');

    expect(response.status).toBe(403);
    expect(response.body.error).toBe('Forbidden');
  });

  it('returns 403 when permit.check throws', async () => {
    mockCheck.mockRejectedValue(new Error('PDP unreachable'));

    const response = await request(app)
      .get('/documents/doc-123')
      .set('Authorization', 'Bearer user-token');

    // Should fail closed — deny access if PDP is down
    expect(response.status).toBe(403);
  });
});

Testing Your Authorization Middleware

// src/middleware/authorize.test.ts
import { Request, Response, NextFunction } from 'express';
import { authorize } from './authorize';
import { permit } from '../lib/permit';

jest.mock('../lib/permit');
const mockCheck = permit.check as jest.Mock;

function mockReq(userId: string, resource: string): Partial<Request> {
  return {
    user: { id: userId },
    params: { id: resource },
  };
}

function mockRes(): Partial<Response> {
  return {
    status: jest.fn().mockReturnThis(),
    json: jest.fn().mockReturnThis(),
  };
}

describe('authorize middleware', () => {
  const next: NextFunction = jest.fn();

  it('calls next() when permission is granted', async () => {
    mockCheck.mockResolvedValue(true);
    const req = mockReq('alice', 'project-1');
    const res = mockRes();

    await authorize('view', 'project')(req as Request, res as Response, next);

    expect(next).toHaveBeenCalled();
    expect(res.status).not.toHaveBeenCalled();
  });

  it('returns 403 when permission is denied', async () => {
    mockCheck.mockResolvedValue(false);
    const req = mockReq('bob', 'project-1');
    const res = mockRes();

    await authorize('delete', 'project')(req as Request, res as Response, next);

    expect(next).not.toHaveBeenCalled();
    expect(res.status).toHaveBeenCalledWith(403);
  });

  it('includes resource attributes in the check', async () => {
    mockCheck.mockResolvedValue(true);
    const req = { ...mockReq('alice', 'project-1'), body: { orgId: 'org-1' } };
    const res = mockRes();

    await authorize('edit', 'project')(req as Request, res as Response, next);

    expect(mockCheck).toHaveBeenCalledWith('alice', 'edit', {
      type: 'project',
      id: 'project-1',
      attributes: { orgId: 'org-1' },
    });
  });
});

Integration Testing with the Local PDP

For integration tests, spin up the Permit.io PDP Docker container and test against real policies.

Docker Compose Setup

docker-compose.test.yml:

version: '3.8'
services:
  pdp:
    image: permitio/pdp-v2:latest
    environment:
      PDP_API_KEY: ${PERMIT_API_KEY}
      PDP_DEBUG: "true"
    ports:
      - "7766:7000"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:7000/health"]
      interval: 5s
      timeout: 3s
      retries: 10

Syncing Test Policies

// test/setup-policies.ts
import { Permit } from 'permitio';

const permit = new Permit({
  token: process.env.PERMIT_API_KEY!,
  pdp: 'https://api.permit.io',
});

export async function setupTestPolicies() {
  // Create roles
  await permit.api.roles.create({ key: 'admin', name: 'Admin' });
  await permit.api.roles.create({ key: 'editor', name: 'Editor' });
  await permit.api.roles.create({ key: 'viewer', name: 'Viewer' });

  // Create resource with actions
  await permit.api.resources.create({
    key: 'document',
    name: 'Document',
    actions: {
      create: { name: 'Create' },
      read: { name: 'Read' },
      update: { name: 'Update' },
      delete: { name: 'Delete' },
    },
  });

  // Assign actions to roles
  await permit.api.roles.assignPermissions('admin', ['document:create', 'document:read', 'document:update', 'document:delete']);
  await permit.api.roles.assignPermissions('editor', ['document:create', 'document:read', 'document:update']);
  await permit.api.roles.assignPermissions('viewer', ['document:read']);

  // Create test users
  await permit.api.syncUser({ key: 'user-admin', email: 'admin@test.com' });
  await permit.api.syncUser({ key: 'user-editor', email: 'editor@test.com' });
  await permit.api.syncUser({ key: 'user-viewer', email: 'viewer@test.com' });

  // Assign roles
  await permit.api.assignRole({ user: 'user-admin', role: 'admin', tenant: 'default' });
  await permit.api.assignRole({ user: 'user-editor', role: 'editor', tenant: 'default' });
  await permit.api.assignRole({ user: 'user-viewer', role: 'viewer', tenant: 'default' });
}

RBAC Policy Tests

// test/rbac-policies.test.ts
import { Permit } from 'permitio';

const pdpUrl = process.env.PDP_URL || 'http://localhost:7766';
const permit = new Permit({ token: process.env.PERMIT_API_KEY!, pdp: pdpUrl });

describe('Document RBAC policies', () => {
  describe('Admin role', () => {
    it('can perform all document actions', async () => {
      const actions = ['create', 'read', 'update', 'delete'];
      for (const action of actions) {
        const allowed = await permit.check('user-admin', action, 'document');
        expect(allowed).toBe(true);
      }
    });
  });

  describe('Editor role', () => {
    it('can create, read, and update documents', async () => {
      expect(await permit.check('user-editor', 'create', 'document')).toBe(true);
      expect(await permit.check('user-editor', 'read', 'document')).toBe(true);
      expect(await permit.check('user-editor', 'update', 'document')).toBe(true);
    });

    it('cannot delete documents', async () => {
      expect(await permit.check('user-editor', 'delete', 'document')).toBe(false);
    });
  });

  describe('Viewer role', () => {
    it('can only read documents', async () => {
      expect(await permit.check('user-viewer', 'read', 'document')).toBe(true);
      expect(await permit.check('user-viewer', 'create', 'document')).toBe(false);
      expect(await permit.check('user-viewer', 'update', 'document')).toBe(false);
      expect(await permit.check('user-viewer', 'delete', 'document')).toBe(false);
    });
  });

  describe('Unknown user', () => {
    it('cannot perform any action', async () => {
      expect(await permit.check('unknown-user', 'read', 'document')).toBe(false);
    });
  });
});

ReBAC Policy Tests

// test/rebac-policies.test.ts — Resource-level ownership testing
describe('Document ReBAC — resource-level ownership', () => {
  it('document owner can edit their own document', async () => {
    // Assign relationship: alice is the "owner" of document-1
    await permit.api.relationshipTuples.create({
      subject: 'user:user-alice',
      relation: 'owner',
      object: 'document:doc-1',
    });

    const allowed = await permit.check(
      'user-alice',
      'update',
      { type: 'document', id: 'doc-1' }
    );
    expect(allowed).toBe(true);
  });

  it('non-owner cannot edit another user\'s document', async () => {
    const allowed = await permit.check(
      'user-bob',
      'update',
      { type: 'document', id: 'doc-1' }
    );
    expect(allowed).toBe(false);
  });

  it('workspace member can read all documents in the workspace', async () => {
    // Assign: doc-1 belongs to workspace-1, carol is member of workspace-1
    await permit.api.relationshipTuples.create({
      subject: 'workspace:workspace-1',
      relation: 'parent',
      object: 'document:doc-1',
    });
    await permit.api.relationshipTuples.create({
      subject: 'user:user-carol',
      relation: 'member',
      object: 'workspace:workspace-1',
    });

    const allowed = await permit.check(
      'user-carol',
      'read',
      { type: 'document', id: 'doc-1' }
    );
    expect(allowed).toBe(true);
  });
});

Testing Multi-Tenant Isolation

Permit.io supports multi-tenant scenarios. Verify tenants can't access each other's resources:

describe('Multi-tenant isolation', () => {
  it('user with role in tenant-A cannot access tenant-B resources', async () => {
    // user-alice has editor role in tenant-A
    await permit.api.assignRole({
      user: 'user-alice',
      role: 'editor',
      tenant: 'tenant-a',
    });

    // Check against tenant-A resource — allowed
    expect(
      await permit.check('user-alice', 'read', { type: 'document', tenant: 'tenant-a' })
    ).toBe(true);

    // Check against tenant-B resource — denied
    expect(
      await permit.check('user-alice', 'read', { type: 'document', tenant: 'tenant-b' })
    ).toBe(false);
  });
});

Testing Fail-Safe Behavior

Your application must fail closed when the PDP is unavailable:

// src/lib/permit.test.ts
describe('PDP failure handling', () => {
  it('returns false (deny) when PDP is unreachable', async () => {
    const permit = new Permit({
      token: 'test-key',
      pdp: 'http://localhost:9999', // Non-existent PDP
    });

    const allowed = await permit.check('user-alice', 'read', 'document').catch(() => false);
    expect(allowed).toBe(false);
  });
});

Continuous Authorization Testing with HelpMeTest

Authorization bugs are security vulnerabilities. A viewer who can suddenly delete records, or a tenant that can read another tenant's data — these must be caught before they hit production.

HelpMeTest can run authorization E2E tests continuously against your staging environment:

Log in as a user with "viewer" role
Navigate to /documents/123
Verify the "Delete" button does not appear
Verify the "Edit" button does not appear
Verify the document content IS visible
Log in as user from Tenant A
Navigate to /documents (should show only Tenant A documents)
Manually navigate to a known Tenant B document URL
Verify you receive a 403 Forbidden response

Run these every 5 minutes against staging. If a policy change accidentally grants too many permissions, you know immediately.

Summary

Testing Permit.io authorization requires two distinct strategies:

  1. Unit tests: Mock permit.check() to test that your application code behaves correctly for allow and deny decisions — routes return 200 vs 403, middleware calls next() vs sends a 403.
  2. Integration/policy tests: Use a local PDP Docker container to test that your policy definitions produce the correct allow/deny outcomes for each role, resource, and action combination.

The key safety property to verify: authorization failures must fail closed (deny by default) when the PDP is unreachable. Never assume availability — test it explicitly.

Read more

Start now free