Compare commits

..

10 Commits

4 changed files with 60 additions and 15 deletions
+1 -1
View File
@@ -1 +1 @@
v0.7.69
v0.7.73
+1
View File
@@ -4177,6 +4177,7 @@ def logout():
session.pop('is_admin', None)
session.pop('favorites', None)
session.pop('favorites_owner', None)
session.pop('tenant_id', None)
return redirect(url_for('login'))
+42 -3
View File
@@ -7,7 +7,7 @@ Supports subdomain-based tenant identification and per-tenant database namespaci
Each tenant can support up to 20+ users with isolated data and resource pools.
"""
from flask import request, g, has_request_context
from flask import request, g, session, has_request_context
from functools import wraps
import logging
import os
@@ -113,6 +113,28 @@ def _normalize_db_name(db_name):
return db_name
def _module_name_candidates(module_name):
normalized = str(module_name or '').strip().lower().replace('-', '_')
if not normalized:
return []
candidates = [normalized]
alias_groups = {
'inventory': {'inventory', 'inventar'},
'library': {'library', 'bib', 'bibliothek'},
'student_cards': {'student_cards', 'studentcards', 'schuelerausweise', 'schueler_ausweise'},
}
for canonical_name, aliases in alias_groups.items():
if normalized in aliases:
for alias in (canonical_name, *sorted(aliases)):
if alias not in candidates:
candidates.append(alias)
break
return candidates
def _tenant_db_aliases(tenant_id):
aliases = []
env_map = _parse_tenant_db_map()
@@ -153,8 +175,12 @@ def _resolve_db_alias(tenant_id, db_name):
def is_tenant_module_enabled(module_name, tenant_id=None, default=False):
"""Resolve whether a feature module is enabled for the current tenant."""
config = get_tenant_config(tenant_id)
enabled = _get_nested_value(config, ['modules', module_name, 'enabled'], default)
return bool(enabled)
for candidate_name in _module_name_candidates(module_name):
enabled = _get_nested_value(config, ['modules', candidate_name, 'enabled'], None)
if enabled is not None:
return bool(enabled)
return bool(default)
class TenantContext:
@@ -183,6 +209,7 @@ class TenantContext:
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)
# Priority 2: Port-based tenant mapping
@@ -195,6 +222,7 @@ class TenantContext:
if tenant_from_port:
self.tenant_id = tenant_from_port
self.config = get_tenant_config(tenant_from_port)
session['tenant_id'] = tenant_from_port
logger.info(
f"Tenant resolution by port: host={host} port={port} tenant={tenant_from_port} config={self.config}"
)
@@ -215,12 +243,23 @@ class TenantContext:
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}")
# Priority 4: sticky tenant from the authenticated session
session_tenant = session.get('tenant_id', '').strip() if session.get('tenant_id') else ''
if session_tenant:
self.tenant_id = session_tenant
self.config = get_tenant_config(session_tenant)
logger.info(
f"Tenant resolution by session: host={host} tenant={session_tenant} config={self.config}"
)
return self._get_db_name(session_tenant)
# Fallback to default tenant if no tenant identifier found.
# If no explicit 'default' tenant config exists, use configured MongoDB DB.
if 'default' in TENANT_REGISTRY:
+16 -11
View File
@@ -9,7 +9,9 @@ if [ ! -f "docker-compose-multitenant.yml" ]; then
exit 1
fi
CONFIG_FILE="$PWD/config.json"
# Resolve script directory so config paths are deterministic even when called via sudo
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_FILE="$SCRIPT_DIR/config.json"
ensure_runtime_config_json() {
local config_path backup_path
@@ -212,13 +214,15 @@ EOF
}
restart_app_container() {
local env_file="$PWD/.docker-build.env"
local workdir="$SCRIPT_DIR"
local env_file="$SCRIPT_DIR/.docker-build.env"
local compose_args=()
ensure_runtime_config_json
# If HOST_WORKDIR is set (called from container), use absolute paths so docker daemon resolves them correctly
if [ -n "$HOST_WORKDIR" ]; then
if [ -n "${HOST_WORKDIR:-}" ]; then
workdir="$HOST_WORKDIR"
compose_args+=( -f "$(readlink -f "$HOST_WORKDIR/docker-compose-multitenant.yml")" )
if [ -f "$HOST_WORKDIR/.docker-compose.runtime.override.yml" ]; then
compose_args+=( -f "$(readlink -f "$HOST_WORKDIR/.docker-compose.runtime.override.yml")" )
@@ -228,9 +232,9 @@ restart_app_container() {
fi
else
# Normal case: called directly from host
compose_args+=( -f "$PWD/docker-compose-multitenant.yml" )
if [ -f "$PWD/.docker-compose.runtime.override.yml" ]; then
compose_args+=( -f "$PWD/.docker-compose.runtime.override.yml" )
compose_args+=( -f "$workdir/docker-compose-multitenant.yml" )
if [ -f "$workdir/.docker-compose.runtime.override.yml" ]; then
compose_args+=( -f "$workdir/.docker-compose.runtime.override.yml" )
fi
if [ -f "$env_file" ]; then
compose_args+=( --env-file "$env_file" )
@@ -238,7 +242,7 @@ restart_app_container() {
fi
# Pass along COMPOSE_PROJECT_NAME if set so the internal docker-compose sees it
if [ -n "$COMPOSE_PROJECT_NAME" ]; then
if [ -n "${COMPOSE_PROJECT_NAME:-}" ]; then
compose_args=( -p "$COMPOSE_PROJECT_NAME" "${compose_args[@]}" )
fi
@@ -373,7 +377,7 @@ 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, re; sys.path.insert(0, '/app/Web'); import settings; from pymongo import MongoClient; import hashlib
import sys, re; sys.path.insert(0, '/app'); sys.path.insert(0, '/app/Web'); from Web.modules.database import settings; from pymongo import MongoClient; import hashlib
tenant_id = sys.argv[1].lower()
sanitized = ''.join(c for c in tenant_id if c.isalnum() or c == '_')
db_name = f'inventar_{sanitized}'
@@ -441,7 +445,7 @@ print(f'Tenant {sys.argv[1]} database initialized. Default admin: admin / admin1
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'); from tenant import TenantContext; import settings; from pymongo import MongoClient
import sys; sys.path.insert(0, '/app'); sys.path.insert(0, '/app/Web'); from tenant import TenantContext; from Web.modules.database import settings; from pymongo import MongoClient
ctx = TenantContext()
db_name = ctx._get_db_name(sys.argv[1])
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
@@ -488,7 +492,7 @@ 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 settings; from pymongo import MongoClient
import sys; sys.path.insert(0, '/app'); sys.path.insert(0, '/app/Web'); from Web.modules.database 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
@@ -535,8 +539,9 @@ PY
if [ -n "$APP_CONTAINER" ]; then
docker exec -i "$APP_CONTAINER" python3 - <<'PY'
import sys
sys.path.insert(0, '/app')
sys.path.insert(0, '/app/Web')
import settings
from Web.modules.database import settings
from pymongo import MongoClient
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
prefix = 'inventar_'