feat(dev_tools): add tenant isolation audit to diagnostics page
Add a new "Tenant Isolation" diagnostic tool that scans all stores and reports where configuration values come from — flagging silent inheritance, missing data, and potential data commingling. Also fix merchant dashboard and onboarding integration tests that were missing require_platform dependency override. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,11 @@ from datetime import UTC, datetime, timedelta
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.api.deps import get_current_merchant_api, get_merchant_for_current_user
|
from app.api.deps import (
|
||||||
|
get_current_merchant_api,
|
||||||
|
get_merchant_for_current_user,
|
||||||
|
require_platform,
|
||||||
|
)
|
||||||
from app.modules.billing.models import (
|
from app.modules.billing.models import (
|
||||||
MerchantSubscription,
|
MerchantSubscription,
|
||||||
SubscriptionStatus,
|
SubscriptionStatus,
|
||||||
@@ -177,7 +181,7 @@ def dash_subscription(db, dash_merchant, dash_platform):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def dash_auth(dash_owner, dash_merchant):
|
def dash_auth(dash_owner, dash_merchant, dash_platform):
|
||||||
"""Override auth dependencies for dashboard merchant."""
|
"""Override auth dependencies for dashboard merchant."""
|
||||||
user_context = UserContext(
|
user_context = UserContext(
|
||||||
id=dash_owner.id,
|
id=dash_owner.id,
|
||||||
@@ -193,11 +197,16 @@ def dash_auth(dash_owner, dash_merchant):
|
|||||||
def _override_user():
|
def _override_user():
|
||||||
return user_context
|
return user_context
|
||||||
|
|
||||||
|
def _override_platform():
|
||||||
|
return dash_platform
|
||||||
|
|
||||||
app.dependency_overrides[get_merchant_for_current_user] = _override_merchant
|
app.dependency_overrides[get_merchant_for_current_user] = _override_merchant
|
||||||
app.dependency_overrides[get_current_merchant_api] = _override_user
|
app.dependency_overrides[get_current_merchant_api] = _override_user
|
||||||
|
app.dependency_overrides[require_platform] = _override_platform
|
||||||
yield {"Authorization": "Bearer fake-token"}
|
yield {"Authorization": "Bearer fake-token"}
|
||||||
app.dependency_overrides.pop(get_merchant_for_current_user, None)
|
app.dependency_overrides.pop(get_merchant_for_current_user, None)
|
||||||
app.dependency_overrides.pop(get_current_merchant_api, None)
|
app.dependency_overrides.pop(get_current_merchant_api, None)
|
||||||
|
app.dependency_overrides.pop(require_platform, None)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -232,10 +241,14 @@ class TestMerchantDashboardStats:
|
|||||||
assert data["total_customers"] == 0
|
assert data["total_customers"] == 0
|
||||||
assert data["team_members"] == 0
|
assert data["team_members"] == 0
|
||||||
|
|
||||||
def test_requires_auth(self, client):
|
def test_requires_auth(self, client, dash_platform):
|
||||||
"""Returns 401 without auth."""
|
"""Returns 401 without auth."""
|
||||||
# Remove any overrides
|
# Remove auth overrides but keep platform to isolate auth check
|
||||||
app.dependency_overrides.pop(get_merchant_for_current_user, None)
|
app.dependency_overrides.pop(get_merchant_for_current_user, None)
|
||||||
app.dependency_overrides.pop(get_current_merchant_api, None)
|
app.dependency_overrides.pop(get_current_merchant_api, None)
|
||||||
response = client.get(f"{BASE}/dashboard/stats")
|
app.dependency_overrides[require_platform] = lambda: dash_platform
|
||||||
assert response.status_code == 401
|
try:
|
||||||
|
response = client.get(f"{BASE}/dashboard/stats")
|
||||||
|
assert response.status_code == 401
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.pop(require_platform, None)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import uuid
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.api.deps import get_current_store_api
|
from app.api.deps import get_current_store_api, require_platform
|
||||||
from app.modules.tenancy.models import Merchant, Platform, Store, User
|
from app.modules.tenancy.models import Merchant, Platform, Store, User
|
||||||
from app.modules.tenancy.models.store_platform import StorePlatform
|
from app.modules.tenancy.models.store_platform import StorePlatform
|
||||||
from app.modules.tenancy.schemas.auth import UserContext
|
from app.modules.tenancy.schemas.auth import UserContext
|
||||||
@@ -108,7 +108,7 @@ def onb_store_platform(db, onb_store, onb_platform):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def onb_auth(onb_owner, onb_store):
|
def onb_auth(onb_owner, onb_store, onb_platform):
|
||||||
"""Override auth dependency for store API auth."""
|
"""Override auth dependency for store API auth."""
|
||||||
user_context = UserContext(
|
user_context = UserContext(
|
||||||
id=onb_owner.id,
|
id=onb_owner.id,
|
||||||
@@ -123,9 +123,14 @@ def onb_auth(onb_owner, onb_store):
|
|||||||
def _override():
|
def _override():
|
||||||
return user_context
|
return user_context
|
||||||
|
|
||||||
|
def _override_platform():
|
||||||
|
return onb_platform
|
||||||
|
|
||||||
app.dependency_overrides[get_current_store_api] = _override
|
app.dependency_overrides[get_current_store_api] = _override
|
||||||
|
app.dependency_overrides[require_platform] = _override_platform
|
||||||
yield {"Authorization": "Bearer fake-token"}
|
yield {"Authorization": "Bearer fake-token"}
|
||||||
app.dependency_overrides.pop(get_current_store_api, None)
|
app.dependency_overrides.pop(get_current_store_api, None)
|
||||||
|
app.dependency_overrides.pop(require_platform, None)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -206,10 +211,14 @@ class TestOnboardingEndpoint:
|
|||||||
if "/store/" in step["route"]:
|
if "/store/" in step["route"]:
|
||||||
assert onb_store.store_code in step["route"]
|
assert onb_store.store_code in step["route"]
|
||||||
|
|
||||||
def test_requires_auth(self, client):
|
def test_requires_auth(self, client, onb_platform):
|
||||||
"""Returns 401 without authentication."""
|
"""Returns 401 without authentication."""
|
||||||
app.dependency_overrides.pop(get_current_store_api, None)
|
app.dependency_overrides.pop(get_current_store_api, None)
|
||||||
response = client.get(
|
app.dependency_overrides[require_platform] = lambda: onb_platform
|
||||||
"/api/v1/store/dashboard/onboarding",
|
try:
|
||||||
)
|
response = client.get(
|
||||||
assert response.status_code == 401
|
"/api/v1/store/dashboard/onboarding",
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.pop(require_platform, None)
|
||||||
|
|||||||
@@ -77,7 +77,8 @@ def trace_platform_resolution(
|
|||||||
))
|
))
|
||||||
|
|
||||||
# ── Step 1b: Referer fallback (storefront API on localhost) ──
|
# ── Step 1b: Referer fallback (storefront API on localhost) ──
|
||||||
host_without_port = host.split(":")[0] if ":" in host else host
|
from middleware.platform_context import _strip_port
|
||||||
|
host_without_port = _strip_port(host)
|
||||||
if (
|
if (
|
||||||
host_without_port in _LOCAL_HOSTS
|
host_without_port in _LOCAL_HOSTS
|
||||||
and path.startswith("/api/v1/storefront/")
|
and path.startswith("/api/v1/storefront/")
|
||||||
@@ -275,6 +276,91 @@ def trace_platform_resolution(
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ── Isolation Audit models ──
|
||||||
|
|
||||||
|
|
||||||
|
class IsolationFinding(BaseModel):
|
||||||
|
check: str
|
||||||
|
check_label: str
|
||||||
|
risk: str
|
||||||
|
resolved_value: str | None = None
|
||||||
|
source: str
|
||||||
|
source_label: str
|
||||||
|
is_explicit: bool
|
||||||
|
note: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class StoreIsolationResult(BaseModel):
|
||||||
|
store_code: str
|
||||||
|
store_name: str
|
||||||
|
merchant_name: str | None = None
|
||||||
|
finding_counts: dict[str, int]
|
||||||
|
findings: list[IsolationFinding]
|
||||||
|
|
||||||
|
|
||||||
|
class IsolationAuditResponse(BaseModel):
|
||||||
|
total_stores: int
|
||||||
|
stores_with_findings: int
|
||||||
|
summary: dict[str, int]
|
||||||
|
results: list[StoreIsolationResult]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/isolation-audit", response_model=IsolationAuditResponse)
|
||||||
|
def isolation_audit(
|
||||||
|
store_code: str | None = Query(None, description="Filter to a single store by store_code"),
|
||||||
|
risk: str | None = Query(None, description="Filter by risk level: critical, high, medium"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: UserContext = Depends(get_current_super_admin_api),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Audit tenant isolation across all stores.
|
||||||
|
|
||||||
|
Scans every active store and reports where configuration values come from,
|
||||||
|
flagging silent inheritance, missing data, and potential data commingling.
|
||||||
|
"""
|
||||||
|
from app.modules.dev_tools.services.isolation_audit_service import (
|
||||||
|
run_isolation_audit,
|
||||||
|
)
|
||||||
|
|
||||||
|
return run_isolation_audit(db, store_code=store_code, risk_filter=risk)
|
||||||
|
|
||||||
|
|
||||||
|
class DomainHealthEntry(BaseModel):
|
||||||
|
domain: str
|
||||||
|
type: str # "subdomain" or "custom_domain"
|
||||||
|
platform_code: str | None = None
|
||||||
|
expected_store: str | None = None
|
||||||
|
resolved_store: str | None = None
|
||||||
|
status: str # "pass" or "fail"
|
||||||
|
note: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class DomainHealthResponse(BaseModel):
|
||||||
|
total: int
|
||||||
|
passed: int
|
||||||
|
failed: int
|
||||||
|
details: list[DomainHealthEntry]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/domain-health", response_model=DomainHealthResponse)
|
||||||
|
def domain_health_check(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: UserContext = Depends(get_current_super_admin_api),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Simulate the middleware resolution pipeline for every active
|
||||||
|
StorePlatform custom subdomain and StoreDomain custom domain.
|
||||||
|
|
||||||
|
Returns a pass/fail report showing whether each domain resolves
|
||||||
|
to the expected store.
|
||||||
|
"""
|
||||||
|
from app.modules.dev_tools.services.domain_health_service import (
|
||||||
|
run_domain_health_check,
|
||||||
|
)
|
||||||
|
|
||||||
|
return run_domain_health_check(db)
|
||||||
|
|
||||||
|
|
||||||
def _sanitize(d: dict | None) -> dict | None:
|
def _sanitize(d: dict | None) -> dict | None:
|
||||||
"""Remove non-serializable objects from dict."""
|
"""Remove non-serializable objects from dict."""
|
||||||
if d is None:
|
if d is None:
|
||||||
|
|||||||
369
app/modules/dev_tools/services/isolation_audit_service.py
Normal file
369
app/modules/dev_tools/services/isolation_audit_service.py
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
# app/modules/dev_tools/services/isolation_audit_service.py
|
||||||
|
"""
|
||||||
|
Tenant Isolation Audit Service
|
||||||
|
|
||||||
|
Scans all stores and reports where configuration values come from —
|
||||||
|
flagging silent inheritance, missing data, and potential data commingling
|
||||||
|
in the multi-tenant system.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def run_isolation_audit(
|
||||||
|
db: Session,
|
||||||
|
store_code: str | None = None,
|
||||||
|
risk_filter: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Audit tenant isolation across all (or one) store(s).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
store_code: Optional filter to a single store
|
||||||
|
risk_filter: Optional 'critical', 'high', or 'medium'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with total_stores, stores_with_findings, summary, results
|
||||||
|
"""
|
||||||
|
from app.modules.billing.models.merchant_subscription import MerchantSubscription
|
||||||
|
from app.modules.tenancy.models import Store
|
||||||
|
from app.modules.tenancy.models.admin import AdminSetting
|
||||||
|
from app.modules.tenancy.models.merchant_domain import MerchantDomain
|
||||||
|
|
||||||
|
# ── Pre-fetch stores with eager-loaded relationships ──
|
||||||
|
query = (
|
||||||
|
db.query(Store)
|
||||||
|
.options(
|
||||||
|
joinedload(Store.merchant),
|
||||||
|
joinedload(Store.store_platforms),
|
||||||
|
joinedload(Store.domains),
|
||||||
|
joinedload(Store.store_theme),
|
||||||
|
)
|
||||||
|
.filter(Store.is_active.is_(True))
|
||||||
|
)
|
||||||
|
if store_code:
|
||||||
|
query = query.filter(Store.store_code == store_code)
|
||||||
|
|
||||||
|
stores = query.all()
|
||||||
|
|
||||||
|
# ── Batch-load MerchantSubscription indexed by (merchant_id, platform_id) ──
|
||||||
|
all_subs = db.query(MerchantSubscription).all()
|
||||||
|
subs_map: dict[tuple[int, int], MerchantSubscription] = {}
|
||||||
|
for sub in all_subs:
|
||||||
|
subs_map[(sub.merchant_id, sub.platform_id)] = sub
|
||||||
|
|
||||||
|
# ── Batch-load MerchantDomain indexed by merchant_id ──
|
||||||
|
all_merchant_domains = db.query(MerchantDomain).all()
|
||||||
|
merchant_domains_map: dict[int, list[MerchantDomain]] = {}
|
||||||
|
for md in all_merchant_domains:
|
||||||
|
merchant_domains_map.setdefault(md.merchant_id, []).append(md)
|
||||||
|
|
||||||
|
# ── Pre-fetch default_storefront_locale from AdminSetting ──
|
||||||
|
default_locale_setting = (
|
||||||
|
db.query(AdminSetting)
|
||||||
|
.filter(AdminSetting.key == "default_storefront_locale")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
default_locale = default_locale_setting.value if default_locale_setting else None
|
||||||
|
|
||||||
|
# ── Run checkers per store ──
|
||||||
|
results = []
|
||||||
|
summary = {"critical": 0, "high": 0, "medium": 0}
|
||||||
|
|
||||||
|
for store in stores:
|
||||||
|
merchant_domains = merchant_domains_map.get(store.merchant_id, [])
|
||||||
|
findings: list[dict] = []
|
||||||
|
|
||||||
|
findings.extend(_check_contact_inheritance(store))
|
||||||
|
findings.extend(_check_domain_resolution(store, merchant_domains))
|
||||||
|
findings.extend(_check_subscription_coverage(store, subs_map))
|
||||||
|
findings.extend(_check_merchant_active(store))
|
||||||
|
findings.extend(_check_merchant_domain_primary(merchant_domains))
|
||||||
|
findings.extend(_check_theme_fallback(store))
|
||||||
|
findings.extend(_check_locale_fallback(store, default_locale))
|
||||||
|
findings.extend(_check_language_config(store))
|
||||||
|
|
||||||
|
# Apply risk filter
|
||||||
|
if risk_filter:
|
||||||
|
findings = [f for f in findings if f["risk"] == risk_filter]
|
||||||
|
|
||||||
|
# Count by risk
|
||||||
|
finding_counts = {"critical": 0, "high": 0, "medium": 0}
|
||||||
|
for f in findings:
|
||||||
|
finding_counts[f["risk"]] = finding_counts.get(f["risk"], 0) + 1
|
||||||
|
summary[f["risk"]] = summary.get(f["risk"], 0) + 1
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"store_code": store.store_code,
|
||||||
|
"store_name": store.name,
|
||||||
|
"merchant_name": store.merchant.name if store.merchant else None,
|
||||||
|
"finding_counts": finding_counts,
|
||||||
|
"findings": findings,
|
||||||
|
})
|
||||||
|
|
||||||
|
stores_with_findings = sum(1 for r in results if r["findings"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_stores": len(results),
|
||||||
|
"stores_with_findings": stores_with_findings,
|
||||||
|
"summary": summary,
|
||||||
|
"results": results,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Checker functions ──
|
||||||
|
|
||||||
|
|
||||||
|
def _finding(
|
||||||
|
check: str,
|
||||||
|
check_label: str,
|
||||||
|
risk: str,
|
||||||
|
resolved_value: str | None,
|
||||||
|
source: str,
|
||||||
|
source_label: str,
|
||||||
|
is_explicit: bool,
|
||||||
|
note: str = "",
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
"check": check,
|
||||||
|
"check_label": check_label,
|
||||||
|
"risk": risk,
|
||||||
|
"resolved_value": resolved_value,
|
||||||
|
"source": source,
|
||||||
|
"source_label": source_label,
|
||||||
|
"is_explicit": is_explicit,
|
||||||
|
"note": note,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_CONTACT_FIELDS = [
|
||||||
|
("contact_email", "Contact Email"),
|
||||||
|
("contact_phone", "Contact Phone"),
|
||||||
|
("website", "Website"),
|
||||||
|
("business_address", "Business Address"),
|
||||||
|
("tax_number", "Tax Number"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _check_contact_inheritance(store) -> list[dict]:
|
||||||
|
"""Check 5 contact fields: store value → merchant fallback → none."""
|
||||||
|
findings = []
|
||||||
|
merchant = store.merchant
|
||||||
|
|
||||||
|
for field, label in _CONTACT_FIELDS:
|
||||||
|
store_val = getattr(store, field, None)
|
||||||
|
merchant_val = getattr(merchant, field, None) if merchant else None
|
||||||
|
|
||||||
|
if store_val:
|
||||||
|
# Explicitly set on store — no finding
|
||||||
|
continue
|
||||||
|
if merchant_val:
|
||||||
|
findings.append(_finding(
|
||||||
|
check=field,
|
||||||
|
check_label=label,
|
||||||
|
risk="critical",
|
||||||
|
resolved_value=str(merchant_val),
|
||||||
|
source="merchant",
|
||||||
|
source_label=f"Inherited from merchant '{merchant.name}'",
|
||||||
|
is_explicit=False,
|
||||||
|
note="Store has no own value; silently falls back to merchant",
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
findings.append(_finding(
|
||||||
|
check=field,
|
||||||
|
check_label=label,
|
||||||
|
risk="critical",
|
||||||
|
resolved_value=None,
|
||||||
|
source="none",
|
||||||
|
source_label="No value anywhere",
|
||||||
|
is_explicit=False,
|
||||||
|
note="Neither store nor merchant has this value set",
|
||||||
|
))
|
||||||
|
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def _check_domain_resolution(store, merchant_domains: list) -> list[dict]:
|
||||||
|
"""Check effective_domain: StoreDomain → MerchantDomain → subdomain fallback."""
|
||||||
|
findings = []
|
||||||
|
|
||||||
|
# Check if store has its own active domain
|
||||||
|
store_domains = [d for d in (store.domains or []) if d.is_active]
|
||||||
|
active_merchant_domains = [d for d in merchant_domains if d.is_active]
|
||||||
|
|
||||||
|
if store_domains:
|
||||||
|
# Store has its own domain — explicit, no finding
|
||||||
|
return findings
|
||||||
|
|
||||||
|
if active_merchant_domains:
|
||||||
|
primary = next((d for d in active_merchant_domains if d.is_primary), None)
|
||||||
|
domain_val = primary.domain if primary else active_merchant_domains[0].domain
|
||||||
|
findings.append(_finding(
|
||||||
|
check="effective_domain",
|
||||||
|
check_label="Effective Domain",
|
||||||
|
risk="critical",
|
||||||
|
resolved_value=domain_val,
|
||||||
|
source="merchant",
|
||||||
|
source_label="Using merchant domain (shared with other stores)",
|
||||||
|
is_explicit=False,
|
||||||
|
note="No store-specific domain; merchant domain may serve wrong store",
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
findings.append(_finding(
|
||||||
|
check="effective_domain",
|
||||||
|
check_label="Effective Domain",
|
||||||
|
risk="critical",
|
||||||
|
resolved_value=f"{store.subdomain}.* (subdomain fallback)",
|
||||||
|
source="hardcoded",
|
||||||
|
source_label="Subdomain fallback only",
|
||||||
|
is_explicit=False,
|
||||||
|
note="No custom domain at store or merchant level",
|
||||||
|
))
|
||||||
|
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def _check_subscription_coverage(store, subs_map: dict) -> list[dict]:
|
||||||
|
"""For each StorePlatform membership, verify a MerchantSubscription exists and is active."""
|
||||||
|
findings = []
|
||||||
|
|
||||||
|
for sp in (store.store_platforms or []):
|
||||||
|
if not sp.is_active:
|
||||||
|
continue
|
||||||
|
|
||||||
|
key = (store.merchant_id, sp.platform_id)
|
||||||
|
sub = subs_map.get(key)
|
||||||
|
|
||||||
|
if not sub:
|
||||||
|
findings.append(_finding(
|
||||||
|
check=f"subscription_{sp.platform_id}",
|
||||||
|
check_label=f"Subscription (platform {sp.platform_id})",
|
||||||
|
risk="critical",
|
||||||
|
resolved_value=None,
|
||||||
|
source="none",
|
||||||
|
source_label="No subscription found",
|
||||||
|
is_explicit=False,
|
||||||
|
note=f"Store active on platform {sp.platform_id} but merchant has no subscription",
|
||||||
|
))
|
||||||
|
elif not sub.is_active:
|
||||||
|
findings.append(_finding(
|
||||||
|
check=f"subscription_{sp.platform_id}",
|
||||||
|
check_label=f"Subscription (platform {sp.platform_id})",
|
||||||
|
risk="critical",
|
||||||
|
resolved_value=sub.status,
|
||||||
|
source="merchant",
|
||||||
|
source_label=f"Subscription exists but status='{sub.status}'",
|
||||||
|
is_explicit=False,
|
||||||
|
note="Store active on platform but subscription is not active",
|
||||||
|
))
|
||||||
|
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def _check_merchant_active(store) -> list[dict]:
|
||||||
|
"""Merchant inactive but store still active."""
|
||||||
|
merchant = store.merchant
|
||||||
|
if not merchant or merchant.is_active:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return [_finding(
|
||||||
|
check="merchant_active",
|
||||||
|
check_label="Merchant Active",
|
||||||
|
risk="high",
|
||||||
|
resolved_value=str(merchant.is_active),
|
||||||
|
source="merchant",
|
||||||
|
source_label=f"Merchant '{merchant.name}' is inactive",
|
||||||
|
is_explicit=True,
|
||||||
|
note="Store is active but its parent merchant is inactive",
|
||||||
|
)]
|
||||||
|
|
||||||
|
|
||||||
|
def _check_merchant_domain_primary(merchant_domains: list) -> list[dict]:
|
||||||
|
"""Multiple MerchantDomains marked primary — non-deterministic resolution."""
|
||||||
|
primary_domains = [d for d in merchant_domains if d.is_primary and d.is_active]
|
||||||
|
|
||||||
|
if len(primary_domains) <= 1:
|
||||||
|
return []
|
||||||
|
|
||||||
|
domain_list = ", ".join(d.domain for d in primary_domains)
|
||||||
|
return [_finding(
|
||||||
|
check="merchant_domain_primary",
|
||||||
|
check_label="Merchant Primary Domain",
|
||||||
|
risk="high",
|
||||||
|
resolved_value=domain_list,
|
||||||
|
source="merchant",
|
||||||
|
source_label=f"{len(primary_domains)} domains marked as primary",
|
||||||
|
is_explicit=True,
|
||||||
|
note="Multiple primary domains cause non-deterministic resolution",
|
||||||
|
)]
|
||||||
|
|
||||||
|
|
||||||
|
def _check_theme_fallback(store) -> list[dict]:
|
||||||
|
"""No active store_theme — falls back to hardcoded default."""
|
||||||
|
theme = store.store_theme
|
||||||
|
|
||||||
|
if theme and theme.is_active:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return [_finding(
|
||||||
|
check="store_theme",
|
||||||
|
check_label="Store Theme",
|
||||||
|
risk="medium",
|
||||||
|
resolved_value="default (hardcoded)",
|
||||||
|
source="hardcoded",
|
||||||
|
source_label="No active theme configured",
|
||||||
|
is_explicit=False,
|
||||||
|
note="Falls back to hardcoded default theme" if not theme else "Theme exists but is inactive",
|
||||||
|
)]
|
||||||
|
|
||||||
|
|
||||||
|
def _check_locale_fallback(store, default_locale: str | None) -> list[dict]:
|
||||||
|
"""storefront_locale is NULL — falls back to platform default or 'fr-LU'."""
|
||||||
|
if store.storefront_locale:
|
||||||
|
return []
|
||||||
|
|
||||||
|
fallback = default_locale or "fr-LU"
|
||||||
|
source = "platform_default" if default_locale else "hardcoded"
|
||||||
|
source_label = (
|
||||||
|
f"Platform default: '{default_locale}'"
|
||||||
|
if default_locale
|
||||||
|
else "Hardcoded fallback 'fr-LU'"
|
||||||
|
)
|
||||||
|
|
||||||
|
return [_finding(
|
||||||
|
check="storefront_locale",
|
||||||
|
check_label="Storefront Locale",
|
||||||
|
risk="medium",
|
||||||
|
resolved_value=fallback,
|
||||||
|
source=source,
|
||||||
|
source_label=source_label,
|
||||||
|
is_explicit=False,
|
||||||
|
note="storefront_locale is NULL; using fallback",
|
||||||
|
)]
|
||||||
|
|
||||||
|
|
||||||
|
_DEFAULT_LANGUAGES = ["fr", "de", "en", "lb"]
|
||||||
|
|
||||||
|
|
||||||
|
def _check_language_config(store) -> list[dict]:
|
||||||
|
"""storefront_languages matches column default — may never have been explicitly set."""
|
||||||
|
languages = store.storefront_languages
|
||||||
|
|
||||||
|
if languages and languages != _DEFAULT_LANGUAGES:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return [_finding(
|
||||||
|
check="storefront_languages",
|
||||||
|
check_label="Storefront Languages",
|
||||||
|
risk="medium",
|
||||||
|
resolved_value=str(languages or _DEFAULT_LANGUAGES),
|
||||||
|
source="hardcoded",
|
||||||
|
source_label="Column default value",
|
||||||
|
is_explicit=False,
|
||||||
|
note="Matches column default — may never have been explicitly configured",
|
||||||
|
)]
|
||||||
@@ -174,6 +174,85 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ─────────────────────────────────────────────────────────── -->
|
||||||
|
<!-- Tool: Domain Health -->
|
||||||
|
<!-- ─────────────────────────────────────────────────────────── -->
|
||||||
|
<div x-show="activeTool === 'domain-health'" x-cloak>
|
||||||
|
<div class="mb-6">
|
||||||
|
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Domain Health</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Simulates the middleware resolution pipeline for every active custom subdomain and custom domain to verify they resolve to the expected store.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Run button -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<button @click="runDomainHealth()" :disabled="domainHealthLoading"
|
||||||
|
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 text-sm font-medium">
|
||||||
|
<span x-show="!domainHealthLoading">Run Health Check</span>
|
||||||
|
<span x-show="domainHealthLoading">Running...</span>
|
||||||
|
</button>
|
||||||
|
<span x-show="domainHealthError" class="ml-3 text-sm text-red-600 dark:text-red-400" x-text="domainHealthError"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary bar -->
|
||||||
|
<template x-if="domainHealthResults">
|
||||||
|
<div class="grid grid-cols-3 gap-4 mb-6">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs p-4 text-center">
|
||||||
|
<div class="text-2xl font-bold text-gray-900 dark:text-white" x-text="domainHealthResults.total"></div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider mt-1">Total Checked</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs p-4 text-center border-t-2 border-green-500">
|
||||||
|
<div class="text-2xl font-bold text-green-600 dark:text-green-400" x-text="domainHealthResults.passed"></div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider mt-1">Passed</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs p-4 text-center border-t-2 border-red-500">
|
||||||
|
<div class="text-2xl font-bold text-red-600 dark:text-red-400" x-text="domainHealthResults.failed"></div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider mt-1">Failed</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Results table -->
|
||||||
|
<template x-if="domainHealthResults && domainHealthResults.details.length > 0">
|
||||||
|
{% call table_wrapper() %}
|
||||||
|
{{ table_header(['Status', 'Domain', 'Type', 'Platform', 'Expected Store', 'Resolved Store', 'Note']) }}
|
||||||
|
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||||
|
<template x-for="entry in domainHealthResults.details" :key="entry.domain">
|
||||||
|
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 text-sm">
|
||||||
|
<td class="px-4 py-2.5">
|
||||||
|
<span class="text-xs px-2 py-0.5 rounded-full font-medium"
|
||||||
|
:class="entry.status === 'pass'
|
||||||
|
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300'
|
||||||
|
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300'"
|
||||||
|
x-text="entry.status"></span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2.5 font-mono text-xs" x-text="entry.domain"></td>
|
||||||
|
<td class="px-4 py-2.5 text-xs">
|
||||||
|
<span class="px-2 py-0.5 rounded-full font-medium"
|
||||||
|
:class="entry.type === 'subdomain'
|
||||||
|
? 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300'
|
||||||
|
: 'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300'"
|
||||||
|
x-text="entry.type"></span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2.5 text-xs font-mono" x-text="entry.platform_code || '—'"></td>
|
||||||
|
<td class="px-4 py-2.5 text-xs font-mono" x-text="entry.expected_store || '—'"></td>
|
||||||
|
<td class="px-4 py-2.5 text-xs font-mono" x-text="entry.resolved_store || '—'"></td>
|
||||||
|
<td class="px-4 py-2.5 text-xs text-gray-500 dark:text-gray-400" x-text="entry.note || '—'"></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
{% endcall %}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="domainHealthResults && domainHealthResults.details.length === 0">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs p-8 text-center">
|
||||||
|
<span x-html="$icon('globe', 'w-12 h-12 mx-auto text-gray-300 dark:text-gray-600')"></span>
|
||||||
|
<p class="mt-3 text-sm text-gray-500 dark:text-gray-400">No custom subdomains or custom domains configured.</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ─────────────────────────────────────────────────────────── -->
|
<!-- ─────────────────────────────────────────────────────────── -->
|
||||||
<!-- Tool: Permissions Audit -->
|
<!-- Tool: Permissions Audit -->
|
||||||
<!-- ─────────────────────────────────────────────────────────── -->
|
<!-- ─────────────────────────────────────────────────────────── -->
|
||||||
@@ -290,6 +369,162 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ─────────────────────────────────────────────────────────── -->
|
||||||
|
<!-- Tool: Tenant Isolation Audit -->
|
||||||
|
<!-- ─────────────────────────────────────────────────────────── -->
|
||||||
|
<div x-show="activeTool === 'tenant-isolation'" x-cloak>
|
||||||
|
<div class="mb-6">
|
||||||
|
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Tenant Isolation Audit</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Scans all stores and reports where configuration values come from — flagging silent inheritance, missing data, and potential data commingling.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Controls -->
|
||||||
|
<div class="mb-6 flex flex-wrap items-end gap-4">
|
||||||
|
<button @click="runIsolationAudit()" :disabled="isolationLoading"
|
||||||
|
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 text-sm font-medium">
|
||||||
|
<span x-show="!isolationLoading">Run Audit</span>
|
||||||
|
<span x-show="isolationLoading">Running...</span>
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Filter by store</label>
|
||||||
|
<input type="text" x-model="isolationFilterStore" placeholder="Store code or name..."
|
||||||
|
class="px-3 py-1.5 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600 dark:text-white w-48">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Risk level</label>
|
||||||
|
<select x-model="isolationFilterRisk"
|
||||||
|
class="px-3 py-1.5 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600 dark:text-white">
|
||||||
|
<option value="all">All</option>
|
||||||
|
<option value="critical">Critical</option>
|
||||||
|
<option value="high">High</option>
|
||||||
|
<option value="medium">Medium</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 cursor-pointer">
|
||||||
|
<input type="checkbox" x-model="isolationShowClean" class="rounded border-gray-300 dark:border-gray-600">
|
||||||
|
Show clean stores
|
||||||
|
</label>
|
||||||
|
<span x-show="isolationError" class="text-sm text-red-600 dark:text-red-400" x-text="isolationError"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary cards -->
|
||||||
|
<template x-if="isolationResults">
|
||||||
|
<div class="grid grid-cols-3 gap-4 mb-6">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs p-4 text-center border-t-2 border-red-500">
|
||||||
|
<div class="text-2xl font-bold text-red-600 dark:text-red-400" x-text="isolationResults.summary.critical"></div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider mt-1">Critical</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs p-4 text-center border-t-2 border-amber-500">
|
||||||
|
<div class="text-2xl font-bold text-amber-600 dark:text-amber-400" x-text="isolationResults.summary.high"></div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider mt-1">High</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs p-4 text-center border-t-2 border-blue-500">
|
||||||
|
<div class="text-2xl font-bold text-blue-600 dark:text-blue-400" x-text="isolationResults.summary.medium"></div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider mt-1">Medium</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Info bar -->
|
||||||
|
<template x-if="isolationResults">
|
||||||
|
<div class="mb-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Showing <span class="font-medium text-gray-900 dark:text-white" x-text="filteredIsolationStores.length"></span>
|
||||||
|
of <span x-text="isolationResults.total_stores"></span> stores
|
||||||
|
(<span x-text="isolationResults.stores_with_findings"></span> with findings)
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Per-store collapsible cards -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<template x-for="store in filteredIsolationStores" :key="store.store_code">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<!-- Card header -->
|
||||||
|
<button @click="store._expanded = !store._expanded"
|
||||||
|
class="w-full flex items-center justify-between px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="font-mono text-sm font-semibold text-gray-900 dark:text-white" x-text="store.store_code"></span>
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400" x-text="store.store_name"></span>
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500" x-text="store.merchant_name ? '(' + store.merchant_name + ')' : ''"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<template x-if="store.finding_counts.critical > 0">
|
||||||
|
<span class="text-xs px-2 py-0.5 rounded-full font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300"
|
||||||
|
x-text="store.finding_counts.critical + ' critical'"></span>
|
||||||
|
</template>
|
||||||
|
<template x-if="store.finding_counts.high > 0">
|
||||||
|
<span class="text-xs px-2 py-0.5 rounded-full font-medium bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-300"
|
||||||
|
x-text="store.finding_counts.high + ' high'"></span>
|
||||||
|
</template>
|
||||||
|
<template x-if="store.finding_counts.medium > 0">
|
||||||
|
<span class="text-xs px-2 py-0.5 rounded-full font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300"
|
||||||
|
x-text="store.finding_counts.medium + ' medium'"></span>
|
||||||
|
</template>
|
||||||
|
<template x-if="store.findings.length === 0">
|
||||||
|
<span class="text-xs px-2 py-0.5 rounded-full font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300">clean</span>
|
||||||
|
</template>
|
||||||
|
<span class="text-[10px] font-mono text-gray-400" x-text="store._expanded ? '−' : '+'"></span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Findings table (collapsed by default) -->
|
||||||
|
<div x-show="store._expanded" x-cloak class="border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<template x-if="visibleFindings(store).length > 0">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-gray-50 dark:bg-gray-900 text-xs text-gray-500 dark:text-gray-400 uppercase">
|
||||||
|
<th class="px-4 py-2 text-left">Risk</th>
|
||||||
|
<th class="px-4 py-2 text-left">Check</th>
|
||||||
|
<th class="px-4 py-2 text-left">Value</th>
|
||||||
|
<th class="px-4 py-2 text-left">Source</th>
|
||||||
|
<th class="px-4 py-2 text-left">Explicit?</th>
|
||||||
|
<th class="px-4 py-2 text-left">Note</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
<template x-for="f in visibleFindings(store)" :key="f.check">
|
||||||
|
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
<span class="text-xs px-2 py-0.5 rounded-full font-medium"
|
||||||
|
:class="{
|
||||||
|
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300': f.risk === 'critical',
|
||||||
|
'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-300': f.risk === 'high',
|
||||||
|
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300': f.risk === 'medium'
|
||||||
|
}" x-text="f.risk"></span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 text-xs font-medium" x-text="f.check_label"></td>
|
||||||
|
<td class="px-4 py-2 text-xs font-mono" x-text="f.resolved_value || '—'"></td>
|
||||||
|
<td class="px-4 py-2 text-xs" x-text="f.source_label"></td>
|
||||||
|
<td class="px-4 py-2 text-xs">
|
||||||
|
<span :class="f.is_explicit ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'"
|
||||||
|
x-text="f.is_explicit ? 'Yes' : 'No'"></span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 text-xs text-gray-500 dark:text-gray-400" x-text="f.note || '—'"></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</template>
|
||||||
|
<template x-if="visibleFindings(store).length === 0">
|
||||||
|
<div class="px-4 py-6 text-center text-sm text-gray-400 dark:text-gray-500">
|
||||||
|
No findings at this risk level.
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<template x-if="isolationResults && filteredIsolationStores.length === 0 && !isolationLoading">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs p-8 text-center">
|
||||||
|
<span x-html="$icon('lock-closed', 'w-12 h-12 mx-auto text-gray-300 dark:text-gray-600')"></span>
|
||||||
|
<p class="mt-3 text-sm text-gray-500 dark:text-gray-400">No stores match your filters. Try adjusting the risk level or enabling "Show clean stores".</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -304,12 +539,13 @@ function platformDebug() {
|
|||||||
|
|
||||||
// ── Sidebar / tool navigation ──
|
// ── Sidebar / tool navigation ──
|
||||||
activeTool: 'platform-trace',
|
activeTool: 'platform-trace',
|
||||||
expandedCategories: ['Resolution', 'Security'],
|
expandedCategories: ['Resolution', 'Security', 'Data Integrity'],
|
||||||
toolGroups: [
|
toolGroups: [
|
||||||
{
|
{
|
||||||
category: 'Resolution',
|
category: 'Resolution',
|
||||||
items: [
|
items: [
|
||||||
{ id: 'platform-trace', label: 'Platform Trace', icon: 'search' },
|
{ id: 'platform-trace', label: 'Platform Trace', icon: 'search' },
|
||||||
|
{ id: 'domain-health', label: 'Domain Health', icon: 'globe' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -318,6 +554,12 @@ function platformDebug() {
|
|||||||
{ id: 'permissions-audit', label: 'Permissions Audit', icon: 'shield-check' },
|
{ id: 'permissions-audit', label: 'Permissions Audit', icon: 'shield-check' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
category: 'Data Integrity',
|
||||||
|
items: [
|
||||||
|
{ id: 'tenant-isolation', label: 'Tenant Isolation', icon: 'lock-closed' },
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
toggleCategory(cat) {
|
toggleCategory(cat) {
|
||||||
@@ -659,6 +901,23 @@ function platformDebug() {
|
|||||||
return html;
|
return html;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Domain Health ──
|
||||||
|
domainHealthResults: null,
|
||||||
|
domainHealthLoading: false,
|
||||||
|
domainHealthError: '',
|
||||||
|
|
||||||
|
async runDomainHealth() {
|
||||||
|
this.domainHealthLoading = true;
|
||||||
|
this.domainHealthError = '';
|
||||||
|
try {
|
||||||
|
const resp = await apiClient.get('/admin/debug/domain-health');
|
||||||
|
this.domainHealthResults = resp;
|
||||||
|
} catch (e) {
|
||||||
|
this.domainHealthError = e.message || 'Failed to run health check';
|
||||||
|
}
|
||||||
|
this.domainHealthLoading = false;
|
||||||
|
},
|
||||||
|
|
||||||
// ── Permissions Audit ──
|
// ── Permissions Audit ──
|
||||||
auditRoutes: [],
|
auditRoutes: [],
|
||||||
auditSummary: null,
|
auditSummary: null,
|
||||||
@@ -687,6 +946,51 @@ function platformDebug() {
|
|||||||
}
|
}
|
||||||
this.auditLoading = false;
|
this.auditLoading = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Tenant Isolation Audit ──
|
||||||
|
isolationResults: null,
|
||||||
|
isolationLoading: false,
|
||||||
|
isolationError: '',
|
||||||
|
isolationFilterStore: '',
|
||||||
|
isolationFilterRisk: 'all',
|
||||||
|
isolationShowClean: false,
|
||||||
|
|
||||||
|
get filteredIsolationStores() {
|
||||||
|
if (!this.isolationResults) return [];
|
||||||
|
return this.isolationResults.results.filter(store => {
|
||||||
|
// Text filter by store_code or store_name
|
||||||
|
if (this.isolationFilterStore) {
|
||||||
|
const q = this.isolationFilterStore.toLowerCase();
|
||||||
|
if (!store.store_code.toLowerCase().includes(q) &&
|
||||||
|
!store.store_name.toLowerCase().includes(q)) return false;
|
||||||
|
}
|
||||||
|
// Risk filter — count findings at this risk level
|
||||||
|
if (this.isolationFilterRisk !== 'all') {
|
||||||
|
const hasRisk = store.findings.some(f => f.risk === this.isolationFilterRisk);
|
||||||
|
if (!hasRisk) return false;
|
||||||
|
}
|
||||||
|
// Hide clean stores unless toggled
|
||||||
|
if (!this.isolationShowClean && store.findings.length === 0) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
visibleFindings(store) {
|
||||||
|
if (this.isolationFilterRisk === 'all') return store.findings;
|
||||||
|
return store.findings.filter(f => f.risk === this.isolationFilterRisk);
|
||||||
|
},
|
||||||
|
|
||||||
|
async runIsolationAudit() {
|
||||||
|
this.isolationLoading = true;
|
||||||
|
this.isolationError = '';
|
||||||
|
try {
|
||||||
|
const resp = await apiClient.get('/admin/debug/isolation-audit');
|
||||||
|
this.isolationResults = resp;
|
||||||
|
} catch (e) {
|
||||||
|
this.isolationError = e.message || 'Failed to run audit';
|
||||||
|
}
|
||||||
|
this.isolationLoading = false;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user