Secure Send documented, and the machine form stops re-creating a controlling-PC link that already exists
This commit is contained in:
42
CHANGELOG.md
42
CHANGELOG.md
@@ -12,6 +12,30 @@ ADR-007 and ADR-002.
|
||||
|
||||
### Added
|
||||
|
||||
- **Secure Send: hand somebody a password without leaving it in a chat
|
||||
window.** A link in Tech Tools that carries one credential, dies when it is
|
||||
opened, and expires on its own if nobody opens it. The secret is encrypted in
|
||||
the sender's browser and the key rides in the URL fragment, which browsers
|
||||
never transmit, so the server stores ciphertext it cannot read and a
|
||||
compromised database yields nothing. Nobody can recover a lost secret either,
|
||||
including an administrator, which is the same property seen from the other
|
||||
side. Redeeming is a POST because chat and mail clients prefetch links to
|
||||
build previews, and a one-view secret consumed by a preview window is a secret
|
||||
the recipient never sees; a GET reports availability only. Expiry defaults to
|
||||
24 hours (maximum a week) and views to one, whichever comes first, and a spent
|
||||
row is deleted. Creating needs a login and `tools.secrets`; reading needs
|
||||
neither, because the recipient usually has no account and the 128-bit token is
|
||||
the authorisation. The 4096-character ciphertext limit is deliberately the
|
||||
whole server-side enforcement: a zero-knowledge server cannot tell a password
|
||||
from a document, so size is the only control it has, and raising it turns this
|
||||
into the file transfer it was scoped not to be. Documented in
|
||||
`docs/SECURE-SEND.md`.
|
||||
- **A site can let people without an account create a Secure Send link.**
|
||||
New `securesend_allow_guests` setting (Settings > Tools > Secure Send), off by
|
||||
default because a guest secret has no creator and the audit trail can then say
|
||||
when and from where but never who. Guest creation is rate limited per address
|
||||
and the setting fails closed if it cannot be read, so an error can never be
|
||||
what opens an unauthenticated write endpoint.
|
||||
- **The code generator can print a back side.** A badge card is two-sided and
|
||||
the generator only ever emitted one page per card. The Back side section
|
||||
turns on a second page per card carrying either the same code again, a fixed
|
||||
@@ -81,9 +105,27 @@ ADR-007 and ADR-002.
|
||||
the wrong code. The page index now travels with the page.
|
||||
- The back label's dotted preview outline outspecified the print reset and drew
|
||||
a dashed rule down the edge of every second card.
|
||||
- **Saving a machine that has a controlling PC answered "this relationship
|
||||
already exists" and refused the save.** Reported against a map position
|
||||
change, but the map was incidental: the form re-POSTed the controlling-PC
|
||||
link on every save, and the row was already there. It used "do I know the
|
||||
existing link's id" as a stand-in for "is there an existing link", and read
|
||||
that id from a key the API does not emit, so the two disagreed on any machine
|
||||
that had a PC. The same conflation meant CLEARING a machine's controlling PC
|
||||
silently did nothing, which was the more dangerous half and had not been
|
||||
reported. The loaded PC is now what decides, and re-selecting the same PC is
|
||||
a no-op rather than a delete-and-recreate.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Tech Tools no longer asks people to sign in for tools that need no
|
||||
account.** `/tools`, `/tools/codes` and `/tools/secure-send` were all behind
|
||||
`requiresAuth`. The label generator runs entirely in the browser - no API, no
|
||||
data, nothing to protect - so gating it only ever cost a guest the use of it,
|
||||
and gating the Secure Send page contradicted the guest setting that lets
|
||||
somebody with no account create a link. The server still decides who may
|
||||
create a secret: making a page reachable is not the same as allowing the
|
||||
action, and the two were conflated.
|
||||
- **Settings on the left, labels on the right.** The controls were a narrow
|
||||
column with the preview trailing underneath, so the labels being adjusted were
|
||||
off the bottom of the screen. The preview is now a column of its own that
|
||||
|
||||
@@ -379,6 +379,7 @@ import PrinterAssignmentPicker from '@/components/PrinterAssignmentPicker.vue'
|
||||
import { currentTheme } from '@/stores/theme'
|
||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
import { planControllingPcChange } from './controllingPc'
|
||||
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
@@ -440,6 +441,9 @@ const relationshipTypes = ref([])
|
||||
const controllingPcId = ref(null)
|
||||
const relationshipTypeId = ref(null)
|
||||
const existingRelationshipId = ref(null)
|
||||
// Which PC the link named when the form loaded, so an unchanged one can be left
|
||||
// alone. Editing a machine is mostly editing something else about it.
|
||||
const loadedControllingPcId = ref(null)
|
||||
const currentMachine = ref(null)
|
||||
|
||||
// Filter models by selected machine vendor
|
||||
@@ -575,7 +579,16 @@ onMounted(async () => {
|
||||
if (rel.sourceasset?.assettypename === 'computer' && (rel.relationshiptypename || '').toLowerCase() === 'controls') {
|
||||
controllingPcId.value = rel.sourceasset.assetid
|
||||
relationshipTypeId.value = rel.relationshiptypeid
|
||||
existingRelationshipId.value = rel.assetrelationshipid
|
||||
// relationshipid. `assetrelationshipid` is not a key the API has
|
||||
// ever emitted - to_dict serialises the column names and the
|
||||
// column is relationshipid - so this read undefined every time,
|
||||
// and saveRelationship then took the "there is nothing to
|
||||
// replace" path on a machine that had a controlling PC. Saving
|
||||
// ANY change, a map position included, re-POSTed the link that
|
||||
// was already there and the server correctly answered 409 "this
|
||||
// relationship already exists".
|
||||
existingRelationshipId.value = rel.relationshipid
|
||||
loadedControllingPcId.value = rel.sourceasset.assetid
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -693,31 +706,40 @@ async function saveMachine() {
|
||||
}
|
||||
|
||||
async function saveRelationship(assetId) {
|
||||
// If no PC selected and no existing relationship, nothing to do
|
||||
if (!controllingPcId.value && !existingRelationshipId.value) {
|
||||
// The decision lives in controllingPc.js, where it is covered by tests.
|
||||
// See that file for why "is there a link" and "do I know its id" are two
|
||||
// different questions and what happened when they were treated as one.
|
||||
const plan = planControllingPcChange({
|
||||
selected: controllingPcId.value,
|
||||
loaded: loadedControllingPcId.value,
|
||||
existingRelationshipId: existingRelationshipId.value,
|
||||
})
|
||||
|
||||
if (plan.action === 'none') {
|
||||
return
|
||||
}
|
||||
|
||||
// If clearing the relationship
|
||||
if (!controllingPcId.value && existingRelationshipId.value) {
|
||||
await assetsApi.deleteRelationship(existingRelationshipId.value)
|
||||
if (plan.deleteId !== null) {
|
||||
await assetsApi.deleteRelationship(plan.deleteId)
|
||||
existingRelationshipId.value = null
|
||||
loadedControllingPcId.value = null
|
||||
}
|
||||
|
||||
if (plan.action === 'unlink') {
|
||||
return
|
||||
}
|
||||
|
||||
// If PC is selected
|
||||
if (controllingPcId.value) {
|
||||
// If there's an existing relationship, delete it first
|
||||
if (existingRelationshipId.value) {
|
||||
await assetsApi.deleteRelationship(existingRelationshipId.value)
|
||||
}
|
||||
// PC controls Machine, so the PC is the source and the machine the target.
|
||||
const created = await assetsApi.createRelationship({
|
||||
sourceassetid: controllingPcId.value,
|
||||
targetassetid: assetId,
|
||||
relationshiptypeid: relationshipTypeId.value
|
||||
})
|
||||
|
||||
// Create new relationship (PC controls Machine, so PC is source, Machine is target)
|
||||
await assetsApi.createRelationship({
|
||||
sourceassetid: controllingPcId.value,
|
||||
targetassetid: assetId,
|
||||
relationshiptypeid: relationshipTypeId.value
|
||||
})
|
||||
}
|
||||
// Keep the form honest about what is now stored, so a second save without a
|
||||
// reload does not repeat the create it just did.
|
||||
existingRelationshipId.value = created?.data?.data?.relationshipid ?? null
|
||||
loadedControllingPcId.value = controllingPcId.value
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
68
plugins/machines/frontend/views/controllingPc.js
Normal file
68
plugins/machines/frontend/views/controllingPc.js
Normal file
@@ -0,0 +1,68 @@
|
||||
// What saving the machine form should do about its controlling-PC link.
|
||||
//
|
||||
// Pulled out of MachineForm.vue because the component decided this inline and
|
||||
// got it wrong in a way nothing could catch: it treated "I know the id of the
|
||||
// existing link" as "there IS an existing link". The id was read from
|
||||
// `rel.assetrelationshipid`, a key the API has never emitted - the column is
|
||||
// `relationshipid` and to_dict serialises column names - so it was undefined on
|
||||
// every load, and the two questions gave opposite answers on a machine that
|
||||
// plainly had a controlling PC.
|
||||
//
|
||||
// Both failures followed from that single conflation:
|
||||
// - Saving ANY change, a map position included, re-POSTed the link that was
|
||||
// already there. The server answered 409 "this relationship already
|
||||
// exists", correctly, and the form surfaced it as a failed save.
|
||||
// - Clearing the controlling PC did nothing at all, because "no selection and
|
||||
// no known id" reads as "nothing to do".
|
||||
//
|
||||
// So THE LOADED PC IS THE AUTHORITY on whether a link exists, and the
|
||||
// relationship id is only ever used to delete a row. If the id goes missing
|
||||
// again the worst case is a link that cannot be removed, never a duplicate
|
||||
// create.
|
||||
|
||||
/**
|
||||
* Decide what to do with the controlling-PC link on save.
|
||||
*
|
||||
* @param {object} state
|
||||
* @param {number|string|null} state.selected - PC id chosen in the form now.
|
||||
* @param {number|string|null} state.loaded - PC id the link named on load.
|
||||
* @param {number|null} state.existingRelationshipId - row id to delete, if known.
|
||||
* @returns {{action: 'none'|'unlink'|'link'|'relink', deleteId: number|null}}
|
||||
*/
|
||||
export function planControllingPcChange({ selected, loaded, existingRelationshipId } = {}) {
|
||||
const now = normalize(selected)
|
||||
const before = normalize(loaded)
|
||||
const deleteId = existingRelationshipId ?? null
|
||||
|
||||
// Unchanged is not a change. Somebody moving a marker on the map is not
|
||||
// asking to be re-linked to the same PC, and delete-then-create on an
|
||||
// untouched link opens a window where the machine has no controlling PC -
|
||||
// which resolve_asset_position walks across, so a failure between the two
|
||||
// calls would lose the machine's inherited position along with the link.
|
||||
if (now === before) {
|
||||
return { action: 'none', deleteId: null }
|
||||
}
|
||||
|
||||
if (now === null) {
|
||||
// Cleared. Nothing to delete means nothing to do, rather than an error:
|
||||
// the link is already absent, which is the state being asked for.
|
||||
return deleteId === null
|
||||
? { action: 'none', deleteId: null }
|
||||
: { action: 'unlink', deleteId }
|
||||
}
|
||||
|
||||
if (before === null) {
|
||||
return { action: 'link', deleteId: null }
|
||||
}
|
||||
|
||||
// Swapped to a different PC. The old row goes first: 'controls' is singular
|
||||
// per source, not per target, so leaving it would leave the machine with two.
|
||||
return { action: 'relink', deleteId }
|
||||
}
|
||||
|
||||
// Ids arrive as numbers from the API and strings from a <select>, and '' means
|
||||
// "nothing chosen". Comparing those raw is how an unchanged PC looks changed.
|
||||
function normalize(value) {
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
return String(value)
|
||||
}
|
||||
81
plugins/machines/frontend/views/controllingPc.spec.js
Normal file
81
plugins/machines/frontend/views/controllingPc.spec.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { planControllingPcChange } from './controllingPc'
|
||||
|
||||
// Reported: moving machine 3016 on the map, then saving, answered "this
|
||||
// relationship already exists" and refused the save. The machine had a
|
||||
// controlling PC and the form re-created that link on every save, because it
|
||||
// asked "do I know the existing link's id" instead of "is there an existing
|
||||
// link" - and the id was read from a key the API does not emit, so it was
|
||||
// always undefined.
|
||||
//
|
||||
// The first two specs are that bug. The rest pin the behaviour it was hiding.
|
||||
|
||||
describe('planControllingPcChange', () => {
|
||||
it('does nothing when the controlling PC is unchanged', () => {
|
||||
// The reported case: only the map position moved.
|
||||
expect(planControllingPcChange({
|
||||
selected: 42, loaded: 42, existingRelationshipId: 7,
|
||||
})).toEqual({ action: 'none', deleteId: null })
|
||||
})
|
||||
|
||||
it('still does nothing when the existing row id was never learned', () => {
|
||||
// The exact shape of the bug: the link is there, its id is not known. The
|
||||
// answer must be "leave it alone", never "create it again" - a create here
|
||||
// is the 409.
|
||||
expect(planControllingPcChange({
|
||||
selected: 42, loaded: 42, existingRelationshipId: undefined,
|
||||
})).toEqual({ action: 'none', deleteId: null })
|
||||
})
|
||||
|
||||
it('removes the link when the PC is cleared', () => {
|
||||
// This never fired before either: no selection and no known id read as
|
||||
// "nothing to do", so clearing a controlling PC silently kept it.
|
||||
expect(planControllingPcChange({
|
||||
selected: null, loaded: 42, existingRelationshipId: 7,
|
||||
})).toEqual({ action: 'unlink', deleteId: 7 })
|
||||
})
|
||||
|
||||
it('creates a link when there was none', () => {
|
||||
expect(planControllingPcChange({
|
||||
selected: 42, loaded: null, existingRelationshipId: null,
|
||||
})).toEqual({ action: 'link', deleteId: null })
|
||||
})
|
||||
|
||||
it('replaces the old row when the PC is swapped', () => {
|
||||
// controls is singular per SOURCE, not per target, so the old row has to go
|
||||
// or the machine ends up with two controlling PCs.
|
||||
expect(planControllingPcChange({
|
||||
selected: 99, loaded: 42, existingRelationshipId: 7,
|
||||
})).toEqual({ action: 'relink', deleteId: 7 })
|
||||
})
|
||||
|
||||
it('treats a select string and an API number as the same PC', () => {
|
||||
// A <select> hands back '42' while the relationship carried 42. Comparing
|
||||
// those raw makes an untouched link look like a swap, which is the same
|
||||
// duplicate POST by another route.
|
||||
expect(planControllingPcChange({
|
||||
selected: '42', loaded: 42, existingRelationshipId: 7,
|
||||
})).toEqual({ action: 'none', deleteId: null })
|
||||
})
|
||||
|
||||
it('treats an empty selection as cleared, not as a PC named ""', () => {
|
||||
expect(planControllingPcChange({
|
||||
selected: '', loaded: 42, existingRelationshipId: 7,
|
||||
})).toEqual({ action: 'unlink', deleteId: 7 })
|
||||
})
|
||||
|
||||
it('does nothing when there was no PC and none was chosen', () => {
|
||||
expect(planControllingPcChange({
|
||||
selected: null, loaded: null, existingRelationshipId: null,
|
||||
})).toEqual({ action: 'none', deleteId: null })
|
||||
})
|
||||
|
||||
it('does not attempt a delete it cannot perform', () => {
|
||||
// Cleared, but the row id was never learned. Asking the API to delete
|
||||
// `undefined` produced a request against /relationships/undefined.
|
||||
expect(planControllingPcChange({
|
||||
selected: null, loaded: 42, existingRelationshipId: undefined,
|
||||
})).toEqual({ action: 'none', deleteId: null })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user