{
“cells”: [
{

“cell_type”: “markdown”, “id”: “intro”, “metadata”: {}, “source”: [

“# Multi-tenant Authentication with API Keysn”, “n”, “This notebook demonstrates how to use TimeDB’s authentication system with API keys and tenant isolation.n”, “n”, “## What we’ll cover:n”, “1. Setting up the database schema with users tablen”, “2. Creating tenants and users via CLIn”, “3. Using API keys to authenticate requestsn”, “4. Demonstrating tenant isolation (each user only sees their own data)n”, “5. Testing authentication enforcement”

]

}, {

“cell_type”: “code”, “execution_count”: 11, “id”: “imports”, “metadata”: {}, “outputs”: [

{

“name”: “stdout”, “output_type”: “stream”, “text”: [

“Database URL configured: postgresql://neondb_owner:npg_hILKzJ4BwbH2@ep-crim…n”, “Environment variables set for API servern”, “API URL: https://timedb-api.onrender.comn”

]

}

], “source”: [

“import osn”, “import uuidn”, “import subprocessn”, “import requestsn”, “import pandas as pdn”, “from datetime import datetime, timezone, timedeltan”, “n”, “from dotenv import load_dotenvn”, “load_dotenv()n”, “n”, “# Use TEST_DATABASE_URL for testing (falls back to DATABASE_URL if not set)n”, “DATABASE_URL = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL")n”, “if not DATABASE_URL:n”, “ raise ValueError("Please set TEST_DATABASE_URL or DATABASE_URL environment variable")n”, “n”, “# IMPORTANT: Override both TIMEDB_DSN and DATABASE_URL so the API server uses the same databasen”, “# The API reads from TIMEDB_DSN first, then falls back to DATABASE_URLn”, “os.environ["TIMEDB_DSN"] = DATABASE_URLn”, “os.environ["DATABASE_URL"] = DATABASE_URLn”, “n”, “# API base URLn”, “API_BASE_URL = os.getenv(‘API_BASE_URL’, ‘http://127.0.0.1:8000’)n”, “n”, “print(f"Database URL configured: {DATABASE_URL[:50]}…")n”, “print(f"Environment variables set for API server")n”, “print(f"API URL: {API_BASE_URL}")”

]

}, {

“cell_type”: “markdown”, “id”: “setup-header”, “metadata”: {}, “source”: [

“## Part 1: Setup Database Schema with Users Tablen”, “n”, “First, we’ll create the database schema including the users table for authentication.”

]

}, {

“cell_type”: “code”, “execution_count”: 12,

<<<<<<< Updated upstream

“execution_count”: 12,

“metadata”: {}, “outputs”: [

{

“name”: “stdout”, “output_type”: “stream”, “text”: [

“Delete tables: u001b[32m✓u001b[0m All timedb tables deletedn”, “n”, “Create tables: Creating database schema…n”, “✓ Schema created successfullyn”, “u001b[32m✓u001b[0m Base timedb tables createdn”, “u001b[32m✓u001b[0m Users table createdn”, “n”, “u001b[1;32mSchema created successfully!u001b[0mn”, “n”

]

}

],

<<<<<<< Updated upstream
“outputs”: [
{

“name”: “stdout”, “output_type”: “stream”, “text”: [

“Delete tables: u001b[32m✓u001b[0m All timedb tables deletedn”, “n”, “Create tables: Creating database schema…n”, “✓ Schema created successfullyn”, “u001b[32m✓u001b[0m Base timedb tables createdn”, “u001b[32m✓u001b[0m Users table createdn”, “n”, “u001b[1;32mSchema created successfully!u001b[0mn”, “n”

]

}

],

“# Delete existing schema (for a clean start)n”, “result = subprocess.run(n”, “ ["timedb", "delete", "tables", "–dsn", DATABASE_URL, "-y"],n”, “ capture_output=True,n”, “ text=Truen”, “)n”, “print("Delete tables:", result.stdout or result.stderr)n”, “n”, “# Create schema with users tablen”, “result = subprocess.run(n”, “ ["timedb", "create", "tables", "–dsn", DATABASE_URL, "–with-users", "-y"],n”, “ capture_output=True,n”, “ text=Truen”, “)n”, “print("Create tables:", result.stdout or result.stderr)”

]

}, {

“cell_type”: “markdown”, “id”: “users-header”, “metadata”: {}, “source”: [

“## Part 2: Create Tenants and Usersn”, “n”, “Now we’ll create two separate tenants, each with their own user. Each user gets a unique API key that is tied to their tenant_id.”

]

}, {

“cell_type”: “code”, “execution_count”: 13, “id”: “create-tenants”, “metadata”: {}, “outputs”: [

{

“name”: “stdout”, “output_type”: “stream”, “text”: [

“Tenant A ID: 3a70db7f-7906-4114-8fa1-756393faf430n”, “Tenant B ID: 40dbb978-c3a9-4327-a678-b61c897da974n”

]

}

], “source”: [

“# Generate two tenant IDsn”, “tenant_a_id = str(uuid.uuid4())n”, “tenant_b_id = str(uuid.uuid4())n”, “n”, “print(f"Tenant A ID: {tenant_a_id}")n”, “print(f"Tenant B ID: {tenant_b_id}")”

]

}, {

“cell_type”: “code”, “execution_count”: 14, “id”: “create-user-a”, “metadata”: {}, “outputs”: [

{

“name”: “stdout”, “output_type”: “stream”, “text”: [

“u001b[32m╭─u001b[0mu001b[32m─────────────────────────────────u001b[0mu001b[32m New User u001b[0mu001b[32m─────────────────────────────────u001b[0mu001b[32m─╮u001b[0mn”, “u001b[32m│u001b[0m u001b[1;32mUser created successfully!u001b[0m u001b[32m│u001b[0mn”, “u001b[32m│u001b[0m u001b[32m│u001b[0mn”, “u001b[32m│u001b[0m User ID: u001b[36m03f80113-8632-4a09-bc11-d57784238d1cu001b[0m u001b[32m│u001b[0mn”, “u001b[32m│u001b[0m Tenant ID: u001b[36m3a70db7f-7906-4114-8fa1-756393faf430u001b[0m u001b[32m│u001b[0mn”, “u001b[32m│u001b[0m Email: u001b[36malice@tenant-a.comu001b[0m u001b[32m│u001b[0mn”, “u001b[32m│u001b[0m u001b[32m│u001b[0mn”, “u001b[32m│u001b[0m u001b[1;33mAPI Key:u001b[0m u001b[1mjHKQwFLVR8yiAp9paX1Vtf5bwqg1NCPg6wd-NL7CV88u001b[0m u001b[32m│u001b[0mn”, “u001b[32m╰──────────────────────────────────────────────────────────────────────────────╯u001b[0mn”, “n”, “u001b[33m⚠ Save the API key - it will not be shown again.u001b[0mn”, “n”, “n”, “Stored API Key B: jHKQwFLVR8yiAp9paX1Vtf5bwqg1NCPg6wd-NL7CV88n”

]

}

], “source”: [

“# Create User A for Tenant An”, “result = subprocess.run(n”, “ ["timedb", "users", "create", n”, “ "–dsn", DATABASE_URL, n”, “ "–tenant-id", tenant_a_id, n”, “ "–email", "alice@tenant-a.com"],n”, “ capture_output=True,n”, “ text=Truen”, “)n”, “print(result.stdout)n”, “if result.returncode != 0:n”, “ print("Error:", result.stderr)n”, “n”, “# Extract API key from outputn”, “import ren”, “clean = re.sub(r’\x1b\[[0-9;]*[A-Za-z]’, ‘’, result.stdout)n”, “m = re.search(r’API Key:\s*([^\s]+)’, clean)n”, “if m:n”, “ api_key_a = m.group(1).strip()n”, “ print(f"\nStored API Key B: {api_key_a}")n”, “else:n”, “ # Fallback: parse cleaned linesn”, “ for line in clean.split(’\n’):n”, “ if ‘API Key:’ in line:n”, “ api_key_a = line.split(‘API Key:’)[1].strip()n”, “ print(f"\nStored API Key A (fallback): {api_key_a}")n”, “ break”

]

}, {

“cell_type”: “code”, “execution_count”: 15, “id”: “create-user-b”, “metadata”: {}, “outputs”: [

{

“name”: “stdout”, “output_type”: “stream”, “text”: [

“u001b[32m╭─u001b[0mu001b[32m─────────────────────────────────u001b[0mu001b[32m New User u001b[0mu001b[32m─────────────────────────────────u001b[0mu001b[32m─╮u001b[0mn”, “u001b[32m│u001b[0m u001b[1;32mUser created successfully!u001b[0m u001b[32m│u001b[0mn”, “u001b[32m│u001b[0m u001b[32m│u001b[0mn”, “u001b[32m│u001b[0m User ID: u001b[36m848f7e12-8631-45c3-832b-d3eef564c3d2u001b[0m u001b[32m│u001b[0mn”, “u001b[32m│u001b[0m Tenant ID: u001b[36m40dbb978-c3a9-4327-a678-b61c897da974u001b[0m u001b[32m│u001b[0mn”, “u001b[32m│u001b[0m Email: u001b[36mbob@tenant-b.comu001b[0m u001b[32m│u001b[0mn”, “u001b[32m│u001b[0m u001b[32m│u001b[0mn”, “u001b[32m│u001b[0m u001b[1;33mAPI Key:u001b[0m u001b[1mfyx1gc8F257DkjO4cIhfVL8fmYekRTG98kvQUG_AiYsu001b[0m u001b[32m│u001b[0mn”, “u001b[32m╰──────────────────────────────────────────────────────────────────────────────╯u001b[0mn”, “n”, “u001b[33m⚠ Save the API key - it will not be shown again.u001b[0mn”, “n”, “CompletedProcess(args=[‘timedb’, ‘users’, ‘create’, ‘–dsn’, ‘postgresql://neondb_owner:npg_hILKzJ4BwbH2@ep-crimson-lab-ago3gouo-pooler.c-2.eu-central-1.aws.neon.tech/neondb?sslmode=require&channel_binding=require’, ‘–tenant-id’, ‘40dbb978-c3a9-4327-a678-b61c897da974’, ‘–email’, ‘bob@tenant-b.com’], returncode=0, stdout=’\x1b[32m╭─\x1b[0m\x1b[32m─────────────────────────────────\x1b[0m\x1b[32m New User \x1b[0m\x1b[32m─────────────────────────────────\x1b[0m\x1b[32m─╮\x1b[0m\n\x1b[32m│\x1b[0m \x1b[1;32mUser created successfully!\x1b[0m \x1b[32m│\x1b[0m\n\x1b[32m│\x1b[0m \x1b[32m│\x1b[0m\n\x1b[32m│\x1b[0m User ID: \x1b[36m848f7e12-8631-45c3-832b-d3eef564c3d2\x1b[0m \x1b[32m│\x1b[0m\n\x1b[32m│\x1b[0m Tenant ID: \x1b[36m40dbb978-c3a9-4327-a678-b61c897da974\x1b[0m \x1b[32m│\x1b[0m\n\x1b[32m│\x1b[0m Email: \x1b[36mbob@tenant-b.com\x1b[0m \x1b[32m│\x1b[0m\n\x1b[32m│\x1b[0m \x1b[32m│\x1b[0m\n\x1b[32m│\x1b[0m \x1b[1;33mAPI Key:\x1b[0m \x1b[1mfyx1gc8F257DkjO4cIhfVL8fmYekRTG98kvQUG_AiYs\x1b[0m \x1b[32m│\x1b[0m\n\x1b[32m╰──────────────────────────────────────────────────────────────────────────────╯\x1b[0m\n\n\x1b[33m⚠ Save the API key - it will not be shown again.\x1b[0m\n’, stderr=’’)n”, “n”, “Stored API Key B: fyx1gc8F257DkjO4cIhfVL8fmYekRTG98kvQUG_AiYsn”

]

}

], “source”: [

“# Create User B for Tenant Bn”, “result = subprocess.run(n”, “ ["timedb", "users", "create", n”, “ "–dsn", DATABASE_URL, n”, “ "–tenant-id", tenant_b_id, n”, “ "–email", "bob@tenant-b.com"],n”, “ capture_output=True,n”, “ text=True,n”, “)n”, “print(result.stdout)n”, “print(result)n”, “if result.returncode != 0:n”, “ print("Error:", result.stderr)n”, “n”, “import ren”, “clean = re.sub(r’\x1b\[[0-9;]*[A-Za-z]’, ‘’, result.stdout)n”, “m = re.search(r’API Key:\s*([^\s]+)’, clean)n”, “if m:n”, “ api_key_b = m.group(1).strip()n”, “ print(f"\nStored API Key B: {api_key_b}")n”, “else:n”, “ # Fallback: parse cleaned linesn”, “ for line in clean.split(’\n’):n”, “ if ‘API Key:’ in line:n”, “ api_key_b = line.split(‘API Key:’)[1].strip()n”, “ print(f"\nStored API Key B (fallback): {api_key_b}")n”, “ breakn”

]

}, {

“cell_type”: “code”, “execution_count”: 16, “id”: “list-users”, “metadata”: {}, “outputs”: [

{

“name”: “stdout”, “output_type”: “stream”, “text”: [

“u001b[3m Users (6) u001b[0mn”, “┏━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━┓n”, “┃u001b[1m u001b[0mu001b[1mEmail u001b[0mu001b[1m u001b[0m┃u001b[1m u001b[0mu001b[1mUser ID u001b[0mu001b[1m u001b[0m┃u001b[1m u001b[0mu001b[1mTenant ID u001b[0mu001b[1m u001b[0m┃u001b[1m u001b[0mu001b[1mStatusu001b[0mu001b[1m u001b[0m┃u001b[1m u001b[0mu001b[1mCreated u001b[0mu001b[1m u001b[0m┃n”, “┡━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━━━┩n”, “│u001b[36m u001b[0mu001b[36mbob@tenant-b.com u001b[0mu001b[36m u001b[0m│u001b[2m u001b[0mu001b[2m848f7e12…u001b[0mu001b[2m u001b[0m│u001b[2m u001b[0mu001b[2m40dbb978…u001b[0mu001b[2m u001b[0m│ u001b[32mactiveu001b[0m │u001b[2m u001b[0mu001b[2m2026-01-13 21:40u001b[0mu001b[2m u001b[0m│n”, “│u001b[36m u001b[0mu001b[36malice@tenant-a.comu001b[0mu001b[36m u001b[0m│u001b[2m u001b[0mu001b[2m03f80113…u001b[0mu001b[2m u001b[0m│u001b[2m u001b[0mu001b[2m3a70db7f…u001b[0mu001b[2m u001b[0m│ u001b[32mactiveu001b[0m │u001b[2m u001b[0mu001b[2m2026-01-13 21:40u001b[0mu001b[2m u001b[0m│n”, “│u001b[36m u001b[0mu001b[36mbob@tenant-b.com u001b[0mu001b[36m u001b[0m│u001b[2m u001b[0mu001b[2mf911ff39…u001b[0mu001b[2m u001b[0m│u001b[2m u001b[0mu001b[2m089db530…u001b[0mu001b[2m u001b[0m│ u001b[32mactiveu001b[0m │u001b[2m u001b[0mu001b[2m2026-01-13 21:37u001b[0mu001b[2m u001b[0m│n”, “│u001b[36m u001b[0mu001b[36malice@tenant-a.comu001b[0mu001b[36m u001b[0m│u001b[2m u001b[0mu001b[2m7597c87c…u001b[0mu001b[2m u001b[0m│u001b[2m u001b[0mu001b[2m70fe3d42…u001b[0mu001b[2m u001b[0m│ u001b[32mactiveu001b[0m │u001b[2m u001b[0mu001b[2m2026-01-13 21:37u001b[0mu001b[2m u001b[0m│n”, “│u001b[36m u001b[0mu001b[36mbob@tenant-b.com u001b[0mu001b[36m u001b[0m│u001b[2m u001b[0mu001b[2m00786226…u001b[0mu001b[2m u001b[0m│u001b[2m u001b[0mu001b[2mb5d04a05…u001b[0mu001b[2m u001b[0m│ u001b[32mactiveu001b[0m │u001b[2m u001b[0mu001b[2m2026-01-13 21:32u001b[0mu001b[2m u001b[0m│n”, “│u001b[36m u001b[0mu001b[36malice@tenant-a.comu001b[0mu001b[36m u001b[0m│u001b[2m u001b[0mu001b[2m63b9b070…u001b[0mu001b[2m u001b[0m│u001b[2m u001b[0mu001b[2m7cecc978…u001b[0mu001b[2m u001b[0m│ u001b[32mactiveu001b[0m │u001b[2m u001b[0mu001b[2m2026-01-13 21:32u001b[0mu001b[2m u001b[0m│n”, “└────────────────────┴─────────────┴─────────────┴────────┴──────────────────┘n”, “n”

]

}

], “source”: [

“# List all users to verifyn”, “result = subprocess.run(n”, “ ["timedb", "users", "list", "–dsn", DATABASE_URL],n”, “ capture_output=True,n”, “ text=Truen”, “)n”, “print(result.stdout)”

]

}, {

“cell_type”: “markdown”, “id”: “api-header”, “metadata”: {}, “source”: [

“## Part 3: Start the API Servern”, “n”, “Now we start the API server. With the users_table present, authentication will be required for all endpoints.”

]

}, {

“cell_type”: “code”, “execution_count”: 17, “id”: “start-api”, “metadata”: {},

<<<<<<< Updated upstream <<<<<<< Updated upstream

“outputs”: [],

>>>>>>> Stashed changes
“outputs”: [
{

“name”: “stdout”, “output_type”: “stream”, “text”: [

“TIMEDB_DSN: postgresql://neondb_owner:npg_hILKzJ4BwbH2@ep-crimson-lab-ag…n”, “DATABASE_URL: postgresql://neondb_owner:npg_hILKzJ4BwbH2@ep-crimson-lab-ag…n”, “Starting API server in background thread on http://127.0.0.1:8000…n”, “Starting TimeDB API server on http://127.0.0.1:8000n”, “API docs available at http://127.0.0.1:8000/docsn”, “Press Ctrl+C to stop the servern”

]

}, {

“name”: “stderr”, “output_type”: “stream”, “text”: [

“INFO: Started server process [10627]n”, “INFO: Waiting for application startup.n”, “INFO: Application startup complete.n”, “INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)n”

]

}, {

“name”: “stdout”, “output_type”: “stream”, “text”: [

“INFO: 127.0.0.1:44970 - "GET / HTTP/1.1" 200 OKn”, “✓ API is runningn”, “ Name: TimeDB APIn”, “ Version: 0.1.1n”, “n”, “Available endpoints:n”, “ - read_values: GET /values - Read time series valuesn”, “ - upload_timeseries: POST /upload - Upload time series data (create a new run with values)n”, “ - create_series: POST /series - Create a new time seriesn”, “ - list_timeseries: GET /list_timeseries - List all time series (series_id -> series_key mapping)n”, “ - update_records: PUT /values - Update existing time series recordsn”, “✓ API server started successfullyn”, “ Server running at http://127.0.0.1:8000n”, “ API docs available at http://127.0.0.1:8000/docsn”, “n”, “API is running: TimeDB APIn”

]

}

],

<<<<<<< Updated upstream >>>>>>> Stashed changes ======= >>>>>>> Stashed changes

“source”: [

“import timedb as tdn”, “import subprocessn”, “import timen”, “n”, “# IMPORTANT: Kill any existing API server first to ensure we start freshn”, “# with the correct database connectionn”, “result = subprocess.run(["pkill", "-f", "uvicorn.*timedb"], capture_output=True)n”, “if result.returncode == 0:n”, “ print("Killed existing API server")n”, “ time.sleep(2) # Wait for port to be releasedn”, “n”, “# Verify environment is set correctlyn”, “import osn”, “print(f"TIMEDB_DSN: {os.environ.get(‘TIMEDB_DSN’, ‘NOT SET’)[:60]}…")n”, “print(f"DATABASE_URL: {os.environ.get(‘DATABASE_URL’, ‘NOT SET’)[:60]}…")n”, “n”, “# Start API server in backgroundn”, “td.start_api_background()n”, “n”, “# Verify it’s runningn”, “time.sleep(2)n”, “n”, “# Check APIn”, “try:n”, “ response = requests.get(f"{API_BASE_URL}/")n”, “ print(f"\nAPI is running: {response.json()[‘name’]}")n”, “except Exception as e:n”, “ print(f"API check failed: {e}")”

]

}, {

“cell_type”: “markdown”, “id”: “auth-test-header”, “metadata”: {}, “source”: [

“## Part 4: Test Authentication Enforcementn”, “n”, “Let’s verify that authentication is required when the users_table exists.”

]

}, {

“cell_type”: “code”, “execution_count”: 18, “id”: “test-no-auth”, “metadata”: {}, “outputs”: [

{

“name”: “stdout”, “output_type”: “stream”, “text”: [

“Request without API key:n”, “ Status: 401n”, “ Response: {‘detail’: ‘API key required. Please provide X-API-Key header.’}n”

]

}

], “source”: [

“# Test request WITHOUT API key - should return 401n”, “response = requests.get(f"{API_BASE_URL}/values")n”, “print(f"Request without API key:")n”, “print(f" Status: {response.status_code}")n”, “print(f" Response: {response.json()}")”

]

}, {

“cell_type”: “code”, “execution_count”: 19, “id”: “test-invalid-key”, “metadata”: {}, “outputs”: [

{

“name”: “stdout”, “output_type”: “stream”, “text”: [

“Request with invalid API key:n”, “ Status: 401n”, “ Response: {‘detail’: ‘Invalid or inactive API key.’}n”

]

}

], “source”: [

“# Test request with INVALID API key - should return 401n”, “response = requests.get(n”, “ f"{API_BASE_URL}/values",n”, “ headers={"X-API-Key": "invalid-key-12345"}n”, “)n”, “print(f"Request with invalid API key:")n”, “print(f" Status: {response.status_code}")n”, “print(f" Response: {response.json()}")”

]

}, {

“cell_type”: “code”, “execution_count”: 20, “id”: “test-valid-key”, “metadata”: {}, “outputs”: [

{

“name”: “stdout”, “output_type”: “stream”, “text”: [

“Request with valid API key (User A):n”, “ Status: 200n”, “ Response: {‘count’: 0, ‘data’: []}n”

]

}

], “source”: [

“# Test request with VALID API key - should return 200n”, “response = requests.get(n”, “ f"{API_BASE_URL}/values",n”, “ headers={"X-API-Key": api_key_a}n”, “)n”, “print(f"Request with valid API key (User A):")n”, “print(f" Status: {response.status_code}")n”, “print(f" Response: {response.json()}")”

]

}, {

“cell_type”: “markdown”, “id”: “isolation-header”, “metadata”: {}, “source”: [

“## Part 5: Demonstrate Tenant Isolationn”, “n”, “Now let’s upload data from both users and verify that each user can only see their own tenant’s data.”

]

}, {

“cell_type”: “code”, “execution_count”: 21, “id”: “upload-user-a”, “metadata”: {}, “outputs”: [

{

“name”: “stdout”, “output_type”: “stream”, “text”: [

“User A upload status: 200n”, “Response: {‘run_id’: ‘55257773-4578-47cc-b88b-50e774fed92c’, ‘message’: ‘Run created successfully’, ‘series_ids’: {‘temperature_a’: ‘a22f0a33-db73-4e88-ade8-da16afb256f5’}}n”

]

}

], “source”: [

“# User A uploads temperature datan”, “base_time = datetime(2025, 1, 1, 0, 0, tzinfo=timezone.utc)n”, “n”, “value_rows_a = [n”, “ {"valid_time": (base_time + timedelta(hours=i)).isoformat(), n”, “ "value_key": "temperature_a", n”, “ "value": 20.0 + i}n”, “ for i in range(6)n”, “]n”, “n”, “response = requests.post(n”, “ f"{API_BASE_URL}/upload",n”, “ json={n”, “ "run_start_time": datetime.now(timezone.utc).isoformat(),n”, “ "value_rows": value_rows_an”, “ },n”, “ headers={"X-API-Key": api_key_a, "Content-Type": "application/json"}n”, “)n”, “print(f"User A upload status: {response.status_code}")n”, “print(f"Response: {response.json()}")”

]

}, {

“cell_type”: “code”, “execution_count”: 22, “id”: “upload-user-b”, “metadata”: {}, “outputs”: [

{

“name”: “stdout”, “output_type”: “stream”, “text”: [

“User B upload status: 200n”, “Response: {‘run_id’: ‘34a780fb-15db-4164-b3d5-27140528b59e’, ‘message’: ‘Run created successfully’, ‘series_ids’: {‘humidity_b’: ‘2ac55ed4-4b0f-468f-a73e-a88a061af631’}}n”

]

}

], “source”: [

“# User B uploads humidity datan”, “value_rows_b = [n”, “ {"valid_time": (base_time + timedelta(hours=i)).isoformat(), n”, “ "value_key": "humidity_b", n”, “ "value": 50.0 + i}n”, “ for i in range(6)n”, “]n”, “n”, “response = requests.post(n”, “ f"{API_BASE_URL}/upload",n”, “ json={n”, “ "run_start_time": datetime.now(timezone.utc).isoformat(),n”, “ "value_rows": value_rows_bn”, “ },n”, “ headers={"X-API-Key": api_key_b, "Content-Type": "application/json"}n”, “)n”, “print(f"User B upload status: {response.status_code}")n”, “print(f"Response: {response.json()}")”

]

}, {

“cell_type”: “code”, “execution_count”: 23, “id”: “read-user-a”, “metadata”: {}, “outputs”: [

{

“name”: “stdout”, “output_type”: “stream”, “text”: [

“User A sees 6 records:n”, “ valid_time series_key valuen”, “0 2025-01-01T00:00:00+00:00 temperature_a 20.0n”, “1 2025-01-01T01:00:00+00:00 temperature_a 21.0n”, “2 2025-01-01T02:00:00+00:00 temperature_a 22.0n”, “3 2025-01-01T03:00:00+00:00 temperature_a 23.0n”, “4 2025-01-01T04:00:00+00:00 temperature_a 24.0n”, “5 2025-01-01T05:00:00+00:00 temperature_a 25.0n”, “n”, “Series keys visible to User A: [‘temperature_a’]n”

]

}

], “source”: [

“# User A reads data - should ONLY see their own data (temperature_a)n”, “response = requests.get(n”, “ f"{API_BASE_URL}/values",n”, “ params={n”, “ "start_valid": base_time.isoformat(),n”, “ "end_valid": (base_time + timedelta(hours=6)).isoformat(),n”, “ "mode": "flat"n”, “ },n”, “ headers={"X-API-Key": api_key_a}n”, “)n”, “data_a = response.json()n”, “print(f"User A sees {data_a[‘count’]} records:")n”, “if data_a[‘count’] > 0:n”, “ df_a = pd.DataFrame(data_a[‘data’])n”, “ print(df_a[[‘valid_time’, ‘series_key’, ‘value’]])n”, “ print(f"\nSeries keys visible to User A: {df_a[‘series_key’].unique().tolist()}")”

]

}, {

“cell_type”: “code”, “execution_count”: 24, “id”: “read-user-b”, “metadata”: {}, “outputs”: [

{

“name”: “stdout”, “output_type”: “stream”, “text”: [

“User B sees 6 records:n”, “ valid_time series_key valuen”, “0 2025-01-01T00:00:00+00:00 humidity_b 50.0n”, “1 2025-01-01T01:00:00+00:00 humidity_b 51.0n”, “2 2025-01-01T02:00:00+00:00 humidity_b 52.0n”, “3 2025-01-01T03:00:00+00:00 humidity_b 53.0n”, “4 2025-01-01T04:00:00+00:00 humidity_b 54.0n”, “5 2025-01-01T05:00:00+00:00 humidity_b 55.0n”, “n”, “Series keys visible to User B: [‘humidity_b’]n”

]

}

], “source”: [

“# User B reads data - should ONLY see their own data (humidity_b)n”, “response = requests.get(n”, “ f"{API_BASE_URL}/values",n”, “ params={n”, “ "start_valid": base_time.isoformat(),n”, “ "end_valid": (base_time + timedelta(hours=6)).isoformat(),n”, “ "mode": "flat"n”, “ },n”, “ headers={"X-API-Key": api_key_b}n”, “)n”, “data_b = response.json()n”, “print(f"User B sees {data_b[‘count’]} records:")n”, “if data_b[‘count’] > 0:n”, “ df_b = pd.DataFrame(data_b[‘data’])n”, “ print(df_b[[‘valid_time’, ‘series_key’, ‘value’]])n”, “ print(f"\nSeries keys visible to User B: {df_b[‘series_key’].unique().tolist()}")”

]

}, {

“cell_type”: “code”, “execution_count”: 25, “id”: “verify-isolation”, “metadata”: {}, “outputs”: [

{

“name”: “stdout”, “output_type”: “stream”, “text”: [

“Tenant Isolation Verification:n”, “ User A (Tenant A) sees: 6 recordsn”, “ User B (Tenant B) sees: 6 recordsn”, “n”, “SUCCESS: No overlapping series between tenants!n”, “ Tenant A series: {‘temperature_a’}n”, “ Tenant B series: {‘humidity_b’}n”

]

}

], “source”: [

“# Verify tenant isolationn”, “print("Tenant Isolation Verification:")n”, “print(f" User A (Tenant A) sees: {data_a[‘count’]} records")n”, “print(f" User B (Tenant B) sees: {data_b[‘count’]} records")n”, “print("")n”, “n”, “# Check that they see different datan”, “if data_a[‘count’] > 0 and data_b[‘count’] > 0:n”, “ series_a = set(pd.DataFrame(data_a[‘data’])[‘series_key’].unique())n”, “ series_b = set(pd.DataFrame(data_b[‘data’])[‘series_key’].unique())n”, “ overlap = series_a & series_bn”, “ n”, “ if len(overlap) == 0:n”, “ print("SUCCESS: No overlapping series between tenants!")n”, “ print(f" Tenant A series: {series_a}")n”, “ print(f" Tenant B series: {series_b}")n”, “ else:n”, “ print(f"WARNING: Found overlapping series: {overlap}")”

]

}, {

“cell_type”: “markdown”, “id”: “deactivate-header”, “metadata”: {}, “source”: [

“## Part 6: User Deactivationn”, “n”, “Let’s demonstrate how deactivating a user revokes their API access.”

]

}, {

“cell_type”: “code”, “execution_count”: null, “id”: “deactivate-user”, “metadata”: {}, “outputs”: [], “source”: [

“# Deactivate User Bn”, “result = subprocess.run(n”, “ ["timedb", "users", "deactivate", n”, “ "–dsn", DATABASE_URL, n”, “ "–email", "bob@tenant-b.com",n”, “ "-y"],n”, “ capture_output=True,n”, “ text=Truen”, “)n”, “print(result.stdout or result.stderr)”

]

}, {

“cell_type”: “code”, “execution_count”: null, “id”: “test-deactivated”, “metadata”: {}, “outputs”: [], “source”: [

“# Try to access API with deactivated user’s key - should return 401n”, “response = requests.get(n”, “ f"{API_BASE_URL}/values",n”, “ headers={"X-API-Key": api_key_b}n”, “)n”, “print(f"Request with deactivated user’s API key:")n”, “print(f" Status: {response.status_code}")n”, “print(f" Response: {response.json()}")”

]

}, {

“cell_type”: “code”, “execution_count”: null, “id”: “reactivate-user”, “metadata”: {}, “outputs”: [], “source”: [

“# Reactivate User Bn”, “result = subprocess.run(n”, “ ["timedb", "users", "activate", n”, “ "–dsn", DATABASE_URL, n”, “ "–email", "bob@tenant-b.com"],n”, “ capture_output=True,n”, “ text=Truen”, “)n”, “print(result.stdout or result.stderr)n”, “n”, “# Verify access is restoredn”, “response = requests.get(n”, “ f"{API_BASE_URL}/values",n”, “ headers={"X-API-Key": api_key_b}n”, “)n”, “print(f"\nRequest after reactivation:")n”, “print(f" Status: {response.status_code}")”

]

}, {

“cell_type”: “markdown”, “id”: “summary-header”, “metadata”: {}, “source”: [

“## Summaryn”, “n”, “This notebook demonstrated TimeDB’s multi-tenant authentication system:n”, “n”, “### Key Concepts:n”, “n”, “1. Users Table: Created with –with-users flag on timedb create tablesn”, “n”, “2. User Management CLI Commands:n”, “ - timedb users create –tenant-id <uuid> –email <email> - Create user with API keyn”, “ - timedb users list - List all usersn”, “ - timedb users deactivate –email <email> - Revoke API accessn”, “ - timedb users activate –email <email> - Restore API accessn”, “ - timedb users regenerate-key –email <email> - Generate new API keyn”, “n”, “3. API Authentication:n”, “ - Use X-API-Key header with all requestsn”, “ - When users_table exists, authentication is requiredn”, “ - Invalid or missing API keys return 401 Unauthorizedn”, “n”, “4. Tenant Isolation:n”, “ - Each user’s API key is tied to a tenant_idn”, “ - Users can only read/write data for their own tenantn”, “ - Data isolation is enforced at the application leveln”, “n”, “### Security Notes:n”, “- API keys are generated securely using secrets.token_urlsafe(32)n”, “- API keys are only displayed once on creation/regenerationn”, “- Deactivated users cannot authenticate until reactivatedn”, “- Consider implementing key rotation policies for production use”

]

}

], “metadata”: {

“kernelspec”: {

“display_name”: “timedb”, “language”: “python”, “name”: “python3”

}, “language_info”: {

“codemirror_mode”: {

“name”: “ipython”, “version”: 3

}, “file_extension”: “.py”, “mimetype”: “text/x-python”, “name”: “python”, “nbconvert_exporter”: “python”, “pygments_lexer”: “ipython3”, “version”: “3.14.2”

}

}, “nbformat”: 4, “nbformat_minor”: 5

}