Skip to content
Testing Authorization

Testing Authorization

Test your tuples view and schema together against a real PostgreSQL database. Mocking the permission layer risks divergence between test and production behavior.

Integration Test Pattern (Go)

func TestPermissions(t *testing.T) {
    // Set up test database with melange functions
    db := setupTestDB(t) // Your test helper that creates a fresh DB
    ctx := context.Background()

    // Apply melange migration
    m := migrator.NewMigrator(db, "schemas/schema.fga")
    _, err := m.Migrate(ctx)
    require.NoError(t, err)

    // Insert test data into domain tables
    tx, err := db.BeginTx(ctx, nil)
    require.NoError(t, err)
    defer tx.Rollback()

    _, err = tx.ExecContext(ctx, `
        INSERT INTO organization_members (user_id, organization_id, role)
        VALUES ('alice', 'org1', 'admin')
    `)
    require.NoError(t, err)

    _, err = tx.ExecContext(ctx, `
        INSERT INTO repositories (id, organization_id)
        VALUES ('repo1', 'org1')
    `)
    require.NoError(t, err)

    // Check permissions within the transaction
    checker := melange.NewChecker(tx)

    allowed, err := checker.Check(ctx,
        melange.Object{Type: "user", ID: "alice"},
        melange.Relation("can_read"),
        melange.Object{Type: "repository", ID: "repo1"},
    )
    require.NoError(t, err)
    assert.True(t, allowed, "admin should be able to read repos in their org")
}

Transaction rollback provides test isolation. Each test starts with a clean state without needing to truncate tables.

Testing with SQL Directly

For database-only tests or debugging, call the SQL functions directly:

-- Set up test data
BEGIN;
INSERT INTO organization_members (user_id, organization_id, role) VALUES ('bob', 'org1', 'member');
INSERT INTO repositories (id, organization_id) VALUES ('repo1', 'org1');

-- Test permission
SELECT check_permission('user', 'bob', 'can_read', 'repository', 'repo1');
-- Expected: 1 (member inherits can_read via "member from org")

-- Test denial
SELECT check_permission('user', 'bob', 'can_delete', 'repository', 'repo1');
-- Expected: 0 (only owners can delete)

ROLLBACK;

Common Test Patterns

Role Hierarchy Inheritance

Verify that higher roles inherit lower role permissions:

// Owner should have all permissions
for _, rel := range []string{"can_read", "can_write", "can_delete"} {
    allowed, err := checker.Check(ctx, owner, melange.Relation(rel), repo)
    require.NoError(t, err)
    assert.True(t, allowed, "owner should have %s", rel)
}

// Member should only have read
allowed, _ := checker.Check(ctx, member, "can_read", repo)
assert.True(t, allowed)

allowed, _ = checker.Check(ctx, member, "can_write", repo)
assert.False(t, allowed)

Exclusion Denials

// User is a writer but also blocked
_, _ = tx.ExecContext(ctx, `INSERT INTO writers (user_id, doc_id) VALUES ('eve', 'doc1')`)
_, _ = tx.ExecContext(ctx, `INSERT INTO blocked_users (user_id, doc_id) VALUES ('eve', 'doc1')`)

// "viewer: writer but not blocked" should deny
allowed, _ := checker.Check(ctx, eve, "viewer", doc)
assert.False(t, allowed, "blocked user should be denied even if they are a writer")

Tuple Removal Removes Access

// Grant access
_, _ = tx.ExecContext(ctx, `INSERT INTO team_members (user_id, team_id) VALUES ('alice', 'team1')`)
allowed, _ := checker.Check(ctx, alice, "member", team)
assert.True(t, allowed)

// Revoke access
_, _ = tx.ExecContext(ctx, `DELETE FROM team_members WHERE user_id = 'alice' AND team_id = 'team1'`)
allowed, _ = checker.Check(ctx, alice, "member", team)
assert.False(t, allowed)

Wildcard Access

// Make repository public
_, _ = tx.ExecContext(ctx, `UPDATE repositories SET is_public = true WHERE id = 'repo1'`)

// Any user should have read access
allowed, _ := checker.Check(ctx, randomUser, "can_read", repo)
assert.True(t, allowed, "public repos should be readable by any user")

Testing Decision Overrides

Use decision overrides to test application behavior for both allowed and denied paths without database setup:

func TestAdminBypass(t *testing.T) {
    // No database needed
    checker := melange.NewChecker(nil, melange.WithDecision(melange.DecisionAllow))
    allowed, err := checker.Check(ctx, anyUser, "anything", anyObject)
    assert.NoError(t, err)
    assert.True(t, allowed)
}

func TestDeniedPath(t *testing.T) {
    checker := melange.NewChecker(nil, melange.WithDecision(melange.DecisionDeny))
    allowed, err := checker.Check(ctx, anyUser, "anything", anyObject)
    assert.NoError(t, err)
    assert.False(t, allowed)
}

Using melange doctor in CI

Run melange doctor as a CI step to catch configuration drift:

# GitHub Actions example
- name: Health check
  run: melange doctor --db "$DATABASE_URL"

Doctor verifies that the schema, migrations, tuples view, and generated functions are all in sync. A non-zero exit code fails the CI step.

Next Steps