Multi-Tenancy Guide
Row-level security and tenant isolation patterns.
A complete example of a production-ready, multi-tenant blogging platform using FraiseQL.
Repository: github.com/fraiseql/examples/saas-blog
CREATE TABLE tb_tenant ( pk_tenant SERIAL PRIMARY KEY, id UUID DEFAULT gen_random_uuid() NOT NULL UNIQUE, identifier TEXT NOT NULL UNIQUE, name TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT now(), subscription_tier TEXT NOT NULL DEFAULT 'free');
CREATE TABLE tb_user ( pk_user SERIAL PRIMARY KEY, id UUID DEFAULT gen_random_uuid() NOT NULL UNIQUE, identifier TEXT NOT NULL UNIQUE, fk_tenant INTEGER NOT NULL REFERENCES tb_tenant(pk_tenant), email TEXT UNIQUE NOT NULL, name TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'subscriber', -- admin, editor, subscriber created_at TIMESTAMPTZ DEFAULT now());
CREATE TABLE tb_post ( pk_post SERIAL PRIMARY KEY, id UUID DEFAULT gen_random_uuid() NOT NULL UNIQUE, identifier TEXT NOT NULL UNIQUE, fk_tenant INTEGER NOT NULL REFERENCES tb_tenant(pk_tenant), fk_user INTEGER NOT NULL REFERENCES tb_user(pk_user), title TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', published BOOLEAN NOT NULL DEFAULT FALSE, deleted_at TIMESTAMPTZ, -- Soft delete created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now());
CREATE TABLE tb_comment ( pk_comment SERIAL PRIMARY KEY, id UUID DEFAULT gen_random_uuid() NOT NULL UNIQUE, identifier TEXT NOT NULL UNIQUE, fk_tenant INTEGER NOT NULL REFERENCES tb_tenant(pk_tenant), fk_post INTEGER NOT NULL REFERENCES tb_post(pk_post), fk_user INTEGER NOT NULL REFERENCES tb_user(pk_user), content TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT now());
-- Views with JSONB data columnCREATE VIEW v_post ASSELECT p.id, p.identifier, jsonb_build_object( 'id', p.id::text, 'identifier', p.identifier, 'title', p.title, 'content', p.content, 'published', p.published, 'deleted_at', p.deleted_at, 'created_at', p.created_at, 'updated_at', p.updated_at, 'author', jsonb_build_object( 'id', u.id::text, 'name', u.name, 'email', u.email ) ) AS data, p.fk_tenant, p.deleted_atFROM tb_post pJOIN tb_user u ON u.pk_user = p.fk_userWHERE p.deleted_at IS NULL;
CREATE VIEW v_comment ASSELECT c.id, c.identifier, jsonb_build_object( 'id', c.id::text, 'content', c.content, 'created_at', c.created_at, 'author', jsonb_build_object( 'id', u.id::text, 'name', u.name ) ) AS data, c.fk_postFROM tb_comment cJOIN tb_user u ON u.pk_user = c.fk_user;import fraiseqlfrom fraiseql.scalars import IDfrom datetime import datetime
@fraiseql.typeclass Tenant: id: ID name: str created_at: datetime subscription_tier: str # free, pro, enterprise
@fraiseql.typeclass User: id: ID email: str name: str role: str # admin, editor, subscriber created_at: datetime
@fraiseql.typeclass Post: id: ID title: str content: str published: bool deleted_at: datetime | None # Soft delete created_at: datetime updated_at: datetime
@fraiseql.typeclass Comment: id: ID post_id: ID user_id: ID content: str created_at: datetime
@fraiseql.query(sql_source="v_post")def posts(first: int = 20, after: str | None = None) -> list[Post]: """Get tenant's published posts (filtered by RLS).""" pass
@fraiseql.query(sql_source="v_comment")def comments(post_id: ID, first: int = 20) -> list[Comment]: """Get comments for a post.""" pass# Get tenant's published posts (with pagination)query { posts(first: 20, after: "cursor123") { edges { node { id title author { name } comments(first: 5) { edges { node { content author { name } } } } } } pageInfo { hasNextPage endCursor } }}
# Real-time subscription for new commentssubscription { commentCreated(postId: "post123") { id content author { name } createdAt }}
# Search postsquery { searchPosts(query: "GraphQL", first: 10) { id title highlight }}# Create postmutation { createPost( title: "Getting Started with FraiseQL" content: "..." ) { id title author { name } }}
# Delete post (soft delete)mutation { deletePost(id: "post123") { id deletedAt }}
# Restore deleted postmutation { restorePost(id: "post123") { id deletedAt }}
# Add commentmutation { createComment(postId: "post123", content: "Great post!") { id content author { name } }}# JWT token includes tenant and role{ "user_id": "user123", "tenant_id": "tenant456", "role": "editor", "scopes": ["read:posts", "write:posts", "read:comments"]}
@fraiseql.query(sql_source="v_post")def posts(info) -> list[Post]: """Automatically filters to current tenant via RLS.""" # PostgreSQL RLS policy: # WHERE fk_tenant = (SELECT pk_tenant FROM tb_tenant WHERE id = current_setting('app.tenant_id')) pass
@fraiseql.mutationdef create_post(info, title: str, content: str) -> Post: """Only editors can create posts.""" user = get_current_user(info) if user['role'] not in ['admin', 'editor']: raise PermissionError("Only editors can create posts") pass-- Enable RLS on all tenant-scoped tablesALTER TABLE tb_post ENABLE ROW LEVEL SECURITY;ALTER TABLE tb_comment ENABLE ROW LEVEL SECURITY;ALTER TABLE tb_user ENABLE ROW LEVEL SECURITY;
-- Create policy for post visibility (tenant isolation)CREATE POLICY posts_tenant_isolation ON tb_postUSING (fk_tenant = ( SELECT pk_tenant FROM tb_tenant WHERE id = current_setting('app.tenant_id')::uuid));
-- Create policy for soft-deleted postsCREATE POLICY posts_exclude_deleted ON tb_postUSING (deleted_at IS NULL);
-- Set tenant context from requestSET app.tenant_id = 'tenant456';@fraiseql.middlewareasync def set_tenant_context(request, next): """Set tenant context from JWT token.""" user = get_current_user_from_request(request) if user: # Set PostgreSQL context for RLS await ctx.db.execute( "SELECT set_config('app.tenant_id', $1, true)", [user['tenant_id']] ) request.user = user return await next(request)@fraiseql.query(sql_source="v_post")def post(info, id: ID) -> Post: """Get single post (v_post already excludes soft-deleted).""" pass
@fraiseql.mutationasync def delete_post(info, id: ID) -> Post: """Soft-delete a post (sets deleted_at, does not remove row).""" result = await ctx.db.query_one( """ UPDATE tb_post SET deleted_at = NOW() WHERE id = $1 RETURNING id, deleted_at """, [id] ) return Post(**result)
@fraiseql.mutationasync def restore_post(info, id: ID) -> Post: """Restore a soft-deleted post.""" result = await ctx.db.query_one( """ UPDATE tb_post SET deleted_at = NULL WHERE id = $1 RETURNING id, deleted_at """, [id] ) return Post(**result)@fraiseql.typeclass AuditLog: id: ID user_id: ID entity_type: str # "post", "comment" entity_id: ID action: str # "create", "update", "delete" changes: dict # { field: { old: value, new: value } } created_at: datetime
# Middleware to log all mutations@fraiseql.middlewareasync def audit_mutations(request, next): """Audit all data modifications.""" result = await next(request) if is_mutation(request): changes = extract_changes(request, result) await log_audit_event(get_current_user(request), changes) return result@fraiseql.query(sql_source="v_post", cache_ttl=3600) # Cache for 1 hourdef popular_posts(first: int = 10) -> list[Post]: """Cache popular posts to reduce database load.""" pass
# Invalidate cache on mutations@fraiseql.mutationasync def create_post(info, title: str, content: str) -> Post: """Invalidate post cache after creating.""" result = await ctx.db.query_one( """ INSERT INTO tb_post (fk_tenant, fk_user, title, content, identifier) VALUES ( (SELECT pk_tenant FROM tb_tenant WHERE id = $1), (SELECT pk_user FROM tb_user WHERE id = $2), $3, $4, lower(regexp_replace($3, '[^a-zA-Z0-9]+', '-', 'g')) ) RETURNING id, title, content, published, deleted_at, created_at, updated_at """, [info.context.tenant_id, info.context.user_id, title, content] ) invalidate_cache("popular_posts") return Post(**result)Clone the example
git clone https://github.com/fraiseql/examples/saas-blogcd saas-blogSet up environment
cp .env.example .envuv syncStart database
docker-compose up -d postgres redisRun migrations
confiture migrateStart FraiseQL server
fraiseql runVisit GraphQL playground
open http://localhost:8000/graphqlTest the API
Create a tenant:
mutation { createTenant(name: "Demo Blog", identifier: "demo") { id name }}Create a user:
mutation { createUser( tenantId: "demo" email: "admin@example.com" name: "Admin User" role: "admin" ) { id email role }}Create a post:
mutation { createPost( tenantId: "demo" title: "Hello World" content: "My first blog post with FraiseQL!" published: true ) { id title published author { name } }}Verify tenant isolation
Query posts from the first tenant:
query { posts(tenantId: "demo", limit: 10) { id title }}Create a second tenant and verify isolation:
mutation { createTenant(name: "Another Blog", identifier: "another") { id }}Query posts from second tenant (should be empty):
query { posts(tenantId: "another", limit: 10) { id title }}Run tests
pytest tests/ -vExpected output:
tests/test_tenancy.py::test_tenant_isolation PASSEDtests/test_posts.py::test_create_post PASSEDtests/test_posts.py::test_soft_delete PASSEDtests/test_auth.py::test_role_based_access PASSEDtests/test_subscriptions.py::test_live_updates PASSEDError: Cannot connect to databaseSolutions:
docker-compose up -d postgrespg_isready -h localhost -p 5432If you get permission errors:
Ensure RLS policy is created:
CREATE POLICY tenant_isolation ON tb_postUSING (fk_tenant = current_setting('app.tenant_id')::int);Verify tenant_id is being set in request context
fraiseql compileError: Unknown type 'Post'Check:
'Post'fraiseql validateIf live updates don’t appear:
wscat -c ws://localhost:8080/wsIf queries are slow:
Add indexes:
CREATE INDEX idx_post_tenant_created ON tb_post(fk_tenant, created_at DESC);CREATE INDEX idx_post_published ON tb_post(published) WHERE published = true;Enable caching in fraiseql.toml:
[caching]enabled = truebackend = "redis"Multi-Tenancy Guide
Row-level security and tenant isolation patterns.
Advanced Patterns
Soft deletes, audit logs, and pagination.
Deployment
Kubernetes deployment with monitoring.
Federation
Scale to multiple databases.