OpenFGA Authorization Testing: How to Test Fine-Grained Access Control

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:

  1. The authorization model: Does the model correctly express your permission rules?
  2. Tuple management: Are your relationship tuples written and deleted correctly?
  3. 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 run

Or with Docker Compose for CI:

# docker-compose.test.yml
services:
  openfga:
    image: openfga/openfga:latest
    command: run
    ports:
      - "8080:8080"
    environment:
      OPENFGA_DATASTORE_ENGINE: memory

The 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 organization

Testing 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: false

Run with the OpenFGA CLI:

fga model test --tests model.fga.yaml

This 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:8080

Key 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.

Read more