Skip to main content

Overview

Athena uses a shared database, shared schema architecture where multiple schools (tenants) share the same PostgreSQL database and tables. Tenant isolation is enforced through:
  1. Database: Every table has a school_id column
  2. Backend: Middleware automatically injects school_id into queries
  3. Frontend: Users belong to one or more schools via school_memberships
Critical: Tenant isolation is enforced in the backend, not with PostgreSQL Row Level Security (RLS). This is an intentional architectural decision.

Why This Approach?

Shared Schema Architecture

✅ Chosen: Shared Schema

  • Single codebase
  • Easy cross-tenant analytics (superadmin)
  • Simple backup/restore
  • Lower operational overhead
  • Cost-effective for 20-40 schools

❌ Not Using: Schema-per-Tenant

  • Complex migration management
  • Harder to aggregate data
  • Higher operational cost
  • Only needed at massive scale (1000+ tenants)

Backend Enforcement (No RLS)

Testable
# Easy to test isolation
async def test_tenant_isolation():
    # User from school A tries to access school B data
    response = await client.get("/students", headers={"X-School-Id": school_b})
    assert response.status_code == 403
Debuggable
  • Stack traces show where tenant check happens
  • No hidden database policies
  • Logs show tenant context
Portable
  • Works on any PostgreSQL
  • No vendor lock-in to Supabase/RLS
  • Easy to migrate to GCP Cloud SQL
Centralized Logic
  • All business rules in Python
  • No split between DB and app
  • Version controlled with Git
Hard to Test
  • Policies run inside PostgreSQL
  • Harder to mock for unit tests
  • Must set app.settings.jwt.claims.tenant_id in every test query
Hard to Debug
  • Silent failures (empty results instead of errors)
  • No Python stack trace
  • Must check PostgreSQL logs
Vendor Lock-In
  • RLS syntax varies between vendors
  • Migration requires rewriting policies
  • Not all managed services support RLS fully
Split Logic
  • Permissions in both database and backend
  • Harder to understand complete flow
  • Two places to update when rules change
Trade-off Accepted: A bug in backend middleware could leak data. This is mitigated by:
  • Comprehensive integration tests for tenant isolation
  • Code reviews focused on tenant checks
  • Middleware that makes forgetting the filter the exception, not the rule

How It Works

1. Database: Every Table Has school_id

CREATE TABLE students (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    school_id UUID NOT NULL REFERENCES schools(id),  -- ← Tenant key
    full_name VARCHAR(255) NOT NULL,
    -- ... other fields
);

-- Composite index for performance
CREATE INDEX idx_students_tenant ON students(school_id);
CREATE INDEX idx_students_tenant_name ON students(school_id, full_name);
The only table without school_id is schools itself (the tenant root).

2. Backend: Automatic Tenant Injection

Middleware Flow

Implementation: deps.py

from fastapi import Depends, Header, HTTPException
from app.models.school import School

@dataclass
class AuthContext:
    user: User
    payload: TokenPayload
    membership: SchoolMembership | None = None
    school: School | None = None
    
    @property
    def school_id(self) -> uuid.UUID | None:
        return self.school.id if self.school else None

async def get_auth_context(
    x_school_id: uuid.UUID | None = Header(None, alias="X-School-Id"),
    current_user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
) -> AuthContext:
    # Get user's memberships
    result = await db.execute(
        select(SchoolMembership).where(
            SchoolMembership.user_id == current_user.id,
            SchoolMembership.is_active.is_(True),
        )
    )
    memberships = list(result.scalars().all())
    
    # Resolve requested school
    if x_school_id:
        membership = next(
            (m for m in memberships if m.school_id == x_school_id),
            None
        )
        if not membership and not is_superadmin(current_user):
            raise HTTPException(403, "No access to this school")
    
    # Load school
    school = await db.get(School, x_school_id)
    
    return AuthContext(
        user=current_user,
        membership=membership,
        school=school,
        memberships=memberships,
    )

async def get_current_tenant(
    auth: AuthContext = Depends(get_auth_context),
) -> School:
    """Dependency that requires a school context."""
    if auth.school is None:
        raise HTTPException(403, "No school context")
    return auth.school
Best Practice: Every endpoint should depend on get_current_tenant or get_auth_context. This makes forgetting the school_id filter impossible.

3. Frontend: School Selection

Single-School Users

// User belongs to only one school
const authStore = useAuthStore();

// Auth store automatically sets X-School-Id header
const students = await api.get('/students');
// → Filtered to user's school automatically

Multi-School Users (Superadmin)

// Superadmin can access multiple schools
const [activeSchool, setActiveSchool] = useState<School | null>(null);

// Explicitly set school context
const students = await api.get('/students', {
  headers: {
    'X-School-Id': activeSchool.id
  }
});
The frontend Axios client automatically injects X-School-Id from Zustand auth store for all requests.

Roles & Permissions

Role Definitions

from enum import StrEnum

class Role(StrEnum):
    SUPERADMIN = "superadmin"    # Multi-tenant access
    RECTOR = "rector"            # School admin
    COORDINATOR = "coordinator"  # Academic + discipline
    SECRETARY = "secretary"      # Enrollment + comms
    TEACHER = "teacher"          # Grades + attendance
    STUDENT = "student"          # Read own data
    GUARDIAN = "acudiente"       # Read child data

ROLE_PERMISSIONS: dict[Role, set[str]] = {
    Role.SUPERADMIN: {
        "read:all", "write:all", "delete:all",
        "manage:schools",  # ← Can bypass tenant checks
        "manage:users",
    },
    Role.RECTOR: {
        "read:all", "write:all", "delete:all",
        "config:institution",
        "manage:users",
    },
    Role.COORDINATOR: {
        "read:students", "read:grades",
        "write:convivencia", "write:due_process",
    },
    Role.SECRETARY: {
        "read:students", "write:enrollment",
        "write:communications",
    },
    Role.TEACHER: {
        "read:own_students", "write:grades",
        "write:attendance", "read:schedule",
    },
    Role.STUDENT: {
        "read:own_data", "read:own_grades",
    },
}

Superadmin Special Case

Superadmin can access any school by sending X-School-Id header, even if not a member.
# In get_auth_context()
if not membership and not has_permission(auth.roles, "manage:schools"):
    raise HTTPException(403, "No access to this school")

# Superadmin bypasses membership check
if has_permission(["superadmin"], "manage:schools"):
    # Can load ANY school_id
    school = await db.get(School, x_school_id)

Testing Tenant Isolation

Integration Test Pattern

# tests/test_tenant_isolation.py
import pytest
from httpx import AsyncClient

@pytest.mark.asyncio
async def test_cannot_access_other_tenant_students(
    client: AsyncClient,
    school_a: School,
    school_b: School,
    user_a: User,  # Member of school_a only
):
    """User from school A cannot see school B students."""
    
    # Create student in school B
    student_b = Student(school_id=school_b.id, full_name="Bob")
    # ... save to DB
    
    # Login as user from school A
    token = create_access_token(user_a.id, school_id=school_a.id)
    
    # Try to access school B data
    response = await client.get(
        "/students",
        headers={
            "Authorization": f"Bearer {token}",
            "X-School-Id": str(school_b.id),  # ← Attempt to access school B
        },
    )
    
    # Should be forbidden
    assert response.status_code == 403
    assert "No access to this school" in response.json()["detail"]

Test Coverage Checklist

1

Cross-Tenant Read

User from school A tries to list school B data → Should get 403
2

Cross-Tenant Write

User from school A tries to create data with school_id=school_b → Should get 403 or ignore the field
3

Cross-Tenant Update

User from school A tries to update school B record by ID → Should get 404 (filtered query returns nothing)
4

Superadmin Access

Superadmin with manage:schools permission → Should be able to access any school with X-School-Id header

Performance Considerations

Index Strategy

Without proper indexes, multi-tenancy kills performance.

❌ Bad: Single-column index

CREATE INDEX idx_students_school ON students(school_id);

-- Query still scans entire table then filters
SELECT * FROM students WHERE school_id = 'xxx' ORDER BY full_name;

✅ Good: Composite index

CREATE INDEX idx_students_school_name ON students(school_id, full_name);

-- Query uses index for both filter AND sort
SELECT * FROM students WHERE school_id = 'xxx' ORDER BY full_name;

Query Patterns

# Always filter by school_id first
result = await db.execute(
    select(Student)
    .where(Student.school_id == tenant.id)  # ← First
    .where(Student.is_active.is_(True))      # ← Then other filters
    .order_by(Student.full_name)
)

Security Checklist

  • Every endpoint depends on get_current_tenant or get_auth_context
  • Every query includes WHERE school_id = tenant.id
  • Superadmin bypass explicitly checked with has_permission("manage:schools")
  • Foreign key references validated within same tenant
  • Integration tests verify 403 on cross-tenant access
  • Every table (except schools) has school_id column
  • Foreign keys include school_id for composite uniqueness
  • Indexes always start with school_id
  • No direct SQL queries bypass ORM (unless explicitly tenant-filtered)
  • Axios interceptor always sends X-School-Id header
  • Multi-school users explicitly select active school
  • School context displayed in UI (navbar, breadcrumbs)
  • No hardcoded school IDs in code

Migration Path

If you later need schema-per-tenant:
1

Phase 1: Add tenant_schema column

ALTER TABLE schools ADD COLUMN tenant_schema VARCHAR(63);
2

Phase 2: Create schemas for large tenants

CREATE SCHEMA school_abc123;
CREATE TABLE school_abc123.students AS SELECT * FROM students WHERE school_id = 'abc123';
3

Phase 3: Route queries to tenant schema

if tenant.tenant_schema:
    # Use dedicated schema
    db.execute(text(f"SET search_path TO {tenant.tenant_schema}"))
else:
    # Use shared schema with school_id filter
    query = query.where(Student.school_id == tenant.id)
4

Phase 4: Gradual migration

Migrate schools one by one based on size/growth
This architecture makes the migration path optional and gradual, not required upfront.

Common Pitfalls

Problem:
# ❌ Leaks data across tenants
student = await db.get(Student, student_id)
Solution:
# ✅ Always filter by tenant
result = await db.execute(
    select(Student).where(
        Student.school_id == tenant.id,
        Student.id == student_id,
    )
)
student = result.scalar_one_or_none()
if not student:
    raise HTTPException(404, "Student not found")
Problem:
# ❌ User could reference another school's student
enrollment = Enrollment(
    school_id=tenant.id,
    student_id=data.student_id,  # Not validated!
)
Solution:
# ✅ Verify student belongs to tenant
student = await db.execute(
    select(Student).where(
        Student.school_id == tenant.id,
        Student.id == data.student_id,
    )
)
if not student.scalar_one_or_none():
    raise HTTPException(400, "Student not in this school")

enrollment = Enrollment(
    school_id=tenant.id,
    student_id=data.student_id,
)
Problem: Multi-school user doesn’t send header, gets ambiguous error.Solution:
if len(memberships) > 1 and not x_school_id:
    raise HTTPException(
        400,
        "You belong to multiple schools. Send X-School-Id header."
    )

Diagram: Request Flow


Next Steps

Technology Stack

Back to the complete tech stack overview

API Reference

See how these patterns apply to specific endpoints

Build docs developers (and LLMs) love