Compare commits

..

17 Commits

Author SHA1 Message Date
github-actions[bot] c69231fa77 chore: bump version to v0.7.75 2026-05-20 18:52:46 +00:00
Aiirondev_dev ac43178b29 feat: implement tenant database resolution and configuration checks 2026-05-20 20:52:06 +02:00
github-actions[bot] a2d58208e6 chore: bump version to v0.7.74 2026-05-20 18:40:06 +00:00
Aiirondev_dev f8171a5f18 feat: enhance tenant aliasing for multi-tenant management in scripts 2026-05-20 20:39:45 +02:00
github-actions[bot] 6f898ffea4 chore: bump version to v0.7.73 2026-05-20 18:12:28 +00:00
Aiirondev_dev b29cc38a3d fix: enhance tenant ID management in session for multi-tenant support 2026-05-20 20:12:01 +02:00
github-actions[bot] 17745c64e0 chore: bump version to v0.7.72 2026-05-20 17:37:17 +00:00
Aiirondev_dev a0279f82e1 Merge remote-tracking branch 'refs/remotes/origin/main' 2026-05-20 19:35:26 +02:00
Aiirondev_dev 8d1e3865d3 fix: improve environment variable handling and path resolution in restart_app_container function 2026-05-20 19:35:11 +02:00
github-actions[bot] 79be502aa1 chore: bump version to v0.7.71 2026-05-20 17:27:29 +00:00
Aiirondev_dev d0f54f0dca fix: ensure deterministic config path resolution in manage-tenant.sh 2026-05-20 19:26:00 +02:00
github-actions[bot] 2b00aab1fa chore: bump version to v0.7.70 2026-05-20 16:45:03 +00:00
Aiirondev_dev 6d4ac073a5 feat: Add module name candidate resolution for tenant feature modules 2026-05-20 18:42:55 +02:00
Aiirondev_dev 73ab1a37d2 feat: Update import paths for database settings in manage-tenant.sh 2026-05-20 18:31:17 +02:00
github-actions[bot] 0eb1ffe9d9 chore: bump version to v0.7.69 2026-05-20 16:24:14 +00:00
Aiirondev_dev 0ea1294237 feat: Update version handling in release workflow and improve directory creation 2026-05-20 18:23:34 +02:00
Aiirondev_dev a7ff2f1552 feat: Refactor logging module structure and implement audit logging functionality 2026-05-20 18:01:08 +02:00
11 changed files with 464 additions and 73 deletions
+1 -3
View File
@@ -1,8 +1,6 @@
services: services:
app: app:
working_dir: /app/Web image: ghcr.io/aiirondev/legendary-octo-garbanzo:v0.7.42
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "4", "--threads", "2", "--timeout", "30", "--graceful-timeout", "20", "--worker-connections", "100", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
image: ghcr.io/aiirondev/legendary-octo-garbanzo:latest
build: null build: null
ports: ports:
- "10000:8000" - "10000:8000"
+19
View File
@@ -134,14 +134,22 @@ jobs:
echo "push_dev=false" >> "$GITHUB_OUTPUT" echo "push_dev=false" >> "$GITHUB_OUTPUT"
fi fi
- name: Update .release-version file
run: |
echo "${{ steps.meta.outputs.tag }}" > .release-version
cat .release-version
- name: Create and push tag for manual releases - name: Create and push tag for manual releases
if: github.event_name == 'workflow_dispatch' if: github.event_name == 'workflow_dispatch'
run: | run: |
TAG="${{ steps.meta.outputs.tag }}" TAG="${{ steps.meta.outputs.tag }}"
git config user.name "github-actions[bot]" git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com" git config user.email "github-actions[bot]@users.noreply.github.com"
git add .release-version
git commit -m "chore: bump version to $TAG" || true
git tag "$TAG" git tag "$TAG"
git push origin "$TAG" git push origin "$TAG"
git push origin HEAD:${{ github.ref_name }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -198,6 +206,17 @@ jobs:
# development tar omitted: dev releases will be versioned (vX.Y.Z-dev) and handled by update.sh using the tag # development tar omitted: dev releases will be versioned (vX.Y.Z-dev) and handled by update.sh using the tag
- name: Commit .release-version for tag pushes
if: github.event_name == 'push'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
if ! git diff --quiet .release-version; then
git add .release-version
git commit -m "chore: update version to ${{ steps.meta.outputs.tag }}"
git push origin HEAD:${{ github.ref_name }}
fi
- name: Create release-only docker bundle - name: Create release-only docker bundle
run: | run: |
mkdir -p release-bundle mkdir -p release-bundle
+1 -1
View File
@@ -1 +1 @@
v0.7.42 v0.7.75
+4 -7
View File
@@ -45,7 +45,7 @@ if _CURRENT_DIR not in sys.path:
import Web.modules.database.user as us import Web.modules.database.user as us
import Web.modules.database.items as it import Web.modules.database.items as it
import Web.modules.database.ausleihung as au import Web.modules.database.ausleihung as au
import Web.modules.logs.audit_log as al import Web.modules.log.audit_log as al
import push_notifications as pn import push_notifications as pn
import Web.modules.inventarsystem.pdf_export as pdf_export import Web.modules.inventarsystem.pdf_export as pdf_export
import datetime import datetime
@@ -120,7 +120,7 @@ app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)
"""--------------------------------------------------------------Path Init-------------------------------------------------------""" """--------------------------------------------------------------Path Init-------------------------------------------------------"""
if not os.path.exists(app.config['UPLOAD_FOLDER']): if not os.path.exists(app.config['UPLOAD_FOLDER']):
os.makedirs(app.config['UPLOAD_FOLDER']) os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
# QR Code directory creation deactivated # QR Code directory creation deactivated
# if not os.path.exists(app.config['QR_CODE_FOLDER']): # if not os.path.exists(app.config['QR_CODE_FOLDER']):
# os.makedirs(app.config['QR_CODE_FOLDER']) # os.makedirs(app.config['QR_CODE_FOLDER'])
@@ -2415,11 +2415,7 @@ def preview_file(filename):
if denied: if denied:
return denied return denied
# Check production path first
prod_path = "/var/Inventarsystem/Web/previews"
dev_path = app.config['PREVIEW_FOLDER'] dev_path = app.config['PREVIEW_FOLDER']
if os.path.exists(os.path.join(prod_path, filename)):
return send_from_directory(prod_path, filename)
if os.path.exists(os.path.join(dev_path, filename)): if os.path.exists(os.path.join(dev_path, filename)):
return send_from_directory(dev_path, filename) return send_from_directory(dev_path, filename)
@@ -2798,7 +2794,7 @@ def library_export_excel(scope='all'):
client.close() client.close()
excel_file = excel_export.generate_library_excel(items) excel_file = excel_export.generate_library_excel(items)
return flask.send_file( return send_file(
excel_file, excel_file,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
as_attachment=True, as_attachment=True,
@@ -4181,6 +4177,7 @@ def logout():
session.pop('is_admin', None) session.pop('is_admin', None)
session.pop('favorites', None) session.pop('favorites', None)
session.pop('favorites_owner', None) session.pop('favorites_owner', None)
session.pop('tenant_id', None)
return redirect(url_for('login')) return redirect(url_for('login'))
+1 -1
View File
@@ -103,7 +103,7 @@ def get_current_status(ausleihung, log_changes=False, user=None):
if log_changes and new_status != original_status and '_id' in ausleihung: if log_changes and new_status != original_status and '_id' in ausleihung:
try: try:
# Importieren Sie das Modul nur bei Bedarf, um zirkuläre Importe zu vermeiden # Importieren Sie das Modul nur bei Bedarf, um zirkuläre Importe zu vermeiden
import Web.modules.logs.ausleihung_log as ausleihung_log import Web.modules.log.ausleihung_log as ausleihung_log
ausleihung_log.log_status_change( ausleihung_log.log_status_change(
str(ausleihung['_id']), str(ausleihung['_id']),
original_status, original_status,
+53 -17
View File
@@ -12,6 +12,7 @@ Provides methods for creating, validating, and retrieving user information.
''' '''
import hashlib import hashlib
import copy import copy
import importlib
import logging import logging
import re import re
import secrets import secrets
@@ -59,6 +60,35 @@ def _get_tenant_db(client):
return client[cfg.MONGODB_DB] return client[cfg.MONGODB_DB]
def _has_tenant_configs():
for module_name in ('tenant', 'Web.tenant'):
try:
tenant_module = importlib.import_module(module_name)
tenant_registry = getattr(tenant_module, 'TENANT_REGISTRY', None)
if isinstance(tenant_registry, dict) and tenant_registry:
return True
except Exception:
continue
return isinstance(getattr(cfg, 'TENANT_CONFIGS', None), dict) and bool(cfg.TENANT_CONFIGS)
def _resolve_request_tenant_db():
for module_name in ('tenant', 'Web.tenant'):
try:
tenant_module = importlib.import_module(module_name)
get_tenant_context = getattr(tenant_module, 'get_tenant_context', None)
if not callable(get_tenant_context):
continue
ctx = get_tenant_context()
if ctx and ctx.tenant_id:
return ctx.db_name or ctx.resolve_tenant(), ctx.tenant_id
if ctx and ctx.db_name and not _has_tenant_configs():
return ctx.db_name, None
except Exception as exc:
logger.debug("Tenant context import %s failed: %s", module_name, exc)
return None, None
def build_name_synonym(first_name, last_name=''): def build_name_synonym(first_name, last_name=''):
"""Build a deterministic, non-personalized short alias from 2 letters each.""" """Build a deterministic, non-personalized short alias from 2 letters each."""
first = _clean_name_fragment(first_name) first = _clean_name_fragment(first_name)
@@ -424,22 +454,26 @@ def check_nm_pwd(username, password):
Returns: Returns:
dict: User document if credentials are valid, None otherwise dict: User document if credentials are valid, None otherwise
""" """
db_name = cfg.MONGODB_DB db_name, tenant_id = _resolve_request_tenant_db()
tenant_db = None
ctx = None ctx = None
try: try:
from tenant import get_tenant_context from tenant import get_tenant_context
ctx = get_tenant_context() ctx = get_tenant_context()
if ctx and ctx.tenant_id: except Exception:
tenant_db = ctx.db_name or ctx.resolve_tenant() ctx = None
db_name = tenant_db
except Exception as exc: if not db_name:
logger.exception(f"Failed to resolve tenant context in check_nm_pwd: {exc}") if _has_tenant_configs():
logger.warning(
"Refusing default DB fallback for login because tenant configs exist and no tenant was resolved."
)
return None
db_name = cfg.MONGODB_DB
logger.info( logger.info(
"check_nm_pwd start: username=%r tenant=%r db=%r host=%r port=%r uri=%r", "check_nm_pwd start: username=%r tenant=%r db=%r host=%r port=%r uri=%r",
username, username,
ctx.tenant_id if ctx else None, tenant_id,
db_name, db_name,
cfg.MONGODB_HOST, cfg.MONGODB_HOST,
cfg.MONGODB_PORT, cfg.MONGODB_PORT,
@@ -644,15 +678,17 @@ def get_user(username):
return users.find_one({'Username': username}) or users.find_one({'username': username}) return users.find_one({'Username': username}) or users.find_one({'username': username})
# Try current tenant first when available # Try current tenant first when available
try: tenant_db, tenant_id = _resolve_request_tenant_db()
from tenant import get_tenant_context if tenant_db:
ctx = get_tenant_context() user = find_in_db(tenant_db)
if ctx and ctx.db_name: if user:
user = find_in_db(ctx.db_name) return user
if user:
return user if _has_tenant_configs() and tenant_db is None:
except Exception: logger.warning(
pass "Refusing default DB fallback for user lookup because tenant configs exist and no tenant was resolved."
)
return None
# Fallback to default configured database # Fallback to default configured database
user = find_in_db(cfg.MONGODB_DB) user = find_in_db(cfg.MONGODB_DB)
+1
View File
@@ -0,0 +1 @@
# Web.modules.log package initialization
+149
View File
@@ -0,0 +1,149 @@
"""
Tamper-evident audit logging helpers.
The audit chain stores each entry with a hash of the previous entry.
Any mutation in history breaks the chain verification.
"""
import datetime
import hashlib
import json
import random
import time
from pymongo.errors import DuplicateKeyError
def _stable_json(value):
"""Serialize dictionaries in a stable way for deterministic hashing."""
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str)
def _entry_hash(prev_hash, payload):
"""Build the chained entry hash from previous hash + canonical payload."""
base = f"{prev_hash}|{_stable_json(payload)}"
return hashlib.sha256(base.encode("utf-8")).hexdigest()
def append_audit_event(db, event_type, actor, payload, request_ip=None, source="web", max_retries=5):
"""
Append an audit event to a tamper-evident chain.
Args:
db: MongoDB database handle.
event_type (str): Event category.
actor (str): User/system who performed the action.
payload (dict): Event details.
request_ip (str, optional): Request origin.
source (str): Source subsystem.
Returns:
dict: Inserted audit entry.
"""
logs = db["audit_log"]
attempts = 0
while attempts <= max_retries:
previous = logs.find_one(sort=[("chain_index", -1)])
prev_hash = previous.get("entry_hash", "") if previous else ""
chain_index = int(previous.get("chain_index", 0)) + 1 if previous else 1
timestamp = datetime.datetime.utcnow()
entry_payload = {
"event_type": event_type,
"actor": actor or "system",
"source": source,
"ip": request_ip or "",
"payload": payload or {},
"timestamp": timestamp.isoformat() + "Z",
}
entry_hash = _entry_hash(prev_hash, entry_payload)
entry = {
**entry_payload,
"created_at": timestamp,
"prev_hash": prev_hash,
"entry_hash": entry_hash,
"chain_index": chain_index,
}
try:
logs.insert_one(entry)
return entry
except DuplicateKeyError:
attempts += 1
if attempts > max_retries:
raise
# Exponential backoff with jitter to avoid retry storms.
delay = min(0.25, (0.005 * (2 ** attempts)) + random.random() * 0.01)
time.sleep(delay)
def ensure_audit_indexes(db):
"""Create indexes required for fast and safe audit operations."""
logs = db["audit_log"]
logs.create_index("chain_index", unique=True, name="audit_chain_index_unique")
logs.create_index("created_at", name="audit_created_at_idx")
logs.create_index("event_type", name="audit_event_type_idx")
def verify_audit_chain(db):
"""Verify hash chain integrity across all stored audit entries."""
logs = db["audit_log"]
entries = list(logs.find({}, {"_id": 1, "event_type": 1, "actor": 1, "source": 1, "ip": 1, "payload": 1, "timestamp": 1, "prev_hash": 1, "entry_hash": 1, "chain_index": 1}).sort("chain_index", 1))
previous_hash = ""
previous_index = 0
mismatches = []
for entry in entries:
chain_index = int(entry.get("chain_index", 0))
prev_hash = entry.get("prev_hash", "")
entry_hash = entry.get("entry_hash", "")
payload = {
"event_type": entry.get("event_type", ""),
"actor": entry.get("actor", ""),
"source": entry.get("source", ""),
"ip": entry.get("ip", ""),
"payload": entry.get("payload", {}),
"timestamp": entry.get("timestamp", ""),
}
expected_hash = _entry_hash(previous_hash, payload)
if chain_index != previous_index + 1:
mismatches.append({
"chain_index": chain_index,
"error": "chain_index_gap",
"expected": previous_index + 1,
"found": chain_index,
})
if prev_hash != previous_hash:
mismatches.append({
"chain_index": chain_index,
"error": "prev_hash_mismatch",
"expected": previous_hash,
"found": prev_hash,
})
if entry_hash != expected_hash:
mismatches.append({
"chain_index": chain_index,
"error": "entry_hash_mismatch",
"expected": expected_hash,
"found": entry_hash,
})
previous_hash = entry_hash
previous_index = chain_index
return {
"ok": len(mismatches) == 0,
"count": len(entries),
"last_chain_index": previous_index,
"last_hash": previous_hash,
"mismatches": mismatches,
}
+45
View File
@@ -0,0 +1,45 @@
'''
Copyright 2025-2026 AIIrondev
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
See Legal/LICENSE for the full license text.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
'''
"""
Funktion zum Protokollieren von Statusänderungen bei Ausleihungen
"""
import os
import datetime
from bson.objectid import ObjectId
def log_status_change(ausleihung_id, old_status, new_status, user=None):
"""
Protokolliert eine Statusänderung einer Ausleihung in einer Log-Datei.
Args:
ausleihung_id: Die ID der Ausleihung
old_status: Der alte Status
new_status: Der neue Status
user: Der Benutzer, der die Änderung vorgenommen hat (optional)
"""
try:
# Erstelle Log-Verzeichnis, falls es nicht existiert
log_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'logs')
if not os.path.exists(log_dir):
os.makedirs(log_dir)
# Log-Datei für Statusänderungen
log_file = os.path.join(log_dir, 'ausleihungen_status_changes.log')
# Protokolliere die Änderung
timestamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
user_info = f" by {user}" if user else ""
with open(log_file, 'a', encoding='utf-8') as f:
f.write(f"{timestamp}: Ausleihung {ausleihung_id} - Status changed from '{old_status}' to '{new_status}'{user_info}\n")
return True
except Exception as e:
print(f"Fehler beim Protokollieren der Statusänderung: {e}")
return False
+102 -23
View File
@@ -7,11 +7,12 @@ Supports subdomain-based tenant identification and per-tenant database namespaci
Each tenant can support up to 20+ users with isolated data and resource pools. 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 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:
@@ -113,6 +141,28 @@ def _normalize_db_name(db_name):
return 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): def _tenant_db_aliases(tenant_id):
aliases = [] aliases = []
env_map = _parse_tenant_db_map() env_map = _parse_tenant_db_map()
@@ -153,8 +203,12 @@ def _resolve_db_alias(tenant_id, db_name):
def is_tenant_module_enabled(module_name, tenant_id=None, default=False): def is_tenant_module_enabled(module_name, tenant_id=None, default=False):
"""Resolve whether a feature module is enabled for the current tenant.""" """Resolve whether a feature module is enabled for the current tenant."""
config = get_tenant_config(tenant_id) config = get_tenant_config(tenant_id)
enabled = _get_nested_value(config, ['modules', module_name, 'enabled'], default) for candidate_name in _module_name_candidates(module_name):
return bool(enabled) enabled = _get_nested_value(config, ['modules', candidate_name, 'enabled'], None)
if enabled is not None:
return bool(enabled)
return bool(default)
class TenantContext: class TenantContext:
@@ -181,13 +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
return self._get_db_name(tenant_from_header) 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 # 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:
@@ -195,6 +251,7 @@ class TenantContext:
if tenant_from_port: if tenant_from_port:
self.tenant_id = tenant_from_port self.tenant_id = tenant_from_port
self.config = get_tenant_config(tenant_from_port) self.config = get_tenant_config(tenant_from_port)
session['tenant_id'] = tenant_from_port
logger.info( logger.info(
f"Tenant resolution by port: host={host} port={port} tenant={tenant_from_port} config={self.config}" f"Tenant resolution by port: host={host} port={port} tenant={tenant_from_port} config={self.config}"
) )
@@ -202,24 +259,46 @@ 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)
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}")
# Filter out common non-tenant subdomains # Priority 4: sticky tenant from the authenticated session
if potential_subdomain not in ('www', 'api', 'admin', 'app', 'mail'): session_tenant = session.get('tenant_id', '').strip() if session.get('tenant_id') else ''
self.subdomain = potential_subdomain if session_tenant:
self.tenant_id = potential_subdomain self.tenant_id = session_tenant
self.config = get_tenant_config(potential_subdomain) self.config = get_tenant_config(session_tenant)
logger.info( logger.info(
f"Tenant resolution by subdomain: host={host} tenant={potential_subdomain} config={self.config}" f"Tenant resolution by session: host={host} tenant={session_tenant} config={self.config}"
) )
return self._get_db_name(potential_subdomain) return self._get_db_name(session_tenant)
logger.info(f"Tenant subdomain ignored: {potential_subdomain}")
# Fallback to default tenant if no tenant identifier found. # Fallback to default tenant if no tenant identifier found.
# If no explicit 'default' tenant config exists, use configured MongoDB DB. # If no explicit 'default' tenant config exists, use configured MongoDB DB.
+88 -21
View File
@@ -9,7 +9,9 @@ if [ ! -f "docker-compose-multitenant.yml" ]; then
exit 1 exit 1
fi 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() { ensure_runtime_config_json() {
local config_path backup_path local config_path backup_path
@@ -70,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"
@@ -85,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)
@@ -212,13 +242,15 @@ EOF
} }
restart_app_container() { 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=() local compose_args=()
ensure_runtime_config_json ensure_runtime_config_json
# If HOST_WORKDIR is set (called from container), use absolute paths so docker daemon resolves them correctly # 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")" ) compose_args+=( -f "$(readlink -f "$HOST_WORKDIR/docker-compose-multitenant.yml")" )
if [ -f "$HOST_WORKDIR/.docker-compose.runtime.override.yml" ]; then if [ -f "$HOST_WORKDIR/.docker-compose.runtime.override.yml" ]; then
compose_args+=( -f "$(readlink -f "$HOST_WORKDIR/.docker-compose.runtime.override.yml")" ) compose_args+=( -f "$(readlink -f "$HOST_WORKDIR/.docker-compose.runtime.override.yml")" )
@@ -228,9 +260,9 @@ restart_app_container() {
fi fi
else else
# Normal case: called directly from host # Normal case: called directly from host
compose_args+=( -f "$PWD/docker-compose-multitenant.yml" ) compose_args+=( -f "$workdir/docker-compose-multitenant.yml" )
if [ -f "$PWD/.docker-compose.runtime.override.yml" ]; then if [ -f "$workdir/.docker-compose.runtime.override.yml" ]; then
compose_args+=( -f "$PWD/.docker-compose.runtime.override.yml" ) compose_args+=( -f "$workdir/.docker-compose.runtime.override.yml" )
fi fi
if [ -f "$env_file" ]; then if [ -f "$env_file" ]; then
compose_args+=( --env-file "$env_file" ) compose_args+=( --env-file "$env_file" )
@@ -238,7 +270,7 @@ restart_app_container() {
fi fi
# Pass along COMPOSE_PROJECT_NAME if set so the internal docker-compose sees it # 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[@]}" ) compose_args=( -p "$COMPOSE_PROJECT_NAME" "${compose_args[@]}" )
fi fi
@@ -259,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)
@@ -373,7 +416,7 @@ case "$COMMAND" in
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1) APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
if [ -n "$APP_CONTAINER" ]; then if [ -n "$APP_CONTAINER" ]; then
docker exec $APP_CONTAINER python3 -c " 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() tenant_id = sys.argv[1].lower()
sanitized = ''.join(c for c in tenant_id if c.isalnum() or c == '_') sanitized = ''.join(c for c in tenant_id if c.isalnum() or c == '_')
db_name = f'inventar_{sanitized}' db_name = f'inventar_{sanitized}'
@@ -441,7 +484,7 @@ print(f'Tenant {sys.argv[1]} database initialized. Default admin: admin / admin1
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1) APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
if [ -n "$APP_CONTAINER" ]; then if [ -n "$APP_CONTAINER" ]; then
docker exec $APP_CONTAINER python3 -c " 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() ctx = TenantContext()
db_name = ctx._get_db_name(sys.argv[1]) db_name = ctx._get_db_name(sys.argv[1])
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT)) client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
@@ -488,7 +531,7 @@ print(f'Database for tenant {sys.argv[1]} dropped.')
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1) APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
if [ -n "$APP_CONTAINER" ]; then if [ -n "$APP_CONTAINER" ]; then
docker exec $APP_CONTAINER python3 -c " 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)) client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
db = client[f'{settings.MONGODB_DB}_{sys.argv[1]}'] db = client[f'{settings.MONGODB_DB}_{sys.argv[1]}']
db.sessions.drop() # Force sign-out / session clear db.sessions.drop() # Force sign-out / session clear
@@ -535,8 +578,9 @@ PY
if [ -n "$APP_CONTAINER" ]; then if [ -n "$APP_CONTAINER" ]; then
docker exec -i "$APP_CONTAINER" python3 - <<'PY' docker exec -i "$APP_CONTAINER" python3 - <<'PY'
import sys import sys
sys.path.insert(0, '/app')
sys.path.insert(0, '/app/Web') sys.path.insert(0, '/app/Web')
import settings from Web.modules.database import settings
from pymongo import MongoClient from pymongo import MongoClient
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT)) client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
prefix = 'inventar_' prefix = 'inventar_'
@@ -575,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:
@@ -591,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