feat: enhance tenant aliasing for multi-tenant management in scripts
This commit is contained in:
+63
-23
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user