Multi-tenant Authentication with API Keys - CLI Usage
This notebook demonstrates how to use TimeDB’s authentication system using the CLI commands with shell syntax.
What we’ll cover:
Setting up the database schema with users table
Creating tenants and users via CLI
Using API keys to authenticate requests
Demonstrating tenant isolation (each user only sees their own data)
Testing authentication enforcement
[ ]:
import os
import uuid
import requests
import pandas as pd
import time
from datetime import datetime, timezone, timedelta
# Use TEST_DATABASE_URL for testing (falls back to DATABASE_URL if not set)
DATABASE_URL = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL")
if not DATABASE_URL:
raise ValueError("Please set TEST_DATABASE_URL or DATABASE_URL environment variable")
# IMPORTANT: Override both TIMEDB_DSN and DATABASE_URL so the API server uses the same database
os.environ["TIMEDB_DSN"] = DATABASE_URL
os.environ["DATABASE_URL"] = DATABASE_URL
# API base URL
API_BASE_URL = "http://127.0.0.1:8000"
print(f"Database URL configured: {DATABASE_URL[:50]}...")
print(f"Environment variables set for API server")
print(f"API URL: {API_BASE_URL}")
Part 1: Setup Database Schema with Users Table
First, we’ll create the database schema including the users table for authentication using CLI commands.
[ ]:
# Delete existing schema (for a clean start)
!timedb delete tables --dsn "{DATABASE_URL}" -y
[ ]:
# Create schema with users table
!timedb create tables --dsn "{DATABASE_URL}" --with-users -y
Part 2: Create Tenants and Users
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.
[ ]:
# Generate two tenant IDs
tenant_a_id = str(uuid.uuid4())
tenant_b_id = str(uuid.uuid4())
print(f"Tenant A ID: {tenant_a_id}")
print(f"Tenant B ID: {tenant_b_id}")
[ ]:
# Create User A for Tenant A
!timedb users create --dsn "{DATABASE_URL}" --tenant-id {tenant_a_id} --email alice@tenant-a.com
[ ]:
# Extract API key from the output above
# You'll need to copy the API key manually from the output above
api_key_a = input("Enter API Key for User A from output above: ")
print(f"Stored API Key A: {api_key_a}")
[ ]:
# Create User B for Tenant B
!timedb users create --dsn "{DATABASE_URL}" --tenant-id {tenant_b_id} --email bob@tenant-b.com
[ ]:
# Extract API key from the output above
api_key_b = input("Enter API Key for User B from output above: ")
print(f"Stored API Key B: {api_key_b}")
[ ]:
# List all users to verify
!timedb users list --dsn "{DATABASE_URL}"
Part 3: Start the API Server
Now we start the API server. With the users_table present, authentication will be required for all endpoints.
[ ]:
# Kill any existing API server first
!pkill -f "uvicorn.*timedb" || true
time.sleep(2)
print("Killed any existing API server")
[ ]:
# Start API server in background using CLI
!timedb api --host 127.0.0.1 --port 8000 &
[ ]:
# Give the server time to start
time.sleep(3)
# Verify API is running
try:
response = requests.get(f"{API_BASE_URL}/")
print(f"API is running: {response.json()['name']}")
except Exception as e:
print(f"API check failed: {e}")
Part 4: Test Authentication Enforcement
Let’s verify that authentication is required when the users_table exists.
[ ]:
# Test request WITHOUT API key - should return 401
response = requests.get(f"{API_BASE_URL}/values")
print(f"Request without API key:")
print(f" Status: {response.status_code}")
print(f" Response: {response.json()}")
[ ]:
# Test request with INVALID API key - should return 401
response = requests.get(
f"{API_BASE_URL}/values",
headers={"X-API-Key": "invalid-key-12345"}
)
print(f"Request with invalid API key:")
print(f" Status: {response.status_code}")
print(f" Response: {response.json()}")
[ ]:
# Test request with VALID API key - should return 200
response = requests.get(
f"{API_BASE_URL}/values",
headers={"X-API-Key": api_key_a}
)
print(f"Request with valid API key (User A):")
print(f" Status: {response.status_code}")
print(f" Response: {response.json()}")
Part 5: Demonstrate Tenant Isolation
Now let’s upload data from both users and verify that each user can only see their own tenant’s data.
[ ]:
# User A uploads temperature data
base_time = datetime(2025, 1, 1, 0, 0, tzinfo=timezone.utc)
value_rows_a = [
{"valid_time": (base_time + timedelta(hours=i)).isoformat(),
"value_key": "temperature_a",
"value": 20.0 + i}
for i in range(6)
]
response = requests.post(
f"{API_BASE_URL}/upload",
json={
"run_start_time": datetime.now(timezone.utc).isoformat(),
"value_rows": value_rows_a
},
headers={"X-API-Key": api_key_a, "Content-Type": "application/json"}
)
print(f"User A upload status: {response.status_code}")
print(f"Response: {response.json()}")
[ ]:
# User B uploads humidity data
value_rows_b = [
{"valid_time": (base_time + timedelta(hours=i)).isoformat(),
"value_key": "humidity_b",
"value": 50.0 + i}
for i in range(6)
]
response = requests.post(
f"{API_BASE_URL}/upload",
json={
"run_start_time": datetime.now(timezone.utc).isoformat(),
"value_rows": value_rows_b
},
headers={"X-API-Key": api_key_b, "Content-Type": "application/json"}
)
print(f"User B upload status: {response.status_code}")
print(f"Response: {response.json()}")
[ ]:
# User A reads data - should ONLY see their own data (temperature_a)
response = requests.get(
f"{API_BASE_URL}/values",
params={
"start_valid": base_time.isoformat(),
"end_valid": (base_time + timedelta(hours=6)).isoformat(),
"mode": "flat"
},
headers={"X-API-Key": api_key_a}
)
data_a = response.json()
print(f"User A sees {data_a['count']} records:")
if data_a['count'] > 0:
df_a = pd.DataFrame(data_a['data'])
print(df_a[['valid_time', 'series_key', 'value']])
print(f"\nSeries keys visible to User A: {df_a['series_key'].unique().tolist()}")
[ ]:
# User B reads data - should ONLY see their own data (humidity_b)
response = requests.get(
f"{API_BASE_URL}/values",
params={
"start_valid": base_time.isoformat(),
"end_valid": (base_time + timedelta(hours=6)).isoformat(),
"mode": "flat"
},
headers={"X-API-Key": api_key_b}
)
data_b = response.json()
print(f"User B sees {data_b['count']} records:")
if data_b['count'] > 0:
df_b = pd.DataFrame(data_b['data'])
print(df_b[['valid_time', 'series_key', 'value']])
print(f"\nSeries keys visible to User B: {df_b['series_key'].unique().tolist()}")
[ ]:
# Verify tenant isolation
print("Tenant Isolation Verification:")
print(f" User A (Tenant A) sees: {data_a['count']} records")
print(f" User B (Tenant B) sees: {data_b['count']} records")
print("")
# Check that they see different data
if data_a['count'] > 0 and data_b['count'] > 0:
series_a = set(pd.DataFrame(data_a['data'])['series_key'].unique())
series_b = set(pd.DataFrame(data_b['data'])['series_key'].unique())
overlap = series_a & series_b
if len(overlap) == 0:
print("SUCCESS: No overlapping series between tenants!")
print(f" Tenant A series: {series_a}")
print(f" Tenant B series: {series_b}")
else:
print(f"WARNING: Found overlapping series: {overlap}")
Part 6: User Deactivation
Let’s demonstrate how deactivating a user revokes their API access using CLI commands.
[ ]:
# Deactivate User B
!timedb users deactivate --dsn "{DATABASE_URL}" --email bob@tenant-b.com -y
[ ]:
# Try to access API with deactivated user's key - should return 401
response = requests.get(
f"{API_BASE_URL}/values",
headers={"X-API-Key": api_key_b}
)
print(f"Request with deactivated user's API key:")
print(f" Status: {response.status_code}")
print(f" Response: {response.json()}")
[ ]:
# Reactivate User B
!timedb users activate --dsn "{DATABASE_URL}" --email bob@tenant-b.com
[ ]:
# Verify access is restored
response = requests.get(
f"{API_BASE_URL}/values",
headers={"X-API-Key": api_key_b}
)
print(f"Request after reactivation:")
print(f" Status: {response.status_code}")
Summary
This notebook demonstrated TimeDB’s multi-tenant authentication system using CLI commands.
Key CLI Commands Used:
Schema Management:
timedb create tables --dsn <url> --with-users -y- Create schema with users tabletimedb delete tables --dsn <url> -y- Delete schema
User Management:
timedb users create --dsn <url> --tenant-id <uuid> --email <email>- Create user with API keytimedb users list --dsn <url>- List all userstimedb users deactivate --dsn <url> --email <email> -y- Revoke API accesstimedb users activate --dsn <url> --email <email>- Restore API accesstimedb users regenerate-key --dsn <url> --email <email>- Generate new API key
API Server:
timedb api --host <host> --port <port>- Start API server
Python Variable Injection in Shell Commands:
Notice how we used {variable} syntax to inject Python variables into shell commands. Important: When the variable contains special characters (like URLs with ? or &), you must wrap it in quotes:
DATABASE_URL = "postgresql://user:pass@host/db?sslmode=require"
# CORRECT - with quotes around the variable
!timedb create tables --dsn "{DATABASE_URL}"
# WRONG - without quotes, shell will interpret ? and & as special characters
!timedb create tables --dsn {DATABASE_URL}
Authentication Concepts:
When users_table exists, authentication is required for all endpoints
Use
X-API-Keyheader with all requestsInvalid or missing API keys return 401 Unauthorized
Each user’s API key is tied to a tenant_id for data isolation