Gate both kiosks behind a device token, and make an unreachable setting a test failure

This commit is contained in:
2026-09-02 18:11:01 -04:00
parent 2f90419ceb
commit 4b21aa3887
14 changed files with 498 additions and 66 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

@@ -162,7 +162,11 @@ class PrintedpartsPlugin(BasePlugin):
{ {
'key': 'printedparts_kiosk_token', 'key': 'printedparts_kiosk_token',
'value': '', 'value': '',
'valuetype': 'secret', # string, not 'secret': the column documents string/boolean/
# integer/json only. Masking is keyed on the NAME containing
# 'token' (settings.py::_is_secret), so this is still hidden in
# the API and the audit log.
'valuetype': 'string',
'category': 'printedparts', 'category': 'printedparts',
'description': 'Device token the kiosk sends as X-Kiosk-Token. ' 'description': 'Device token the kiosk sends as X-Kiosk-Token. '
'Generate one, then open the kiosk once as ' 'Generate one, then open the kiosk once as '

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