?

Day 0338 minPreface+60 XP

Data Residency and GDPR.

System Design Mastery Series — Multi-Tenancy, Security, and Compliance Week

4 stages17 code

watch it first · newThe whole lesson in a 19-minute animated lecture5 quiz checkpoints · ask the AI about any moment · captions, chapters and speed

Preface · 1 min

Preface#

Your startup just closed a deal with a German enterprise customer. Celebration! Then their legal team sends this:

The data residency reality

From: legal@deutschebank.example.de Subject: Data Processing Requirements

Dear Vendor,

Before we can proceed, please confirm:

  1. All personal data of our employees will be stored exclusively in the European Union
  2. No personal data will be transferred to the United States or any other third country without appropriate safeguards
  3. You will sign our Data Processing Agreement (DPA) based on GDPR Article 28
  4. You can demonstrate compliance with:
    • GDPR (EU)
    • BDSG (German Federal Data Protection Act)
    • Schrems II ruling implications
  5. You can provide data residency certification and audit logs

Please provide your technical architecture showing how you ensure EU data stays in the EU.

Regards, Deutsche Bank Legal

You look at your architecture — current state
  • Single AWS region: us-east-1
  • All data in one PostgreSQL cluster
  • Backups replicated to us-west-2
  • Analytics processed in BigQuery (US)
  • Customer support via Zendesk (US)
  • Error tracking via Sentry (US)
Your response options
  • No: "Sorry, we can't do this" (lose the deal)
  • No: "Sure, we comply!" (lie and risk massive fines)
  • Yes: "We need to re-architect" (this week's lesson)

Today, we'll learn how to build systems that respect data residency requirements while running a global platform.

ExplainerThe whole lesson: data residency and GDPR

Deutsche Bank wants its employees' personal data kept in the EU, but the whole stack runs in us-east-1 with backups in us-west-2. Of the four patterns (a single region, fully isolated regional deployments, regional data under a global control plane that holds only metadata, and a region per tenant), the last two keep an EU tenant's data in eu-central-1 even when a request lands in the US. Personal data, pseudonymized data, backups and caches stay in the region while aggregated analytics, metrics and config can be global; every processing needs a lawful basis such as contract or consent, consent withdrawals are appended to a log, and transfers out of the EU need an adequacy decision, SCCs or BCRs plus a DPA with every processor.

Part I · Stage 1 of 4 · 7 min

Part I: Foundations#

Chapter 1: Understanding Data Residency#

1.1 What Is Data Residency?#

Data residency refers to the physical or geographic location where data is stored and processed. Data residency requirements are laws or policies that mandate data must remain within specific geographic boundaries.

Data residency concepts
Concept What it means Example
DATA RESIDENCY Where data is physically stored · Geographic location of servers · Can be chosen by organization "Our EU data is in Frankfurt"
DATA SOVEREIGNTY Legal jurisdiction over data · Which country's laws apply · Based on storage location + company location "EU data subject to GDPR"
DATA LOCALIZATION Legal requirement to keep data in-country · Government-mandated residency · Often for national security or privacy "Russian personal data must stay in Russia"
DATA TRANSFER Moving data across borders · May require legal basis · Subject to adequacy decisions "EU to US transfer requires SCCs"
Your turn · checkpoint 1not answered

Estimate: what is "massive fines" for you?

GDPR fines go up to €20 million or 4% of global annual turnover, whichever is higher. Your company turns over €40 million a year. What's the ceiling for you, and which half of the rule sets it? What changes for a company turning over €1 billion?

Think it through, or jot it down. Then compare.

One strong answer

  • You: 4% of €40 million is €1.6 million, so the fixed €20 million is higher, and that's your ceiling: half a year's turnover.
  • At €1 billion: 4% is €40 million, so the percentage takes over.
  • So for a small company the fixed amount dominates, which is why "Sure, we comply!" without the architecture is the riskiest of the three answers.

1.2 Why Data Residency Matters#

Regulatory landscape
Jurisdiction Applies to Key rules Enforcement Notable
EUROPEAN UNION (GDPR) EU residents' personal data Transfers outside EU need legal basis · Adequacy decisions (few countries qualify) · Standard Contractual Clauses (SCCs) · Binding Corporate Rules (BCRs) Fines: Up to €20M or 4% of global annual turnover (whichever is higher) Schrems II (2020) invalidated Privacy Shield; replaced by the EU-US Data Privacy Framework (2023)
RUSSIA (Federal Law 242-FZ) Russian citizens' personal data Initial collection must be in Russia · Primary database must be in Russia · Cross-border transfer allowed after local storage Website blocking, fines LinkedIn blocked in Russia
CHINA (PIPL + DSL + Cybersecurity Law) Data collected in China Critical data must stay in China · Security assessment for cross-border transfers above volume thresholds (exemptions since March 2024) · Government access requirements Business license revocation Extremely broad scope
INDIA (DPDP Act 2023) Indian residents' data Transfers allowed except to countries the government restricts (negative list) · Sectoral rules can still require localization · Still evolving Fines up to ₹250 crore Localization for payment data (RBI)
BRAZIL (LGPD) Brazilian residents' data Similar to GDPR · Adequate protection required for transfers · Consent or legitimate interest basis Fines up to 2% of Brazilian revenue, capped at R$50M per infraction Closely modeled on GDPR
WorksheetAn EU region against the fine it guards against (turnover and region cost are assumed)
Value
Annual turnover (assumed) €40 million
4% of turnover €1.6 million
GDPR ceiling: the higher of €20 million and 4% €20 million, half the turnover
Running an EU deployment: app, database, storage, search (assumed) €30,000 a month → €360,000 a year
EU region's yearly cost as a share of the ceiling €360,000 ÷ €20 million ≈ 1.8%

Conclusion: for a company this size the fixed €20 million, not the 4%, sets the exposure, and a year of the EU region costs under 2% of it. Building for residency is the cheap option; the expensive part is getting it wrong in backups, logs and third parties.

1.3 GDPR Deep Dive#

GDPR key concepts for engineers
  • PERSONAL DATA — (Article 4)
    • Any information relating to identified/identifiable person
    • Examples
      • Name, email, phone number
      • IP address, cookie IDs
      • Location data
      • Behavioral data
      • Device identifiers
    • Note: Pseudonymized data is still personal data
  • SPECIAL CATEGORIES — (Article 9)
    • Extra protection required for:
      • Racial or ethnic origin
      • Political opinions
      • Religious beliefs
      • Trade union membership
      • Genetic/biometric data
      • Health data
      • Sexual orientation
    • Generally prohibited unless an Article 9(2) exception applies (e.g. explicit consent)
  • DATA SUBJECT RIGHTS
    • Right to access — (Article 15)
    • Right to rectification — (Article 16)
    • Right to erasure — (Article 17) ← Tomorrow's topic
    • Right to data portability — (Article 20)
    • Right to object — (Article 21)
    • Rights related to automated decisions — (Article 22)
  • LAWFUL BASIS FOR PROCESSING — (Article 6)
    • Consent — (freely given, specific, informed, unambiguous, withdrawable)
    • Contract performance
    • Legal obligation
    • Vital interests
    • Public interest
    • Legitimate interests — (balance test)
  • CROSS-BORDER TRANSFERS — (Chapter 5)
    • Adequacy decision — (safest)
      • Countries include: Andorra, Argentina, Brazil (2026), Canada (commercial), Faroe Islands, Guernsey, Israel, Isle of Man, Japan, Jersey, New Zealand, South Korea, Switzerland, UK, Uruguay; for the US, only companies self-certified under the EU-US Data Privacy Framework
    • Standard Contractual Clauses (SCCs)
    • Binding Corporate Rules (BCRs)
    • Derogations — (limited cases)
    • Note: Must assess destination country's laws
Your turn · checkpoint 2not answered

Trade-off: one stack per region, or one control plane?

You can run a fully separate copy of the product in each region, or keep data in regions and run one global control plane for tenants, configuration and routing. What does each give you, and what does each cost?

Think it through, or jot it down. Then compare.

One strong answer

  • Fully separate regions: the strongest compliance story, since nothing is shared. But you run several products, pay for each, and lose every cross-region feature.
  • Regional data, global control plane: personal data stays in the tenant's region while routing and configuration stay global, so the product still works as one.
  • The catch: you must be strict about what counts as "metadata". Anything that can identify a person doesn't belong in the global plane.

Chapter 2: Data Residency Architecture Patterns#

2.1 Architecture Options#

DiagramPattern 1 · Single region (limited compliance)
%% title: Pattern 1 · Single region (limited compliance)
flowchart TD
  subgraph USE1["US-EAST-1 (Virginia) · ALL data from ALL regions stored here"]
    direction TB
    APP["App Servers"]:::core
    DB@{ icon: "sd:database", label: "Database Cluster" }
    S3@{ icon: "tech:s3", label: "Storage (S3)" }
  end
  class DB,S3 store
  • Pros: Simple, cheap
  • Cons: Can't comply with data residency laws
  • Use: Internal tools, non-regulated data
DiagramPattern 2 · Regional deployments (full isolation)
%% title: Pattern 2 · Regional deployments (full isolation)
flowchart TD
  subgraph USD["US Deployment (us-east-1)"]
    US1["App + DB + Storage<br/>US customers only"]:::core
  end
  subgraph EUD["EU Deployment (eu-central-1)"]
    EU1["App + DB + Storage<br/>EU customers only"]:::core
  end
  subgraph APD["APAC Deployment (ap-southeast-1)"]
    AP1["App + DB + Storage<br/>APAC customers"]:::core
  end
  NS["NO DATA SHARING BETWEEN REGIONS"]:::bad
  US1 -.- NS
  EU1 -.- NS
  AP1 -.- NS
  • Pros: Strong compliance, data stays in region
  • Cons: Complex, expensive, no cross-region features
  • Use: Highly regulated industries, strict localization
DiagramPattern 3 · Regional data, global control plane
%% title: Pattern 3 · Regional data, global control plane
flowchart TD
  GC["Global Control (Metadata only)<br/>· Tenant registry<br/>· Configuration<br/>· Routing rules"]:::core
  subgraph USR["US Region"]
    USDATA@{ icon: "sd:database", label: "US Data" }
  end
  subgraph EUR["EU Region"]
    EUDATA@{ icon: "sd:database", label: "EU Data" }
  end
  subgraph APR["APAC Region"]
    APDATA@{ icon: "sd:database", label: "APAC Data" }
  end
  GC --> USDATA
  GC --> EUDATA
  GC --> APDATA
  class USDATA,EUDATA,APDATA store
  • Pros: Compliance + some global features
  • Cons: Complex routing, careful about what's "metadata"
  • Use: Most SaaS companies with compliance needs
DiagramPattern 4 · Data residency by tenant
%% title: Pattern 4 · Data residency by tenant
flowchart TD
  subgraph GA["Global Application"]
    R(["Request"]):::client --> TL["Tenant Lookup"]:::core --> RT{"Route to Tenant's Region"} --> P["Process"]:::service
  end
  RT -->|"Tenant A (US)"| USE@{ icon: "sd:region", label: "us-east-1" }
  RT -->|"Tenant B (EU)"| EUC@{ icon: "sd:region", label: "eu-central-1" }
  RT -->|"Tenant C (EU)"| EUC
  RT -->|"Tenant D (APAC)"| APS@{ icon: "sd:region", label: "ap-southeast-1" }
  class USE,EUC,APS service
  • Pros: Flexible, per-tenant compliance
  • Cons: Routing complexity, cross-tenant features limited
  • Use: B2B SaaS with enterprise customers
Your turn · checkpoint 3not answered

Sort it: global or regional?

For an EU tenant, which of these may live in the global control plane, and which must stay in the EU: the tenant registry entry, users' email addresses, feature flags, nightly database backups, error logs with stack traces, analytics keyed by pseudonymized user IDs?

Think it through, or jot it down. Then compare.

One strong answer

  • Global is fine: the tenant registry entry and feature flags. They're configuration, not data about a person.
  • Must stay in the EU: email addresses, backups (same rules as the primary data) and pseudonymized analytics (still personal data under GDPR).
  • It depends: error logs can go global only once PII is stripped; a stack trace holding an email address is personal data.

2.2 What Data Goes Where?#

Data classification for residency
  • PERSONAL DATA — Must respect residency
    • User profiles (name, email, phone)
    • Employee data
    • Customer communications
    • Support tickets with PII
    • Files uploaded by users
    • Activity logs with user IDs
    • IP addresses and location data
    • Any data linked to an individual
  • OPERATIONAL DATA — Usually can be global
    • Anonymized/aggregated analytics
    • System metrics and monitoring
    • Error logs (if PII stripped)
    • Configuration data
    • Feature flags
    • Infrastructure metadata
    • Audit logs of system events (not user actions)
  • GRAY AREAS — Careful analysis needed
    • Pseudonymized data — Still personal data under GDPR!
    • Behavioral analytics — May be personal if linkable
    • Machine learning training data — Depends on source
    • Backups — Same rules as primary data
    • Cached data — Same rules as primary data
DiagramDecision framework
%% title: Decision framework
flowchart TD
  Q1{"Question 1:<br/>Can this data identify a person?"}
  Q1 -->|"Yes"| PD["Personal data →<br/>Residency rules apply"]:::core
  Q1 -->|"No · continue"| Q2{"Question 2:<br/>Can this data be combined<br/>with other data to identify?"}
  Q2 -->|"Yes"| PD
  Q2 -->|"No"| OD1["Operational data →<br/>Usually global OK"]:::ok
  Q3{"Question 3:<br/>Is this derived from personal data?"}
  Q3 -->|"Yes"| AN["Analyze if truly anonymous"]:::queue
  Q3 -->|"No"| OD2["Operational data"]:::ok

3.1 Lawful Basis Decision Tree#

DiagramChoosing lawful basis for processing
%% title: Choosing lawful basis for processing
flowchart TD
  W{"Why are you processing<br/>this personal data?"}
  W --> U["User requested<br/>a service"]:::client
  W --> L["Required by law"]:::client
  W --> B["Business benefit"]:::client
  U --> CP["CONTRACT PERFORMANCE<br/>Examples:<br/>· Deliver product<br/>· Process payment<br/>· Send order updates"]:::core
  L --> LO["LEGAL OBLIGATION<br/>Examples:<br/>· Tax records<br/>· Employment law<br/>· Court order"]:::core
  B --> LI["LEGITIMATE INTEREST<br/>(needs test)<br/>Examples:<br/>· Fraud prevention<br/>· Analytics<br/>· Marketing (maybe)"]:::queue
  LI --> C["Need CONSENT?<br/>· Marketing<br/>· Cookies<br/>· Third-party sharing<br/>· Special categories"]:::hot
Quick check3 questions · +5 XP each

Residency foundations and patterns

  1. Q1/3 Your EU tenant's primary database is in Frankfurt, but nightly backups are copied to us-west-2 with user IDs pseudonymized. Does this respect EU residency?

    Answer & explanation

    Answer: C

    The lesson's classification puts backups and caches under "same rules as primary data", and pseudonymized data is still personal data under GDPR. Pseudonymization reduces risk but does not take the data out of residency rules.

  2. Q2/3 A B2B SaaS has mostly US customers plus a few EU enterprises demanding EU residency, and still wants a global tenant registry and routing. Which pattern fits best?

    Answer & explanation

    Answer: A

    Regional data plus a global control plane keeps personal data in the tenant's region while non-personal metadata stays global. Full isolation also complies but gives up cross-region features at higher cost; a single region cannot satisfy residency no matter how it is encrypted.

  3. Q3/3 You email customers "your order has shipped" and also want to send them a promotional newsletter. Which lawful basis fits each?

    Answer & explanation

    Answer: D

    Order updates are part of delivering the service the user asked for, so contract performance covers them. Marketing is a business benefit that the lesson's decision tree routes to consent; being a customer does not make marketing necessary for the contract.

Part II · Stage 2 of 4 · 21 min

Part II: Implementation#

Chapter 4: Regional Data Architecture#

4.1 Tenant Region Assignment#

pythonDataRegion · 218 lines
# data_residency/tenant_region.py

"""
Tenant region assignment and routing.

Each tenant is assigned to a region based on their data residency requirements.
"""

from dataclasses import dataclass
from typing import Optional, Dict
from enum import Enum
import logging

logger = logging.getLogger(__name__)


class DataRegion(Enum):
    """Supported data regions."""
    US = "us"
    EU = "eu"
    UK = "uk"
    APAC = "apac"
    BRAZIL = "brazil"
    INDIA = "india"


@dataclass
class RegionConfig:
    """Configuration for a data region."""
    region_id: DataRegion
    display_name: str
    aws_region: str
    database_endpoint: str
    storage_bucket: str
    search_endpoint: str
    cache_endpoint: str
    
    # Compliance attributes
    gdpr_compliant: bool = False
    data_localization: bool = False
    adequacy_decision: bool = False


# Region configurations
REGION_CONFIGS = {
    DataRegion.US: RegionConfig(
        region_id=DataRegion.US,
        display_name="United States",
        aws_region="us-east-1",
        database_endpoint="db-us.example.com",
        storage_bucket="data-us-example",
        search_endpoint="search-us.example.com",
        cache_endpoint="cache-us.example.com",
        gdpr_compliant=False,
        adequacy_decision=False  # DPF covers only certified recipients, not the US as a whole
    ),
    DataRegion.EU: RegionConfig(
        region_id=DataRegion.EU,
        display_name="European Union",
        aws_region="eu-central-1",
        database_endpoint="db-eu.example.com",
        storage_bucket="data-eu-example",
        search_endpoint="search-eu.example.com",
        cache_endpoint="cache-eu.example.com",
        gdpr_compliant=True,
        adequacy_decision=True
    ),
    DataRegion.UK: RegionConfig(
        region_id=DataRegion.UK,
        display_name="United Kingdom",
        aws_region="eu-west-2",
        database_endpoint="db-uk.example.com",
        storage_bucket="data-uk-example",
        search_endpoint="search-uk.example.com",
        cache_endpoint="cache-uk.example.com",
        gdpr_compliant=True,  # UK GDPR
        adequacy_decision=True
    ),
    DataRegion.APAC: RegionConfig(
        region_id=DataRegion.APAC,
        display_name="Asia Pacific",
        aws_region="ap-southeast-1",
        database_endpoint="db-apac.example.com",
        storage_bucket="data-apac-example",
        search_endpoint="search-apac.example.com",
        cache_endpoint="cache-apac.example.com",
        gdpr_compliant=False,
        adequacy_decision=False
    ),
}


@dataclass
class TenantRegionAssignment:
    """A tenant's region assignment."""
    tenant_id: str
    data_region: DataRegion
    reason: str  # Why this region was assigned
    assigned_at: str
    can_change: bool = True  # Some contracts lock the region


class TenantRegionService:
    """
    Service for managing tenant region assignments.
    """
    
    def __init__(self, db, cache):
        self.db = db
        self.cache = cache
    
    async def get_tenant_region(self, tenant_id: str) -> RegionConfig:
        """
        Get the region configuration for a tenant.
        """
        # Check cache first
        cache_key = f"tenant_region:{tenant_id}"
        cached = await self.cache.get(cache_key)
        
        if cached:
            return REGION_CONFIGS[DataRegion(cached)]
        
        # Load from database
        result = await self.db.fetchrow(
            "SELECT data_region FROM tenants WHERE id = $1",
            tenant_id
        )
        
        if not result:
            raise ValueError(f"Tenant not found: {tenant_id}")
        
        region = DataRegion(result["data_region"])
        
        # Cache for 1 hour
        await self.cache.set(cache_key, region.value, ttl=3600)
        
        return REGION_CONFIGS[region]
    
    async def assign_region(
        self,
        tenant_id: str,
        region: DataRegion,
        reason: str
    ) -> TenantRegionAssignment:
        """
        Assign a tenant to a data region.
        
        This should be done during tenant onboarding.
        Changing region later requires data migration.
        """
        from datetime import datetime, timezone
        
        # Validate region is supported
        if region not in REGION_CONFIGS:
            raise ValueError(f"Unsupported region: {region}")
        
        # Update tenant
        await self.db.execute(
            """
            UPDATE tenants 
            SET data_region = $2, region_assigned_at = $3, region_reason = $4
            WHERE id = $1
            """,
            tenant_id, region.value, datetime.now(timezone.utc), reason
        )
        
        # Invalidate cache
        await self.cache.delete(f"tenant_region:{tenant_id}")
        
        logger.info(
            f"Assigned tenant {tenant_id} to region {region.value}",
            extra={"tenant_id": tenant_id, "region": region.value, "reason": reason}
        )
        
        return TenantRegionAssignment(
            tenant_id=tenant_id,
            data_region=region,
            reason=reason,
            assigned_at=datetime.now(timezone.utc).isoformat()
        )
    
    async def suggest_region(
        self,
        country_code: str,
        compliance_requirements: list
    ) -> DataRegion:
        """
        Suggest a region based on country and compliance needs.
        """
        # EU countries → EU region
        eu_countries = [
            "AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR",
            "DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL",
            "PL", "PT", "RO", "SK", "SI", "ES", "SE"
        ]
        
        if country_code in eu_countries:
            return DataRegion.EU
        
        if country_code == "GB":
            return DataRegion.UK
        
        if country_code == "US":
            return DataRegion.US
        
        if country_code == "BR":
            return DataRegion.BRAZIL
        
        if country_code == "IN":
            return DataRegion.INDIA
        
        # APAC countries
        apac_countries = ["AU", "NZ", "SG", "JP", "KR", "HK", "TW"]
        if country_code in apac_countries:
            return DataRegion.APAC
        
        # Default to US for others (with SCCs if needed)
        return DataRegion.US

4.2 Regional Database Routing#

pythonRegionalDatabaseRouter · 159 lines
# data_residency/database_router.py

"""
Database routing based on tenant region.

Routes database queries to the correct regional database.
"""

from typing import Dict, Any
import asyncpg
import logging

logger = logging.getLogger(__name__)


class RegionalDatabaseRouter:
    """
    Routes database connections to regional databases.
    
    Each tenant's data is stored in their assigned region's database.
    """
    
    def __init__(
        self,
        region_configs: Dict[DataRegion, RegionConfig],
        tenant_region_service: TenantRegionService
    ):
        self.region_configs = region_configs
        self.tenant_region_service = tenant_region_service
        self._pools: Dict[DataRegion, asyncpg.Pool] = {}
    
    async def initialize(self):
        """Initialize connection pools for all regions."""
        for region, config in self.region_configs.items():
            pool = await asyncpg.create_pool(
                host=config.database_endpoint,
                database="app_db",
                min_size=5,
                max_size=20
            )
            self._pools[region] = pool
            logger.info(f"Initialized database pool for region: {region.value}")
    
    async def get_connection(self, tenant_id: str):
        """
        Get database connection for a tenant.
        
        Routes to the correct regional database.
        """
        # Get tenant's region
        region_config = await self.tenant_region_service.get_tenant_region(tenant_id)
        region = region_config.region_id
        
        if region not in self._pools:
            raise ValueError(f"No database pool for region: {region}")
        
        return self._pools[region].acquire()
    
    async def execute_in_region(
        self,
        region: DataRegion,
        query: str,
        *args
    ) -> Any:
        """
        Execute a query in a specific region.
        
        Used for admin operations that target a specific region.
        """
        if region not in self._pools:
            raise ValueError(f"No database pool for region: {region}")
        
        async with self._pools[region].acquire() as conn:
            return await conn.fetch(query, *args)


class RegionalStorageRouter:
    """
    Routes file storage to regional S3 buckets.
    """
    
    def __init__(
        self,
        region_configs: Dict[DataRegion, RegionConfig],
        tenant_region_service: TenantRegionService
    ):
        self.region_configs = region_configs
        self.tenant_region_service = tenant_region_service
        self._clients: Dict[DataRegion, Any] = {}
    
    async def initialize(self):
        """Initialize S3 clients for all regions."""
        import aioboto3
        
        for region, config in self.region_configs.items():
            session = aioboto3.Session()
            client = await session.client(
                's3',
                region_name=config.aws_region
            ).__aenter__()
            self._clients[region] = (client, config.storage_bucket)
    
    async def upload_file(
        self,
        tenant_id: str,
        file_key: str,
        file_data: bytes,
        content_type: str
    ) -> str:
        """
        Upload a file to the tenant's regional storage.
        """
        region_config = await self.tenant_region_service.get_tenant_region(tenant_id)
        region = region_config.region_id
        
        client, bucket = self._clients[region]
        
        # Include tenant_id in key for organization
        full_key = f"tenants/{tenant_id}/{file_key}"
        
        await client.put_object(
            Bucket=bucket,
            Key=full_key,
            Body=file_data,
            ContentType=content_type,
            Metadata={
                "tenant_id": tenant_id,
                "region": region.value
            }
        )
        
        logger.info(
            f"Uploaded file to regional storage",
            extra={
                "tenant_id": tenant_id,
                "region": region.value,
                "bucket": bucket,
                "key": full_key
            }
        )
        
        return f"s3://{bucket}/{full_key}"
    
    async def get_file(
        self,
        tenant_id: str,
        file_key: str
    ) -> bytes:
        """
        Get a file from the tenant's regional storage.
        """
        region_config = await self.tenant_region_service.get_tenant_region(tenant_id)
        region = region_config.region_id
        
        client, bucket = self._clients[region]
        full_key = f"tenants/{tenant_id}/{file_key}"
        
        response = await client.get_object(Bucket=bucket, Key=full_key)
        return await response['Body'].read()
ExplainerData residency: routing by tenant region

An EU tenant's upload that lands on a US app server is still written to eu-central-1: the router looks up the tenant's region (cache, then the tenants table), and only metadata stays global. If a migration forgets to delete the tenant_region cache key, traffic keeps going to the old region for up to the 1-hour TTL.

Quick check2 questions · +5 XP each

Regional routing

  1. Q1/2 A tenant is migrated from the US region to the EU region, but the code that updates the tenants row forgets to delete the tenant_region: cache key. What happens next?

    Answer & explanation

    Answer: B

    get_tenant_region checks the cache first and caches the region for one hour, so a stale entry keeps sending traffic to the old region. That is why assign_region invalidates the key; the database alone is not consulted on a cache hit.

  2. Q2/2 An EU tenant's employee uploads a file while travelling in the US, and the request lands on a US app server. Where does the file go?

    Answer & explanation

    Answer: C

    RegionalStorageRouter.upload_file resolves the tenant's region and writes to that region's bucket. Residency is a per-tenant assignment made at onboarding, not a function of where the request or the user happens to be.

Your turn · checkpoint 4not answered

Design it: a user unticks "marketing emails"

A user who granted marketing-email consent last year now withdraws it. What should the system store, what should it keep from last year, and how do the services that send email find out?

Think it through, or jot it down. Then compare.

One strong answer

  • Store a new record saying "withdrawn", with when and from where, and set the user's current status to withdrawn. Never edit or delete the old grant.
  • Keep last year's grant with its exact text and version: it's the proof that the emails you sent before were lawful.
  • Publish an event so every system that sends marketing stops at once, and have senders check the current status before each send.
pythonConsentPurpose · 332 lines
# data_residency/consent.py

"""
Consent management for GDPR compliance.

Tracks user consent for different processing purposes.
"""

from dataclasses import dataclass
from typing import List, Optional, Dict
from datetime import datetime, timezone
from enum import Enum
import uuid
import logging

logger = logging.getLogger(__name__)


class ConsentPurpose(Enum):
    """Purposes for which consent can be given."""
    SERVICE_DELIVERY = "service_delivery"  # Usually contract basis, not consent
    MARKETING_EMAIL = "marketing_email"
    MARKETING_PHONE = "marketing_phone"
    ANALYTICS = "analytics"
    PERSONALIZATION = "personalization"
    THIRD_PARTY_SHARING = "third_party_sharing"
    PROFILING = "profiling"
    COOKIES_ESSENTIAL = "cookies_essential"
    COOKIES_ANALYTICS = "cookies_analytics"
    COOKIES_MARKETING = "cookies_marketing"


class ConsentStatus(Enum):
    """Status of consent."""
    GRANTED = "granted"
    DENIED = "denied"
    WITHDRAWN = "withdrawn"
    NOT_ASKED = "not_asked"


@dataclass
class ConsentRecord:
    """Record of a consent decision."""
    id: str
    user_id: str
    tenant_id: str
    purpose: ConsentPurpose
    status: ConsentStatus
    granted_at: Optional[datetime]
    withdrawn_at: Optional[datetime]
    ip_address: str
    user_agent: str
    consent_text: str  # Exact text shown to user
    consent_version: str  # Version of consent form


@dataclass
class ConsentPreferences:
    """A user's current consent preferences."""
    user_id: str
    consents: Dict[ConsentPurpose, ConsentStatus]
    last_updated: datetime


class ConsentService:
    """
    Service for managing user consent.
    
    Key principles:
    - Consent must be freely given, specific, informed, unambiguous
    - Must be as easy to withdraw as to give
    - Must keep records of when/how consent was given
    - Consent is per-purpose, not blanket
    """
    
    def __init__(self, db, event_publisher):
        self.db = db
        self.events = event_publisher
    
    async def record_consent(
        self,
        user_id: str,
        tenant_id: str,
        purpose: ConsentPurpose,
        granted: bool,
        ip_address: str,
        user_agent: str,
        consent_text: str,
        consent_version: str
    ) -> ConsentRecord:
        """
        Record a consent decision.
        
        This creates an immutable audit record.
        """
        record = ConsentRecord(
            id=str(uuid.uuid4()),
            user_id=user_id,
            tenant_id=tenant_id,
            purpose=purpose,
            status=ConsentStatus.GRANTED if granted else ConsentStatus.DENIED,
            granted_at=datetime.now(timezone.utc) if granted else None,
            withdrawn_at=None,
            ip_address=ip_address,
            user_agent=user_agent,
            consent_text=consent_text,
            consent_version=consent_version
        )
        
        # Store in database (immutable log)
        await self.db.execute(
            """
            INSERT INTO consent_records 
            (id, user_id, tenant_id, purpose, status, granted_at, 
             ip_address, user_agent, consent_text, consent_version, created_at)
            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
            """,
            record.id, record.user_id, record.tenant_id,
            record.purpose.value, record.status.value, record.granted_at,
            record.ip_address, record.user_agent, record.consent_text,
            record.consent_version, datetime.now(timezone.utc)
        )
        
        # Update current preferences
        await self._update_current_preferences(
            user_id, tenant_id, purpose,
            ConsentStatus.GRANTED if granted else ConsentStatus.DENIED
        )
        
        # Publish event for downstream systems
        await self.events.publish(
            "consent",
            {
                "type": "consent.recorded",
                "user_id": user_id,
                "tenant_id": tenant_id,
                "purpose": purpose.value,
                "granted": granted
            }
        )
        
        logger.info(
            f"Consent recorded",
            extra={
                "user_id": user_id,
                "purpose": purpose.value,
                "granted": granted
            }
        )
        
        return record
    
    async def withdraw_consent(
        self,
        user_id: str,
        tenant_id: str,
        purpose: ConsentPurpose,
        ip_address: str,
        user_agent: str
    ) -> ConsentRecord:
        """
        Withdraw previously given consent.
        
        Must be as easy as giving consent.
        """
        record = ConsentRecord(
            id=str(uuid.uuid4()),
            user_id=user_id,
            tenant_id=tenant_id,
            purpose=purpose,
            status=ConsentStatus.WITHDRAWN,
            granted_at=None,
            withdrawn_at=datetime.now(timezone.utc),
            ip_address=ip_address,
            user_agent=user_agent,
            consent_text="Consent withdrawn by user",
            consent_version="withdrawal"
        )
        
        await self.db.execute(
            """
            INSERT INTO consent_records 
            (id, user_id, tenant_id, purpose, status, withdrawn_at,
             ip_address, user_agent, consent_text, consent_version, created_at)
            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
            """,
            record.id, record.user_id, record.tenant_id,
            record.purpose.value, record.status.value, record.withdrawn_at,
            record.ip_address, record.user_agent, record.consent_text,
            record.consent_version, datetime.now(timezone.utc)
        )
        
        await self._update_current_preferences(
            user_id, tenant_id, purpose, ConsentStatus.WITHDRAWN
        )
        
        # Publish event - systems must stop processing
        await self.events.publish(
            "consent",
            {
                "type": "consent.withdrawn",
                "user_id": user_id,
                "tenant_id": tenant_id,
                "purpose": purpose.value
            }
        )
        
        logger.info(
            f"Consent withdrawn",
            extra={"user_id": user_id, "purpose": purpose.value}
        )
        
        return record
    
    async def get_current_consent(
        self,
        user_id: str,
        tenant_id: str,
        purpose: ConsentPurpose
    ) -> ConsentStatus:
        """
        Get current consent status for a purpose.
        """
        result = await self.db.fetchrow(
            """
            SELECT status FROM user_consent_preferences
            WHERE user_id = $1 AND tenant_id = $2 AND purpose = $3
            """,
            user_id, tenant_id, purpose.value
        )
        
        if not result:
            return ConsentStatus.NOT_ASKED
        
        return ConsentStatus(result["status"])
    
    async def has_consent(
        self,
        user_id: str,
        tenant_id: str,
        purpose: ConsentPurpose
    ) -> bool:
        """
        Check if user has given consent for a purpose.
        """
        status = await self.get_current_consent(user_id, tenant_id, purpose)
        return status == ConsentStatus.GRANTED
    
    async def get_all_preferences(
        self,
        user_id: str,
        tenant_id: str
    ) -> ConsentPreferences:
        """
        Get all consent preferences for a user.
        """
        results = await self.db.fetch(
            """
            SELECT purpose, status, updated_at
            FROM user_consent_preferences
            WHERE user_id = $1 AND tenant_id = $2
            """,
            user_id, tenant_id
        )
        
        consents = {}
        last_updated = datetime.min.replace(tzinfo=timezone.utc)
        
        for row in results:
            consents[ConsentPurpose(row["purpose"])] = ConsentStatus(row["status"])
            if row["updated_at"] > last_updated:
                last_updated = row["updated_at"]
        
        return ConsentPreferences(
            user_id=user_id,
            consents=consents,
            last_updated=last_updated
        )
    
    async def get_consent_history(
        self,
        user_id: str,
        tenant_id: str
    ) -> List[ConsentRecord]:
        """
        Get full consent history for a user.
        
        Required for data subject access requests.
        """
        results = await self.db.fetch(
            """
            SELECT * FROM consent_records
            WHERE user_id = $1 AND tenant_id = $2
            ORDER BY created_at DESC
            """,
            user_id, tenant_id
        )
        
        return [
            ConsentRecord(
                id=row["id"],
                user_id=row["user_id"],
                tenant_id=row["tenant_id"],
                purpose=ConsentPurpose(row["purpose"]),
                status=ConsentStatus(row["status"]),
                granted_at=row["granted_at"],
                withdrawn_at=row["withdrawn_at"],
                ip_address=row["ip_address"],
                user_agent=row["user_agent"],
                consent_text=row["consent_text"],
                consent_version=row["consent_version"]
            )
            for row in results
        ]
    
    async def _update_current_preferences(
        self,
        user_id: str,
        tenant_id: str,
        purpose: ConsentPurpose,
        status: ConsentStatus
    ):
        """Update current preferences table."""
        await self.db.execute(
            """
            INSERT INTO user_consent_preferences (user_id, tenant_id, purpose, status, updated_at)
            VALUES ($1, $2, $3, $4, $5)
            ON CONFLICT (user_id, tenant_id, purpose) 
            DO UPDATE SET status = $4, updated_at = $5
            """,
            user_id, tenant_id, purpose.value, status.value, datetime.now(timezone.utc)
        )
pythonrequires_consent · 112 lines
# data_residency/consent_middleware.py

"""
Middleware and decorators for consent-aware processing.
"""

from functools import wraps
from fastapi import HTTPException


def requires_consent(purpose: ConsentPurpose):
    """
    Decorator that ensures user has consented to a purpose.
    
    Usage:
        @requires_consent(ConsentPurpose.MARKETING_EMAIL)
        async def send_marketing_email(user_id: str):
            ...
    """
    def decorator(func):
        @wraps(func)
        async def wrapper(*args, **kwargs):
            # Get user_id from kwargs or context
            user_id = kwargs.get("user_id")
            tenant_id = get_current_tenant_id()
            
            if not user_id:
                raise ValueError("user_id required for consent check")
            
            # Check consent
            has_consent = await consent_service.has_consent(
                user_id, tenant_id, purpose
            )
            
            if not has_consent:
                raise ConsentRequiredError(
                    f"User has not consented to {purpose.value}"
                )
            
            return await func(*args, **kwargs)
        return wrapper
    return decorator


class ConsentRequiredError(Exception):
    """Raised when required consent is not present."""
    pass


# Example usage in a service
class MarketingService:
    """Service that requires consent for operations."""
    
    def __init__(self, consent_service: ConsentService, email_client):
        self.consent = consent_service
        self.email = email_client
    
    async def send_newsletter(self, user_id: str, content: str):
        """
        Send newsletter to user.
        
        Requires marketing email consent.
        """
        tenant_id = get_current_tenant_id()
        
        # Check consent before sending
        if not await self.consent.has_consent(
            user_id, tenant_id, ConsentPurpose.MARKETING_EMAIL
        ):
            logger.info(
                f"Skipping newsletter for user without consent",
                extra={"user_id": user_id}
            )
            return False
        
        await self.email.send(
            to=user_id,
            subject="Newsletter",
            content=content
        )
        
        return True
    
    async def send_bulk_newsletter(self, user_ids: List[str], content: str):
        """
        Send newsletter to multiple users.
        
        Filters to only users with consent.
        """
        tenant_id = get_current_tenant_id()
        
        # Batch check consent
        consented_users = []
        
        for user_id in user_ids:
            if await self.consent.has_consent(
                user_id, tenant_id, ConsentPurpose.MARKETING_EMAIL
            ):
                consented_users.append(user_id)
        
        logger.info(
            f"Sending newsletter to {len(consented_users)}/{len(user_ids)} users with consent"
        )
        
        for user_id in consented_users:
            await self.email.send(
                to=user_id,
                subject="Newsletter",
                content=content
            )
        
        return len(consented_users)
Quick check3 questions · +5 XP each

Consent management

  1. Q1/3 Why does ConsentService both append to consent_records and upsert user_consent_preferences?

    Answer & explanation

    Answer: B

    The immutable log keeps exact text, version, IP and timestamp as audit evidence, and feeds access requests. The preferences table holds only the current status per purpose, which is what checks like has_consent need on every call.

  2. Q2/3 A user withdraws marketing consent. What does the lesson's design do?

    Answer & explanation

    Answer: D

    Withdrawal must be as easy as giving consent, and records are never edited: a new record is appended and consent.withdrawn tells downstream systems to stop. Deleting the grant would destroy the proof that earlier processing was lawful.

  3. Q3/3 A user was never shown the analytics consent prompt. What does has_consent(..., ANALYTICS) return?

    Answer & explanation

    Answer: A

    get_current_consent returns NOT_ASKED when no row exists, and has_consent is true only for GRANTED. Consent must be an unambiguous affirmative action, so never-asked behaves exactly like denied.

Chapter 6: Cross-Border Data Transfers#

6.1 Transfer Impact Assessment#

pythonTransferMechanism · 156 lines
# data_residency/transfer_assessment.py

"""
Cross-border data transfer assessment and documentation.
"""

from dataclasses import dataclass
from typing import List, Optional
from datetime import datetime, timezone
from enum import Enum


class TransferMechanism(Enum):
    """Legal mechanisms for cross-border transfers."""
    ADEQUACY_DECISION = "adequacy_decision"
    STANDARD_CONTRACTUAL_CLAUSES = "sccs"
    BINDING_CORPORATE_RULES = "bcrs"
    DEROGATION = "derogation"
    CONSENT = "consent"


class RiskLevel(Enum):
    """Risk level for data transfers."""
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"
    PROHIBITED = "prohibited"


@dataclass
class TransferAssessment:
    """Assessment of a cross-border data transfer."""
    id: str
    source_region: str
    destination_region: str
    data_categories: List[str]
    data_subjects: str  # Description of affected individuals
    transfer_mechanism: TransferMechanism
    risk_level: RiskLevel
    supplementary_measures: List[str]
    assessment_date: datetime
    next_review_date: datetime
    approved_by: str
    notes: str


class TransferImpactAssessment:
    """
    Performs Transfer Impact Assessments (TIAs) as required by Schrems II.
    """
    
    # Countries with adequacy decisions (simplified)
    ADEQUATE_COUNTRIES = {
        "EU", "EEA", "GB", "CH", "JP", "KR", "CA", "NZ", "IL", "UY", "AR", "BR"
    }
    
    # Countries with high surveillance risk (simplified assessment)
    HIGH_RISK_COUNTRIES = {
        "CN", "RU"  # This is a simplification - real assessment is more nuanced
    }
    
    def assess_transfer(
        self,
        source_country: str,
        destination_country: str,
        data_categories: List[str],
        special_categories: bool = False
    ) -> TransferAssessment:
        """
        Assess a proposed data transfer.
        """
        # Same region = no transfer
        if source_country == destination_country:
            return self._create_assessment(
                source_country, destination_country, data_categories,
                TransferMechanism.ADEQUACY_DECISION,  # Not really a transfer
                RiskLevel.LOW,
                []
            )
        
        # Check adequacy
        if destination_country in self.ADEQUATE_COUNTRIES:
            return self._create_assessment(
                source_country, destination_country, data_categories,
                TransferMechanism.ADEQUACY_DECISION,
                RiskLevel.LOW,
                []
            )
        
        # US-specific handling (Data Privacy Framework)
        if destination_country == "US":
            return self._create_assessment(
                source_country, destination_country, data_categories,
                TransferMechanism.ADEQUACY_DECISION,  # DPF
                RiskLevel.MEDIUM,  # Some risk remains
                ["Verify recipient is DPF certified (if not: SCCs + TIA)",
                 "Review specific data categories"]
            )
        
        # High-risk countries
        if destination_country in self.HIGH_RISK_COUNTRIES:
            if special_categories:
                return self._create_assessment(
                    source_country, destination_country, data_categories,
                    TransferMechanism.DEROGATION,
                    RiskLevel.PROHIBITED,
                    ["Transfer of special categories to this jurisdiction is not recommended"]
                )
            
            return self._create_assessment(
                source_country, destination_country, data_categories,
                TransferMechanism.STANDARD_CONTRACTUAL_CLAUSES,
                RiskLevel.HIGH,
                ["Implement encryption in transit and at rest",
                 "Minimize data transferred",
                 "Regular review of legal situation",
                 "Consider pseudonymization"]
            )
        
        # Default: SCCs with supplementary measures
        return self._create_assessment(
            source_country, destination_country, data_categories,
            TransferMechanism.STANDARD_CONTRACTUAL_CLAUSES,
            RiskLevel.MEDIUM,
            ["Sign SCCs with recipient",
             "Document supplementary measures",
             "Review annually"]
        )
    
    def _create_assessment(
        self,
        source: str,
        dest: str,
        data_categories: List[str],
        mechanism: TransferMechanism,
        risk: RiskLevel,
        measures: List[str]
    ) -> TransferAssessment:
        """Create assessment record."""
        import uuid
        from datetime import timedelta
        
        return TransferAssessment(
            id=str(uuid.uuid4()),
            source_region=source,
            destination_region=dest,
            data_categories=data_categories,
            data_subjects="Users and employees of tenant",
            transfer_mechanism=mechanism,
            risk_level=risk,
            supplementary_measures=measures,
            assessment_date=datetime.now(timezone.utc),
            next_review_date=datetime.now(timezone.utc) + timedelta(days=365),
            approved_by="",
            notes=""
        )

6.2 Data Processing Agreements#

pythonDPAStatus · 184 lines
# data_residency/dpa.py

"""
Data Processing Agreement (DPA) management.

GDPR Article 28 requires written contracts with data processors.
"""

from dataclasses import dataclass
from typing import List, Optional
from datetime import datetime, timezone
from enum import Enum


class DPAStatus(Enum):
    """Status of a DPA."""
    DRAFT = "draft"
    PENDING_SIGNATURE = "pending_signature"
    ACTIVE = "active"
    EXPIRED = "expired"
    TERMINATED = "terminated"


@dataclass
class DataProcessingAgreement:
    """A Data Processing Agreement with a tenant or vendor."""
    id: str
    tenant_id: str
    counterparty_name: str
    counterparty_type: str  # "customer" or "vendor"
    
    # Processing details
    processing_purposes: List[str]
    data_categories: List[str]
    data_subject_categories: List[str]
    retention_period: str
    
    # Transfer details
    processing_locations: List[str]
    subprocessors: List[str]
    transfer_mechanism: Optional[str]
    
    # Agreement details
    status: DPAStatus
    signed_date: Optional[datetime]
    effective_date: Optional[datetime]
    expiration_date: Optional[datetime]
    document_url: str
    
    # Audit
    created_at: datetime
    updated_at: datetime


class DPAService:
    """
    Service for managing Data Processing Agreements.
    """
    
    def __init__(self, db, document_storage):
        self.db = db
        self.storage = document_storage
    
    async def create_dpa(
        self,
        tenant_id: str,
        counterparty_name: str,
        counterparty_type: str,
        processing_purposes: List[str],
        data_categories: List[str],
        processing_locations: List[str]
    ) -> DataProcessingAgreement:
        """
        Create a new DPA.
        """
        import uuid
        
        dpa_id = str(uuid.uuid4())
        
        dpa = DataProcessingAgreement(
            id=dpa_id,
            tenant_id=tenant_id,
            counterparty_name=counterparty_name,
            counterparty_type=counterparty_type,
            processing_purposes=processing_purposes,
            data_categories=data_categories,
            data_subject_categories=["Employees", "End users"],
            retention_period="As specified in main agreement",
            processing_locations=processing_locations,
            subprocessors=[],
            transfer_mechanism=None,
            status=DPAStatus.DRAFT,
            signed_date=None,
            effective_date=None,
            expiration_date=None,
            document_url="",
            created_at=datetime.now(timezone.utc),
            updated_at=datetime.now(timezone.utc)
        )
        
        # Store in database
        await self._save_dpa(dpa)
        
        return dpa
    
    async def get_active_dpas(self, tenant_id: str) -> List[DataProcessingAgreement]:
        """Get all active DPAs for a tenant."""
        results = await self.db.fetch(
            """
            SELECT * FROM data_processing_agreements
            WHERE tenant_id = $1 AND status = 'active'
            """,
            tenant_id
        )
        
        return [self._row_to_dpa(row) for row in results]
    
    async def get_subprocessors(self, tenant_id: str) -> List[dict]:
        """
        Get list of subprocessors for a tenant.
        
        Required for GDPR transparency.
        """
        # Our subprocessors (third-party services we use)
        our_subprocessors = [
            {
                "name": "Amazon Web Services",
                "purpose": "Cloud infrastructure",
                "location": "EU (Frankfurt)",
                "dpa_url": "https://aws.amazon.com/compliance/gdpr-center/"
            },
            {
                "name": "Stripe",
                "purpose": "Payment processing",
                "location": "US (with DPF certification)",
                "dpa_url": "https://stripe.com/legal/dpa"
            },
            {
                "name": "SendGrid",
                "purpose": "Email delivery",
                "location": "US (with SCCs)",
                "dpa_url": "https://www.twilio.com/en-us/legal/data-protection-addendum"
            }
        ]
        
        return our_subprocessors
    
    async def _save_dpa(self, dpa: DataProcessingAgreement):
        """Save DPA to database."""
        await self.db.execute(
            """
            INSERT INTO data_processing_agreements 
            (id, tenant_id, counterparty_name, counterparty_type,
             processing_purposes, data_categories, processing_locations,
             status, created_at, updated_at)
            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
            """,
            dpa.id, dpa.tenant_id, dpa.counterparty_name, dpa.counterparty_type,
            dpa.processing_purposes, dpa.data_categories, dpa.processing_locations,
            dpa.status.value, dpa.created_at, dpa.updated_at
        )
    
    def _row_to_dpa(self, row) -> DataProcessingAgreement:
        """Convert database row to DPA object."""
        return DataProcessingAgreement(
            id=row["id"],
            tenant_id=row["tenant_id"],
            counterparty_name=row["counterparty_name"],
            counterparty_type=row["counterparty_type"],
            processing_purposes=row["processing_purposes"],
            data_categories=row["data_categories"],
            data_subject_categories=row.get("data_subject_categories", []),
            retention_period=row.get("retention_period", ""),
            processing_locations=row["processing_locations"],
            subprocessors=row.get("subprocessors", []),
            transfer_mechanism=row.get("transfer_mechanism"),
            status=DPAStatus(row["status"]),
            signed_date=row.get("signed_date"),
            effective_date=row.get("effective_date"),
            expiration_date=row.get("expiration_date"),
            document_url=row.get("document_url", ""),
            created_at=row["created_at"],
            updated_at=row["updated_at"]
        )
Quick check3 questions · +5 XP each

Cross-border transfers and DPAs

  1. Q1/3 Using the lesson's assess_transfer logic, EU data is to be sent to a vendor in a country with no adequacy decision that is not on the high-risk list. What comes back?

    Answer & explanation

    Answer: B

    Without adequacy and outside the high-risk set, the code falls through to SCCs at medium risk with "sign SCCs", "document supplementary measures" and "review annually". Prohibited is reserved for special-category data going to high-risk countries.

  2. Q2/3 Why does every transfer assessment carry a next_review_date instead of being a one-time check?

    Answer & explanation

    Answer: D

    Transfer Impact Assessments exist because of Schrems II, which struck down a mechanism many companies relied on. Periodic review catches changes in the destination's laws or in adequacy status before you are transferring without a valid basis.

  3. Q3/3 Your EU-hosted app keeps all user data in Frankfurt but sends transactional emails through a US email provider. What is still required?

    Answer & explanation

    Answer: A

    The provider receives EU personal data (addresses, content), so it is a subprocessor: Article 28 requires a DPA, the US transfer needs DPF or SCCs, and customers must see it in the subprocessor list. Where your database sits does not cover data you hand to third parties.

Part III · Stage 3 of 4 · 4 min

Part III: Real-World Application#

Chapter 7: Case Studies#

7.1 Slack's Data Residency#

Slack data residency architecture
Challenge
  • Global customer base
  • Enterprise customers need EU data residency
  • Real-time messaging requires low latency
  • Collaboration features need cross-region access
Solution: DATA RESIDENCY (Business+ and Enterprise plans)

Global Services (outside the residency scope): Authentication/SSO · Member profiles · Workspace and channel membership · Slack-generated IDs · Usage analytics

Regional Data Stores (around a dozen regions, e.g. Frankfurt, Paris, London, Tokyo, Mumbai, Sydney, Montreal):

US (default) EU (e.g. Frankfurt) Other regions
Messages Messages Messages
Files Files Files
Search index Search index Search index
App-generated content App-generated content App-generated content

Per-Organization Choice:

  • Organization assigned to one region
  • All data for that org stays in region
  • Slack Connect (cross-org) respects both orgs' residency
Key decisions
  • ORGANIZATION = RESIDENCY BOUNDARY — Not user-level, org-level
  • SOME DATA STAYS GLOBAL — IDs, membership and even member profiles fall outside Slack's residency scope, so define "metadata" carefully and disclose it
  • ENCRYPTION AT REST — Customer-managed keys (Enterprise Key Management); per-organization keys
  • SLACK CONNECT HANDLING — Each org's messages are stored in its own region, and the shared channel's search index is kept in each org's region; both parties must allow the connection
Lessons
  • Organization-level residency is manageable
  • Distinguish metadata from content
  • Encryption adds extra protection
  • Cross-org features need careful design

7.2 AWS Regional Architecture#

AWS approach to data residency

AWS provides building blocks for customers to implement residency.

Regional services
  • Data stays in chosen region by default
  • Customer controls replication
  • Some services (IAM, Route53) are global
  • S3 can be configured for single-region
Tools for compliance
  • AWS Config Rules — Detect resources outside approved regions
  • Service Control Policies (SCPs) — Prevent creating resources in wrong regions
  • AWS Artifact — Compliance reports and DPAs
  • Data residency guardrails — AWS Control Tower for multi-account
Example SCP for EU-only
json23 lines
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Deny",
      "NotAction": [
        "iam:*", "organizations:*", "route53:*", "cloudfront:*",
        "sts:*", "support:*", "budgets:*", "waf:*"
      ],
      "Resource": "*",
      "Condition": {
        "StringNotEquals": {
          "aws:RequestedRegion": [
            "eu-central-1",
            "eu-west-1",
            "eu-west-3",
            "eu-north-1"
          ]
        }
      }
    }
  ]
}

NotAction exempts global services (IAM, Route 53, CloudFront, STS…), which are served from us-east-1; a plain "Action": "*" would lock you out of them.

Lessons
  • Cloud providers offer tools, not solutions
  • You must architect for residency
  • Policy enforcement prevents accidents
  • Global services need special handling
Your turn · checkpoint 5not answered

What still breaks after the database moves?

You've moved the German bank's database to Frankfurt. The rest of the preface's stack is unchanged: backups replicated to us-west-2, analytics in BigQuery (US), support in Zendesk (US), errors in Sentry (US). Which of these still break the promise "EU data stays in the EU", and why?

Think it through, or jot it down. Then compare.

One strong answer

  • Backups: they're the same personal data as the database, so US backups break residency on their own.
  • Error tracking and analytics: anything carrying an email, IP or user ID is personal data, even pseudonymized.
  • Support tool: tickets contain names and messages. Every one of these third parties needs a DPA and a transfer mechanism, or its data must stay in the EU.

Chapter 8: Common Mistakes#

✗ Pitfall

8.1 Data Residency Anti-Patterns#

Data residency mistakes
Mistake 1: Forgetting About Backups

Wrong:

python5 lines
# Data in EU region
database = "eu-central-1-db.example.com"

# But backups go to US!
backup_bucket = "s3://backups-us-east-1/"

Problem: Backups are still personal data. US backups violate EU residency.

Right:

python3 lines
# Data and backups in same region
database = "eu-central-1-db.example.com"
backup_bucket = "s3://backups-eu-central-1/"
Mistake 2: Logging PII to Global Services

Wrong:

python3 lines
# Global Datadog/Splunk instance
logger.info(f"User {user.email} from {user.country} logged in")
# PII now in US logging infrastructure

Problem: User email is personal data. Now stored in global logging service.

Right:

python8 lines
# A user_id is still personal data (pseudonymous), so user-linked
# logs go to the user's regional logging stack
regional_logger(user.region).info(
    "User logged in",
    extra={"user_id": user.id}
)
# Global tools only get aggregates with no identifiers
metrics.increment("logins", tags={"region": user.region})
Mistake 3: Analytics Without Consent

Wrong:

python6 lines
# Track everything, figure out consent later
analytics.track("page_view", {
    "user_id": user.id,
    "page": request.path,
    "ip": request.client.ip
})

Problem: Analytics tracking may require consent. IP addresses are personal data.

Right:

python6 lines
if await consent_service.has_consent(user.id, tenant_id, ConsentPurpose.ANALYTICS):
    analytics.track("page_view", {
        "user_id": user.id,
        "page": request.path
        # No IP - minimize data
    })
Mistake 4: Third-Party Services Without DPAs

Wrong:

python2 lines
# Just use Mixpanel, they're big so probably fine
mixpanel.track(user.email, "signup")

Problem: No DPA with Mixpanel. Data transferred to US without safeguards. You're liable as data controller.

Right:

python7 lines
# Verify DPA exists, use SCCs, minimize data
if mixpanel_dpa_signed:
    mixpanel.track(
        pseudonymize(user.id),  # Not email; still personal data, hence DPA + SCCs/DPF
        "signup",
        {"region": user.region}
    )
Mistake 5: Assuming Adequacy Decisions Are Permanent

Wrong:

python2 lines
# US has Privacy Shield, we're fine forever!
transfer_data_to_us(eu_user_data)

Problem: Privacy Shield was invalidated (Schrems II). Adequacy decisions can be revoked.

Right:

python7 lines
# Monitor regulatory changes
# Have fallback mechanisms
# Document your transfer assessment
if us_adequacy_valid():
    transfer_data_to_us(eu_user_data)
else:
    use_sccs_with_supplementary_measures(eu_user_data)
Quick check3 questions · +5 XP each

Case studies and common mistakes

  1. Q1/3 Slack makes the organization, not the individual user, the residency boundary. What does that buy them?

    Answer & explanation

    Answer: C

    With one region per organization, every request for that org routes to one place and its data stays together. Per-user residency inside a shared workspace would scatter collaborative content across regions; and content is still personal data, so it does not become global.

  2. Q2/3 You want to make it impossible for an engineer to create a database outside the EU in your EU-only AWS account. Which control does that?

    Answer & explanation

    Answer: B

    SCPs prevent the action at the API level, using a Deny on aws:RequestedRegion. Config rules detect resources outside approved regions after the fact, and Artifact only supplies compliance reports and DPAs.

  3. Q3/3 Your team moves EU data to the US, relying on the current US adequacy framework. What does the lesson say a robust design adds?

    Answer & explanation

    Answer: D

    Privacy Shield was invalidated by Schrems II, so adequacy decisions can be revoked. The fix is to monitor regulatory changes, keep a fallback mechanism ready and document the assessment; pseudonymized data remains personal data.

Part IV · Stage 4 of 4 · 5 min

Part IV: Interview Preparation#

Chapter 9: Interview Tips#

9.1 Data Residency Discussion Framework#

Discussing data residency in interviews
  1. CLARIFY REQUIREMENTS — "What are the data residency requirements? Are we dealing with GDPR (EU), specific country laws, or enterprise customer demands?"
  2. IDENTIFY DATA CATEGORIES — "Let me categorize the data:
    • Personal data that needs residency: user profiles, content
    • Metadata that might be global: routing, configuration
    • Truly anonymous data: aggregated analytics"
  3. PROPOSE ARCHITECTURE — "I'd implement regional deployments with a global control plane. Each tenant is assigned to a region during onboarding. Personal data stays in that region. Metadata and routing information can be global since it's not personal data."
  4. ADDRESS CROSS-REGION — "For features that span regions, like messaging between users in different regions, data stays in the more restrictive region. Or we block cross-region features for strict compliance tenants."
  5. MENTION ENFORCEMENT — "I'd use infrastructure-as-code with policy enforcement to prevent accidental data leakage. AWS SCPs or GCP Organization Policies can block resource creation in wrong regions."
❯ Say this

9.2 Key Phrases#

Data residency key phrases
On Regional Architecture

"I'd deploy regional data stores with a global control plane. The control plane handles routing and metadata - things that aren't personal data. All personal data stays in the tenant's assigned region, including backups and logs."

On GDPR Transfers

"For cross-border transfers, we need a legal basis. If the destination has an adequacy decision, we're good. Otherwise, we need Standard Contractual Clauses with supplementary measures. Post-Schrems II, we also need a Transfer Impact Assessment."

On Consent

"Consent must be freely given, specific, informed, and unambiguous. I'd implement a consent management system that records the exact text shown, timestamp, IP, and allows easy withdrawal. Different purposes need separate consent - no bundling."

On Third Parties

"Every third-party processor needs a Data Processing Agreement. We need to track subprocessors and their locations. If they process EU data in the US, they need appropriate safeguards like DPF certification or SCCs."

Chapter 10: Practice Problems#

Practice · problem 1

Problem 1: Multi-Region SaaS#

Scenario: Your B2B SaaS has customers in US, EU, and Asia. EU customers require GDPR compliance including data residency. You currently have one region (us-east-1).

Questions:

  1. How do you migrate to support EU data residency?
  2. What happens to features that need cross-region data?
  3. How do you handle a user who moves from EU to US?
Click for hints
  • Add EU region with separate database
  • Tenant-level region assignment
  • Cross-region features: either block or store in most restrictive
  • User moving: they might need to be re-assigned to new region
  • Consider data migration tools and procedures

Practice · problem 2

Problem 2: Analytics Pipeline Compliance#

Scenario: You run analytics on user behavior using BigQuery (US). EU customers are complaining about GDPR compliance.

Questions:

  1. Can you continue using BigQuery for EU user data?
  2. What changes would make this compliant?
  3. How do you handle historical data that's already in BigQuery?
Click for hints
  • BigQuery has EU regions - use them for EU data
  • Anonymize/aggregate before cross-border transfer
  • Historical data: delete or anonymize
  • Consider consent basis for analytics
  • Document Transfer Impact Assessment

Chapter 11: Sample Interview Dialogue#

Transcript
Interviewer

"We need to serve EU customers. How do you handle GDPR compliance?"

You

"GDPR compliance has several aspects. Let me break it down by the main requirements.

For data residency, I'd deploy EU infrastructure - database, storage, and search in eu-central-1 or eu-west-1. Each tenant is assigned a region during onboarding based on their location. All personal data stays in that region.

For lawful basis, we'd use contract performance for core functionality - storing their data to provide the service. For marketing or analytics, we need consent. I'd implement a consent management system..."

DiagramConsent flow
%% title: Consent flow
flowchart TD
  S(["User signs up"]):::client --> F["Show consent form:<br/>Essential cookies: Required (strictly necessary, no consent needed)<br/>Analytics: Optional, unchecked by default<br/>Marketing: Optional, unchecked by default"]:::core
  F --> R[("Record consent with timestamp,<br/>IP, exact text shown")]:::store
  R --> W(["If they later withdraw,<br/>immediately stop processing"]):::bad
Transcript
Interviewer

"What about our analytics that currently runs in the US?"

You

"A few options:

  1. Regional analytics: Run BigQuery in EU multi-region for EU data. More expensive but cleanest.

  2. Anonymize before transfer: Aggregate data to the point it's no longer personal data before sending to US. For example, 'Users in Germany viewed page X 1000 times' is not personal data.

  3. Transfer with safeguards: Use BigQuery in US but with SCCs and supplementary measures. Requires Transfer Impact Assessment and ongoing monitoring of US surveillance laws.

I'd recommend option 1 for personal data and option 2 for aggregated metrics. We'd need to document this in our Records of Processing Activities."

Interviewer

"How do you prove compliance to customers?"

You

"Several mechanisms:

  • DPA signing: Automated DPA generation and signing during enterprise onboarding
  • Subprocessor list: Published list of all third parties that process data
  • Data residency documentation: Architecture diagrams showing data flows
  • Audit logs: Records of all data access, exportable for audits
  • Certifications: SOC 2 Type II, ISO 27001 for security controls

For enterprise customers, we could offer a compliance portal showing their data location, consent records, and processing activities."

Summary#

Day 3 key takeaways
Data residency basics
  • Residency = where data is stored
  • Sovereignty = which laws apply
  • Localization = legal requirement to keep data in-country
  • Transfer = moving data across borders
Key regulations
  • GDPR (EU): Most influential, extraterritorial
  • LGPD (Brazil): GDPR-like
  • PIPL (China): Strict localization
  • DPDP (India): Emerging requirements
  • Various country-specific laws
Architecture patterns
  • Single region: Simple but limited compliance
  • Regional deployments: Full isolation
  • Global control + regional data: Balance
  • Per-tenant region: Maximum flexibility
Implementation
  • Tenant region assignment at onboarding
  • Regional database routing
  • Regional storage routing
  • Consent management system
  • DPA tracking
GDPR transfers
  • Adequacy decision: Easiest
  • SCCs: Most common
  • BCRs: For corporate groups
  • Supplementary measures: Post-Schrems II
Consent requirements
  • Freely given
  • Specific (per purpose)
  • Informed (clear language)
  • Unambiguous (affirmative action)
  • Withdrawable (easy as giving)
Common mistakes
  • Forgetting backups
  • PII in global logs
  • Analytics without consent
  • Missing DPAs
  • Assuming adequacy is permanent
Default approach
  • Regional data stores for personal data
  • Global control plane for metadata
  • Consent management from day one
  • DPAs with all processors
  • Document everything

Further Reading#

Official Resources:

Compliance Tools:

  • Read

    OneTrust (consent management)

  • Read

    TrustArc (privacy management)

  • Read

    BigID (data discovery)

Cloud Provider Resources:

this lesson in shorts · 2

play them all →

One idea from this lesson in about a minute each, made for your phone.

End of Day 03 · Data Residency and GDPR
Finish Day 3Stages clear as you read them (0/4 so far). Skipping ahead doesn't count: marking complete unlocks after ~7 more min of reading.

Go deeper · reading trail

all reading →

Real write-ups from the teams that built these systems. Each opens on the publisher's site in a new tab.

CloudflareBlog post · ~8 min

Introducing the Cloudflare Data Localization Suite

John Graham-Cumming · 2020
What
Cloudflare's product for customers who must keep traffic, keys and logs in a region.
Key idea
Regional Services decides where traffic is decrypted and inspected (elsewhere it only gets L3/L4 DDoS protection and is sent on securely), and Geo Key Manager decides where private keys live.
Why here
Data residency for a global edge network: decide separately where data is processed, where keys live and where logs are stored.
taught here: Regional data architecture
RBIDocs · ~5 min

Storage of Payment System Data

2018
What
The April 2018 RBI circular on where payment data may be stored.
Key idea
All system providers must store the entire data relating to payment systems they operate, with end-to-end transaction details, only in India.
Why here
A non-GDPR residency rule that shaped every Indian payments architecture, and an example of how a regulation becomes a design constraint.
taught here: Data localisation rules

New lessons by email

New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.

Discussion · …

Public: everyone reading this lesson sees comments. Be kind and keep it on the material.

Sign in to ask a question or answer one. Reading stays open to everyone.
loading the discussion…