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`.
|
with the same 2 GB bound; log `/tmp/archy-fleet-history-typecheck.log`.
|
||||||
Completed logs and browser fixture sources are archived at
|
Completed logs and browser fixture sources are archived at
|
||||||
`~/.local/state/archipelago/release-qualification/fleet-acceptance-20261007/`.
|
`~/.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)"
|
:class="alertSeverityDot(alert.rule)"
|
||||||
></span>
|
></span>
|
||||||
<div class="flex-1 min-w-0">
|
<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="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>
|
<span class="text-xs text-white/40">{{ alertTypeLabel(alert.rule) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-white/80">{{ alert.message }}</p>
|
<p class="text-sm text-white/80">{{ alert.message }}</p>
|
||||||
@@ -33,7 +34,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { type FleetAlert, alertSeverityDot, alertTypeLabel, formatTimestamp } from './useFleetData'
|
import { type FleetAlert, alertSeverityDot, alertTypeLabel, formatTimestamp, fleetSourceLabel } from './useFleetData'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
alerts: FleetAlert[]
|
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 } }))
|
const wrapper = mount(defineComponent({ setup() { fleet = useFleetData(); return () => null } }))
|
||||||
try {
|
try {
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(fleet.fleetAlerts.value).toEqual([alert])
|
expect(fleet.fleetAlerts.value).toMatchObject([alert])
|
||||||
expect(fleet.alertsErrorMessage.value).toContain('last received alerts')
|
expect(fleet.alertsErrorMessage.value).toContain('last received alerts')
|
||||||
const panel = mount(FleetAlerts, { props: { alerts: fleet.fleetAlerts.value, alertsLoading: false, errorMessage: fleet.alertsErrorMessage.value } })
|
const panel = mount(FleetAlerts, { props: { alerts: fleet.fleetAlerts.value, alertsLoading: false, errorMessage: fleet.alertsErrorMessage.value } })
|
||||||
expect(panel.text()).toContain('Earlier alert')
|
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')
|
expect(fleet.nodeHistory.value[0]?.timestamp).toBe('legacy-available')
|
||||||
} finally { wrapper.unmount() }
|
} 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 {
|
export interface FleetAlert {
|
||||||
|
source?: string
|
||||||
|
trust_level?: string
|
||||||
|
identity_authenticated?: boolean
|
||||||
node_id: string
|
node_id: string
|
||||||
rule: string
|
rule: string
|
||||||
message: string
|
message: string
|
||||||
@@ -140,7 +143,7 @@ export function fleetNodeDisplayName(node: FleetNode): string {
|
|||||||
return name || node.node_id.slice(0, 8)
|
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 === 'collector') return 'Collector · identity unverified'
|
||||||
if (node.source === 'federation' && node.trust_level === 'trusted' && node.identity_authenticated === true) return 'Trusted federation'
|
if (node.source === 'federation' && node.trust_level === 'trusted' && node.identity_authenticated === true) return 'Trusted federation'
|
||||||
return 'Source unverified'
|
return 'Source unverified'
|
||||||
@@ -237,6 +240,19 @@ type FleetCache = {
|
|||||||
|
|
||||||
const FLEET_CACHE_KEY = 'archipelago.fleet.cache.v2'
|
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> {
|
function readFleetCache(): Partial<FleetCache> {
|
||||||
if (typeof window === 'undefined') return {}
|
if (typeof window === 'undefined') return {}
|
||||||
try {
|
try {
|
||||||
@@ -244,8 +260,8 @@ function readFleetCache(): Partial<FleetCache> {
|
|||||||
if (!raw) return {}
|
if (!raw) return {}
|
||||||
const parsed = JSON.parse(raw) as Partial<FleetCache>
|
const parsed = JSON.parse(raw) as Partial<FleetCache>
|
||||||
return {
|
return {
|
||||||
nodes: Array.isArray(parsed.nodes) ? parsed.nodes.map(normalizeFleetNode) : [],
|
nodes: Array.isArray(parsed.nodes) ? parsed.nodes.map(normalizeFleetNode).map(withoutCachedProvenance) : [],
|
||||||
fleetAlerts: Array.isArray(parsed.fleetAlerts) ? parsed.fleetAlerts : [],
|
fleetAlerts: Array.isArray(parsed.fleetAlerts) ? parsed.fleetAlerts.filter(isFleetAlert).map(withoutCachedProvenance) : [],
|
||||||
lastRefreshed: typeof parsed.lastRefreshed === 'string' ? parsed.lastRefreshed : '',
|
lastRefreshed: typeof parsed.lastRefreshed === 'string' ? parsed.lastRefreshed : '',
|
||||||
selectedNodeId: typeof parsed.selectedNodeId === 'string' ? parsed.selectedNodeId : null,
|
selectedNodeId: typeof parsed.selectedNodeId === 'string' ? parsed.selectedNodeId : null,
|
||||||
sortBy: parsed.sortBy === 'last-seen' || parsed.sortBy === 'name' ? parsed.sortBy : 'status',
|
sortBy: parsed.sortBy === 'last-seen' || parsed.sortBy === 'name' ? parsed.sortBy : 'status',
|
||||||
@@ -393,9 +409,7 @@ export function useFleetData() {
|
|||||||
method: 'telemetry.fleet-alerts',
|
method: 'telemetry.fleet-alerts',
|
||||||
})
|
})
|
||||||
if (disposed || request !== alertsRequest) return
|
if (disposed || request !== alertsRequest) return
|
||||||
if (!Array.isArray(data?.alerts) || data.alerts.some(alert => !alert ||
|
if (!Array.isArray(data?.alerts) || !data.alerts.every(isFleetAlert)) {
|
||||||
typeof alert.node_id !== 'string' || typeof alert.rule !== 'string' ||
|
|
||||||
typeof alert.message !== 'string' || typeof alert.timestamp !== 'string')) {
|
|
||||||
throw new Error('Invalid fleet alerts response')
|
throw new Error('Invalid fleet alerts response')
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user