feat: enhance tenant aliasing for multi-tenant management in scripts

This commit is contained in:
2026-05-20 20:39:45 +02:00
parent 6f898ffea4
commit f8171a5f18
2 changed files with 135 additions and 33 deletions
+63 -23
View File
@@ -12,6 +12,7 @@ from functools import wraps
import logging
import os
import re
import ipaddress
import Web.modules.database.settings as cfg
from Web.modules.database.settings import MongoClient
@@ -88,6 +89,33 @@ def _tenant_id_for_port(port):
return None
def _find_registered_tenant_id(candidate):
candidate = str(candidate or '').strip()
if not candidate:
return None
if candidate in TENANT_REGISTRY:
return candidate
lowered = candidate.lower()
for tenant_id in TENANT_REGISTRY:
if str(tenant_id).lower() == lowered:
return tenant_id
return None
def _is_ip_host(hostname):
hostname = str(hostname or '').strip()
if not hostname:
return False
try:
ipaddress.ip_address(hostname)
return True
except ValueError:
return False
def get_tenant_config(tenant_id=None):
"""Return the registered config for a tenant, falling back to default."""
if tenant_id is None:
@@ -207,14 +235,15 @@ class TenantContext:
# Priority 1: X-Tenant-ID header (for testing/internal APIs)
tenant_from_header = request.headers.get('X-Tenant-ID', '').strip()
if tenant_from_header:
self.tenant_id = tenant_from_header
self.config = get_tenant_config(tenant_from_header)
session['tenant_id'] = tenant_from_header
return self._get_db_name(tenant_from_header)
matched_tenant = _find_registered_tenant_id(tenant_from_header) or tenant_from_header
self.tenant_id = matched_tenant
self.config = get_tenant_config(matched_tenant)
session['tenant_id'] = matched_tenant
return self._get_db_name(matched_tenant)
# Priority 2: Port-based tenant mapping
host = request.host.lower()
_, port = _parse_port_from_host(host)
hostname, port = _parse_port_from_host(host)
self.port = port
logger.info(f"Tenant resolution start: request.host={host} request.headers={dict(request.headers)}")
if port:
@@ -230,25 +259,36 @@ class TenantContext:
logger.info(f"Tenant port not mapped: host={host} port={port}")
# Priority 3: Subdomain extraction
parts = host.split('.')
host_without_port = (hostname or '').strip().lower()
direct_host_match = _find_registered_tenant_id(host_without_port)
if direct_host_match:
self.subdomain = host_without_port
self.tenant_id = direct_host_match
self.config = get_tenant_config(direct_host_match)
session['tenant_id'] = direct_host_match
logger.info(
f"Tenant resolution by direct host match: host={host} tenant={direct_host_match} config={self.config}"
)
return self._get_db_name(direct_host_match)
# Extract subdomain from host
# Examples: schule1.example.com → schule1
# app.example.com → app (skip wildcard/app)
if len(parts) >= 3:
potential_subdomain = parts[0]
# Filter out common non-tenant subdomains
if potential_subdomain not in ('www', 'api', 'admin', 'app', 'mail'):
self.subdomain = potential_subdomain
self.tenant_id = potential_subdomain
self.config = get_tenant_config(potential_subdomain)
session['tenant_id'] = potential_subdomain
logger.info(
f"Tenant resolution by subdomain: host={host} tenant={potential_subdomain} config={self.config}"
)
return self._get_db_name(potential_subdomain)
logger.info(f"Tenant subdomain ignored: {potential_subdomain}")
if host_without_port and not _is_ip_host(host_without_port):
parts = host_without_port.split('.')
if len(parts) >= 2:
potential_subdomain = parts[0]
if potential_subdomain not in ('www', 'api', 'admin', 'app', 'mail'):
matched_tenant = _find_registered_tenant_id(potential_subdomain)
if matched_tenant:
self.subdomain = potential_subdomain
self.tenant_id = matched_tenant
self.config = get_tenant_config(matched_tenant)
session['tenant_id'] = matched_tenant
logger.info(
f"Tenant resolution by subdomain: host={host} tenant={matched_tenant} config={self.config}"
)
return self._get_db_name(matched_tenant)
logger.info(f"Tenant subdomain not registered: {potential_subdomain}")
else:
logger.info(f"Tenant subdomain ignored: {potential_subdomain}")
# Priority 4: sticky tenant from the authenticated session
session_tenant = session.get('tenant_id', '').strip() if session.get('tenant_id') else ''
+72 -10
View File
@@ -72,6 +72,24 @@ show_help() {
exit 1
}
tenant_aliases() {
local tenant_id="$1"
local normalized alias
normalized="$(printf '%s' "$tenant_id" | tr '[:upper:]' '[:lower:]')"
printf '%s\n' "$tenant_id"
if [[ "$normalized" == schule* ]]; then
alias="school${normalized#schule}"
if [[ "$alias" != "$tenant_id" ]]; then
printf '%s\n' "$alias"
fi
elif [[ "$normalized" == school* ]]; then
alias="schule${normalized#school}"
if [[ "$alias" != "$tenant_id" ]]; then
printf '%s\n' "$alias"
fi
fi
}
register_tenant_port() {
local tenant_id="$1"
local port="$2"
@@ -87,15 +105,25 @@ with open(path, 'r', encoding='utf-8') as f:
tenants = cfg.get('tenants')
if tenants is None or not isinstance(tenants, dict):
tenants = {}
aliases = {tenant_id}
normalized = tenant_id.lower()
if normalized.startswith('schule'):
aliases.add('school' + normalized[len('schule'):])
elif normalized.startswith('school'):
aliases.add('schule' + normalized[len('school'):])
for tid, conf in tenants.items():
if isinstance(conf, dict) and str(conf.get('port')) == port_str and tid != tenant_id:
if isinstance(conf, dict) and str(conf.get('port')) == port_str and tid not in aliases:
print(f"Error: port {port_str} is already mapped to tenant {tid}", file=sys.stderr)
sys.exit(2)
existing = tenants.get(tenant_id)
if existing is None or not isinstance(existing, dict):
existing = {}
existing = {}
for alias in aliases:
alias_cfg = tenants.get(alias)
if isinstance(alias_cfg, dict):
existing = alias_cfg
break
existing['port'] = int(port_str)
tenants[tenant_id] = existing
for alias in aliases:
tenants[alias] = dict(existing)
cfg['tenants'] = tenants
with open(path, 'w', encoding='utf-8') as f:
json.dump(cfg, f, indent=4, ensure_ascii=False)
@@ -263,9 +291,20 @@ if not os.path.isfile(path):
with open(path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
tenants = cfg.get('tenants', {})
if not isinstance(tenants, dict) or tenant_id not in tenants or not isinstance(tenants[tenant_id], dict):
if not isinstance(tenants, dict):
sys.exit(2)
removed = tenants.pop(tenant_id, None)
aliases = {tenant_id}
normalized = tenant_id.lower()
if normalized.startswith('schule'):
aliases.add('school' + normalized[len('schule'):])
elif normalized.startswith('school'):
aliases.add('schule' + normalized[len('school'):])
removed = None
for alias in list(aliases):
alias_cfg = tenants.get(alias)
if isinstance(alias_cfg, dict):
removed = alias_cfg if removed is None else removed
tenants.pop(alias, None)
cfg['tenants'] = tenants
with open(path, 'w', encoding='utf-8') as f:
json.dump(cfg, f, indent=4, ensure_ascii=False)
@@ -580,11 +619,26 @@ with open(path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
tenants = cfg.setdefault('tenants', {})
tenant_cfg = tenants.setdefault(tenant_id, {})
if 'port' not in tenant_cfg:
print(f"Warning: Tenant {tenant_id} doesn't have a port mapping in config.json.", file=sys.stderr)
aliases = {tenant_id}
normalized = tenant_id.lower()
if normalized.startswith('schule'):
aliases.add('school' + normalized[len('schule'):])
elif normalized.startswith('school'):
aliases.add('schule' + normalized[len('school'):])
tenant_cfg = None
for alias in aliases:
alias_cfg = tenants.get(alias)
if isinstance(alias_cfg, dict):
tenant_cfg = alias_cfg
break
if tenant_cfg is None:
tenant_cfg = {}
modules = tenant_cfg.setdefault('modules', {})
port = tenant_cfg.get('port')
if port is None:
print(f"Warning: Tenant {tenant_id} doesn't have a port mapping in config.json.", file=sys.stderr)
for arg in module_args:
if '=' not in arg:
@@ -596,6 +650,14 @@ for arg in module_args:
module_cfg['enabled'] = state
print(f"Module '{mod_name}' set to '{'on' if state else 'off'}' for tenant '{tenant_id}'.")
for alias in aliases:
alias_cfg = tenants.get(alias)
if not isinstance(alias_cfg, dict):
alias_cfg = {}
alias_cfg.update(tenant_cfg)
alias_cfg['modules'] = modules
tenants[alias] = alias_cfg
with open(path, 'w', encoding='utf-8') as f:
json.dump(cfg, f, indent=4, ensure_ascii=False)
PY