Prepare ERP source for Gitea deployment
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ipaddress import ip_address, ip_network
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
from app.core.settings import get_settings
|
||||
|
||||
|
||||
_CONTEXT_SECRET_HEADER = "X-AuditFirm-Context-Secret"
|
||||
_TENANT_HEADER = "X-Tenant-Code"
|
||||
_BRANCH_HEADER = "X-Branch-Code"
|
||||
_YEAR_HEADER = "X-Year-Code"
|
||||
|
||||
|
||||
def _csv_values(value: str | None) -> list[str]:
|
||||
return [item.strip() for item in (value or "").split(",") if item.strip()]
|
||||
|
||||
|
||||
def _safe_env(value: str | None) -> str:
|
||||
return (value or "").strip().lower()
|
||||
|
||||
|
||||
def _host_matches_trusted_entry(client_host: str, trusted_entry: str) -> bool:
|
||||
"""Return True when client_host matches a trusted host/IP/CIDR entry.
|
||||
|
||||
Deliberately does not support '*' wildcard. For Docker/Coolify internal
|
||||
networks, use an explicit CIDR such as 172.16.0.0/12.
|
||||
"""
|
||||
client_host = (client_host or "").strip().lower()
|
||||
trusted_entry = (trusted_entry or "").strip().lower()
|
||||
if not client_host or not trusted_entry:
|
||||
return False
|
||||
|
||||
if client_host == trusted_entry:
|
||||
return True
|
||||
|
||||
try:
|
||||
client_ip = ip_address(client_host)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
try:
|
||||
if "/" in trusted_entry:
|
||||
return client_ip in ip_network(trusted_entry, strict=False)
|
||||
return client_ip == ip_address(trusted_entry)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _normalise_session_int(value):
|
||||
if value in (None, "", 0, "0"):
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
class ContextResolveMiddleware(BaseHTTPMiddleware):
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
super().__init__(app)
|
||||
self.s = get_settings()
|
||||
|
||||
def _context_headers_are_trusted(self, request: Request) -> bool:
|
||||
"""Permit context headers only from trusted internal callers.
|
||||
|
||||
Public users must not be able to switch tenant/branch/FY by adding
|
||||
X-Tenant-Code, X-Branch-Code or X-Year-Code headers. The production-safe
|
||||
default is TRUST_CONTEXT_HEADERS=false.
|
||||
"""
|
||||
if not bool(getattr(self.s, "TRUST_CONTEXT_HEADERS", False)):
|
||||
return False
|
||||
|
||||
required_secret = (getattr(self.s, "CONTEXT_HEADER_SECRET", "") or "").strip()
|
||||
if required_secret:
|
||||
supplied_secret = (request.headers.get(_CONTEXT_SECRET_HEADER) or "").strip()
|
||||
if supplied_secret != required_secret:
|
||||
return False
|
||||
elif _safe_env(getattr(self.s, "ENV", "")) in {"prod", "production"}:
|
||||
return False
|
||||
|
||||
client_host = request.client.host if request.client else ""
|
||||
trusted_entries = _csv_values(getattr(self.s, "TRUST_CONTEXT_HEADER_HOSTS", ""))
|
||||
return any(_host_matches_trusted_entry(client_host, item) for item in trusted_entries)
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# Trusted production context priority:
|
||||
# 1) Authenticated UI session selected tenant/branch/FY.
|
||||
# 2) Domain resolver mapping for pre-login/domain-routed requests.
|
||||
# 3) Trusted internal headers only when explicitly enabled with secret/host.
|
||||
# 4) Application defaults.
|
||||
session = request.scope.get("session") or {}
|
||||
trust_headers = self._context_headers_are_trusted(request)
|
||||
|
||||
session_tenant_id = _normalise_session_int(session.get("active_tenant_id") or session.get("tenant_id"))
|
||||
session_branch_id = _normalise_session_int(session.get("active_branch_id") or session.get("branch_id"))
|
||||
|
||||
session_tenant_code = (session.get("active_tenant_code") or session.get("tenant_code") or "").strip() or None
|
||||
session_branch_code = (session.get("active_branch_code") or session.get("branch_code") or "").strip() or None
|
||||
|
||||
domain_tenant_code = getattr(request.state, "domain_tenant_code", None)
|
||||
domain_branch_code = getattr(request.state, "domain_branch_code", None)
|
||||
|
||||
tenant_code = (
|
||||
session_tenant_code
|
||||
or domain_tenant_code
|
||||
or (request.headers.get(_TENANT_HEADER) if trust_headers else None)
|
||||
or self.s.DEFAULT_TENANT_CODE
|
||||
)
|
||||
|
||||
branch_code = (
|
||||
session_branch_code
|
||||
or domain_branch_code
|
||||
or (request.headers.get(_BRANCH_HEADER) if trust_headers else None)
|
||||
or self.s.DEFAULT_BRANCH_CODE
|
||||
)
|
||||
|
||||
year_code = (
|
||||
session.get("active_financial_year")
|
||||
or (request.headers.get(_YEAR_HEADER) if trust_headers else None)
|
||||
or self.s.DEFAULT_YEAR_CODE
|
||||
)
|
||||
|
||||
request.state.active_tenant_id = session_tenant_id
|
||||
request.state.active_branch_id = session_branch_id
|
||||
request.state.tenant_code = tenant_code
|
||||
request.state.branch_code = branch_code
|
||||
request.state.year_code = year_code
|
||||
request.state.context_headers_trusted = trust_headers
|
||||
return await call_next(request)
|
||||
@@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
from app.core.db.common import CommonSessionLocal
|
||||
from app.modules.domain_management.services import normalize_request_host, resolve_domain_context
|
||||
|
||||
|
||||
class DomainResolverMiddleware(BaseHTTPMiddleware):
|
||||
"""Resolve request host to platform / tenant / consultant context.
|
||||
|
||||
Phase 7T.2 is intentionally read-only:
|
||||
- It does not redirect users.
|
||||
- It does not change database records.
|
||||
- It does not override logged-in user permissions.
|
||||
- It only exposes a trusted runtime context on request.state.
|
||||
|
||||
Later phases use this context for branding, marketplace mode, tenant subdomains,
|
||||
consultant domains, and custom domain verification.
|
||||
"""
|
||||
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
super().__init__(app)
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
host_header = request.headers.get("x-forwarded-host") or request.headers.get("host")
|
||||
host = normalize_request_host(host_header)
|
||||
|
||||
# Safe defaults; every template/route can read these without checking existence.
|
||||
request.state.request_host = host
|
||||
request.state.domain_resolved = False
|
||||
request.state.domain_mapping_id = None
|
||||
request.state.domain_name = host
|
||||
request.state.domain_type = None
|
||||
request.state.domain_tenant_id = None
|
||||
request.state.domain_tenant_code = None
|
||||
request.state.domain_branch_id = None
|
||||
request.state.domain_branch_code = None
|
||||
request.state.domain_consultant_id = None
|
||||
request.state.domain_parent_tenant_id = None
|
||||
request.state.domain_is_verified = False
|
||||
request.state.domain_status = None
|
||||
request.state.domain_context = {
|
||||
"is_resolved": False,
|
||||
"host": host,
|
||||
"mapping_id": None,
|
||||
"domain_name": host,
|
||||
"domain_type": None,
|
||||
"tenant_id": None,
|
||||
"tenant_code": None,
|
||||
"branch_id": None,
|
||||
"branch_code": None,
|
||||
"consultant_id": None,
|
||||
"parent_tenant_id": None,
|
||||
"is_verified": False,
|
||||
"status": None,
|
||||
}
|
||||
|
||||
# Static files and empty/invalid host can proceed without DB lookup.
|
||||
if host and not request.url.path.startswith("/static/"):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
resolved = resolve_domain_context(db, host)
|
||||
if resolved.is_resolved:
|
||||
request.state.domain_resolved = True
|
||||
request.state.domain_mapping_id = resolved.mapping_id
|
||||
request.state.domain_name = resolved.domain_name
|
||||
request.state.domain_type = resolved.domain_type
|
||||
request.state.domain_tenant_id = resolved.tenant_id
|
||||
request.state.domain_tenant_code = resolved.tenant_code
|
||||
request.state.domain_branch_id = resolved.branch_id
|
||||
request.state.domain_branch_code = resolved.branch_code
|
||||
request.state.domain_consultant_id = resolved.consultant_id
|
||||
request.state.domain_parent_tenant_id = resolved.parent_tenant_id
|
||||
request.state.domain_is_verified = resolved.is_verified
|
||||
request.state.domain_status = resolved.status
|
||||
request.state.domain_context = {
|
||||
"is_resolved": True,
|
||||
"host": resolved.host,
|
||||
"mapping_id": resolved.mapping_id,
|
||||
"domain_name": resolved.domain_name,
|
||||
"domain_type": resolved.domain_type,
|
||||
"tenant_id": resolved.tenant_id,
|
||||
"tenant_code": resolved.tenant_code,
|
||||
"branch_id": resolved.branch_id,
|
||||
"branch_code": resolved.branch_code,
|
||||
"consultant_id": resolved.consultant_id,
|
||||
"parent_tenant_id": resolved.parent_tenant_id,
|
||||
"is_verified": resolved.is_verified,
|
||||
"status": resolved.status,
|
||||
}
|
||||
except Exception:
|
||||
# Domain resolution must never take the ERP down. If the domain table is
|
||||
# missing during deployment or DB is temporarily unavailable, continue
|
||||
# with the normal default context.
|
||||
pass
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
response = await call_next(request)
|
||||
if getattr(request.state, "domain_resolved", False):
|
||||
response.headers["X-AuditFirm-Domain-Resolved"] = "1"
|
||||
response.headers["X-AuditFirm-Domain-Type"] = str(getattr(request.state, "domain_type", "") or "")
|
||||
return response
|
||||
@@ -0,0 +1,21 @@
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
|
||||
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
resp = await call_next(request)
|
||||
resp.headers["X-Content-Type-Options"] = "nosniff"
|
||||
resp.headers["X-Frame-Options"] = "DENY"
|
||||
resp.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
resp.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
|
||||
|
||||
# CSP: allow Tailwind CDN only
|
||||
resp.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self'; "
|
||||
"style-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com; "
|
||||
"script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com; "
|
||||
"img-src 'self' data:; "
|
||||
"connect-src 'self'; "
|
||||
"frame-ancestors 'none';"
|
||||
)
|
||||
return resp
|
||||
Reference in New Issue
Block a user