Capstone: Secure Multi-Tenant Application

Auth, Tenant Isolation, Encrypted Storage, Secure API, Secrets & Audit

Introduction

This capstone integrates every security principle from the course into a single cohesive application: a secure multi-tenant API where each tenant's data is cryptographically isolated, users authenticate with industry-standard tokens, access is controlled by roles, and all configuration is managed safely. This is the architecture pattern used by SaaS companies handling sensitive customer data.

Architecture Overview

┌─────────────────────────────────────────────────────────────────┐
│                  Secure Multi-Tenant API                        │
│                                                                 │
│  ┌──────────┐   TLS    ┌──────────────────────────────────┐     │
│  │  Client  │◄────────►│         API Gateway              │     │
│  └──────────┘          │ Rate Limiting + Security Headers │     │
│                        └────────────────┬─────────────────┘     │
│                                         │                       │
│                         ┌───────────────▼──────────────────┐    │
│                         │       Auth Middleware            │    │
│                         │  JWT validation + RBAC check     │    │
│                         └───────────────┬──────────────────┘    │
│                                         │                       │
│              ┌──────────────────────────▼──────────────────┐    │
│              │            Application Layer                │    │
│              │  ┌──────────────┐   ┌──────────────────┐    │    │
│              │  │ Tenant A Data│   │  Tenant B Data   │    │    │
│              │  │ AES-GCM Key A│   │  AES-GCM Key B   │    │    │
│              │  └──────────────┘   └──────────────────┘    │    │
│              └─────────────────────────────────────────────┘    │
│                         │                                       │
│              ┌───────────▼──────────────┐                       │
│              │  Secrets Manager         │                       │
│              │ (env vars / Vault / AWS) │                       │
│              └──────────────────────────┘                       │
└─────────────────────────────────────────────────────────────────┘

Secure User Authentication

Install: pip install bcrypt PyJWT pydantic pydantic-settings

# auth.py, bcrypt + JWT authentication
import bcrypt
import jwt
import secrets
from datetime import datetime, timezone, timedelta
from dataclasses import dataclass
from pydantic import SecretStr, Field
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env")
    jwt_secret: SecretStr = Field(..., min_length=32)
    jwt_algorithm: str = "HS256"
    access_token_ttl_minutes: int = 15
    refresh_token_ttl_days: int = 30

settings = Settings()

@dataclass
class User:
    user_id: int
    tenant_id: str
    email: str
    password_hash: bytes
    roles: list[str]

# In-memory user store (use database in production)
USERS: dict[str, User] = {}

def register_user(email: str, password: str, tenant_id: str) -> User:
    """Register with securely hashed password."""
    hashed = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt(rounds=12))
    user = User(
        user_id=len(USERS) + 1,
        tenant_id=tenant_id,
        email=email,
        password_hash=hashed,
        roles=["member"],
    )
    USERS[email] = user
    return user

def authenticate(email: str, password: str) -> User | None:
    """Verify credentials. Returns user or None."""
    user = USERS.get(email)
    if user and bcrypt.checkpw(password.encode("utf-8"), user.password_hash):
        return user
    return None

def issue_tokens(user: User) -> dict:
    """Issue JWT access + refresh tokens."""
    now = datetime.now(timezone.utc)
    secret = settings.jwt_secret.get_secret_value()

    access_payload = {
        "sub": str(user.user_id),
        "tenant_id": user.tenant_id,
        "roles": user.roles,
        "iat": now,
        "exp": now + timedelta(minutes=settings.access_token_ttl_minutes),
        "type": "access",
    }
    refresh_payload = {
        "sub": str(user.user_id),
        "iat": now,
        "exp": now + timedelta(days=settings.refresh_token_ttl_days),
        "type": "refresh",
    }
    return {
        "access_token": jwt.encode(access_payload, secret, algorithm=settings.jwt_algorithm),
        "refresh_token": jwt.encode(refresh_payload, secret, algorithm=settings.jwt_algorithm),
        "token_type": "Bearer",
    }

# Demo
user = register_user("alice@tenant-a.com", "SecureP@ss2024!", "tenant-a")
tokens = issue_tokens(user)
print(f"User registered: {user.email} in {user.tenant_id}")
print(f"Access token:  {tokens['access_token'][:50]}...")
Expected Output:
User registered: alice@tenant-a.com in tenant-a
Access token:  eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOi...

Tenant Isolation with RBAC Middleware

# tenant_middleware.py, JWT + RBAC enforcement
import jwt
from functools import wraps
from dataclasses import dataclass

@dataclass
class TokenClaims:
    user_id: str
    tenant_id: str
    roles: list[str]

def decode_token(token: str) -> TokenClaims | None:
    """Verify and decode access token."""
    try:
        secret = settings.jwt_secret.get_secret_value()
        payload = jwt.decode(
            token, secret,
            algorithms=[settings.jwt_algorithm],
            options={"require": ["exp", "sub", "tenant_id"]},
        )
        if payload.get("type") != "access":
            return None
        return TokenClaims(
            user_id=payload["sub"],
            tenant_id=payload["tenant_id"],
            roles=payload.get("roles", []),
        )
    except jwt.InvalidTokenError:
        return None

def require_auth(required_role: str | None = None):
    """Decorator: validate JWT + optionally check role."""
    def decorator(func):
        @wraps(func)
        def wrapper(bearer_token: str, *args, **kwargs):
            if not bearer_token.startswith("Bearer "):
                raise PermissionError("Missing or malformed Authorization header")
            token = bearer_token[7:]
            claims = decode_token(token)
            if not claims:
                raise PermissionError("Invalid or expired token")
            if required_role and required_role not in claims.roles:
                raise PermissionError(f"Role '{required_role}' required")
            # Inject claims into function
            return func(claims, *args, **kwargs)
        return wrapper
    return decorator

@require_auth()
def get_my_profile(claims: TokenClaims) -> dict:
    return {"user_id": claims.user_id, "tenant": claims.tenant_id, "roles": claims.roles}

@require_auth(required_role="admin")
def delete_user(claims: TokenClaims, target_user_id: str) -> str:
    return f"Admin {claims.user_id} deleted user {target_user_id}"

# Test
auth_header = f"Bearer {tokens['access_token']}"
profile = get_my_profile(auth_header)
print(f"Profile: {profile}")

try:
    delete_user(auth_header, "user-99")
except PermissionError as e:
    print(f"Access denied: {e}")   # alice is 'member', not 'admin'

Encrypted Data Storage (Per-Tenant Keys)

# encrypted_storage.py, AES-GCM per-tenant encryption
import os
import secrets
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

# Each tenant gets a unique encryption key (store in secrets manager)
_tenant_keys: dict[str, bytes] = {}

def get_tenant_key(tenant_id: str) -> bytes:
    """Get or generate a tenant-specific AES-256 encryption key."""
    if tenant_id not in _tenant_keys:
        # In production: retrieve from AWS Secrets Manager / Vault
        _tenant_keys[tenant_id] = secrets.token_bytes(32)
    return _tenant_keys[tenant_id]

def encrypt_record(tenant_id: str, plaintext: bytes) -> bytes:
    """Encrypt a record with the tenant's key. Returns nonce||ciphertext."""
    key = get_tenant_key(tenant_id)
    nonce = os.urandom(12)
    aad = tenant_id.encode()   # Bind ciphertext to this tenant
    ciphertext = AESGCM(key).encrypt(nonce, plaintext, aad)
    return nonce + ciphertext

def decrypt_record(tenant_id: str, blob: bytes) -> bytes:
    """Decrypt a record. Raises if wrong tenant or tampered."""
    key = get_tenant_key(tenant_id)
    nonce, ciphertext = blob[:12], blob[12:]
    aad = tenant_id.encode()
    return AESGCM(key).decrypt(nonce, ciphertext, aad)

# Store sensitive data
record = b'{"ssn": "123-45-6789", "account": "9876"}'

encrypted_a = encrypt_record("tenant-a", record)
print(f"Encrypted ({len(encrypted_a)} bytes): {encrypted_a.hex()[:40]}...")

# Tenant A can decrypt their own data
decrypted = decrypt_record("tenant-a", encrypted_a)
print(f"Decrypted: {decrypted.decode()}")

# Tenant B CANNOT decrypt Tenant A's data (different key + wrong AAD)
from cryptography.exceptions import InvalidTag
try:
    decrypt_record("tenant-b", encrypted_a)
except InvalidTag:
    print("Cross-tenant access DENIED ✅, tenant-b cannot read tenant-a's data")
Expected Output:
Encrypted (54 bytes): a3f2e1d4c5b6a7f8e9d0c1b2a3f4e5d6c7b8a9f0...
Decrypted: {"ssn": "123-45-6789", "account": "9876"}
Cross-tenant access DENIED ✅, tenant-b cannot read tenant-a's data

Secure API Layer & Security Audit

Install: pip install fastapi uvicorn pydantic

# secure_api.py, FastAPI with security headers + input validation
from fastapi import FastAPI, Request, Response, HTTPException, Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel, Field, field_validator
import re

app = FastAPI(title="Secure Multi-Tenant API", docs_url=None)   # Disable docs in production
security = HTTPBearer()

# ── Security headers middleware ───────────────────────────────
@app.middleware("http")
async def add_security_headers(request: Request, call_next):
    response: Response = await call_next(request)
    response.headers["X-Content-Type-Options"] = "nosniff"
    response.headers["X-Frame-Options"] = "DENY"
    response.headers["Content-Security-Policy"] = "default-src 'self'"
    response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
    response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
    return response

# ── Input validation with Pydantic ───────────────────────────
class CreateRecordRequest(BaseModel):
    title: str = Field(..., min_length=1, max_length=200)
    content: str = Field(..., min_length=1, max_length=10000)
    tags: list[str] = Field(default=[], max_length=10)

    @field_validator("title", "content")
    @classmethod
    def no_null_bytes(cls, v: str) -> str:
        if "\x00" in v:
            raise ValueError("Null bytes not allowed")
        return v.strip()

    @field_validator("tags")
    @classmethod
    def validate_tags(cls, tags: list[str]) -> list[str]:
        pattern = re.compile(r"^[a-zA-Z0-9-_]+$")
        for tag in tags:
            if not pattern.match(tag):
                raise ValueError(f"Invalid tag: {tag!r}. Only alphanumeric, hyphen, underscore allowed.")
        return tags

# ── Protected endpoint ────────────────────────────────────────
@app.post("/api/records")
async def create_record(
    body: CreateRecordRequest,
    credentials: HTTPAuthorizationCredentials = Depends(security),
):
    claims = decode_token(credentials.credentials)
    if not claims:
        raise HTTPException(status_code=401, detail="Invalid token")

    # Encrypt record with tenant's key
    record_data = f"{body.title}:{body.content}".encode()
    encrypted = encrypt_record(claims.tenant_id, record_data)

    return {
        "record_id": secrets.token_hex(16),
        "tenant_id": claims.tenant_id,
        "encrypted_size": len(encrypted),
        "status": "created",
    }

print("FastAPI app with:")
print("  ✅ Security headers middleware")
print("  ✅ JWT authentication (Depends)")
print("  ✅ Pydantic input validation (injection prevention)")
print("  ✅ Per-tenant AES-GCM encryption")
print("  ✅ Tenant isolation via claims")

Course Topics Applied in This Capstone

L1
Threat Modeling
Architecture designed with CIA triad and STRIDE threat categories in mind before writing code
L2
Cryptography
HMAC-SHA256 signs HS256 JWTs; symmetric hashing concepts applied throughout
L3
Password Storage
bcrypt with rounds=12 for secure user registration
L4
Encryption
AES-GCM per-tenant data encryption with per-tenant keys
L5-7
Networking
HTTP/HTTPS protocol knowledge applied in building the FastAPI API layer
L8
Serialization
JSON as API wire format; Pydantic enforces schema validation at every boundary
L9
TLS/SSL
HSTS header instructs clients to always use HTTPS; TLS termination handled at reverse proxy
L10
Authentication
JWT access + refresh token flow with short-lived access tokens
L11
Authorization
RBAC decorator enforces role checks on protected endpoints
L12-13
OWASP/Injection
Pydantic field validators reject null bytes, oversized input, and invalid tag formats
L14
Web Security
CSP, HSTS, X-Frame-Options, and Referrer-Policy headers on every response
L15
Secrets
Pydantic SecretStr prevents accidental logging; comments reference Vault/AWS Secrets Manager for production
L16
Security Testing
bandit and pip audit are the recommended CI/CD checks for this codebase
L17
Container Security
Non-root user and image scanning apply at deployment stage, outside this code walkthrough
L18
Security Ops
Security headers and token validation are the first response layer; infrastructure-level monitoring applies at deployment

Key Takeaways, Course Complete

  • Security is layered, no single control is sufficient; the capstone applies 14 independent security controls
  • Cryptography is the foundation, bcrypt for passwords, AES-GCM for data, RSA/ECDHE for key exchange, JWT for tokens
  • Tenant isolation requires both logic and cryptography, RBAC prevents cross-tenant access in code; separate encryption keys prevent it at the data layer
  • Input validation is non-negotiable, Pydantic on every API boundary; validate type, length, format, and content
  • Secrets need lifecycle management, not just storage; generation, rotation, and revocation are equally important
  • Security testing belongs in CI/CD, bandit, pip audit, and Hypothesis run automatically on every change
  • Monitor and respond, a breach you detect in minutes causes less damage than one you detect in months

Congratulations!

You've completed the Network & Security course. You now have the knowledge and Python skills to build production-grade secure applications, conduct security testing, and operate secure infrastructure.

Continue applying these skills in your projects. Security engineering is a practice, not a destination, the threat landscape evolves, and so should your defenses.