Authorization Without Sprawl
Three coordinated layers. No scattered checks. No forgotten guards. No data leaks.
The Authorization Sprawl Problem
In traditional GraphQL, authorization logic is scattered everywhere:
Typical Authorization Mess
# Authorization in resolver 1
@resolver
async def user(info, id: str) -> User:
if not info.context["user"].can_view(id): # Check 1
raise PermissionError()
return db.find_user(id)
# Authorization in resolver 2
@resolver
async def posts(parent: User, info) -> list[Post]:
if not info.context["user"].can_view_posts(parent.id): # Check 2
raise PermissionError()
return db.find_posts(parent.id)
# Authorization in resolver 3
@resolver
async def comments(parent: Post, info) -> list[Comment]:
if not info.context["user"].can_view_comments(parent.id): # Check 3
raise PermissionError()
return db.find_comments(parent.id)
# By resolver 47, you've written 47 different authorization checks
# Some check roles, some check ownership, some check teams
# Consistency: ❌ ZERO 🔓 Authorization Bypass
Forget one check and data leaks
🔀 Inconsistent Rules
Same user, different permissions in different resolvers
🐛 Hard to Audit
Where is authorization actually enforced?
😰 Maintenance Nightmare
Change a permission rule = update 20+ resolvers
FraiseQL Solution: Three Coordinated Layers
FraiseQL replaces scattered resolver checks with three complementary authorization layers that work together automatically.
Row Visibility
SQL views filter which rows exist. Unauthorized data is never returned — not filtered client-side, not returned and discarded. It simply doesn't exist from the query's perspective.
RLS Policies
Application-layer Row-Level Security policies compose WHERE clauses before execution. Built-in policies handle tenant isolation and ownership. Custom policies handle complex rules.
Field-Level RBAC
Individual fields require OAuth-style scopes. Roles are defined once in configuration and map to granular permissions. Sensitive fields are invisible to unauthorized roles.
Layer 1: Row Visibility via SQL Views
Authorization is a data visibility problem, not an application logic problem. SQL views encode who can see what — the database enforces it on every query.
Define visibility rules in SQL
CREATE VIEW v_user_visible_posts AS
SELECT
jsonb_build_object(
'id', p.id::text,
'title', p.title,
'content', p.content,
'author_id', p.author_id::text,
'created_at', p.created_at
) AS data
FROM tb_post p
WHERE
p.is_public = true
OR p.author_id = current_setting('app.user_id')::uuid
OR EXISTS (
SELECT 1 FROM tb_team_member tm
WHERE tm.team_id = p.team_id
AND tm.user_id = current_setting('app.user_id')::uuid
); Bind once, enforce everywhere
# The Rust runtime sets user context from the JWT before every query.
# No per-resolver checks. No forgotten guards.
@fraiseql.query(sql_source="v_user_visible_posts")
def posts() -> list[Post]:
pass Key insight: If a row is not in the view, it cannot be returned — regardless of what the application layer does. The database enforces it unconditionally.
Layer 2: Application-Layer RLS Policies
FraiseQL's built-in RLS policies compose WHERE clauses at the application layer before queries reach the database. This provides a second enforcement point with richer context from the authenticated user's JWT.
Configure policies in fraiseql.toml
[security]
default_policy = "authenticated"
# Built-in: multi-tenant isolation + owner filtering
[security.rls]
enable_tenant_isolation = true
tenant_field = "tenant_id"
owner_field = "author_id"
# Custom rules for write operations
[[security.rules]]
name = "allow_account_owners"
description = "Only account owners can modify account settings"
rule = "mutation * Account"
cacheable = false
[[security.rules]]
name = "allow_billing_admin"
description = "Only billing admins can manage subscriptions"
rule = "mutation * Subscription"
cacheable = false DefaultRLSPolicy: tenant isolation + ownership
# The DefaultRLSPolicy handles the most common cases automatically:
# - Admin users bypass RLS (no filter applied)
# - Non-admin users filtered by tenant_id (multi-tenant isolation)
# - Owner-based filtering via author_id field
#
# No code required — configure in fraiseql.toml.
# The Rust runtime composes the WHERE clause before every query. CompiledRLSPolicy: expression-based rules
# For complex policies, use expression syntax:
# "user.id == object.author_id" → owner check
# "user.roles includes 'admin'" → role bypass
# "user.tenant_id == object.tenant_id" → tenant isolation
#
# Expressions are compiled at startup, evaluated at runtime.
# Results are cacheable with configurable TTL. Layer 3: Field-Level RBAC
Even within an authorized row, some fields should only be visible to specific roles. FraiseQL uses OAuth-inspired scopes for fine-grained field access control.
Define roles and scopes in fraiseql.toml
[[security.role_definitions]]
name = "admin"
description = "Full access"
scopes = ["admin:*"]
[[security.role_definitions]]
name = "manager"
description = "Read users, write team content"
scopes = ["read:User.*", "read:Employee.*", "write:Post.content"]
[[security.role_definitions]]
name = "viewer"
description = "Read-only access to public fields"
scopes = ["read:Post.*", "read:User.name"] Protect fields with scope requirements
@fraiseql.type
class Employee:
id: ID
name: str # Public — no scope required
@fraiseql.requires_scope("read:Employee.email")
email: str # Managers and above only
@fraiseql.requires_scope("admin:*")
salary: float # Admins only
@fraiseql.requires_scope("read:Employee.*")
department: str # Managers and above Role-based resolver guards
@fraiseql.type
class OrgSettings:
id: ID
# Single role required
@fraiseql.role_required(roles="admin")
def system_config(self) -> str:
pass
# Any of multiple roles
@fraiseql.role_required(
roles=["manager", "hr", "admin"],
strategy=RoleMatchStrategy.ANY,
)
def headcount_report(self) -> int:
pass Policy-based authorization for complex rules
@fraiseql.authz_policy(
name="billingAdminOnly",
policy_type=AuthzPolicyType.RBAC,
rule="hasRole($context, 'billing_admin')",
audit_logging=True, # Every access attempt is logged
)
class BillingPolicy:
pass
@fraiseql.type
class Subscription:
id: ID
plan: str
@fraiseql.authorize(policy="billingAdminOnly")
billing_details: BillingDetails Key insight: Fields without a scope requirement are always accessible. Fields with a scope requirement are silently omitted — returning null — for unauthorized roles. No error, no information leakage.
Real-World: Multi-Team SaaS
All three layers work together. Each enforces a different dimension of authorization.
Authorization Rules:
- Users see only their own team's posts (Layer 1 — SQL view)
- Tenant data is always isolated (Layer 2 — RLS policy)
- Salary and PII fields require explicit scopes (Layer 3 — RBAC)
- Only billing admins can touch subscription mutations (Layer 2 — custom rule)
Layer 1: Row visibility
CREATE VIEW v_accessible_posts AS
SELECT jsonb_build_object(
'id', p.id::text,
'title', p.title,
'can_edit', (current_setting('app.user_role') IN ('admin', 'editor')
AND tm.user_id IS NOT NULL),
'team', jsonb_build_object('id', t.id::text, 'name', t.name)
) AS data
FROM tb_post p
JOIN tb_team t ON p.team_id = t.id
LEFT JOIN tb_team_member tm ON (
t.id = tm.team_id AND tm.user_id = current_setting('app.user_id')::uuid
)
WHERE current_setting('app.user_role') = 'admin'
OR EXISTS (
SELECT 1 FROM tb_team_member
WHERE team_id = p.team_id
AND user_id = current_setting('app.user_id')::uuid
); Layer 2: RLS policy (fraiseql.toml)
[security.rls]
enable_tenant_isolation = true # Tenant WHERE clause added automatically
tenant_field = "tenant_id"
[[security.rules]]
name = "billing_admin_only"
rule = "mutation * Subscription"
cacheable = false Layer 3: Field RBAC
@fraiseql.type
class Post:
id: ID
title: str # Public
@fraiseql.requires_scope("read:Post.content")
content: str # Viewers and above
@fraiseql.requires_scope("write:Post.*")
edit_history: list[str] # Editors and admins only Result: Each layer catches what the others cannot. No single point of failure.
Writes: Mutations and Authorization
Read authorization is handled by views and RLS. Write authorization is handled by database functions with explicit guards — keeping the same principle of centralized, auditable rules.
Write guards in DB functions
CREATE OR REPLACE FUNCTION fn_update_post(
p_post_id UUID,
p_title TEXT,
p_content TEXT
) RETURNS BOOLEAN AS $$
DECLARE
v_author_id UUID;
BEGIN
SELECT author_id INTO v_author_id FROM tb_post WHERE id = p_post_id;
-- Authorization guard: only author or admin can edit
IF v_author_id != current_setting('app.user_id')::uuid
AND current_setting('app.user_role') != 'admin' THEN
RAISE EXCEPTION 'Access denied';
END IF;
UPDATE tb_post SET title = p_title, content = p_content
WHERE id = p_post_id;
RETURN TRUE;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER; Bind to FraiseQL mutation
@fraiseql.mutation(sql_function="fn_update_post")
@fraiseql.role_required(roles=["editor", "admin"])
def update_post(post_id: ID, title: str, content: str) -> bool:
# Layer 3 (RBAC decorator) checks role before the call reaches the DB.
# Layer 2 (RLS rule) is enforced by fraiseql.toml config.
# The DB function enforces authorship — final line of defence.
pass Testing Authorization
Test row visibility directly in SQL
-- Test: team isolation
SET app.user_id = 'team-a-user-id';
SET app.user_role = 'viewer';
SELECT count(*) FROM v_accessible_posts; -- Returns only Team A posts
SET app.user_id = 'team-b-user-id';
SELECT count(*) FROM v_accessible_posts; -- Returns only Team B posts
-- No mocking. No resolver simulation. Deterministic and close to the source. Test field RBAC in Python
def test_salary_hidden_from_viewer():
context = SecurityContext.for_role("viewer")
employee = resolve_employee(id="emp-1", context=context)
assert employee.salary is None # Field silently omitted
def test_salary_visible_to_admin():
context = SecurityContext.for_role("admin")
employee = resolve_employee(id="emp-1", context=context)
assert employee.salary is not None # Full access Test RLS policies in isolation
def test_tenant_isolation():
tenant_a_ctx = SecurityContext(user_id="u1", tenant_id="tenant-a")
tenant_b_ctx = SecurityContext(user_id="u2", tenant_id="tenant-b")
posts_a = resolve_posts(context=tenant_a_ctx)
posts_b = resolve_posts(context=tenant_b_ctx)
# DefaultRLSPolicy guarantees no cross-tenant leakage
assert all(p.tenant_id == "tenant-a" for p in posts_a)
assert all(p.tenant_id == "tenant-b" for p in posts_b) Authorization Benefits
✅ Defense in Depth
Three independent layers. A bug in one doesn't expose data — the others hold.
✅ No Bypass Possible
Views filter at the database. RLS policies add WHERE clauses. Neither can be skipped by application code.
✅ Easy to Audit
SQL views and fraiseql.toml are readable. Compliance teams can verify rules without reading resolver code.
✅ Change Once, Apply Everywhere
Update a view or role definition — every query that uses it picks up the change automatically.
✅ Granular Field Control
Sensitive fields (salary, PII, billing) are invisible to unauthorized roles — not hidden client-side, absent at the API layer.
✅ Writes Are Covered Too
DB functions + role guards + RLS rules handle mutation authorization with the same clarity as reads.
Eliminate Authorization Sprawl
Three layers. Zero scattered checks. Clear, auditable, testable authorization.