Let a site close the legacy collector routes, once it can afford to

Turning "Shared key allowed" off refused the key on the endpoints plugin and
left four core collector routes open to it. Anything still holding the old key
could keep rewriting logged-in user, last boot time, serial number and installed
app inventory for ANY hostname, while the admin believed the shared key was off.
Data integrity only - no command execution, no credential issuance - but it is a
hole in a boundary the product advertises as closed.

It cannot simply be closed. Both GE-Enforce reporters still authenticate with
that key, so refusing it fleet-wide would stop asset and printer reporting
everywhere, and on an air-gapped site mid-migration that is the wrong order of
operations. collector.legacysharedkeyrefused therefore DEFAULTS TO TODAY'S
BEHAVIOUR, and its description names exactly what turning it on breaks and when
it should be turned on: after the reporters move to per-PC credentials, not
before.

A legacy route used with the shared key now logs a warning, so a site can find
out who still depends on the key before flipping anything. On a stock Windows
deployment that warning may name the proxy rather than the caller; the token
prefix in the same line is the identifier to go by.

The switch is seeded with the core defaults and has a control on the
authentication settings page. Undeclared, it was a row that did not exist, and
this UI has no generic key/value editor, so seeding alone would have rendered
nothing for an operator to click.

Two contract declarations corrected while in here:

notifications.alertscope was declared db.JSON in the model while the migration
that creates it ships TEXT, and those migrations state the constraint in their
own docstrings: no JSON columns. The model now matches the shipped column rather
than the schema being bent to the model.

usb and printedparts both import a name that arrived in contract 0.35.0 while
declaring core_version floors of 0.1.0 and 0.16.0, so the loader would have
accepted them on a core that cannot satisfy them - an ImportError at blueprint
registration instead of a refusal with a reason. Both floors now say what the
plugin actually needs.
This commit is contained in:
cproudlock
2026-09-18 13:30:40 -04:00
parent 7ac1ee3077
commit 2f98188feb
10 changed files with 850 additions and 16 deletions

View File

@@ -133,6 +133,59 @@
</template>
</div>
<!-- Collector API credentials. This page is where a site manages who
may authenticate, and the shared collector key is a credential like
any other - it just belongs to scripts rather than people. It is
rendered here because there is no generic key/value settings editor
in this UI: without a control the seeded row can only be flipped by
editing the database by hand, which on an air-gapped prod box means
it is not a setting at all. -->
<div class="setting-group">
<h3>Collector API</h3>
<p class="setting-description">
The four legacy collector routes (<code>/api/collector/pc</code>,
<code>/apps</code>, <code>/heartbeat</code>, <code>/bulk</code>) name
the PC they write in the request body, so the shared collector key is
an any-host write key on them: whoever holds it can rewrite the
logged-in user, last boot time, serial number and installed apps of
any hostname. A collector-scoped managed token is unaffected.
</p>
<div class="setting-row">
<label class="toggle-label">
<span>Refuse the shared key on legacy collector routes</span>
<button
class="toggle-btn"
:class="{ active: legacySharedKeyRefused }"
@click="toggleLegacySharedKey"
:disabled="legacySharedKeySaving"
>
<span class="toggle-slider"></span>
</button>
</label>
<small class="input-hint toggle-hint">
Off by default, which is what every site does today. Turning it on
stops <code>Report-AssetToShopDB.ps1</code> and
<code>Report-PrintersToShopDB.ps1</code> reporting through those
routes until they are moved to per-PC credentials, along with
anything else still calling them with the shared key. Every
shared-key call is logged as "Legacy collector route used with the
shared key" whether this is on or off - read those warnings for a
while first, or turning it on is a guess. On the shipped IIS deploy
that line names the route but not the caller: the X-Forwarded-For
rewrite rule in web.config ships disabled, so every request reaches
the app from 127.0.0.1. Count the routes, not the addresses, unless
that rule is on. The two GE-Enforce
reporters that post to <code>/api/collector/computers</code> and
<code>/api/collector/printers</code> are not affected. This should
follow the move to per-PC credentials, not lead it.
</small>
</div>
<div v-if="legacySharedKeyError" class="error-message">
{{ legacySharedKeyError }}
</div>
</div>
<div class="setting-group">
<h3>User Management</h3>
<p class="setting-description">
@@ -151,14 +204,66 @@
</template>
<script setup>
import { onMounted, computed } from 'vue'
import { onMounted, computed, ref } from 'vue'
import { useSystemSettings } from '../../composables/systemSettings'
import { settingsApi } from '../../api'
import { apiError } from '../../utils/apiError'
const {
settings, saving, error, success,
loadSettings, saveSetting, toggleSetting,
} = useSystemSettings()
// collector.legacysharedkeyrefused is read and written here directly rather
// than through useSystemSettings: that composable only keeps keys listed in
// its own reactive map, and this one is core-owned but belongs to no page it
// already serves. Default false = today's behaviour, so anything but an
// explicit true renders off.
const LEGACY_SHARED_KEY_SETTING = 'collector.legacysharedkeyrefused'
const legacySharedKeyRefused = ref(false)
const legacySharedKeySaving = ref(false)
const legacySharedKeyError = ref('')
function isTruthySetting(value) {
// A row seeded boolean comes back as a real true; one created by hand
// through the settings API before the key was declared can be the string.
return value === true || value === 'true'
}
async function loadLegacySharedKey() {
try {
const { data } = await settingsApi.get(LEGACY_SHARED_KEY_SETTING)
legacySharedKeyRefused.value = isTruthySetting(data.data?.value)
} catch (e) {
// 404 = never seeded on this install. The backend default is off.
if (e.response?.status !== 404) console.error(e)
legacySharedKeyRefused.value = false
}
}
async function toggleLegacySharedKey() {
const next = !legacySharedKeyRefused.value
if (next && !window.confirm(
'Refuse the shared collector key on the legacy routes?\n\n'
+ 'Report-AssetToShopDB.ps1, Report-PrintersToShopDB.ps1 and anything '
+ 'else still using the shared key on /pc, /apps, /heartbeat or /bulk '
+ 'stop reporting until they move to per-PC credentials.')) {
return
}
legacySharedKeySaving.value = true
legacySharedKeyError.value = ''
try {
// PUT upserts, so this also works where the row was never seeded.
await settingsApi.update(LEGACY_SHARED_KEY_SETTING, next)
legacySharedKeyRefused.value = next
} catch (e) {
legacySharedKeyError.value = apiError(e, 'Failed to save setting')
console.error(e)
} finally {
legacySharedKeySaving.value = false
}
}
// Per-page connection status (stays with the page, not the composable).
const samlStatus = computed(() => {
if (!settings.saml_enabled) return 'inactive'
@@ -178,5 +283,8 @@ const samlMessage = computed(() => {
return 'Settings saved - waiting on SAML support, local accounts still in use'
})
onMounted(loadSettings)
onMounted(() => {
loadSettings()
loadLegacySharedKey()
})
</script>