OpenFGA Authorization Testing: How to Test Fine-Grained Access Control
OpenFGA (Fine-Grained Authorization) is an open-source authorization system developed by Okta, based on Google Zanzibar. It lets you define relationship-based access control (ReBAC) models — "user X has role Y on resource Z" — and check them at runtime. It's the engine behind Auth0's FGA product and increasingly used by teams who've outgrown simple RBAC.
Testing authorization is critical and often overlooked. A broken permission check doesn't crash your app — it silently exposes data to the wrong users. This guide covers how to test OpenFGA authorization models comprehensively.
What to Test in OpenFGA
OpenFGA authorization has three distinct testable concerns:
- The authorization model: Does the model correctly express your permission rules?
- Tuple management: Are your relationship tuples written and deleted correctly?
- Check calls: Does your application check permissions before serving data?
Setting Up OpenFGA Locally
OpenFGA can run locally via Docker:
docker run -p 8080:8080 openfga/openfga runOr with Docker Compose for CI:
# docker-compose.test.yml
services:
openfga:
image: openfga/openfga:latest
command: run
ports:
- "8080:8080"
environment:
OPENFGA_DATASTORE_ENGINE: memoryThe memory datastore means no external database needed for testing.
Defining Your Authorization Model
Authorization models in OpenFGA use the DSL format. For a document management system:
# authorization-model.fga
model
schema 1.1
type user
type organization
relations
define member: [user]
define admin: [user]
define can_manage_members: admin
type document
relations
define organization: [organization]
define owner: [user]
define editor: [user] or owner or admin from organization
define viewer: [user] or editor or member from organization
define can_view: viewer
define can_edit: editor
define can_delete: owner or admin from organizationTesting the Authorization Model
OpenFGA provides a built-in test framework for models. Create model.fga.yaml:
# model.fga.yaml
name: Document Management Authorization Tests
model_file: ./authorization-model.fga
tuples:
- user: user:alice
relation: admin
object: organization:acme
- user: user:bob
relation: member
object: organization:acme
- user: user:carol
relation: member
object: organization:other-org
- user: organization:acme
relation: organization
object: document:doc1
- user: user:alice
relation: owner
object: document:doc1
- user: user:dave
relation: editor
object: document:doc1
tests:
- name: Alice (admin and owner) has full access to doc1
check:
- user: user:alice
object: document:doc1
assertions:
can_view: true
can_edit: true
can_delete: true
- name: Bob (org member) can view but not edit or delete
check:
- user: user:bob
object: document:doc1
assertions:
can_view: true
can_edit: false
can_delete: false
- name: Carol (member of different org) cannot access
check:
- user: user:carol
object: document:doc1
assertions:
can_view: false
can_edit: false
can_delete: false
- name: Dave (explicit editor) can view and edit but not delete
check:
- user: user:dave
object: document:doc1
assertions:
can_view: true
can_edit: true
can_delete: false
- name: Unknown user has no access
check:
- user: user:unknown
object: document:doc1
assertions:
can_view: false
can_edit: false
can_delete: falseRun with the OpenFGA CLI:
fga model test --tests model.fga.yamlThis validates your authorization model logic without writing any application code.
Unit Testing Permission Check Logic
In your application, you call OpenFGA's Check API before serving data. Test this with a mocked OpenFGA client:
// src/auth/authorization.ts
import { OpenFgaClient, CheckRequest } from '@openfga/sdk';
export class AuthorizationService {
constructor(private fga: OpenFgaClient) {}
async canUserAccessDocument(
userId: string,
documentId: string,
action: 'can_view' | 'can_edit' | 'can_delete',
): Promise<boolean> {
const { allowed } = await this.fga.check({
user: `user:${userId}`,
relation: action,
object: `document:${documentId}`,
});
return allowed ?? false;
}
async requirePermission(
userId: string,
documentId: string,
action: 'can_view' | 'can_edit' | 'can_delete',
): Promise<void> {
const allowed = await this.canUserAccessDocument(userId, documentId, action);
if (!allowed) {
throw new Error(`User ${userId} is not authorized to ${action} document ${documentId}`);
}
}
}// src/auth/authorization.test.ts
import { OpenFgaClient } from '@openfga/sdk';
import { AuthorizationService } from './authorization';
jest.mock('@openfga/sdk');
describe('AuthorizationService', () => {
let service: AuthorizationService;
let mockFga: jest.Mocked<OpenFgaClient>;
beforeEach(() => {
mockFga = new OpenFgaClient({} as any) as jest.Mocked<OpenFgaClient>;
service = new AuthorizationService(mockFga);
});
describe('canUserAccessDocument', () => {
it('returns true when FGA allows the action', async () => {
mockFga.check.mockResolvedValue({ allowed: true } as any);
const result = await service.canUserAccessDocument('alice', 'doc1', 'can_view');
expect(result).toBe(true);
expect(mockFga.check).toHaveBeenCalledWith({
user: 'user:alice',
relation: 'can_view',
object: 'document:doc1',
});
});
it('returns false when FGA denies the action', async () => {
mockFga.check.mockResolvedValue({ allowed: false } as any);
const result = await service.canUserAccessDocument('carol', 'doc1', 'can_edit');
expect(result).toBe(false);
});
it('returns false when allowed is undefined', async () => {
mockFga.check.mockResolvedValue({} as any);
const result = await service.canUserAccessDocument('unknown', 'doc1', 'can_view');
expect(result).toBe(false);
});
});
describe('requirePermission', () => {
it('does not throw when permission is granted', async () => {
mockFga.check.mockResolvedValue({ allowed: true } as any);
await expect(
service.requirePermission('alice', 'doc1', 'can_delete'),
).resolves.not.toThrow();
});
it('throws with descriptive message when permission is denied', async () => {
mockFga.check.mockResolvedValue({ allowed: false } as any);
await expect(
service.requirePermission('bob', 'doc1', 'can_delete'),
).rejects.toThrow('bob is not authorized to can_delete document doc1');
});
});
});Integration Testing Authorization Flows
Test the full authorization flow — writing tuples and checking permissions — against a real OpenFGA instance:
// tests/integration/openfga.integration.test.ts
import { OpenFgaClient, CredentialsMethod } from '@openfga/sdk';
let fgaClient: OpenFgaClient;
let storeId: string;
let authModelId: string;
beforeAll(async () => {
// Connect to local OpenFGA
fgaClient = new OpenFgaClient({
apiUrl: 'http://localhost:8080',
credentials: { method: CredentialsMethod.None },
});
// Create a test store
const store = await fgaClient.createStore({ name: 'integration-test-store' });
storeId = store.id;
fgaClient = new OpenFgaClient({
apiUrl: 'http://localhost:8080',
storeId,
credentials: { method: CredentialsMethod.None },
});
// Load the authorization model
const modelDsl = `
model
schema 1.1
type user
type document
relations
define owner: [user]
define editor: [user] or owner
define viewer: [user] or editor
define can_view: viewer
define can_edit: editor
define can_delete: owner
`;
const { authorization_model_id } = await fgaClient.writeAuthorizationModel({
schema_version: '1.1',
type_definitions: [], // parsed from DSL above
});
authModelId = authorization_model_id;
});
afterAll(async () => {
// Delete the test store
await fgaClient.deleteStore();
});
afterEach(async () => {
// Clean up all tuples
const { tuples } = await fgaClient.readTuples();
if (tuples.length > 0) {
await fgaClient.deleteTuples(tuples.map((t) => t.key));
}
});
describe('Document permissions', () => {
it('owner can view, edit, and delete', async () => {
await fgaClient.writeTuples([
{ user: 'user:alice', relation: 'owner', object: 'document:doc1' },
]);
const [canView, canEdit, canDelete] = await Promise.all([
fgaClient.check({ user: 'user:alice', relation: 'can_view', object: 'document:doc1' }),
fgaClient.check({ user: 'user:alice', relation: 'can_edit', object: 'document:doc1' }),
fgaClient.check({ user: 'user:alice', relation: 'can_delete', object: 'document:doc1' }),
]);
expect(canView.allowed).toBe(true);
expect(canEdit.allowed).toBe(true);
expect(canDelete.allowed).toBe(true);
});
it('editor can view and edit but not delete', async () => {
await fgaClient.writeTuples([
{ user: 'user:bob', relation: 'editor', object: 'document:doc1' },
]);
const [canView, canEdit, canDelete] = await Promise.all([
fgaClient.check({ user: 'user:bob', relation: 'can_view', object: 'document:doc1' }),
fgaClient.check({ user: 'user:bob', relation: 'can_edit', object: 'document:doc1' }),
fgaClient.check({ user: 'user:bob', relation: 'can_delete', object: 'document:doc1' }),
]);
expect(canView.allowed).toBe(true);
expect(canEdit.allowed).toBe(true);
expect(canDelete.allowed).toBe(false);
});
it('unrelated user has no access', async () => {
const canView = await fgaClient.check({
user: 'user:unknown',
relation: 'can_view',
object: 'document:doc1',
});
expect(canView.allowed).toBe(false);
});
it('revoked access is denied after tuple deletion', async () => {
await fgaClient.writeTuples([
{ user: 'user:carol', relation: 'viewer', object: 'document:doc1' },
]);
const beforeRevoke = await fgaClient.check({
user: 'user:carol',
relation: 'can_view',
object: 'document:doc1',
});
expect(beforeRevoke.allowed).toBe(true);
// Revoke access
await fgaClient.deleteTuples([
{ user: 'user:carol', relation: 'viewer', object: 'document:doc1' },
]);
const afterRevoke = await fgaClient.check({
user: 'user:carol',
relation: 'can_view',
object: 'document:doc1',
});
expect(afterRevoke.allowed).toBe(false);
});
});Testing Tuple Management
Tuple writes should happen in your application when users are assigned roles. Test these writes explicitly:
// src/services/document-share.ts
export class DocumentShareService {
constructor(
private fga: OpenFgaClient,
private db: Database,
) {}
async shareDocumentWithUser(
documentId: string,
userId: string,
role: 'viewer' | 'editor',
) {
// Write to database first
await this.db.documentPermissions.create({
documentId,
userId,
role,
grantedAt: new Date(),
});
// Then write FGA tuple
await this.fga.writeTuples([
{
user: `user:${userId}`,
relation: role,
object: `document:${documentId}`,
},
]);
}
async revokeDocumentAccess(documentId: string, userId: string) {
const permission = await this.db.documentPermissions.findOne({
where: { documentId, userId },
});
if (!permission) return;
await this.db.documentPermissions.delete({ where: { documentId, userId } });
await this.fga.deleteTuples([
{
user: `user:${userId}`,
relation: permission.role,
object: `document:${documentId}`,
},
]);
}
}// src/services/document-share.test.ts
import { DocumentShareService } from './document-share';
const mockFga = {
writeTuples: jest.fn(),
deleteTuples: jest.fn(),
};
const mockDb = {
documentPermissions: {
create: jest.fn(),
findOne: jest.fn(),
delete: jest.fn(),
},
};
describe('DocumentShareService', () => {
let service: DocumentShareService;
beforeEach(() => {
service = new DocumentShareService(mockFga as any, mockDb as any);
jest.clearAllMocks();
});
describe('shareDocumentWithUser', () => {
it('writes to DB and FGA tuple in order', async () => {
const calls: string[] = [];
mockDb.documentPermissions.create.mockImplementation(async () => {
calls.push('db');
});
mockFga.writeTuples.mockImplementation(async () => {
calls.push('fga');
});
await service.shareDocumentWithUser('doc-1', 'user-1', 'editor');
expect(calls).toEqual(['db', 'fga']); // DB write before FGA tuple
expect(mockFga.writeTuples).toHaveBeenCalledWith([
{ user: 'user:user-1', relation: 'editor', object: 'document:doc-1' },
]);
});
});
describe('revokeDocumentAccess', () => {
it('deletes DB record and FGA tuple', async () => {
mockDb.documentPermissions.findOne.mockResolvedValue({ role: 'viewer' });
await service.revokeDocumentAccess('doc-1', 'user-1');
expect(mockFga.deleteTuples).toHaveBeenCalledWith([
{ user: 'user:user-1', relation: 'viewer', object: 'document:doc-1' },
]);
});
it('does nothing if no permission record exists', async () => {
mockDb.documentPermissions.findOne.mockResolvedValue(null);
await service.revokeDocumentAccess('doc-1', 'user-1');
expect(mockFga.deleteTuples).not.toHaveBeenCalled();
});
});
});CI Configuration
# .github/workflows/test.yml
name: Authorization Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
openfga:
image: openfga/openfga:latest
env:
OPENFGA_DATASTORE_ENGINE: memory
ports:
- 8080:8080
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
# Test the authorization model with FGA CLI
- name: Install FGA CLI
run: |
curl -L https://github.com/openfga/cli/releases/latest/download/fga_linux_amd64.tar.gz | tar xz
sudo mv fga /usr/local/bin/
- name: Test authorization model
run: fga model test --tests ./authorization/model.fga.yaml
env:
FGA_SERVER_URL: http://localhost:8080
- run: npm test -- --testPathPattern="unit"
- run: npm test -- --testPathPattern="integration"
env:
OPENFGA_URL: http://localhost:8080Key Testing Principles
Test the model, not just the implementation. OpenFGA's built-in test framework (fga model test) lets you verify your authorization model logic independently of your application code. Run this in CI to catch model regressions.
Test denial, not just grant. The most dangerous authorization bugs are incorrect grants. Test that users who should not have access are correctly denied — don't just verify that authorized users can proceed.
Test tuple lifecycle. Access can be granted and revoked. Test that revocation removes the FGA tuple and that subsequent checks correctly return false.
Keep the DB and FGA tuple in sync. If your app stores permissions in a database and in FGA, test that both are written and deleted together. A mismatch causes stale permissions.
Use per-test store isolation. In integration tests, create a fresh OpenFGA store per test suite (or clean all tuples in afterEach) to prevent interference between test runs.
For production applications using OpenFGA, HelpMeTest can run authorization smoke tests on a schedule — verifying that critical permission checks work correctly after model updates or application deployments.