Refactor tenant management script to use MongoClient directly for database operations and add documentation for multi-tenant architecture

This commit is contained in:
2026-04-20 15:48:46 +02:00
parent eb912c3a73
commit 96f918828c
2 changed files with 135 additions and 8 deletions
+127
View File
@@ -0,0 +1,127 @@
# Multi-Tenant Python Management API
This document explains how the multi-tenant architecture isolates data within Python, what the return values are, and how developers can build internal administrative scripts using native Python instead of the Docker CLI.
## 1. Architectural Concept
In the system, each "tenant" is essentially a dedicated MongoDB database identified by a dynamically generated string based on a subdomain or header (`inventar_<tenant_id>`).
App containers share a connection pool using `pymongo.MongoClient`, and requests are routed to specific databases dynamically based on the current Flask `g.tenant_context`.
All MongoDB administrative tasks (creating tenants, restarting apps, fetching lists) are done via standard MongoDB Python drivers because the core multi-tenancy happens at the **database level**.
## 2. Managing Tenants via Python
If you want to perform multi-tenant administrative operations without traversing through `manage-tenant.sh`, you can execute native Python scripts connecting to the system's `MongoClient`.
### Basic Connection Boilerplate
Whenever automating an administrative task in Python, you simply need to connect to MongoDB using the properties defined in `settings.py`.
```python
import sys
import os
# Append Web folder so we can access configuration
sys.path.insert(0, '/app/Web')
import settings
from pymongo import MongoClient
# Establish connection pooling
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
```
### A. Adding a New Tenant (Database Initialization)
A new tenant database isnt provisioned until the first actual data insert happens. We trigger this manually by creating an `admin` user for them.
**Operation:**
```python
def create_tenant(tenant_id, admin_password="hashed_password_here"):
db_name = f"{settings.MONGODB_DB}_{tenant_id}"
db = client[db_name]
# MongoDB creates the DB automatically on first insert
result = db.users.insert_one({
'username': 'admin',
'password': admin_password,
'role': 'admin'
})
return result.inserted_id # Returns the BSON ObjectId of the new user
```
### B. List Active Tenants
To find out how many isolated tenants have active databases, you query the raw `MongoClient` for all databases and search for your configured MongoDB prefix (default: `inventar_`).
**Operation:**
```python
def list_tenants():
prefix = f"{settings.MONGODB_DB}_"
# Returns a Python list of string database names
all_dbs = client.list_database_names()
# Filter and strip the prefix to return just the tenant_ids
active_tenants = [d.replace(prefix, "") for d in all_dbs if d.startswith(prefix)]
return active_tenants # e.g., ['schule1', 'schule2', 'test']
```
### C. Soft-Restarting a Tenant (Invalidating Sessions)
"Restarting" a single tenant means signing out all of their users and forcing an application refresh. Because Session data is coupled to the tenant database, dropping their `sessions` collection achieves an instant sign-out.
**Operation:**
```python
def restart_tenant(tenant_id):
db_name = f"{settings.MONGODB_DB}_{tenant_id}"
db = client[db_name]
# Drops the collection. All active user cookies immediately become invalid.
result = db.sessions.drop()
return result # Returns None. Raises PyMongoError if connection fails.
```
### D. Removing a Tenant Completely (Wipe Data)
If a tenant is removed from the service or their lease expires, you can permanently obliterate their data container footprint.
**Operation:**
```python
def remove_tenant(tenant_id):
db_name = f"{settings.MONGODB_DB}_{tenant_id}"
# Erases the isolated database. Can't be undone.
client.drop_database(db_name)
return True # Returns True. Raises PyMongoError if connection fails.
```
## 3. Resolving Context Inside Flask (app.py)
If you are building custom application endpoints inside `Web/app.py`, you shouldn't use the direct MongoDB `client` manually. Instead, you rely on the built-in Flask context manager (`Web/tenant.py`) to give you the correct isolated scope.
### The `get_tenant_db()` function
Every route must use `get_tenant_db(client)` to ensure users can only ever access their own school/domain's database.
```python
from pymongo import MongoClient
import settings
from tenant import get_tenant_db
# Example Route
@app.route('/api/items')
def get_items():
# 1. Establish/reuse pooling connection
client = MongoClient(settings.MONGODB_HOST, settings.MONGODB_PORT)
# 2. Get the dynamically routed DB for THIS user
# (Based on Nginx Subdomain or X-Tenant-Id header)
db = get_tenant_db(client)
# 3. Runs query solely on `inventar_schule1.items`
items = list(db.items.find())
return items # List of BSON Dictionaries
```
**What it returns internally:**
The `get_tenant_db` function queries `g.tenant_context` inside Flask, calculates the database name from the subdomain, and returns a live `pymongo.database.Database` object.
This ensures that scaling is extremely cheap on resources because 1 Application Container connects to 100 separate Tenant Databases using just 1 shared `MongoClient` pool.
+8 -8
View File
@@ -48,8 +48,8 @@ case "$COMMAND" in
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
if [ -n "$APP_CONTAINER" ]; then
docker exec $APP_CONTAINER python3 -c "
import sys; sys.path.insert(0, '/app/Web'); import tenant, settings
client = tenant.get_tenant_db_client()
import sys; sys.path.insert(0, '/app/Web'); import settings; from pymongo import MongoClient
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
db = client[f'{settings.MONGODB_DB}_{sys.argv[1]}']
db.users.insert_one({'username': 'admin', 'password': 'hashed_password_here', 'role': 'admin'})
print(f'Tenant {sys.argv[1]} database initialized.')
@@ -73,8 +73,8 @@ print(f'Tenant {sys.argv[1]} database initialized.')
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
if [ -n "$APP_CONTAINER" ]; then
docker exec $APP_CONTAINER python3 -c "
import sys; sys.path.insert(0, '/app/Web'); import tenant, settings
client = tenant.get_tenant_db_client()
import sys; sys.path.insert(0, '/app/Web'); import settings; from pymongo import MongoClient
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
client.drop_database(f'{settings.MONGODB_DB}_{sys.argv[1]}')
print(f'Database for tenant {sys.argv[1]} dropped.')
" "$TENANT_ID"
@@ -98,8 +98,8 @@ print(f'Database for tenant {sys.argv[1]} dropped.')
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
if [ -n "$APP_CONTAINER" ]; then
docker exec $APP_CONTAINER python3 -c "
import sys; sys.path.insert(0, '/app/Web'); import tenant, settings
client = tenant.get_tenant_db_client()
import sys; sys.path.insert(0, '/app/Web'); import settings; from pymongo import MongoClient
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
db = client[f'{settings.MONGODB_DB}_{sys.argv[1]}']
db.sessions.drop() # Force sign-out / session clear
print(f'Tenant {sys.argv[1]} session cache cleared. Tenant restarted.')
@@ -121,8 +121,8 @@ print(f'Tenant {sys.argv[1]} session cache cleared. Tenant restarted.')
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
if [ -n "$APP_CONTAINER" ]; then
docker exec $APP_CONTAINER python3 -c "
import sys; sys.path.insert(0, '/app/Web'); import tenant, settings
client = tenant.get_tenant_db_client()
import sys; sys.path.insert(0, '/app/Web'); import settings; from pymongo import MongoClient
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
prefix = f'{settings.MONGODB_DB}_'
dbs = [d for d in client.list_database_names() if d.startswith(prefix)]
for db in dbs: