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:
- Mock the SDK: Bypass the PDP entirely in unit/integration tests
- 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-v2jest.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: 10Syncing 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 visibleLog 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 responseRun 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:
- Unit tests: Mock
permit.check()to test that your application code behaves correctly for allow and deny decisions — routes return 200 vs 403, middleware callsnext()vs sends a 403. - 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.