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 logging
import os import os
import re import re
import ipaddress
import Web.modules.database.settings as cfg import Web.modules.database.settings as cfg
from Web.modules.database.settings import MongoClient from Web.modules.database.settings import MongoClient
@@ -88,6 +89,33 @@ def _tenant_id_for_port(port):
return None 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): def get_tenant_config(tenant_id=None):
"""Return the registered config for a tenant, falling back to default.""" """Return the registered config for a tenant, falling back to default."""
if tenant_id is None: if tenant_id is None:
@@ -207,14 +235,15 @@ class TenantContext:
# Priority 1: X-Tenant-ID header (for testing/internal APIs) # Priority 1: X-Tenant-ID header (for testing/internal APIs)
tenant_from_header = request.headers.get('X-Tenant-ID', '').strip() tenant_from_header = request.headers.get('X-Tenant-ID', '').strip()
if tenant_from_header: if tenant_from_header:
self.tenant_id = tenant_from_header matched_tenant = _find_registered_tenant_id(tenant_from_header) or tenant_from_header
self.config = get_tenant_config(tenant_from_header) self.tenant_id = matched_tenant
session['tenant_id'] = tenant_from_header self.config = get_tenant_config(matched_tenant)
return self._get_db_name(tenant_from_header) session['tenant_id'] = matched_tenant
return self._get_db_name(matched_tenant)
# Priority 2: Port-based tenant mapping # Priority 2: Port-based tenant mapping
host = request.host.lower() host = request.host.lower()
_, port = _parse_port_from_host(host) hostname, port = _parse_port_from_host(host)
self.port = port self.port = port
logger.info(f"Tenant resolution start: request.host={host} request.headers={dict(request.headers)}") logger.info(f"Tenant resolution start: request.host={host} request.headers={dict(request.headers)}")
if port: if port:
@@ -230,25 +259,36 @@ class TenantContext:
logger.info(f"Tenant port not mapped: host={host} port={port}") logger.info(f"Tenant port not mapped: host={host} port={port}")
# Priority 3: Subdomain extraction # 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 if host_without_port and not _is_ip_host(host_without_port):
# Examples: schule1.example.com → schule1 parts = host_without_port.split('.')
# app.example.com → app (skip wildcard/app) if len(parts) >= 2:
if len(parts) >= 3: potential_subdomain = parts[0]
potential_subdomain = parts[0] if potential_subdomain not in ('www', 'api', 'admin', 'app', 'mail'):
matched_tenant = _find_registered_tenant_id(potential_subdomain)
# Filter out common non-tenant subdomains if matched_tenant:
if potential_subdomain not in ('www', 'api', 'admin', 'app', 'mail'): self.subdomain = potential_subdomain
self.subdomain = potential_subdomain self.tenant_id = matched_tenant
self.tenant_id = potential_subdomain self.config = get_tenant_config(matched_tenant)
self.config = get_tenant_config(potential_subdomain) session['tenant_id'] = matched_tenant
session['tenant_id'] = potential_subdomain logger.info(
logger.info( f"Tenant resolution by subdomain: host={host} tenant={matched_tenant} config={self.config}"
f"Tenant resolution by subdomain: host={host} tenant={potential_subdomain} config={self.config}" )
) return self._get_db_name(matched_tenant)
return self._get_db_name(potential_subdomain) logger.info(f"Tenant subdomain not registered: {potential_subdomain}")
logger.info(f"Tenant subdomain ignored: {potential_subdomain}") else:
logger.info(f"Tenant subdomain ignored: {potential_subdomain}")
# Priority 4: sticky tenant from the authenticated session # Priority 4: sticky tenant from the authenticated session
session_tenant = session.get('tenant_id', '').strip() if session.get('tenant_id') else '' 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 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() { register_tenant_port() {
local tenant_id="$1" local tenant_id="$1"
local port="$2" local port="$2"
@@ -87,15 +105,25 @@ with open(path, 'r', encoding='utf-8') as f:
tenants = cfg.get('tenants') tenants = cfg.get('tenants')
if tenants is None or not isinstance(tenants, dict): if tenants is None or not isinstance(tenants, dict):
tenants = {} 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(): 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) print(f"Error: port {port_str} is already mapped to tenant {tid}", file=sys.stderr)
sys.exit(2) sys.exit(2)
existing = tenants.get(tenant_id) existing = {}
if existing is None or not isinstance(existing, dict): for alias in aliases:
existing = {} alias_cfg = tenants.get(alias)
if isinstance(alias_cfg, dict):
existing = alias_cfg
break
existing['port'] = int(port_str) existing['port'] = int(port_str)
tenants[tenant_id] = existing for alias in aliases:
tenants[alias] = dict(existing)
cfg['tenants'] = tenants cfg['tenants'] = tenants
with open(path, 'w', encoding='utf-8') as f: with open(path, 'w', encoding='utf-8') as f:
json.dump(cfg, f, indent=4, ensure_ascii=False) 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: with open(path, 'r', encoding='utf-8') as f:
cfg = json.load(f) cfg = json.load(f)
tenants = cfg.get('tenants', {}) 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) 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 cfg['tenants'] = tenants
with open(path, 'w', encoding='utf-8') as f: with open(path, 'w', encoding='utf-8') as f:
json.dump(cfg, f, indent=4, ensure_ascii=False) 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) cfg = json.load(f)
tenants = cfg.setdefault('tenants', {}) tenants = cfg.setdefault('tenants', {})
tenant_cfg = tenants.setdefault(tenant_id, {}) aliases = {tenant_id}
if 'port' not in tenant_cfg: normalized = tenant_id.lower()
print(f"Warning: Tenant {tenant_id} doesn't have a port mapping in config.json.", file=sys.stderr) 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', {}) 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: for arg in module_args:
if '=' not in arg: if '=' not in arg:
@@ -596,6 +650,14 @@ for arg in module_args:
module_cfg['enabled'] = state module_cfg['enabled'] = state
print(f"Module '{mod_name}' set to '{'on' if state else 'off'}' for tenant '{tenant_id}'.") 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: with open(path, 'w', encoding='utf-8') as f:
json.dump(cfg, f, indent=4, ensure_ascii=False) json.dump(cfg, f, indent=4, ensure_ascii=False)
PY PY