title: FraiseQL Security Model: Authorization, Row-Level Security, Field Masking, and Audit description: FraiseQL security operates on five pillars. keywords: ["design", "scalability", "performance", "patterns", "security"] tags: ["documentation", "reference"]
FraiseQL Security Model: Authorization, Row-Level Security, Field Masking, and Audit¶
Status: System overview for v1 (PostgreSQL, Python/FastAPI runtime) Audience: Security architects, compliance engineers, application developers, operations teams
Executive Summary¶
FraiseQL security operates on five pillars:
- Authentication — Verify user identity (an
AuthProvider, e.g. Auth0 or a custom JWT issuer) - Operation & role authorization — Control which operations a user may run (an app-supplied
Authorizerplus therequires_*resolver decorators) - Row-Level Security (RLS) — Filter data per user/tenant (PostgreSQL-enforced policies)
- Field-level authorization & redaction — Hide sensitive fields per user (
authorize_field, and view-layer redaction) - Audit Logging — Track who did what and when (durable rows in PostgreSQL)
Core principle: Enforcement is layered. The Python runtime gates operations and fields; PostgreSQL enforces row scoping. FraiseQL never returns data the authorization layer denies, and the fail-closed enforcement applies even on the Rust/turbo/APQ resolver-bypass paths.
Security properties:
- ✅ Runtime enforcement — Authorization is evaluated on every request, at app startup the schema is wired with the enforcement layer.
- ✅ No resolver bypasses — Operation and field gates are re-applied on the Rust passthrough, turbo, and APQ chokepoints, so they fail closed.
- ✅ Deterministic — The same context + operation + arguments produce the same decision (and may be cached by a
DecisionCache). - ✅ Database-enforced row scoping — RLS policies live in PostgreSQL, so even a direct database session honors them.
- ✅ Auditable — Access attempts can be recorded as durable rows.
Security Pipeline¶
Diagram: Security Architecture — Multi-layer pipeline from request to response.
direction: right
Request: "GraphQL Request\n(with JWT token)" {
shape: box
style.fill: "#e3f2fd"
}
Authn: "1. Authentication\n(Verify identity)" {
shape: box
style.fill: "#f3e5f5"
}
QueryAuth: "2. Operation Authorization\n(Authorizer / requires_*)" {
shape: box
style.fill: "#fff3e0"
}
RLS: "3. Row-Level Security\n(PostgreSQL filters rows)" {
shape: box
style.fill: "#f1f8e9"
}
FieldAuth: "4. Field Authorization\n(authorize_field / view redaction)" {
shape: box
style.fill: "#ffe0b2"
}
Audit: "5. Audit Logging\n(Record access)" {
shape: box
style.fill: "#ffccbc"
}
Response: "Response\n(Authorized data)" {
shape: box
style.fill: "#c8e6c9"
}
Denied: "Access Denied" {
shape: box
style.fill: "#ffebee"
}
Request -> Authn
Authn -> QueryAuth: "UserContext"
Authn -> Denied: "Token invalid"
QueryAuth -> RLS: "Operation allowed"
QueryAuth -> Denied: "Operation denied"
RLS -> FieldAuth: "Row-filtered data"
FieldAuth -> Audit: "Authorized fields"
Audit -> Response: "Log recorded"
1. Authentication Context¶
1.1 User Context¶
Authenticated requests carry a UserContext at info.context["user"]. It is a plain
dataclass (fraiseql.auth.UserContext):
from fraiseql.auth import UserContext
# Built by your AuthProvider after the JWT is validated.
user = UserContext(
user_id="user-456", # subject claim
email="alice@company.com",
name="Alice",
roles=["user", "member", "team-lead"],
permissions=["read:posts", "write:own:posts"],
metadata={"organization_id": "org-123", "department": "engineering"},
)
user.has_role("team-lead") # True
user.has_permission("read:posts") # True
user.has_any_role(["admin", "team-lead"]) # True
user.has_any_permission(["write:posts", "write:own:posts"]) # True
UserContext exposes has_role, has_permission, has_any_role, has_any_permission,
has_all_roles, and has_all_permissions. Anything not modeled as a role or permission
(organization, department, tenant) lives in metadata.
1.2 Context Binding¶
A provider validates the token and builds the UserContext; FastAPI's request lifecycle
places it on info.context["user"]. v1 ships Auth0 (Auth0Provider/Auth0Config)
and a generic/native path, plus a Rust-accelerated JWT validator
(RustCustomJWTProvider) — all Python-facing:
from fraiseql.auth import AuthProvider, UserContext
class MyProvider(AuthProvider):
async def validate_token(self, token: str) -> dict:
# Verify signature/issuer/audience against your JWKS, return the claims.
...
async def get_user_from_token(self, token: str) -> UserContext:
claims = await self.validate_token(token)
return UserContext(
user_id=claims["sub"],
email=claims.get("email"),
roles=claims.get("roles", []),
permissions=claims.get("permissions", []),
metadata={"organization_id": claims.get("org_id")},
)
Wire it in when building the app:
from fraiseql.fastapi import create_fraiseql_app
app = create_fraiseql_app(
database_url="postgresql://localhost/mydb",
types=[User, Post],
queries=[users, user],
mutations=[create_post],
auth=MyProvider(),
production=True,
)
For any OIDC issuer (Google, Keycloak, Okta, ...) there are two honest paths: front it
with Auth0 as a broker (auth_provider="auth0"), or set auth_provider="custom"
and subclass AuthProvider to validate that issuer's JWTs. v1 does not ship per-vendor
provider classes.
1.3 Context Immutability¶
Treat the UserContext as read-only for the lifetime of a request. It is built once
during authentication; resolvers read it but must never mutate it to escalate roles or
impersonate another user. Tenant/row-scoping decisions derived from it are pushed into
PostgreSQL session variables (Section 3) inside the request transaction, so privilege
cannot be changed mid-request.
2. Operation Authorization¶
v1 gives you two complementary mechanisms for operation-level (query/mutation/subscription)
authorization. Both are Python and both surface a denial as a GraphQL error with
extensions.code (no HTTP 4xx from a separate router).
2.1 requires_* resolver decorators¶
The quickest gate: decorate a resolver with one of the decorators from
fraiseql.auth. They read info.context["user"] (a UserContext) and raise a
GraphQLError when the check fails.
import fraiseql
from fraiseql.auth import (
requires_auth,
requires_role,
requires_permission,
)
from fraiseql.auth.decorators import requires_any_role, requires_any_permission
from fraiseql.types import ID
@fraiseql.query
@requires_auth
async def me(info) -> "User":
"""Any authenticated user."""
db = info.context["db"]
user = info.context["user"]
return await db.find_one("v_user", id=user.user_id)
@fraiseql.query
@requires_role("admin")
async def all_users(info) -> list["User"]:
"""Only users with the 'admin' role."""
db = info.context["db"]
return await db.find("v_user")
@fraiseql.mutation
@requires_permission("posts:write")
async def create_post(info, input: "CreatePostInput") -> "CreatePostSuccess":
"""Requires the 'posts:write' permission."""
db = info.context["db"]
result = await db.execute_function("fn_create_post", {...})
...
@fraiseql.mutation
@requires_any_role("admin", "moderator")
async def delete_post(info, id: ID) -> "DeletePostSuccess":
"""Either 'admin' or 'moderator'."""
...
| Decorator | Denies with extensions.code |
Passes when |
|---|---|---|
requires_auth |
UNAUTHENTICATED (no UserContext) |
a UserContext is present |
requires_role("admin") |
UNAUTHENTICATED or FORBIDDEN |
user.has_role("admin") |
requires_permission("posts:write") |
UNAUTHENTICATED or FORBIDDEN |
user.has_permission(...) |
requires_any_role("admin", "mod") |
UNAUTHENTICATED or FORBIDDEN |
user.has_any_role([...]) |
requires_any_permission(...) |
UNAUTHENTICATED or FORBIDDEN |
user.has_any_permission([...]) |
2.2 The Authorizer policy contract¶
For richer, centralized policy (RBAC/ABAC, per-operation decisions, row-scoping filters,
decision caching), supply an Authorizer. It is a structural protocol
(fraiseql.security.Authorizer) with a single method; the framework enforces, the
authorizer decides:
from typing import Any
from fraiseql.security import Authorizer, AuthorizationDecision
class PolicyAuthorizer:
async def authorize_operation(
self,
*,
context: dict[str, Any],
operation_type: str, # "query" | "mutation" | "subscription"
operation_name: str,
arguments: dict[str, Any],
) -> AuthorizationDecision | bool:
user = context.get("user")
if user is None:
return AuthorizationDecision.deny(
code="UNAUTHENTICATED", message="Login required"
)
if operation_name == "all_users" and not user.has_role("admin"):
return AuthorizationDecision.deny(message="Admins only")
# Allow, and AND a row-scoping filter into the read path:
return AuthorizationDecision.allow(
filters={"organization_id": user.metadata["organization_id"]}
)
AuthorizationDecision is an immutable value with two constructors:
AuthorizationDecision.allow(filters=None)— allow; optionalfiltersare AND-ed into the repository'smandatory_filterson the read path (ignored on mutations and bypass paths).AuthorizationDecision.deny(code="FORBIDDEN", message=None)— deny;codeis surfaced as the GraphQL error'sextensions.code.
A plain bool is sugar: True → allow(), False → deny(). Implementations may be
sync or async.
Attach an authorizer per operation or globally:
# Per operation — overrides the global default for this resolver:
@fraiseql.query(authorizer=PolicyAuthorizer())
async def all_users(info) -> list["User"]: ...
@fraiseql.subscription(authorizer=PolicyAuthorizer())
async def order_events(info): ...
# Globally — applied to every top-level operation:
app = create_fraiseql_app(
database_url="postgresql://localhost/mydb",
types=[User],
queries=[all_users],
authorizer=PolicyAuthorizer(),
authorization_cache=..., # optional DecisionCache
)
Fail-closed semantics (verified). Enforcement lives in one place so every site
inherits it: if no authorizer is configured the operation is allowed (no-op fast path);
an authorizer that raises anything other than a GraphQLError is treated as deny
(never falls through to allow) and the raw exception is logged but never surfaced. The
same enforcement is re-applied at the Rust passthrough, turbo-router, and APQ chokepoints
so those bypass paths cannot fail open.
Decision caching. When an authorization_cache (a DecisionCache) is supplied, a
fresh cache hit replays the prior allow/deny without re-invoking the authorizer; an
authorizer that raises hits the fail-closed branch and is never cached, so a transient
error can neither pin a deny nor leak an allow.
3. Row-Level Security (RLS)¶
Row scoping in v1 is PostgreSQL Row-Level Security — raw SQL policies on the underlying tables, not a FraiseQL decorator. The framework's job is to push request context into PostgreSQL session variables so your policies can read it.
3.1 How tenant/user context reaches the database¶
The CQRS repository (FraiseQLRepository) issues SET LOCAL for known keys in
info.context at the start of each transaction (verified in src/fraiseql/db.py,
_set_session_variables). When the context carries tenant_id, user_id,
is_super_admin, etc., it sets matching GUCs:
-- Emitted automatically per transaction when present in info.context:
SET LOCAL app.tenant_id = '...' ;
SET LOCAL app.user_id = '...' ;
SET LOCAL app.is_super_admin = ... ;
So tenant context flows: request → info.context["tenant_id"] → SET LOCAL app.tenant_id
→ your RLS policy via current_setting('app.tenant_id'). Populate info.context from the
UserContext in your context_getter.
3.2 Writing the policies (PostgreSQL)¶
RLS lives on the write tables (tb_) and is inherited by the read views (v_/tv_) that
select from them. Enable RLS and write policies that read the session GUCs:
-- Multi-tenant isolation: a row is visible only to its tenant.
ALTER TABLE tb_post ENABLE ROW LEVEL SECURITY;
CREATE POLICY post_tenant_isolation ON tb_post
USING (tenant_id = current_setting('app.tenant_id')::uuid);
-- Owner-or-admin: see your own rows, or all rows if super admin.
ALTER TABLE tb_user ENABLE ROW LEVEL SECURITY;
CREATE POLICY user_owner_or_admin ON tb_user
USING (
id = current_setting('app.user_id')::uuid
OR current_setting('app.is_super_admin', true)::boolean
);
-- Team access: same tenant AND (owner OR team member OR admin).
CREATE POLICY task_team_access ON tb_task
USING (
tenant_id = current_setting('app.tenant_id')::uuid
AND (
owner_id = current_setting('app.user_id')::uuid
OR team_id = ANY(
string_to_array(current_setting('app.team_ids', true), ',')::uuid[]
)
OR current_setting('app.is_super_admin', true)::boolean
)
);
Use the two-argument form current_setting('app.x', true) (the true = "missing OK")
for variables that may be absent, so an unauthenticated/system query degrades to a safe
default rather than erroring.
3.3 RLS at query time¶
A @query resolver reads a v_/tv_ view via db.find / db.find_one. The repository
opens a transaction, sets the session GUCs, then runs the SELECT — and PostgreSQL applies
the policies transparently:
@fraiseql.query
async def posts(info) -> list[Post]:
db = info.context["db"]
return await db.find("v_post") # RLS filters rows by app.tenant_id
-- What PostgreSQL effectively executes (policy AND-ed in by the engine):
SELECT id, data FROM v_post
WHERE tenant_id = current_setting('app.tenant_id')::uuid
ORDER BY ...;
A caller who supplies where: { tenant_id: "other-org" } still cannot widen the result:
the RLS policy is AND-ed in by PostgreSQL itself, so the predicate is tenant_id = <mine>
AND tenant_id = 'other-org' — empty for a different tenant.
3.4 Belt-and-braces: mandatory_filters¶
In addition to (not instead of) database RLS, reads can pass explicit
mandatory_filters to db.find/db.count, and an Authorizer can return
row-scoping filters that the repository AND-merges into mandatory_filters:
return await db.find("v_post", mandatory_filters={"tenant_id": tenant_id})
RLS is the authoritative, database-enforced control; mandatory_filters and authorizer
filters are application-level reinforcement.
4. Field-Level Authorization and Redaction¶
"Field masking" in v1 is achieved two ways — pick per field:
authorize_field— a field-level authorization gate. When the check fails, the field raises aFieldAuthorizationError(GraphQL error withextensions.code = "FIELD_AUTHORIZATION_ERROR"); it does not silently return a substitute value.- View-layer redaction — design the
v_/tv_view'sdataJSONB to exclude or replace sensitive keys per role/tenant in SQL, so unauthorized callers never receive the value at all.
4.1 authorize_field¶
authorize_field (from fraiseql.security) wraps a computed @fraiseql.field resolver
with a permission check. The check receives info (and optionally the parent root) and
returns a bool or an AuthorizationDecision:
import fraiseql
from fraiseql.security import authorize_field, any_permission, combine_permissions
@fraiseql.type(sql_source="v_user", jsonb_column="data")
class User:
id: ID
username: str
@fraiseql.field
@authorize_field(
any_permission(
lambda info, root: info.context["user"].user_id == root.id, # owner
lambda info: info.context["user"].has_role("admin"), # or admin
),
error_message="You can only view your own email",
)
def email(self) -> str:
return self._email
@fraiseql.field
@authorize_field(lambda info: info.context["user"].has_role("admin"))
def ssn(self) -> str:
return self._ssn
Two combinators ship with it:
combine_permissions(*checks)— AND, every check must pass.any_permission(*checks)— OR, at least one check must pass.
Checks may be sync or async, plain lambdas or named functions; they can read roles and
permissions off info.context["user"].
4.2 Declaring authorized fields on a type¶
@fraiseql.type(..., authorize_fields=[...]) marks fields that require the per-field gate.
This list is the per-field authorization gate that the runtime re-applies even on the
Rust/turbo/APQ bypass paths (so JSONB served directly still honors it):
@fraiseql.type(
sql_source="v_user",
jsonb_column="data",
authorize_fields=["email", "ssn"],
)
class User:
id: ID
username: str
email: str
ssn: str
4.3 Redaction at the view layer¶
For data that should never reach an unauthorized client, redact in the view's data
JSONB rather than fetching then hiding. The view can branch on the session GUCs RLS uses:
CREATE VIEW v_customer AS
SELECT
c.id,
jsonb_build_object(
'id', c.id,
'name', c.name,
-- Full card number only for admins; everyone else gets the last 4.
'cardNumber',
CASE
WHEN current_setting('app.is_super_admin', true)::boolean
THEN c.card_number
ELSE '**** **** **** ' || right(c.card_number, 4)
END
) AS data
FROM tb_customer c;
This keeps the sensitive value inside PostgreSQL and out of the wire entirely for unauthorized callers — the most robust form of "masking" in v1.
5. Built-In Role and Permission Checks¶
v1's authorization is role- and permission-based, evaluated against the
UserContext. There is no registry of named string rules; you compose checks from the
UserContext predicates, the requires_* decorators, and your Authorizer:
# "authenticated"
user = info.context.get("user")
if user is None:
raise GraphQLError("Login required", extensions={"code": "UNAUTHENTICATED"})
# "admin only"
@requires_role("admin")
# "owner or admin" (field check)
any_permission(
lambda info, root: info.context["user"].user_id == root.owner_id,
lambda info: info.context["user"].has_role("admin"),
)
# "organization member" (in an Authorizer)
if arguments["org_id"] != user.metadata.get("organization_id"):
return AuthorizationDecision.deny(message="Not a member of this organization")
Centralize business-specific policy in one Authorizer so it is testable headless and
reused across operations.
6. Audit Logging¶
Audit logging in v1 is an application pattern backed by PostgreSQL: you record access
attempts as durable rows (and optionally export them). FraiseQL gives you the hooks —
the UserContext, the operation name in resolver/authorizer context, and fn_
functions — to write those rows.
6.1 Audit log table¶
CREATE TABLE tb_audit_log (
pk_audit_log BIGSERIAL PRIMARY KEY,
id UUID NOT NULL DEFAULT gen_random_uuid(),
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
user_id UUID NOT NULL,
tenant_id UUID,
action TEXT NOT NULL, -- 'query' | 'mutation' | 'subscription'
resource_type TEXT NOT NULL, -- 'Post', 'User', ...
resource_id UUID,
operation_name TEXT NOT NULL,
authorized BOOLEAN NOT NULL,
fields_accessed JSONB,
error_code TEXT,
ip_address INET,
trace_id UUID
);
CREATE INDEX idx_audit_user_time ON tb_audit_log (user_id, occurred_at DESC);
CREATE INDEX idx_audit_resource ON tb_audit_log (resource_type, resource_id);
6.2 Writing audit rows¶
Record allow/deny from your Authorizer or inside a mutation fn_ function. Example
JSONB payload your fn_audit_log might persist:
{
"occurredAt": "2026-01-15T10:30:45.123Z",
"userId": "user-456",
"action": "query",
"resourceType": "Post",
"resourceId": "post-789",
"operationName": "GetUserPosts",
"authorized": true,
"fieldsAccessed": ["id", "title", "author"],
"errorCode": null,
"ipAddress": "203.0.113.45",
"traceId": "trace-abc123"
}
A denied attempt records the failure mode:
{
"occurredAt": "2026-01-15T10:30:46.456Z",
"userId": "user-456",
"action": "mutation",
"resourceType": "Post",
"resourceId": "post-789",
"operationName": "DeletePost",
"authorized": false,
"errorCode": "FORBIDDEN",
"ipAddress": "203.0.113.45",
"traceId": "trace-def456"
}
6.3 Querying the audit trail¶
-- Who accessed this sensitive record?
SELECT * FROM tb_audit_log
WHERE resource_type = 'User' AND resource_id = 'user-123'
ORDER BY occurred_at DESC;
-- Did authorization ever fail for this user?
SELECT * FROM tb_audit_log
WHERE user_id = 'user-456' AND authorized = false
ORDER BY occurred_at DESC;
-- What did anyone do in the last 24 hours?
SELECT * FROM tb_audit_log
WHERE occurred_at > now() - INTERVAL '1 day'
ORDER BY occurred_at DESC;
-- When was a sensitive field accessed?
SELECT * FROM tb_audit_log
WHERE fields_accessed @> '["ssn", "credit_card"]'
ORDER BY occurred_at DESC;
Make the table append-only with a BEFORE UPDATE/BEFORE DELETE trigger (or restricted
grants) if your compliance regime requires immutability.
7. Compliance & Security Standards¶
The same primitives map onto common compliance requirements.
7.1 GDPR¶
- Right to erasure — a
@fraiseql.mutationguarded by@requires_auth(and an ownership check) calls anfn_request_data_deletionfunction that marks the record for deletion while preserving the audit trail. - Access logging — every data access is recorded in
tb_audit_log(Section 6). - Data portability — an owner-scoped
@fraiseql.queryreturning aJSONscalar exports the user's data from av_user_exportview.
import fraiseql
from fraiseql.auth import requires_auth
from fraiseql.types import ID, JSON
@fraiseql.mutation
@requires_auth
async def request_data_deletion(info, user_id: ID) -> "DeletionSuccess":
"""Mark personal data for deletion (audit trail preserved)."""
db = info.context["db"]
return await db.execute_function("fn_request_data_deletion", {"user_id": user_id})
@fraiseql.query
@requires_auth
async def export_user_data(info, user_id: ID) -> JSON:
"""Export all data for the authenticated owner."""
db = info.context["db"]
return await db.find_one("v_user_export", id=user_id)
7.2 HIPAA¶
- Access controls — restrict PHI types to clinical roles with
@requires_role/anAuthorizer; enforce patient/provider row scoping with RLS on the underlying tables. - Field-level protection — gate sensitive columns with
authorize_field, or redact in thev_view so PHI never leaves PostgreSQL for unauthorized callers. - Audit trail — log all PHI access to
tb_audit_log; retain per policy. - Transport/at-rest — terminate TLS at the app, encrypt sensitive columns in
PostgreSQL (e.g.
pgcrypto).
@fraiseql.type(
sql_source="v_patient_record",
jsonb_column="data",
authorize_fields=["medical_history"],
)
class PatientRecord:
id: ID
patient_id: ID
medical_history: str
7.3 PCI-DSS¶
- Never log cardholder data — keep card numbers out of audit payloads.
- Field redaction — show only a masked PAN to non-admins via the view's
dataJSONB (Section 4.3), or gatecard_numberwithauthorize_field. - Tokenization — store a token reference (the
ApiKey/HashSHA256-style scalars andpgcryptohelp), not the raw card data.
CREATE VIEW v_payment AS
SELECT
p.id,
jsonb_build_object(
'id', p.id,
'cardNumber',
CASE
WHEN current_setting('app.is_super_admin', true)::boolean
THEN p.card_number
ELSE '**** **** **** ' || right(p.card_number, 4)
END
) AS data
FROM tb_payment p;
8. Security Best Practices¶
8.1 Authorization¶
DO:
- ✅ Gate sensitive operations with
@requires_*and/or a centralAuthorizer. - ✅ Enforce row scoping with PostgreSQL RLS — it survives even a direct DB session.
- ✅ Derive every decision from
info.context["user"], never from client-supplied claims. - ✅ Keep policy in one testable
Authorizerand unit-test it headless. - ✅ Test authorization with multiple roles, including the unauthenticated case.
DON'T:
- ❌ Rely on client-side authorization checks.
- ❌ Trust user-provided role/organization/tenant claims from the request body.
- ❌ Use overly permissive defaults — fail closed.
- ❌ Hardcode user IDs; always read from the
UserContext.
8.2 Field Protection¶
DO:
- ✅ Redact PII/PHI/financial fields at the view layer when they should never leave the DB.
- ✅ Gate computed-field access with
authorize_field+authorize_fields=[...]. - ✅ Test field access with unauthorized users; confirm bypass paths fail closed.
- ✅ Document which fields are protected and why.
DON'T:
- ❌ Rely on field gates instead of RLS for row-level secrets.
- ❌ Return misleading substitute values where a clear denial is expected.
- ❌ Skip protection for "less important" data.
8.3 Audit Logging¶
DO:
- ✅ Record access (allow and deny) for sensitive resources.
- ✅ Retain audit rows per your compliance requirements.
- ✅ Make the audit table append-only; alert on suspicious patterns.
DON'T:
- ❌ Log secrets or cardholder data in the audit trail.
- ❌ Allow audit rows to be silently modified or deleted.
9. Security Configuration¶
Security in v1 is configured through create_fraiseql_app(...) keyword arguments,
FraiseQLConfig (pydantic, FRAISEQL_ env vars), and PostgreSQL itself — not a TOML
file or a separate config object.
9.1 Application wiring¶
from fraiseql.fastapi import create_fraiseql_app
app = create_fraiseql_app(
database_url="postgresql://localhost/mydb",
types=[User, Post],
queries=[users, user],
mutations=[create_post],
auth=MyProvider(), # authentication
authorizer=PolicyAuthorizer(), # global operation authorization
authorization_cache=..., # optional DecisionCache
production=True, # disables the playground / introspection conveniences
)
create_fraiseql_app has no middleware= kwarg — add FastAPI middleware (CORS,
security headers, rate limiting) with app.add_middleware(...) on the returned app, or
pass your own app via create_fraiseql_app(app=...). The fraiseql.security module also
ships rate-limiting, CSRF, and security-header middleware helpers.
9.2 Environment variables¶
Authentication and related settings live in FraiseQLConfig and are overridable via
FRAISEQL_-prefixed environment variables, for example:
# Provider selection: "auth0" | "custom" | "none" (default "none")
FRAISEQL_AUTH_PROVIDER=auth0
FRAISEQL_AUTH0_DOMAIN=your-tenant.auth0.com
FRAISEQL_AUTH0_API_IDENTIFIER=https://api.example.com
# Database connection (PostgreSQL); enforce TLS + scram-sha-256 in pg_hba.conf
FRAISEQL_DATABASE_URL=postgresql://user@host/db?sslmode=require
Database connection authentication (SCRAM-SHA-256, TLS) is configured in PostgreSQL's
pg_hba.conf and the database_url/sslmode, not in FraiseQL code.
10. Troubleshooting Security Issues¶
10.1 User getting "Access Denied"¶
- Authenticated? Confirm
info.context["user"]is aUserContext— a missing user denies withextensions.code = "UNAUTHENTICATED". - Right role/permission? Inspect
user.roles/user.permissions; arequires_role("admin")gate denies withFORBIDDENand anextensions.required_role. - Authorizer decision? Log the
AuthorizationDecisionfor the operation — anAuthorizerthat raises fails closed (deny) by design. - Owns the resource? For ownership rules, compare
user.user_idagainst the row's owner column. - Audit row? Query
tb_audit_log WHERE user_id = ... AND authorized = false.
10.2 User seeing data they shouldn't see¶
- RLS enabled?
SELECT relrowsecurity FROM pg_class WHERE relname = 'tb_post';— and confirm a policy exists withSELECT * FROM pg_policies WHERE tablename = 'tb_post';. - Session GUC set? Verify
info.contextcarriestenant_id/user_idsoSET LOCAL app.tenant_idis emitted — an unset GUC with a one-argcurrent_settingwould error; with the two-arg form it falls to a default. - Field gate present? Check the field is listed in
authorize_fieldsor wrapped withauthorize_field, and that the view redacts it where required. - Bypass path? Confirm the operation/field gate is re-applied on the relevant Rust/turbo/APQ chokepoint (these are the historical fail-open risk).
11. Summary: Security Architecture¶
┌──────────────────────────────────────────┐
│ User Request (with JWT token) │
└────────────┬─────────────────────────────┘
│
┌──────▼──────┐
│ Authenticate│ AuthProvider validates JWT, builds UserContext
│ (Python) │
└──────┬──────┘
│
┌──────▼──────────────┐
│ Operation Authz │ requires_* decorators / Authorizer
│ (runtime, Python) │ — fail closed, re-applied on bypass paths
└──────┬──────────────┘
│
┌──────▼──────────────┐
│ Row-Level Security │ PostgreSQL policies read SET LOCAL app.* GUCs
│ (PostgreSQL) │
└──────┬──────────────┘
│
┌──────▼──────────────┐
│ Field Authorization │ authorize_field / authorize_fields / view redaction
│ (runtime + SQL) │
└──────┬──────────────┘
│
┌──────▼──────────────┐
│ Audit Log │ durable rows in tb_audit_log (append-only)
└──────┬──────────────┘
│
┌──────▼──────────────┐
│ Response │ Authorized data to client
└─────────────────────┘
FraiseQL v1 provides defense-in-depth: authentication (AuthProvider), operation
authorization (requires_* / Authorizer, fail-closed across bypass paths), PostgreSQL
Row-Level Security for row scoping, field-level authorization and view-layer redaction for
sensitive columns, and PostgreSQL-backed audit logging. Enforcement is layered across the
Python runtime and the database.