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.

1

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.

2

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.

3

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.