Enforce tenant isolation on verified audit firm domains
This commit is contained in:
@@ -59,6 +59,19 @@ def _normalise_session_int(value):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_TENANT_BOUND_DOMAIN_TYPES = {"audit_firm_domain", "audit_firm_subdomain"}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_trusted_tenant_bound_domain(request: Request) -> bool:
|
||||||
|
return (
|
||||||
|
bool(getattr(request.state, "domain_resolved", False))
|
||||||
|
and bool(getattr(request.state, "domain_is_verified", False))
|
||||||
|
and (getattr(request.state, "domain_status", None) or "").strip().lower() == "active"
|
||||||
|
and (getattr(request.state, "domain_type", None) or "").strip() in _TENANT_BOUND_DOMAIN_TYPES
|
||||||
|
and _normalise_session_int(getattr(request.state, "domain_tenant_id", None)) is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ContextResolveMiddleware(BaseHTTPMiddleware):
|
class ContextResolveMiddleware(BaseHTTPMiddleware):
|
||||||
def __init__(self, app: ASGIApp) -> None:
|
def __init__(self, app: ASGIApp) -> None:
|
||||||
super().__init__(app)
|
super().__init__(app)
|
||||||
@@ -101,16 +114,29 @@ class ContextResolveMiddleware(BaseHTTPMiddleware):
|
|||||||
session_tenant_code = (session.get("active_tenant_code") or session.get("tenant_code") or "").strip() or None
|
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
|
session_branch_code = (session.get("active_branch_code") or session.get("branch_code") or "").strip() or None
|
||||||
|
|
||||||
|
domain_tenant_id = _normalise_session_int(getattr(request.state, "domain_tenant_id", None))
|
||||||
|
domain_branch_id = _normalise_session_int(getattr(request.state, "domain_branch_id", None))
|
||||||
domain_tenant_code = getattr(request.state, "domain_tenant_code", None)
|
domain_tenant_code = getattr(request.state, "domain_tenant_code", None)
|
||||||
domain_branch_code = getattr(request.state, "domain_branch_code", None)
|
domain_branch_code = getattr(request.state, "domain_branch_code", None)
|
||||||
|
tenant_bound_domain = _is_trusted_tenant_bound_domain(request)
|
||||||
|
|
||||||
|
# On a verified active audit-firm hostname, the domain mapping is the
|
||||||
|
# authoritative tenant/branch context. Session selection must never
|
||||||
|
# escape to another tenant while the user remains on that hostname.
|
||||||
|
if tenant_bound_domain:
|
||||||
|
active_tenant_id = domain_tenant_id
|
||||||
|
active_branch_id = domain_branch_id if domain_branch_id is not None else session_branch_id
|
||||||
|
tenant_code = domain_tenant_code or self.s.DEFAULT_TENANT_CODE
|
||||||
|
branch_code = domain_branch_code or session_branch_code or self.s.DEFAULT_BRANCH_CODE
|
||||||
|
else:
|
||||||
|
active_tenant_id = session_tenant_id
|
||||||
|
active_branch_id = session_branch_id
|
||||||
tenant_code = (
|
tenant_code = (
|
||||||
session_tenant_code
|
session_tenant_code
|
||||||
or domain_tenant_code
|
or domain_tenant_code
|
||||||
or (request.headers.get(_TENANT_HEADER) if trust_headers else None)
|
or (request.headers.get(_TENANT_HEADER) if trust_headers else None)
|
||||||
or self.s.DEFAULT_TENANT_CODE
|
or self.s.DEFAULT_TENANT_CODE
|
||||||
)
|
)
|
||||||
|
|
||||||
branch_code = (
|
branch_code = (
|
||||||
session_branch_code
|
session_branch_code
|
||||||
or domain_branch_code
|
or domain_branch_code
|
||||||
@@ -124,8 +150,9 @@ class ContextResolveMiddleware(BaseHTTPMiddleware):
|
|||||||
or self.s.DEFAULT_YEAR_CODE
|
or self.s.DEFAULT_YEAR_CODE
|
||||||
)
|
)
|
||||||
|
|
||||||
request.state.active_tenant_id = session_tenant_id
|
request.state.active_tenant_id = active_tenant_id
|
||||||
request.state.active_branch_id = session_branch_id
|
request.state.active_branch_id = active_branch_id
|
||||||
|
request.state.tenant_bound_domain = tenant_bound_domain
|
||||||
request.state.tenant_code = tenant_code
|
request.state.tenant_code = tenant_code
|
||||||
request.state.branch_code = branch_code
|
request.state.branch_code = branch_code
|
||||||
request.state.year_code = year_code
|
request.state.year_code = year_code
|
||||||
|
|||||||
@@ -114,6 +114,25 @@ def _store_active_branch_context(request: Request, branch: Branch | None) -> Non
|
|||||||
request.session["active_branch_code"] = branch.code
|
request.session["active_branch_code"] = branch.code
|
||||||
|
|
||||||
|
|
||||||
|
_TENANT_BOUND_DOMAIN_TYPES = {"audit_firm_domain", "audit_firm_subdomain"}
|
||||||
|
|
||||||
|
|
||||||
|
def _bound_domain_tenant_id(request: Request) -> int | None:
|
||||||
|
if not bool(getattr(request.state, "domain_resolved", False)):
|
||||||
|
return None
|
||||||
|
if not bool(getattr(request.state, "domain_is_verified", False)):
|
||||||
|
return None
|
||||||
|
if (getattr(request.state, "domain_status", None) or "").strip().lower() != "active":
|
||||||
|
return None
|
||||||
|
if (getattr(request.state, "domain_type", None) or "").strip() not in _TENANT_BOUND_DOMAIN_TYPES:
|
||||||
|
return None
|
||||||
|
value = getattr(request.state, "domain_tenant_id", None)
|
||||||
|
try:
|
||||||
|
return int(value) if value not in (None, "", 0, "0") else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _can_manage_financial_years(db, user) -> bool:
|
def _can_manage_financial_years(db, user) -> bool:
|
||||||
roles = set(get_user_roles(db, user.id))
|
roles = set(get_user_roles(db, user.id))
|
||||||
perms = set(get_user_permissions(db, user.id))
|
perms = set(get_user_permissions(db, user.id))
|
||||||
@@ -1435,6 +1454,10 @@ def switch_active_tenant(request: Request, tenant_id: int):
|
|||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login", status_code=303)
|
return RedirectResponse(url="/login", status_code=303)
|
||||||
|
|
||||||
|
bound_tenant_id = _bound_domain_tenant_id(request)
|
||||||
|
if bound_tenant_id is not None and int(tenant_id) != int(bound_tenant_id):
|
||||||
|
return _redirect_denied()
|
||||||
|
|
||||||
roles = get_user_roles(db, user.id)
|
roles = get_user_roles(db, user.id)
|
||||||
perms = set(get_user_permissions(db, user.id))
|
perms = set(get_user_permissions(db, user.id))
|
||||||
if "System Admin" not in roles or "services.cross_tenant" not in perms:
|
if "System Admin" not in roles or "services.cross_tenant" not in perms:
|
||||||
|
|||||||
@@ -33,6 +33,54 @@ SAFE_POST_LOGIN_REDIRECTS = {
|
|||||||
"/employee/dashboard",
|
"/employee/dashboard",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Verified active audit-firm domains are tenant authentication boundaries.
|
||||||
|
# Marketplace and consultant domain behaviour remains unchanged.
|
||||||
|
TENANT_BOUND_DOMAIN_TYPES = {"audit_firm_domain", "audit_firm_subdomain"}
|
||||||
|
|
||||||
|
|
||||||
|
def _bound_domain_tenant_id(request: Request) -> int | None:
|
||||||
|
"""Return the trusted tenant id bound to the current audit-firm domain.
|
||||||
|
|
||||||
|
DomainResolverMiddleware only marks exact, active and verified mappings as
|
||||||
|
resolved. The additional checks here make the authentication boundary
|
||||||
|
explicit and safe if the middleware evolves later.
|
||||||
|
"""
|
||||||
|
if not bool(getattr(request.state, "domain_resolved", False)):
|
||||||
|
return None
|
||||||
|
if not bool(getattr(request.state, "domain_is_verified", False)):
|
||||||
|
return None
|
||||||
|
if (getattr(request.state, "domain_status", None) or "").strip().lower() != "active":
|
||||||
|
return None
|
||||||
|
if (getattr(request.state, "domain_type", None) or "").strip() not in TENANT_BOUND_DOMAIN_TYPES:
|
||||||
|
return None
|
||||||
|
tenant_id = getattr(request.state, "domain_tenant_id", None)
|
||||||
|
try:
|
||||||
|
return int(tenant_id) if tenant_id not in (None, "", 0, "0") else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_login_session(request: Request) -> None:
|
||||||
|
"""Remove authentication/context state without disturbing CSRF/session middleware."""
|
||||||
|
for key in (
|
||||||
|
SESSION_USER_ID_KEY,
|
||||||
|
SESSION_LOGIN_AT_KEY,
|
||||||
|
"user_email",
|
||||||
|
"tenant_id",
|
||||||
|
"branch_id",
|
||||||
|
"tenant_code",
|
||||||
|
"branch_code",
|
||||||
|
"active_tenant_id",
|
||||||
|
"active_branch_id",
|
||||||
|
"active_tenant_code",
|
||||||
|
"active_branch_code",
|
||||||
|
"active_financial_year",
|
||||||
|
"must_change_password",
|
||||||
|
"post_login_redirect",
|
||||||
|
"otp_verified",
|
||||||
|
):
|
||||||
|
request.session.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
def _consume_safe_post_login_redirect(request: Request) -> str | None:
|
def _consume_safe_post_login_redirect(request: Request) -> str | None:
|
||||||
value = request.session.pop(PENDING_POST_LOGIN_REDIRECT_KEY, None)
|
value = request.session.pop(PENDING_POST_LOGIN_REDIRECT_KEY, None)
|
||||||
@@ -447,6 +495,20 @@ def login_submit(
|
|||||||
permissions = _user_permissions(db, user_id)
|
permissions = _user_permissions(db, user_id)
|
||||||
bs = _get_branch_security_policy(db, user)
|
bs = _get_branch_security_policy(db, user)
|
||||||
|
|
||||||
|
# A verified active audit-firm domain is a hard tenant boundary.
|
||||||
|
# System Admin retains the existing platform-support capability, but all
|
||||||
|
# tenant users must belong to the tenant mapped to this hostname.
|
||||||
|
bound_tenant_id = _bound_domain_tenant_id(request)
|
||||||
|
is_system_admin = "System Admin" in set(roles or [])
|
||||||
|
user_tenant_id = int(tenant_id) if tenant_id not in (None, "", 0, "0") else None
|
||||||
|
if bound_tenant_id is not None and not is_system_admin and user_tenant_id != bound_tenant_id:
|
||||||
|
_clear_login_session(request)
|
||||||
|
return _render_login(
|
||||||
|
request,
|
||||||
|
flash="This account does not belong to the firm associated with this domain. Please use your firm's login URL.",
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
|
||||||
request.session[SESSION_USER_ID_KEY] = user_id
|
request.session[SESSION_USER_ID_KEY] = user_id
|
||||||
request.session[SESSION_LOGIN_AT_KEY] = now.isoformat()
|
request.session[SESSION_LOGIN_AT_KEY] = now.isoformat()
|
||||||
request.session["user_email"] = user_email
|
request.session["user_email"] = user_email
|
||||||
|
|||||||
Reference in New Issue
Block a user