Gate the USB kiosk the same way, and make an unreachable setting a test failure

The USB kiosk had a feature flag and nothing else. A flag is a decision, not a
credential: with the kiosk on, anyone who could reach the app could move a drive
between checked-out and checked-in and sign it with a badge they guessed. Same
reasoning that closed the parts kiosk, smaller blast radius - which is why
Darkwing did not raise it and why it was worth asking about anyway.

Both kiosks now share one mechanism. The device token moved out of the
printedparts api wrapper into a request interceptor that attaches X-Kiosk-Token
by URL prefix, with one localStorage key per kiosk - they are separate screens
in separate rooms, and a shared token would mean provisioning one grants the
other. claimKioskToken() is shared too, so both views take the token from a
?token= URL once and strip it from the address bar identically.

Then the part that matters more than either fix. I told you to set the new
settings in the UI, and that was impossible: every plugin settings page carries
a hardcoded key list, and nothing had added them. printedparts_kiosk_enabled,
printedparts_kiosk_token and usb_kiosk_token were declared, seeded and read
correctly, and rendered by no page - changeable only by writing SQL.

tests/test_plugin_settings_reachable.py now fails when any declared setting
appears nowhere in its plugin's frontend. It found seven MORE that were already
in that state, all pre-existing:

  collector_corporate_ranges          computers_configpolicies_enabled
  computers_machinelink_alerts        computers_quietreporthours
  geenforce_reportstaleminutes        printers_dashboardpercent
  warranty_expiringdays

All seven now have fields. I first parked them in a KNOWN_GAPS ratchet, then
fixed them and deleted the ratchet - a backlog list in a new check is a way of
agreeing the check does not apply yet. There is no backlog; the check is
absolute.

Two of my own mistakes on the way, both caught by tests rather than by reading:
the blanket rename of the USB guard also rewrote the call inside _kiosk_open, so
it called itself until the stack ran out; and the first version of the reachable
check skipped all 17 plugins because it looked for get_settings() when the
method is get_settings_defaults() - it passed by testing nothing, which is the
exact shape it exists to catch.

Suite 2336, frontend 316, build clean.

Scope: product
This commit is contained in:
cproudlock
2026-09-02 18:08:34 -04:00
parent e4db112296
commit 7f6aaf2e64
13 changed files with 493 additions and 65 deletions

View File

@@ -11,6 +11,54 @@ const api = axios.create({
}) })
// Add auth token and normalize pagination params // Add auth token and normalize pagination params
// Kiosk device tokens. The kiosk routes are the only ones that write without a
// login, so each is additionally gated on a token an administrator provisioned
// into THAT screen's browser - see the kiosk views, which claim it from a
// ?token= URL once and strip it from the address bar. A token served inside the
// page would be readable by anyone who could load the page, so it is per-device
// and never shipped with the bundle.
//
// One entry per kiosk: the two are separate screens in separate rooms and a
// shared token would mean provisioning one grants the other.
export const KIOSK_TOKEN_KEYS = {
'/printedparts/kiosk/': 'printedparts-kiosk-token',
'/usb/kiosk/': 'usb-kiosk-token',
}
function kioskTokenFor(url) {
const path = url || ''
for (const [prefix, storageKey] of Object.entries(KIOSK_TOKEN_KEYS)) {
if (path.includes(prefix)) {
try { return localStorage.getItem(storageKey) || '' } catch { return '' }
}
}
return ''
}
// Claim a kiosk's device token, once, from the URL an admin used to set the
// screen up. Kept in THIS browser and stripped from the address bar straight
// away, so it does not sit there for a shoulder to read or travel in a bookmark
// or a screenshot.
export function claimKioskToken(storageKey) {
try {
const url = new URL(window.location.href)
const token = (url.searchParams.get('token') || '').trim()
if (!token) return
localStorage.setItem(storageKey, token)
url.searchParams.delete('token')
window.history.replaceState({}, '', url.pathname + url.search + url.hash)
} catch {
// A browser refusing localStorage cannot be provisioned. Nothing to
// recover; the routes 404 and the operator sees the standard error.
}
}
api.interceptors.request.use(config => {
const token = kioskTokenFor(config.url)
if (token) config.headers['X-Kiosk-Token'] = token
return config
})
api.interceptors.request.use(config => { api.interceptors.request.use(config => {
const token = localStorage.getItem('token') const token = localStorage.getItem('token')
if (token) { if (token) {
@@ -1501,17 +1549,6 @@ export const measuringtoolsApi = {
} }
// 3D printed parts (printedparts plugin) // 3D printed parts (printedparts plugin)
// Device token for the parts kiosk, provisioned once per screen and kept in
// that browser. Absent means the routes answer 404, which is what an
// unprovisioned browser should see.
export const KIOSK_TOKEN_KEY = 'printedparts-kiosk-token'
function kioskHeaders() {
let token = ''
try { token = localStorage.getItem(KIOSK_TOKEN_KEY) || '' } catch { token = '' }
return token ? { 'X-Kiosk-Token': token } : {}
}
export const printedpartsApi = { export const printedpartsApi = {
list(params = {}) { list(params = {}) {
return api.get('/printedparts/items', { params }) return api.get('/printedparts/items', { params })
@@ -1552,17 +1589,11 @@ export const printedpartsApi = {
adjust(printeditemid, data) { adjust(printeditemid, data) {
return api.post(`/printedparts/items/${printeditemid}/adjust`, data) return api.post(`/printedparts/items/${printeditemid}/adjust`, data)
}, },
// The kiosk routes are the only unauthenticated write in the product, so they
// are gated on a per-DEVICE token rather than on the page. It lives in this
// browser's localStorage, put there once by an admin opening the kiosk with
// ?token=... - see PartsKiosk.vue. A token shipped inside the page would be
// readable by anyone who could load it, which is not a gate.
kioskItem(itemcode) { kioskItem(itemcode) {
return api.get(`/printedparts/kiosk/item/${encodeURIComponent(itemcode)}`, return api.get(`/printedparts/kiosk/item/${encodeURIComponent(itemcode)}`)
{ headers: kioskHeaders() })
}, },
kioskTake(data) { kioskTake(data) {
return api.post('/printedparts/kiosk/take', data, { headers: kioskHeaders() }) return api.post('/printedparts/kiosk/take', data)
}, },
listFiles(printeditemid) { listFiles(printeditemid) {
return api.get(`/printedparts/items/${printeditemid}/files`) return api.get(`/printedparts/items/${printeditemid}/files`)

View File

@@ -10,6 +10,50 @@
is nothing in it to find and edit. is nothing in it to find and edit.
</p> </p>
<div class="card form-card">
<h3>Collector behaviour</h3>
<div class="form-group">
<label>Corporate ranges (CIDR)</label>
<input v-model="extra.collector_corporate_ranges" type="text"
class="form-control" placeholder="10.0.0.0/8, 192.168.0.0/16" />
<p class="field-hint">
Comma-separated CIDRs naming this site's routable networks. The server
picks a PC's primary address from these when the agent reports several.
</p>
</div>
<div class="form-group">
<label>Quiet report hours</label>
<input v-model.number="extra.computers_quietreporthours" type="number"
min="1" class="form-control" />
<p class="field-hint">
Hours without a collector report before a PC counts as silent on the
dashboard.
</p>
</div>
<div class="form-group">
<label class="check-row">
<input type="checkbox" v-model="machinelinkAlerts" />
<span>Alert when a PC takes over another PC's machine</span>
</label>
</div>
<div class="form-group">
<label class="check-row">
<input type="checkbox" v-model="configPoliciesEnabled" />
<span>Configuration and business policies</span>
</label>
<p class="field-hint">
Master switch for the RMM-style policy pages. Off by default.
</p>
</div>
<button class="btn btn-primary" :disabled="savingExtra" @click="saveExtra">
{{ savingExtra ? 'Saving...' : 'Save' }}
</button>
</div>
<div class="card form-card"> <div class="card form-card">
<div v-if="message" class="settings-success">{{ message }}</div> <div v-if="message" class="settings-success">{{ message }}</div>
<div v-if="error" class="error-message">{{ error }}</div> <div v-if="error" class="error-message">{{ error }}</div>
@@ -61,13 +105,57 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { settingsApi } from '@/api' import { settingsApi } from '@/api'
import api from '@/api' import api from '@/api'
const RANGES_KEY = 'computers_routableranges' const RANGES_KEY = 'computers_routableranges'
const ranges = ref('') const ranges = ref('')
// The rest of the plugin's settings. They were declared and seeded but rendered
// nowhere, so a site could only change them with SQL - see
// tests/test_plugin_settings_reachable.py, written after that shipped twice.
const EXTRA_KEYS = [
'collector_corporate_ranges',
'computers_quietreporthours',
'computers_machinelink_alerts',
'computers_configpolicies_enabled',
]
const extra = ref({
collector_corporate_ranges: '',
computers_quietreporthours: 24,
computers_machinelink_alerts: 'false',
computers_configpolicies_enabled: 'false',
})
const savingExtra = ref(false)
// Settings store strings; bridge the checkboxes rather than letting a boolean
// round-trip into the string 'True'.
function boolSetting(key) {
return computed({
get: () => String(extra.value[key]).toLowerCase() === 'true',
set: (on) => { extra.value[key] = on ? 'true' : 'false' },
})
}
const machinelinkAlerts = boolSetting('computers_machinelink_alerts')
const configPoliciesEnabled = boolSetting('computers_configpolicies_enabled')
async function saveExtra() {
savingExtra.value = true
error.value = ''
message.value = ''
try {
for (const key of EXTRA_KEYS) {
await settingsApi.update(key, String(extra.value[key]))
}
message.value = 'Saved.'
} catch (saveError) {
error.value = saveError?.response?.data?.data?.error?.message || 'Save failed.'
} finally {
savingExtra.value = false
}
}
const saving = ref(false) const saving = ref(false)
const downloading = ref(false) const downloading = ref(false)
const digest = ref('') const digest = ref('')
@@ -79,6 +167,9 @@ onMounted(async () => {
const response = await settingsApi.list({ category: 'computers' }) const response = await settingsApi.list({ category: 'computers' })
const row = (response.data.data || []).find(entry => entry.key === RANGES_KEY) const row = (response.data.data || []).find(entry => entry.key === RANGES_KEY)
if (row) ranges.value = row.value || '' if (row) ranges.value = row.value || ''
for (const entry of response.data.data || []) {
if (EXTRA_KEYS.includes(entry.key)) extra.value[entry.key] = entry.value
}
} catch (loadError) { } catch (loadError) {
error.value = 'Could not load settings' error.value = 'Could not load settings'
console.error(loadError) console.error(loadError)

View File

@@ -1,5 +1,18 @@
<template> <template>
<div class="geenforce-settings"> <div class="geenforce-settings">
<h2>Reporting</h2>
<p class="muted">
How long a bay may go without posting an enforcement report before the
fleet view calls it stale.
</p>
<label class="field-label" for="stale">Stale after (minutes)</label>
<input id="stale" type="number" min="1" class="cidr-input"
v-model.number="staleminutes" @blur="saveStale">
<small class="input-hint">
Default 30. A bay enforces on its own cycle, so set this above the cycle
length or every bay reads as stale between runs.
</small>
<h2>Client Access</h2> <h2>Client Access</h2>
<p class="muted"> <p class="muted">
Fleet PCs on a trusted network can reach the GE-Enforce client endpoints Fleet PCs on a trusted network can reach the GE-Enforce client endpoints
@@ -40,9 +53,12 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import api from '@/api' import api, { settingsApi } from '@/api'
const cidrs = ref('') const cidrs = ref('')
// Held here rather than in /geenforce/config: that endpoint owns the CIDR
// allowlist, and this is an ordinary site setting.
const staleminutes = ref(30)
const saving = ref(false) const saving = ref(false)
const message = ref('') const message = ref('')
const ok = ref(false) const ok = ref(false)
@@ -57,6 +73,23 @@ async function load() {
message.value = 'Could not load config.' message.value = 'Could not load config.'
ok.value = false ok.value = false
} }
try {
const setting = unwrap(await settingsApi.get('geenforce_reportstaleminutes'))
staleminutes.value = Number(setting?.value ?? 30) || 30
} catch (e) {
staleminutes.value = 30
}
}
async function saveStale() {
try {
await settingsApi.update('geenforce_reportstaleminutes', String(staleminutes.value))
ok.value = true
message.value = 'Saved.'
} catch (e) {
ok.value = false
message.value = e?.response?.data?.data?.error?.message || 'Save failed.'
}
} }
async function save() { async function save() {

View File

@@ -92,7 +92,7 @@
<script setup> <script setup>
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue' import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { printedpartsApi, settingsApi, KIOSK_TOKEN_KEY } from '@/api' import { printedpartsApi, settingsApi, claimKioskToken, KIOSK_TOKEN_KEYS } from '@/api'
import { withBase } from '@/utils/basePath' import { withBase } from '@/utils/basePath'
import TouchKeypad from '@/components/TouchKeypad.vue' import TouchKeypad from '@/components/TouchKeypad.vue'
@@ -116,28 +116,8 @@ const scannedRevision = ref(null)
const entryInput = ref(null) const entryInput = ref(null)
let resetTimer = null let resetTimer = null
// Claim the device token, once, from the URL an admin used to set this screen
// up: /parts-kiosk?token=<value>. It is kept in THIS browser and stripped from
// the address bar immediately, so it does not sit in the URL for a shoulder to
// read, and does not travel in a bookmark or a screenshot. Every kiosk call
// then carries it as X-Kiosk-Token; without it the routes answer 404.
function claimTokenFromUrl() {
try {
const url = new URL(window.location.href)
const token = (url.searchParams.get('token') || '').trim()
if (!token) return
localStorage.setItem(KIOSK_TOKEN_KEY, token)
url.searchParams.delete('token')
window.history.replaceState({}, '', url.pathname + url.search + url.hash)
} catch {
// A browser refusing localStorage is a browser this kiosk cannot be
// provisioned in. Nothing to recover here; the routes will 404 and the
// operator sees the standard error.
}
}
onMounted(async () => { onMounted(async () => {
claimTokenFromUrl() claimKioskToken(KIOSK_TOKEN_KEYS['/printedparts/kiosk/'])
// A kiosk left running for weeks reads this once at load, which is fine: the // A kiosk left running for weeks reads this once at load, which is fine: the
// label format is a property of the site's physical tags, not something that // label format is a property of the site's physical tags, not something that
// changes during a shift. // changes during a shift.

View File

@@ -36,6 +36,38 @@
<p class="field-hint">Seed value for new items; each item can override.</p> <p class="field-hint">Seed value for new items; each item can override.</p>
</div> </div>
<div class="form-group">
<label>
<input type="checkbox" v-model="kioskEnabled" />
Serve the parts kiosk
</label>
<p class="field-hint">
Off by default. The kiosk routes are the only ones that write without
a login, so a site opts in.
</p>
</div>
<div class="form-group">
<label>Kiosk device token</label>
<div class="token-row">
<input v-model="values.printedparts_kiosk_token" type="text"
class="form-control" placeholder="not set" />
<button type="button" class="btn btn-secondary" @click="generateToken">
Generate
</button>
</div>
<p class="field-hint">
Each kiosk screen is provisioned once by opening
<code>/parts-kiosk?token=&lt;this value&gt;</code> on it. The browser
keeps it; the address bar does not. A screen without it gets a 404,
and so does anyone else who finds the route.
</p>
<p v-if="tokenJustGenerated" class="field-hint token-warn">
Copy this now and save. It is masked once stored, so this is the only
time it is shown.
</p>
</div>
<div class="form-group"> <div class="form-group">
<label>Unknown badge at the kiosk</label> <label>Unknown badge at the kiosk</label>
<select v-model="values.printedparts_unknown_badge" class="form-control"> <select v-model="values.printedparts_unknown_badge" class="form-control">
@@ -109,13 +141,15 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { settingsApi, usersApi, supportteamsApi } from '@/api' import { settingsApi, usersApi, supportteamsApi } from '@/api'
const KEYS = [ const KEYS = [
'printedparts_code_prefix', 'printedparts_code_prefix',
'printedparts_label_prefix', 'printedparts_label_prefix',
'printedparts_default_threshold', 'printedparts_default_threshold',
'printedparts_kiosk_enabled',
'printedparts_kiosk_token',
'printedparts_unknown_badge', 'printedparts_unknown_badge',
'printedparts_alert_email', 'printedparts_alert_email',
'printedparts_alert_userids', 'printedparts_alert_userids',
@@ -127,12 +161,33 @@ const values = ref({
printedparts_code_prefix: '3DP', printedparts_code_prefix: '3DP',
printedparts_label_prefix: '', printedparts_label_prefix: '',
printedparts_default_threshold: 5, printedparts_default_threshold: 5,
printedparts_kiosk_enabled: 'false',
printedparts_kiosk_token: '',
printedparts_unknown_badge: 'deny', printedparts_unknown_badge: 'deny',
printedparts_alert_email: '', printedparts_alert_email: '',
printedparts_alert_userids: '', printedparts_alert_userids: '',
printedparts_alert_roleids: '', printedparts_alert_roleids: '',
printedparts_alert_supportteamid: '' printedparts_alert_supportteamid: ''
}) })
// Settings are stored as strings. Bridge the checkbox to that rather than
// letting a boolean reach the API and come back as the string 'True'.
const kioskEnabled = computed({
get: () => String(values.value.printedparts_kiosk_enabled).toLowerCase() === 'true',
set: (on) => { values.value.printedparts_kiosk_enabled = on ? 'true' : 'false' },
})
const tokenJustGenerated = ref(false)
// A token an operator would not guess and does not have to invent. Generated in
// the browser so it never travels anywhere it did not need to.
function generateToken() {
const bytes = new Uint8Array(24)
crypto.getRandomValues(bytes)
values.value.printedparts_kiosk_token =
Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
tokenJustGenerated.value = true
}
const supportTeams = ref([]) const supportTeams = ref([])
const users = ref([]) const users = ref([])
const selectedUserids = ref([]) const selectedUserids = ref([])
@@ -188,6 +243,10 @@ async function save() {
</script> </script>
<style scoped> <style scoped>
.token-row { display: flex; gap: 8px; align-items: center; }
.token-row .form-control { flex: 1 1 auto; }
.token-warn { color: var(--warning-dark); }
.field-hint { color: var(--text-light); font-size: 0.85rem; margin-top: 0.25rem; } .field-hint { color: var(--text-light); font-size: 0.85rem; margin-top: 0.25rem; }
.user-picker { .user-picker {
max-height: 12rem; max-height: 12rem;

View File

@@ -26,6 +26,16 @@
<input v-model.number="values.printers_alert_critical_threshold" <input v-model.number="values.printers_alert_critical_threshold"
type="number" min="0" max="100" class="form-control" /> type="number" min="0" max="100" class="form-control" />
</div> </div>
<div class="form-group">
<label>Dashboard threshold (% remaining)</label>
<input v-model.number="values.printers_dashboardpercent"
type="number" min="0" max="100" class="form-control" />
<p class="field-hint">
At or below this, a printer's supply shows on the dashboard. Separate
from the alert thresholds above: this one only decides what is
listed, it sends nothing.
</p>
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -101,7 +111,8 @@ const KEYS = [
'printers_alert_roleids', 'printers_alert_roleids',
'printers_alert_supportteamid', 'printers_alert_supportteamid',
'printers_alert_warning_threshold', 'printers_alert_warning_threshold',
'printers_alert_critical_threshold' 'printers_alert_critical_threshold',
'printers_dashboardpercent'
] ]
const values = ref({ const values = ref({
@@ -110,6 +121,7 @@ const values = ref({
printers_alert_roleids: '', printers_alert_roleids: '',
printers_alert_supportteamid: '', printers_alert_supportteamid: '',
printers_alert_warning_threshold: 5, printers_alert_warning_threshold: 5,
printers_dashboardpercent: 5,
printers_alert_critical_threshold: 0 printers_alert_critical_threshold: 0
}) })
const supportTeams = ref([]) const supportTeams = ref([])

View File

@@ -24,6 +24,8 @@ import logging
import re import re
from datetime import datetime, timedelta from datetime import datetime, timedelta
import hmac
from flask import Blueprint, request, Response from flask import Blueprint, request, Response
from flask_jwt_extended import jwt_required from flask_jwt_extended import jwt_required
@@ -741,6 +743,31 @@ def _kiosk_enabled():
return _kiosk_setting('usb_kiosk_enabled', False) return _kiosk_setting('usb_kiosk_enabled', False)
def _kiosk_open():
"""True when the kiosk is on for this site AND the caller has its token.
The flag alone was the whole gate, and a flag is a decision, not a
credential: with the kiosk on, anyone who could reach the app could move a
device between checked-out and checked-in and have it recorded against a
badge they guessed. A badge is a short numeric SSO, so it is attribution,
never authorization - the same reasoning that closed the parts kiosk.
The token is per-DEVICE. A kiosk is a browser on a screen by the cabinet, so
a token served inside the page would be readable by anyone who could load
it. An admin opens the screen once as /usb-kiosk?token=..., the page keeps
it in that browser and sends it as X-Kiosk-Token.
"""
if not _kiosk_enabled():
return False
expected = (Setting.get('usb_kiosk_token') or '').strip()
presented = (request.headers.get('X-Kiosk-Token') or '').strip()
if not expected or not presented:
return False
# compare_digest, not ==: == leaks the token's length and matching prefix
# through timing.
return hmac.compare_digest(presented, expected)
def _kiosk_disabled_response(): def _kiosk_disabled_response():
# 404, not 403: when the kiosk is off these routes should not read as # 404, not 403: when the kiosk is off these routes should not read as
# "there is a door here and you may not use it". # "there is a door here and you may not use it".
@@ -765,7 +792,7 @@ def kiosk_device(device_id):
the person standing there cannot tell whether they are doing the right the person standing there cannot tell whether they are doing the right
thing. No badge number, no history, no contact details. thing. No badge number, no history, no contact details.
""" """
if not _kiosk_enabled(): if not _kiosk_open():
return _kiosk_disabled_response() return _kiosk_disabled_response()
if _usb_selfhosted(): if _usb_selfhosted():
return selfhosted.kiosk_device(device_id) return selfhosted.kiosk_device(device_id)
@@ -825,7 +852,7 @@ def kiosk_session():
COUNT of everything else - names for those are a separate, deliberate step, COUNT of everything else - names for those are a separate, deliberate step,
so a passer-by does not read the whole cabinet's holders off a wall screen. so a passer-by does not read the whole cabinet's holders off a wall screen.
""" """
if not _kiosk_enabled(): if not _kiosk_open():
return _kiosk_disabled_response() return _kiosk_disabled_response()
data = request.get_json() or {} data = request.get_json() or {}
if _usb_selfhosted(): if _usb_selfhosted():
@@ -881,7 +908,7 @@ def kiosk_others():
who is on leave - but the names that makes visible should not be the first who is on leave - but the names that makes visible should not be the first
thing on a shopfloor screen. thing on a shopfloor screen.
""" """
if not _kiosk_enabled(): if not _kiosk_open():
return _kiosk_disabled_response() return _kiosk_disabled_response()
if not _kiosk_setting('usb_kiosk_allow_return_for_others', True): if not _kiosk_setting('usb_kiosk_allow_return_for_others', True):
return _kiosk_disabled_response() return _kiosk_disabled_response()
@@ -925,7 +952,7 @@ def kiosk_others():
@usb_bp.route('/kiosk/checkout', methods=['POST']) @usb_bp.route('/kiosk/checkout', methods=['POST'])
def kiosk_checkout(): def kiosk_checkout():
"""Check a device out at the kiosk. Body: {device_id, badge}.""" """Check a device out at the kiosk. Body: {device_id, badge}."""
if not _kiosk_enabled(): if not _kiosk_open():
return _kiosk_disabled_response() return _kiosk_disabled_response()
data = request.get_json() or {} data = request.get_json() or {}
if _usb_selfhosted(): if _usb_selfhosted():
@@ -998,7 +1025,7 @@ def kiosk_checkin():
handed the device back, which is the honest record and needs no change to a handed the device back, which is the honest record and needs no change to a
schema this app does not own. schema this app does not own.
""" """
if not _kiosk_enabled(): if not _kiosk_open():
return _kiosk_disabled_response() return _kiosk_disabled_response()
data = request.get_json() or {} data = request.get_json() or {}
if _usb_selfhosted(): if _usb_selfhosted():

View File

@@ -133,7 +133,7 @@
<script setup> <script setup>
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue' import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import api, { settingsApi } from '@/api' import api, { settingsApi, claimKioskToken, KIOSK_TOKEN_KEYS } from '@/api'
import TouchKeypad from '@/components/TouchKeypad.vue' import TouchKeypad from '@/components/TouchKeypad.vue'
import { filterdrives } from '../drivefilter' import { filterdrives } from '../drivefilter'
@@ -150,6 +150,7 @@ const entryInput = ref(null)
let resetTimer = null let resetTimer = null
onMounted(async () => { onMounted(async () => {
claimKioskToken(KIOSK_TOKEN_KEYS['/usb/kiosk/'])
// The kiosk ships off. Reading the flag turns "everything 404s" into a screen // The kiosk ships off. Reading the flag turns "everything 404s" into a screen
// that says so - a kiosk merely switched off must not look broken. // that says so - a kiosk merely switched off must not look broken.
try { try {

View File

@@ -33,6 +33,28 @@
</p> </p>
</div> </div>
<div class="form-group">
<label>Kiosk device token</label>
<div class="token-row">
<input v-model="values.usb_kiosk_token" type="text"
class="form-control" placeholder="not set" />
<button type="button" class="btn btn-secondary" @click="generateToken">
Generate
</button>
</div>
<p class="field-hint">
Provision each screen once by opening
<code>/usb-kiosk?token=&lt;this value&gt;</code> on it. That browser
keeps it; the address bar does not. Turning the kiosk on is a
decision, not a credential - without this token the routes answer 404,
so nobody who merely finds the URL can move a drive and sign it with a
badge they guessed.
</p>
<p v-if="tokenJustGenerated" class="field-hint token-warn">
Copy this now and save. It is masked once stored.
</p>
</div>
<div class="form-group"> <div class="form-group">
<label class="check-row"> <label class="check-row">
<input type="checkbox" v-model="values.usb_kiosk_allow_return_for_others" <input type="checkbox" v-model="values.usb_kiosk_allow_return_for_others"
@@ -61,12 +83,25 @@ import { ref, onMounted } from 'vue'
import { settingsApi } from '@/api' import { settingsApi } from '@/api'
import { withBase } from '@/utils/basePath' import { withBase } from '@/utils/basePath'
const KEYS = ['usb_kiosk_enabled', 'usb_kiosk_allow_return_for_others'] const KEYS = ['usb_kiosk_enabled', 'usb_kiosk_allow_return_for_others',
'usb_kiosk_token']
const values = ref({ const values = ref({
usb_kiosk_enabled: false, usb_kiosk_enabled: false,
usb_kiosk_allow_return_for_others: true, usb_kiosk_allow_return_for_others: true,
usb_kiosk_token: '',
}) })
const tokenJustGenerated = ref(false)
// Generated in the browser so it never travels anywhere it did not need to.
function generateToken() {
const bytes = new Uint8Array(24)
crypto.getRandomValues(bytes)
values.value.usb_kiosk_token =
Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
tokenJustGenerated.value = true
}
const saving = ref(false) const saving = ref(false)
const message = ref('') const message = ref('')
const error = ref('') const error = ref('')
@@ -106,6 +141,10 @@ onMounted(load)
</script> </script>
<style scoped> <style scoped>
.token-row { display: flex; gap: 8px; align-items: center; }
.token-row .form-control { flex: 1 1 auto; }
.token-warn { color: var(--warning-dark); }
.field-hint { color: var(--text-light); font-size: 0.85rem; margin-top: 0.25rem; } .field-hint { color: var(--text-light); font-size: 0.85rem; margin-top: 0.25rem; }
.check-row { display: flex; align-items: center; gap: 0.5rem; font-weight: 600; } .check-row { display: flex; align-items: center; gap: 0.5rem; font-weight: 600; }
.check-row input { width: auto; } .check-row input { width: auto; }

View File

@@ -138,6 +138,18 @@ class USBPlugin(BasePlugin):
'a device between checked-out and checked-in - it ' 'a device between checked-out and checked-in - it '
'cannot add, edit, retire or list devices.', 'cannot add, edit, retire or list devices.',
}, },
{
'key': 'usb_kiosk_token',
'value': '',
# string: the column documents string/boolean/integer/json.
# Masking keys on the NAME containing 'token'.
'valuetype': 'string',
'category': 'usb',
'description': 'Device token the USB kiosk sends as '
'X-Kiosk-Token. Generate one, then open the '
'kiosk once as /usb-kiosk?token=<value> on each '
'screen',
},
{ {
'key': 'usb_kiosk_allow_return_for_others', 'key': 'usb_kiosk_allow_return_for_others',
'value': 'true', 'value': 'true',

View File

@@ -4,6 +4,23 @@
<h2>Dell Warranty Lookup</h2> <h2>Dell Warranty Lookup</h2>
</div> </div>
<div class="section-card">
<div class="setting-group">
<p class="setting-description">
How far ahead the dashboard counts a warranty as expiring.
</p>
<div class="setting-row">
<label>
<span>Expiring window (days)</span>
<input type="number" min="1"
v-model.number="settings.warranty_expiringdays"
@blur="saveSetting('warranty_expiringdays', settings.warranty_expiringdays)"
:disabled="saving">
</label>
</div>
</div>
</div>
<div class="section-card"> <div class="section-card">
<div class="setting-group"> <div class="setting-group">
<p class="setting-description"> <p class="setting-description">

View File

@@ -0,0 +1,88 @@
"""Every setting a plugin declares must be reachable in its settings page.
A setting exists so a site can change it. One that is seeded into the database
but rendered by no page is not configurable - it is a constant with extra steps,
and the only way to change it is direct SQL.
This was written after shipping exactly that twice in one afternoon:
printedparts_kiosk_enabled / _kiosk_token and then usb_kiosk_token were declared
in get_settings(), seeded correctly, and read correctly by the API - and none of
them appeared on any page, because each settings view carries a hardcoded KEYS
list that nobody remembered to extend. The instructions for turning the kiosk
back on were impossible to follow.
The check is deliberately crude: it greps the plugin's frontend for the key as a
literal. That is enough to catch the failure it exists for, and it cannot be
satisfied by accident.
"""
import pathlib
import re
import pytest
REPO = pathlib.Path(__file__).resolve().parents[1]
PLUGINS = REPO / 'plugins'
# Settings a site is not meant to edit through a page. Each needs a reason.
EXEMPT = {
# Written by the app itself when the agent key is minted, not typed.
'plugin.endpoints.agentkeyprovisioned',
'plugin.endpoints.envkeyfallback',
}
def _declared_settings(plugin_dir):
"""Keys the plugin declares in get_settings_defaults(), read from the source.
Importing the plugin would need an app context and a database; the
declaration is a literal list, so reading it is enough and much cheaper.
"""
source = (plugin_dir / 'plugin.py')
if not source.is_file():
return []
text = source.read_text(encoding='utf-8')
match = re.search(r'def get_settings_defaults\(self\).*?(?=\n def |\Z)', text, re.S)
if not match:
return []
return re.findall(r"'key':\s*'([^']+)'", match.group(0))
def _frontend_text(plugin_dir):
frontend = plugin_dir / 'frontend'
if not frontend.is_dir():
return ''
parts = []
for path in frontend.rglob('*'):
if path.suffix in ('.vue', '.js') and path.is_file():
parts.append(path.read_text(encoding='utf-8'))
return '\n'.join(parts)
PLUGIN_DIRS = sorted(
d for d in PLUGINS.iterdir()
if d.is_dir() and (d / 'manifest.json').is_file()
)
@pytest.mark.parametrize('plugin_dir', PLUGIN_DIRS, ids=lambda d: d.name)
def test_every_declared_setting_is_reachable_in_the_ui(plugin_dir):
declared = [k for k in _declared_settings(plugin_dir) if k not in EXEMPT]
if not declared:
pytest.skip('declares no settings')
frontend = _frontend_text(plugin_dir)
if not frontend:
pytest.skip('ships no frontend')
missing = [key for key in declared if key not in frontend]
assert not missing, (
'%s declares %d setting(s) that appear nowhere in its frontend, so a '
'site cannot change them without writing SQL:\n %s\n'
'Add them to the plugin settings view (its KEYS list AND a field), or '
'to EXEMPT in this file with a reason. There is no backlog list: the '
'seven this check found when it was written were all fixed, and it '
'stays that way.'
% (plugin_dir.name, len(missing), '\n '.join(missing)))

View File

@@ -16,6 +16,12 @@ import pytest
from shopdb.core.models import Setting from shopdb.core.models import Setting
KIOSK_TOKEN = 'usb-kiosk-device-token-for-tests'
# What a provisioned screen sends. Turning the kiosk on is a decision; this is
# the credential.
KIOSK_HEADERS = {'X-Kiosk-Token': KIOSK_TOKEN}
@pytest.fixture @pytest.fixture
def kiosk(db): def kiosk(db):
"""Self-hosted USB, kiosk ON, one employee and one available drive.""" """Self-hosted USB, kiosk ON, one employee and one available drive."""
@@ -26,6 +32,8 @@ def kiosk(db):
valuetype='string', category='site')) valuetype='string', category='site'))
db.session.add(Setting(key='usb_kiosk_enabled', value='true', db.session.add(Setting(key='usb_kiosk_enabled', value='true',
valuetype='boolean', category='usb')) valuetype='boolean', category='usb'))
db.session.add(Setting(key='usb_kiosk_token', value=KIOSK_TOKEN,
valuetype='string', category='usb'))
db.session.add(DirectoryEmployee(sso=270015376, firstname='Dana', db.session.add(DirectoryEmployee(sso=270015376, firstname='Dana',
lastname='Reyes')) lastname='Reyes'))
db.session.add(DirectoryEmployee(sso=270099999, firstname='Sam', db.session.add(DirectoryEmployee(sso=270099999, firstname='Sam',
@@ -48,12 +56,12 @@ def _setting(db, key, value):
def _out(client, badge='270015376', **extra): def _out(client, badge='270015376', **extra):
return client.post('/api/usb/kiosk/checkout', return client.post('/api/usb/kiosk/checkout',
json=dict({'device_id': 'USB-1', 'badge': badge}, **extra)) json=dict({'device_id': 'USB-1', 'badge': badge}, **extra), headers=KIOSK_HEADERS)
def _in(client, badge='270015376', **extra): def _in(client, badge='270015376', **extra):
return client.post('/api/usb/kiosk/checkin', return client.post('/api/usb/kiosk/checkin',
json=dict({'device_id': 'USB-1', 'badge': badge}, **extra)) json=dict({'device_id': 'USB-1', 'badge': badge}, **extra), headers=KIOSK_HEADERS)
# --- the door itself -------------------------------------------------------- # --- the door itself --------------------------------------------------------
@@ -67,7 +75,7 @@ def test_the_kiosk_does_not_exist_until_a_site_turns_it_on(client, kiosk):
""" """
_setting(kiosk, 'usb_kiosk_enabled', 'false') _setting(kiosk, 'usb_kiosk_enabled', 'false')
assert client.get('/api/usb/kiosk/device/USB-1').status_code == 404 assert client.get('/api/usb/kiosk/device/USB-1', headers=KIOSK_HEADERS).status_code == 404
assert _out(client).status_code == 404 assert _out(client).status_code == 404
assert _in(client).status_code == 404 assert _in(client).status_code == 404
@@ -81,14 +89,14 @@ def test_it_ships_off(app):
def test_no_login_is_required(client, kiosk): def test_no_login_is_required(client, kiosk):
"""The whole point: no Authorization header anywhere in this file.""" """The whole point: no Authorization header anywhere in this file."""
response = client.get('/api/usb/kiosk/device/USB-1') response = client.get('/api/usb/kiosk/device/USB-1', headers=KIOSK_HEADERS)
assert response.status_code == 200 assert response.status_code == 200
assert response.get_json()['data']['device_id'] == 'USB-1' assert response.get_json()['data']['device_id'] == 'USB-1'
def test_the_kiosk_cannot_create_edit_or_delete(client, kiosk): def test_the_kiosk_cannot_create_edit_or_delete(client, kiosk):
"""It moves a device between two states. It is not an admin surface.""" """It moves a device between two states. It is not an admin surface."""
assert client.post('/api/usb/kiosk/device/USB-1').status_code in (404, 405) assert client.post('/api/usb/kiosk/device/USB-1', headers=KIOSK_HEADERS).status_code in (404, 405)
assert client.delete('/api/usb/kiosk/device/USB-1').status_code in (404, 405) assert client.delete('/api/usb/kiosk/device/USB-1').status_code in (404, 405)
# And the authenticated write routes are still gated. # And the authenticated write routes are still gated.
assert client.post('/api/usb/USB-1/checkout', assert client.post('/api/usb/USB-1/checkout',
@@ -113,7 +121,7 @@ def test_a_badge_it_cannot_name_is_refused(client, kiosk):
def test_a_missing_badge_is_refused(client, kiosk): def test_a_missing_badge_is_refused(client, kiosk):
assert client.post('/api/usb/kiosk/checkout', assert client.post('/api/usb/kiosk/checkout',
json={'device_id': 'USB-1'}).status_code == 400 json={'device_id': 'USB-1'}, headers=KIOSK_HEADERS).status_code == 400
# --- taking one ------------------------------------------------------------- # --- taking one -------------------------------------------------------------
@@ -139,7 +147,7 @@ def test_a_drive_already_out_cannot_be_taken_again(client, kiosk):
def test_an_unknown_code_is_a_404(client, kiosk): def test_an_unknown_code_is_a_404(client, kiosk):
response = client.post('/api/usb/kiosk/checkout', response = client.post('/api/usb/kiosk/checkout',
json={'device_id': 'NOPE', 'badge': '270015376'}) json={'device_id': 'NOPE', 'badge': '270015376'}, headers=KIOSK_HEADERS)
assert response.status_code == 404 assert response.status_code == 404
@@ -198,7 +206,7 @@ def test_a_drive_that_is_not_out_cannot_be_returned(client, kiosk):
def test_the_lookup_names_the_holder_but_not_their_badge(client, kiosk): def test_the_lookup_names_the_holder_but_not_their_badge(client, kiosk):
"""The confirm step has to show whose it is; it does not need their SSO.""" """The confirm step has to show whose it is; it does not need their SSO."""
_out(client) _out(client)
data = client.get('/api/usb/kiosk/device/USB-1').get_json()['data'] data = client.get('/api/usb/kiosk/device/USB-1', headers=KIOSK_HEADERS).get_json()['data']
assert data['holdername'] == 'Dana Reyes' assert data['holdername'] == 'Dana Reyes'
assert '270015376' not in str(data) assert '270015376' not in str(data)
@@ -211,11 +219,11 @@ def test_the_lookup_names_the_holder_but_not_their_badge(client, kiosk):
# real person, the same bar taking a drive already had to clear. # real person, the same bar taking a drive already had to clear.
def _session(client, badge='270015376'): def _session(client, badge='270015376'):
return client.post('/api/usb/kiosk/session', json={'badge': badge}) return client.post('/api/usb/kiosk/session', json={'badge': badge}, headers=KIOSK_HEADERS)
def test_the_device_list_costs_a_valid_badge(client, kiosk): def test_the_device_list_costs_a_valid_badge(client, kiosk):
assert client.post('/api/usb/kiosk/session', json={}).status_code == 400 assert client.post('/api/usb/kiosk/session', json={}, headers=KIOSK_HEADERS).status_code == 400
assert _session(client, badge='999999999').status_code == 422 assert _session(client, badge='999999999').status_code == 422
@@ -223,7 +231,7 @@ def test_the_list_is_not_reachable_when_the_kiosk_is_off(client, kiosk):
_setting(kiosk, 'usb_kiosk_enabled', 'false') _setting(kiosk, 'usb_kiosk_enabled', 'false')
assert _session(client).status_code == 404 assert _session(client).status_code == 404
assert client.post('/api/usb/kiosk/others', assert client.post('/api/usb/kiosk/others',
json={'badge': '270015376'}).status_code == 404 json={'badge': '270015376'}, headers=KIOSK_HEADERS).status_code == 404
def test_the_session_separates_yours_from_available(client, kiosk): def test_the_session_separates_yours_from_available(client, kiosk):
@@ -254,7 +262,7 @@ def test_names_appear_only_on_the_deliberate_second_step(client, kiosk):
_out(client) _out(client)
rows = client.post('/api/usb/kiosk/others', rows = client.post('/api/usb/kiosk/others',
json={'badge': '270099999'}).get_json()['data'] json={'badge': '270099999'}, headers=KIOSK_HEADERS).get_json()['data']
assert [r['device_id'] for r in rows] == ['USB-1'] assert [r['device_id'] for r in rows] == ['USB-1']
assert rows[0]['holdername'] == 'Dana Reyes' assert rows[0]['holdername'] == 'Dana Reyes'
@@ -265,7 +273,7 @@ def test_the_others_list_is_gone_when_returns_for_others_are_refused(client, kio
_out(client) _out(client)
assert client.post('/api/usb/kiosk/others', assert client.post('/api/usb/kiosk/others',
json={'badge': '270099999'}).status_code == 404 json={'badge': '270099999'}, headers=KIOSK_HEADERS).status_code == 404
def test_a_retired_drive_is_not_offered(client, kiosk): def test_a_retired_drive_is_not_offered(client, kiosk):
@@ -276,3 +284,33 @@ def test_a_retired_drive_is_not_offered(client, kiosk):
data = _session(client).get_json()['data'] data = _session(client).get_json()['data']
assert data['available'] == [] assert data['available'] == []
# --- the flag is a decision, the token is the credential ---------------------
#
# usb_kiosk_enabled alone used to be the whole gate. With the kiosk on, anyone
# who could reach the app could move a drive between checked-out and checked-in
# and have it recorded against a badge they guessed.
def test_kiosk_is_shut_without_the_device_token(client, kiosk):
assert client.get('/api/usb/kiosk/device/USB-1').status_code == 404
assert client.post('/api/usb/kiosk/checkout',
json={'device_id': 'USB-1',
'badge': '270015376'}).status_code == 404
def test_kiosk_is_shut_with_the_wrong_device_token(client, kiosk):
wrong = {'X-Kiosk-Token': KIOSK_TOKEN + 'x'}
assert client.get('/api/usb/kiosk/device/USB-1',
headers=wrong).status_code == 404
assert client.post('/api/usb/kiosk/checkout', headers=wrong,
json={'device_id': 'USB-1',
'badge': '270015376'}).status_code == 404
def test_the_token_alone_is_not_enough_when_the_kiosk_is_off(client, kiosk):
"""Both halves are required: a provisioned screen at a site that has since
switched the kiosk off gets nothing."""
_setting(kiosk, 'usb_kiosk_enabled', 'false')
assert client.get('/api/usb/kiosk/device/USB-1',
headers=KIOSK_HEADERS).status_code == 404