{ "cells": [ { "cell_type": "markdown", "id": "intro", "metadata": {}, "source": [ "# Multi-tenant Authentication with API Keys\n", "\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 table\n", "2. Creating tenants and users via CLI\n", "3. Using API keys to authenticate requests\n", "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 server\n", "API URL: https://timedb-api.onrender.com\n" ] } ], "source": [ "import os\n", "import uuid\n", "import subprocess\n", "import requests\n", "import pandas as pd\n", "from datetime import datetime, timezone, timedelta\n", "\n", "from dotenv import load_dotenv\n", "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 database\n", "# The API reads from TIMEDB_DSN first, then falls back to DATABASE_URL\n", "os.environ[\"TIMEDB_DSN\"] = DATABASE_URL\n", "os.environ[\"DATABASE_URL\"] = DATABASE_URL\n", "\n", "# API base URL\n", "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 Table\n", "\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, ======= >>>>>>> Stashed changes "id": "setup-schema", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Delete tables: \u001b[32m✓\u001b[0m All timedb tables deleted\n", "\n", "Create tables: Creating database schema...\n", "✓ Schema created successfully\n", "\u001b[32m✓\u001b[0m Base timedb tables created\n", "\u001b[32m✓\u001b[0m Users table created\n", "\n", "\u001b[1;32mSchema created successfully!\u001b[0m\n", "\n" ] } ], <<<<<<< Updated upstream "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Delete tables: \u001b[32m✓\u001b[0m All timedb tables deleted\n", "\n", "Create tables: Creating database schema...\n", "✓ Schema created successfully\n", "\u001b[32m✓\u001b[0m Base timedb tables created\n", "\u001b[32m✓\u001b[0m Users table created\n", "\n", "\u001b[1;32mSchema created successfully!\u001b[0m\n", "\n" ] } ], ======= >>>>>>> Stashed changes "source": [ "# 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=True\n", ")\n", "print(\"Delete tables:\", result.stdout or result.stderr)\n", "\n", "# Create schema with users table\n", "result = subprocess.run(\n", " [\"timedb\", \"create\", \"tables\", \"--dsn\", DATABASE_URL, \"--with-users\", \"-y\"],\n", " capture_output=True,\n", " text=True\n", ")\n", "print(\"Create tables:\", result.stdout or result.stderr)" ] }, { "cell_type": "markdown", "id": "users-header", "metadata": {}, "source": [ "## Part 2: Create Tenants and Users\n", "\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-756393faf430\n", "Tenant B ID: 40dbb978-c3a9-4327-a678-b61c897da974\n" ] } ], "source": [ "# Generate two tenant IDs\n", "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[0m\u001b[32m─────────────────────────────────\u001b[0m\u001b[32m New User \u001b[0m\u001b[32m─────────────────────────────────\u001b[0m\u001b[32m─╮\u001b[0m\n", "\u001b[32m│\u001b[0m \u001b[1;32mUser created successfully!\u001b[0m \u001b[32m│\u001b[0m\n", "\u001b[32m│\u001b[0m \u001b[32m│\u001b[0m\n", "\u001b[32m│\u001b[0m User ID: \u001b[36m03f80113-8632-4a09-bc11-d57784238d1c\u001b[0m \u001b[32m│\u001b[0m\n", "\u001b[32m│\u001b[0m Tenant ID: \u001b[36m3a70db7f-7906-4114-8fa1-756393faf430\u001b[0m \u001b[32m│\u001b[0m\n", "\u001b[32m│\u001b[0m Email: \u001b[36malice@tenant-a.com\u001b[0m \u001b[32m│\u001b[0m\n", "\u001b[32m│\u001b[0m \u001b[32m│\u001b[0m\n", "\u001b[32m│\u001b[0m \u001b[1;33mAPI Key:\u001b[0m \u001b[1mjHKQwFLVR8yiAp9paX1Vtf5bwqg1NCPg6wd-NL7CV88\u001b[0m \u001b[32m│\u001b[0m\n", "\u001b[32m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n", "\n", "\u001b[33m⚠ Save the API key - it will not be shown again.\u001b[0m\n", "\n", "\n", "Stored API Key B: jHKQwFLVR8yiAp9paX1Vtf5bwqg1NCPg6wd-NL7CV88\n" ] } ], "source": [ "# Create User A for Tenant A\n", "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=True\n", ")\n", "print(result.stdout)\n", "if result.returncode != 0:\n", " print(\"Error:\", result.stderr)\n", "\n", "# Extract API key from output\n", "import re\n", "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 lines\n", " 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[0m\u001b[32m─────────────────────────────────\u001b[0m\u001b[32m New User \u001b[0m\u001b[32m─────────────────────────────────\u001b[0m\u001b[32m─╮\u001b[0m\n", "\u001b[32m│\u001b[0m \u001b[1;32mUser created successfully!\u001b[0m \u001b[32m│\u001b[0m\n", "\u001b[32m│\u001b[0m \u001b[32m│\u001b[0m\n", "\u001b[32m│\u001b[0m User ID: \u001b[36m848f7e12-8631-45c3-832b-d3eef564c3d2\u001b[0m \u001b[32m│\u001b[0m\n", "\u001b[32m│\u001b[0m Tenant ID: \u001b[36m40dbb978-c3a9-4327-a678-b61c897da974\u001b[0m \u001b[32m│\u001b[0m\n", "\u001b[32m│\u001b[0m Email: \u001b[36mbob@tenant-b.com\u001b[0m \u001b[32m│\u001b[0m\n", "\u001b[32m│\u001b[0m \u001b[32m│\u001b[0m\n", "\u001b[32m│\u001b[0m \u001b[1;33mAPI Key:\u001b[0m \u001b[1mfyx1gc8F257DkjO4cIhfVL8fmYekRTG98kvQUG_AiYs\u001b[0m \u001b[32m│\u001b[0m\n", "\u001b[32m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n", "\n", "\u001b[33m⚠ Save the API key - it will not be shown again.\u001b[0m\n", "\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_AiYs\n" ] } ], "source": [ "# Create User B for Tenant B\n", "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 re\n", "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 lines\n", " 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", " break\n" ] }, { "cell_type": "code", "execution_count": 16, "id": "list-users", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "\u001b[3m Users (6) \u001b[0m\n", "┏━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━┓\n", "┃\u001b[1m \u001b[0m\u001b[1mEmail \u001b[0m\u001b[1m \u001b[0m┃\u001b[1m \u001b[0m\u001b[1mUser ID \u001b[0m\u001b[1m \u001b[0m┃\u001b[1m \u001b[0m\u001b[1mTenant ID \u001b[0m\u001b[1m \u001b[0m┃\u001b[1m \u001b[0m\u001b[1mStatus\u001b[0m\u001b[1m \u001b[0m┃\u001b[1m \u001b[0m\u001b[1mCreated \u001b[0m\u001b[1m \u001b[0m┃\n", "┡━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━━━┩\n", "│\u001b[36m \u001b[0m\u001b[36mbob@tenant-b.com \u001b[0m\u001b[36m \u001b[0m│\u001b[2m \u001b[0m\u001b[2m848f7e12...\u001b[0m\u001b[2m \u001b[0m│\u001b[2m \u001b[0m\u001b[2m40dbb978...\u001b[0m\u001b[2m \u001b[0m│ \u001b[32mactive\u001b[0m │\u001b[2m \u001b[0m\u001b[2m2026-01-13 21:40\u001b[0m\u001b[2m \u001b[0m│\n", "│\u001b[36m \u001b[0m\u001b[36malice@tenant-a.com\u001b[0m\u001b[36m \u001b[0m│\u001b[2m \u001b[0m\u001b[2m03f80113...\u001b[0m\u001b[2m \u001b[0m│\u001b[2m \u001b[0m\u001b[2m3a70db7f...\u001b[0m\u001b[2m \u001b[0m│ \u001b[32mactive\u001b[0m │\u001b[2m \u001b[0m\u001b[2m2026-01-13 21:40\u001b[0m\u001b[2m \u001b[0m│\n", "│\u001b[36m \u001b[0m\u001b[36mbob@tenant-b.com \u001b[0m\u001b[36m \u001b[0m│\u001b[2m \u001b[0m\u001b[2mf911ff39...\u001b[0m\u001b[2m \u001b[0m│\u001b[2m \u001b[0m\u001b[2m089db530...\u001b[0m\u001b[2m \u001b[0m│ \u001b[32mactive\u001b[0m │\u001b[2m \u001b[0m\u001b[2m2026-01-13 21:37\u001b[0m\u001b[2m \u001b[0m│\n", "│\u001b[36m \u001b[0m\u001b[36malice@tenant-a.com\u001b[0m\u001b[36m \u001b[0m│\u001b[2m \u001b[0m\u001b[2m7597c87c...\u001b[0m\u001b[2m \u001b[0m│\u001b[2m \u001b[0m\u001b[2m70fe3d42...\u001b[0m\u001b[2m \u001b[0m│ \u001b[32mactive\u001b[0m │\u001b[2m \u001b[0m\u001b[2m2026-01-13 21:37\u001b[0m\u001b[2m \u001b[0m│\n", "│\u001b[36m \u001b[0m\u001b[36mbob@tenant-b.com \u001b[0m\u001b[36m \u001b[0m│\u001b[2m \u001b[0m\u001b[2m00786226...\u001b[0m\u001b[2m \u001b[0m│\u001b[2m \u001b[0m\u001b[2mb5d04a05...\u001b[0m\u001b[2m \u001b[0m│ \u001b[32mactive\u001b[0m │\u001b[2m \u001b[0m\u001b[2m2026-01-13 21:32\u001b[0m\u001b[2m \u001b[0m│\n", "│\u001b[36m \u001b[0m\u001b[36malice@tenant-a.com\u001b[0m\u001b[36m \u001b[0m│\u001b[2m \u001b[0m\u001b[2m63b9b070...\u001b[0m\u001b[2m \u001b[0m│\u001b[2m \u001b[0m\u001b[2m7cecc978...\u001b[0m\u001b[2m \u001b[0m│ \u001b[32mactive\u001b[0m │\u001b[2m \u001b[0m\u001b[2m2026-01-13 21:32\u001b[0m\u001b[2m \u001b[0m│\n", "└────────────────────┴─────────────┴─────────────┴────────┴──────────────────┘\n", "\n" ] } ], "source": [ "# List all users to verify\n", "result = subprocess.run(\n", " [\"timedb\", \"users\", \"list\", \"--dsn\", DATABASE_URL],\n", " capture_output=True,\n", " text=True\n", ")\n", "print(result.stdout)" ] }, { "cell_type": "markdown", "id": "api-header", "metadata": {}, "source": [ "## Part 3: Start the API Server\n", "\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:8000\n", "API docs available at http://127.0.0.1:8000/docs\n", "Press Ctrl+C to stop the server\n" ] }, { "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 OK\n", "✓ API is running\n", " Name: TimeDB API\n", " Version: 0.1.1\n", "\n", "Available endpoints:\n", " - read_values: GET /values - Read time series values\n", " - upload_timeseries: POST /upload - Upload time series data (create a new run with values)\n", " - create_series: POST /series - Create a new time series\n", " - list_timeseries: GET /list_timeseries - List all time series (series_id -> series_key mapping)\n", " - update_records: PUT /values - Update existing time series records\n", "✓ API server started successfully\n", " Server running at http://127.0.0.1:8000\n", " API docs available at http://127.0.0.1:8000/docs\n", "\n", "API is running: TimeDB API\n" ] } ], <<<<<<< Updated upstream >>>>>>> Stashed changes ======= >>>>>>> Stashed changes "source": [ "import timedb as td\n", "import subprocess\n", "import time\n", "\n", "# IMPORTANT: Kill any existing API server first to ensure we start fresh\n", "# with the correct database connection\n", "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 released\n", "\n", "# Verify environment is set correctly\n", "import os\n", "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 background\n", "td.start_api_background()\n", "\n", "# Verify it's running\n", "time.sleep(2)\n", "\n", "# Check API\n", "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 Enforcement\n", "\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: 401\n", " Response: {'detail': 'API key required. Please provide X-API-Key header.'}\n" ] } ], "source": [ "# Test request WITHOUT API key - should return 401\n", "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: 401\n", " Response: {'detail': 'Invalid or inactive API key.'}\n" ] } ], "source": [ "# Test request with INVALID API key - should return 401\n", "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: 200\n", " Response: {'count': 0, 'data': []}\n" ] } ], "source": [ "# Test request with VALID API key - should return 200\n", "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 Isolation\n", "\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: 200\n", "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 data\n", "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_a\n", " },\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: 200\n", "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 data\n", "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_b\n", " },\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 value\n", "0 2025-01-01T00:00:00+00:00 temperature_a 20.0\n", "1 2025-01-01T01:00:00+00:00 temperature_a 21.0\n", "2 2025-01-01T02:00:00+00:00 temperature_a 22.0\n", "3 2025-01-01T03:00:00+00:00 temperature_a 23.0\n", "4 2025-01-01T04:00:00+00:00 temperature_a 24.0\n", "5 2025-01-01T05:00:00+00:00 temperature_a 25.0\n", "\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 value\n", "0 2025-01-01T00:00:00+00:00 humidity_b 50.0\n", "1 2025-01-01T01:00:00+00:00 humidity_b 51.0\n", "2 2025-01-01T02:00:00+00:00 humidity_b 52.0\n", "3 2025-01-01T03:00:00+00:00 humidity_b 53.0\n", "4 2025-01-01T04:00:00+00:00 humidity_b 54.0\n", "5 2025-01-01T05:00:00+00:00 humidity_b 55.0\n", "\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 records\n", " User B (Tenant B) sees: 6 records\n", "\n", "SUCCESS: No overlapping series between tenants!\n", " Tenant A series: {'temperature_a'}\n", " Tenant B series: {'humidity_b'}\n" ] } ], "source": [ "# Verify tenant isolation\n", "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 data\n", "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_b\n", " \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 Deactivation\n", "\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 B\n", "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=True\n", ")\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 401\n", "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 B\n", "result = subprocess.run(\n", " [\"timedb\", \"users\", \"activate\", \n", " \"--dsn\", DATABASE_URL, \n", " \"--email\", \"bob@tenant-b.com\"],\n", " capture_output=True,\n", " text=True\n", ")\n", "print(result.stdout or result.stderr)\n", "\n", "# Verify access is restored\n", "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": [ "## Summary\n", "\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 tables`\n", "\n", "2. **User Management CLI Commands**:\n", " - `timedb users create --tenant-id --email ` - Create user with API key\n", " - `timedb users list` - List all users\n", " - `timedb users deactivate --email ` - Revoke API access\n", " - `timedb users activate --email ` - Restore API access\n", " - `timedb users regenerate-key --email ` - Generate new API key\n", "\n", "3. **API Authentication**:\n", " - Use `X-API-Key` header with all requests\n", " - When users_table exists, authentication is **required**\n", " - Invalid or missing API keys return 401 Unauthorized\n", "\n", "4. **Tenant Isolation**:\n", " - Each user's API key is tied to a tenant_id\n", " - Users can only read/write data for their own tenant\n", " - Data isolation is enforced at the application level\n", "\n", "### Security Notes:\n", "- API keys are generated securely using `secrets.token_urlsafe(32)`\n", "- API keys are only displayed once on creation/regeneration\n", "- Deactivated users cannot authenticate until reactivated\n", "- 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 }