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:
@@ -174,6 +174,85 @@
|
||||
</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 -->
|
||||
<!-- ─────────────────────────────────────────────────────────── -->
|
||||
@@ -290,6 +369,162 @@
|
||||
</template>
|
||||
</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>
|
||||
|
||||
@@ -304,12 +539,13 @@ function platformDebug() {
|
||||
|
||||
// ── Sidebar / tool navigation ──
|
||||
activeTool: 'platform-trace',
|
||||
expandedCategories: ['Resolution', 'Security'],
|
||||
expandedCategories: ['Resolution', 'Security', 'Data Integrity'],
|
||||
toolGroups: [
|
||||
{
|
||||
category: 'Resolution',
|
||||
items: [
|
||||
{ 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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Data Integrity',
|
||||
items: [
|
||||
{ id: 'tenant-isolation', label: 'Tenant Isolation', icon: 'lock-closed' },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
toggleCategory(cat) {
|
||||
@@ -659,6 +901,23 @@ function platformDebug() {
|
||||
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 ──
|
||||
auditRoutes: [],
|
||||
auditSummary: null,
|
||||
@@ -687,6 +946,51 @@ function platformDebug() {
|
||||
}
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user