Label Fleet alert provenance and distrust cached identity claims
This commit is contained in:
@@ -165,3 +165,25 @@ The exact app-project typecheck also passed again after the history follow-up,
|
||||
with the same 2 GB bound; log `/tmp/archy-fleet-history-typecheck.log`.
|
||||
Completed logs and browser fixture sources are archived at
|
||||
`~/.local/state/archipelago/release-qualification/fleet-acceptance-20261007/`.
|
||||
|
||||
## Alert provenance and cache migration follow-up
|
||||
|
||||
Alert rows now reuse the same explicit provenance label as node cards, with a
|
||||
wrapping header for narrow displays. Collector alerts do not inherit trust from
|
||||
the claimed/truncated node ID. Existing cached node and alert provenance is
|
||||
stripped on read: historical unsigned collector responses could supply those
|
||||
fields. Content, metrics and staleness remain available; only a fresh corrected
|
||||
backend response may restore authenticated labels. Invalid cached alert rows
|
||||
are discarded. Regression checks cover cached claimed trust and a later fresh
|
||||
response, as well as actual alert-panel labels. This does not authenticate a
|
||||
collector identity or replace the required backend gate.
|
||||
|
||||
The unchanged combined baseline passed **1,459 tests in 177 files** from a frozen
|
||||
771-input copy, all hashes unchanged, at
|
||||
`~/.local/state/archipelago/release-qualification/fleet-combined-ui-20261007/`.
|
||||
The subsequent three-file alert/cache delta passed **31 focused tests in five
|
||||
files** and the exact app-project typecheck (2 GB cap, exit 0), logs
|
||||
`/tmp/archy-fleet-alert-cache-corrected.log` and
|
||||
`/tmp/archy-fleet-alert-cache-typecheck.log`. This is full baseline plus focused
|
||||
delta evidence, not a full-suite rerun after the delta. No production deployment
|
||||
or additional artwork investigation occurred.
|
||||
|
||||
@@ -20,8 +20,9 @@
|
||||
:class="alertSeverityDot(alert.rule)"
|
||||
></span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-0.5">
|
||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-1 mb-0.5">
|
||||
<span class="fleet-node-badge">{{ alert.node_id.slice(0, 8) }}</span>
|
||||
<span class="text-xs text-white/50">{{ fleetSourceLabel(alert) }}</span>
|
||||
<span class="text-xs text-white/40">{{ alertTypeLabel(alert.rule) }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-white/80">{{ alert.message }}</p>
|
||||
@@ -33,7 +34,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type FleetAlert, alertSeverityDot, alertTypeLabel, formatTimestamp } from './useFleetData'
|
||||
import { type FleetAlert, alertSeverityDot, alertTypeLabel, formatTimestamp, fleetSourceLabel } from './useFleetData'
|
||||
|
||||
defineProps<{
|
||||
alerts: FleetAlert[]
|
||||
|
||||
@@ -41,7 +41,7 @@ it('retains previous alerts on malformed refresh without calling them current',
|
||||
const wrapper = mount(defineComponent({ setup() { fleet = useFleetData(); return () => null } }))
|
||||
try {
|
||||
await flushPromises()
|
||||
expect(fleet.fleetAlerts.value).toEqual([alert])
|
||||
expect(fleet.fleetAlerts.value).toMatchObject([alert])
|
||||
expect(fleet.alertsErrorMessage.value).toContain('last received alerts')
|
||||
const panel = mount(FleetAlerts, { props: { alerts: fleet.fleetAlerts.value, alertsLoading: false, errorMessage: fleet.alertsErrorMessage.value } })
|
||||
expect(panel.text()).toContain('Earlier alert')
|
||||
@@ -111,3 +111,46 @@ it('reports explicitly unavailable history and clears the message when changing
|
||||
expect(fleet.nodeHistory.value[0]?.timestamp).toBe('legacy-available')
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
|
||||
|
||||
it('labels alert provenance without treating an unsigned collector as a trusted node', () => {
|
||||
const common = { rule: 'cpu_high', message: 'Fixture alert', timestamp: '2026-10-07T12:00:00Z' }
|
||||
const panel = mount(FleetAlerts, { props: { alertsLoading: false, alerts: [
|
||||
{ ...common, node_id: 'collector', source: 'collector', trust_level: 'unverified', identity_authenticated: false },
|
||||
{ ...common, node_id: 'trusted', source: 'federation', trust_level: 'trusted', identity_authenticated: true },
|
||||
{ ...common, node_id: 'legacy', source: 'federation', trust_level: 'trusted' },
|
||||
] } })
|
||||
try {
|
||||
const rows = panel.findAll('.fleet-node-badge').map(badge => badge.element.parentElement!.textContent)
|
||||
expect(rows[0]).toContain('Collector · identity unverified')
|
||||
expect(rows[1]).toContain('Trusted federation')
|
||||
expect(rows[2]).toContain('Source unverified')
|
||||
expect(rows[2]).not.toContain('Trusted federation')
|
||||
} finally { panel.unmount() }
|
||||
})
|
||||
|
||||
|
||||
it('does not authenticate cached report claims before a fresh server response', async () => {
|
||||
const provenance = { source: 'federation', trust_level: 'trusted', identity_authenticated: true }
|
||||
sessionStorage.setItem('archipelago.fleet.cache.v2', JSON.stringify({
|
||||
nodes: [{ node_id: 'cached-node', cpu_pct: 0, ...provenance }],
|
||||
fleetAlerts: [null, { node_id: 'claimed-id', rule: 'cpu_high', message: 'Saved alert', timestamp: '', ...provenance }],
|
||||
}))
|
||||
vi.mocked(rpcClient.call).mockRejectedValue(new Error('Unavailable'))
|
||||
let fleet!: ReturnType<typeof useFleetData>
|
||||
const wrapper = mount(defineComponent({ setup() { fleet = useFleetData(); return () => null } }))
|
||||
try {
|
||||
await flushPromises()
|
||||
expect(fleet.nodes.value[0]).toMatchObject({ cpu_pct: 0, source: 'unknown', trust_level: 'unverified', identity_authenticated: false })
|
||||
expect(fleet.fleetAlerts.value).toHaveLength(1)
|
||||
expect(fleet.fleetAlerts.value[0]).toMatchObject({ message: 'Saved alert', source: 'unknown', trust_level: 'unverified', identity_authenticated: false })
|
||||
const panel = mount(FleetAlerts, { props: { alerts: fleet.fleetAlerts.value, alertsLoading: false } })
|
||||
expect(panel.text()).toContain('Source unverified')
|
||||
expect(panel.text()).not.toContain('Trusted federation')
|
||||
panel.unmount()
|
||||
vi.mocked(rpcClient.call).mockResolvedValue({ nodes: [{ node_id: 'cached-node', ...provenance }], alerts: [{ node_id: 'claimed-id', rule: 'cpu_high', message: 'Fresh alert', timestamp: '', ...provenance }] })
|
||||
await fleet.refreshAll()
|
||||
expect(fleet.nodes.value[0]?.identity_authenticated).toBe(true)
|
||||
expect(fleet.fleetAlerts.value[0]?.identity_authenticated).toBe(true)
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
|
||||
@@ -29,6 +29,9 @@ export interface FleetNode {
|
||||
}
|
||||
|
||||
export interface FleetAlert {
|
||||
source?: string
|
||||
trust_level?: string
|
||||
identity_authenticated?: boolean
|
||||
node_id: string
|
||||
rule: string
|
||||
message: string
|
||||
@@ -140,7 +143,7 @@ export function fleetNodeDisplayName(node: FleetNode): string {
|
||||
return name || node.node_id.slice(0, 8)
|
||||
}
|
||||
|
||||
export function fleetSourceLabel(node: FleetNode): string {
|
||||
export function fleetSourceLabel(node: Pick<FleetNode, 'source' | 'trust_level' | 'identity_authenticated'>): string {
|
||||
if (node.source === 'collector') return 'Collector · identity unverified'
|
||||
if (node.source === 'federation' && node.trust_level === 'trusted' && node.identity_authenticated === true) return 'Trusted federation'
|
||||
return 'Source unverified'
|
||||
@@ -237,6 +240,19 @@ type FleetCache = {
|
||||
|
||||
const FLEET_CACHE_KEY = 'archipelago.fleet.cache.v2'
|
||||
|
||||
function isFleetAlert(alert: unknown): alert is FleetAlert {
|
||||
if (!alert || typeof alert !== 'object') return false
|
||||
const value = alert as Partial<FleetAlert>
|
||||
return typeof value.node_id === 'string' && typeof value.rule === 'string' &&
|
||||
typeof value.message === 'string' && typeof value.timestamp === 'string'
|
||||
}
|
||||
|
||||
function withoutCachedProvenance<T extends object>(report: T) {
|
||||
// Historical collector payloads could claim provenance. Only a fresh
|
||||
// corrected server response may assign authenticated identity labels.
|
||||
return { ...report, source: 'unknown', trust_level: 'unverified', identity_authenticated: false }
|
||||
}
|
||||
|
||||
function readFleetCache(): Partial<FleetCache> {
|
||||
if (typeof window === 'undefined') return {}
|
||||
try {
|
||||
@@ -244,8 +260,8 @@ function readFleetCache(): Partial<FleetCache> {
|
||||
if (!raw) return {}
|
||||
const parsed = JSON.parse(raw) as Partial<FleetCache>
|
||||
return {
|
||||
nodes: Array.isArray(parsed.nodes) ? parsed.nodes.map(normalizeFleetNode) : [],
|
||||
fleetAlerts: Array.isArray(parsed.fleetAlerts) ? parsed.fleetAlerts : [],
|
||||
nodes: Array.isArray(parsed.nodes) ? parsed.nodes.map(normalizeFleetNode).map(withoutCachedProvenance) : [],
|
||||
fleetAlerts: Array.isArray(parsed.fleetAlerts) ? parsed.fleetAlerts.filter(isFleetAlert).map(withoutCachedProvenance) : [],
|
||||
lastRefreshed: typeof parsed.lastRefreshed === 'string' ? parsed.lastRefreshed : '',
|
||||
selectedNodeId: typeof parsed.selectedNodeId === 'string' ? parsed.selectedNodeId : null,
|
||||
sortBy: parsed.sortBy === 'last-seen' || parsed.sortBy === 'name' ? parsed.sortBy : 'status',
|
||||
@@ -393,9 +409,7 @@ export function useFleetData() {
|
||||
method: 'telemetry.fleet-alerts',
|
||||
})
|
||||
if (disposed || request !== alertsRequest) return
|
||||
if (!Array.isArray(data?.alerts) || data.alerts.some(alert => !alert ||
|
||||
typeof alert.node_id !== 'string' || typeof alert.rule !== 'string' ||
|
||||
typeof alert.message !== 'string' || typeof alert.timestamp !== 'string')) {
|
||||
if (!Array.isArray(data?.alerts) || !data.alerts.every(isFleetAlert)) {
|
||||
throw new Error('Invalid fleet alerts response')
|
||||
}
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user