Promote the 2026-08-30 review security fixes and supplies ordering to main

This commit is contained in:
2026-08-31 09:24:13 -04:00
parent 6680c3c5de
commit 0e3d5a6226
30 changed files with 991 additions and 98 deletions

View File

@@ -128,6 +128,91 @@ ADR-007 and ADR-002.
### Fixed
- **Global search answered twelve domains behind one permission.** The route is
gated on `assets.view`, and that was the entire check: looking up an asset
number also read out of the staff directory, the USB checkout history, the
notification log and the network inventory, none of which consult their own
permission. The `SEARCH_DOMAINS` settings are site feature toggles, not
authorization, and were never doing that job. Each domain now answers only to
a caller holding its permission - `employees.view` for the directory,
`usb.view` for checkouts, `network.view` for address and subnet matches,
`computers.view` for hostnames. Asset, custom-field and vendor/model results
stay on `assets.view`, since all three emit asset rows. The check is
`caller_holds` rather than `require_permission`: a search that refused the
whole request because one domain was off-limits would be useless, so a domain
the caller cannot see simply contributes nothing. This is also what makes
`assets.view` safe to hand to the guest role again, which is what lets a
signed-out visitor look up an asset number without also getting the staff
directory.
- **The printer supplies card was ordered by printer name, so the emptiest
cartridge was not first and sometimes was not shown at all.** Rows sorted on
"is critical" and then alphabetically, and critical is everything at or below
5% - so a cartridge at 5% and one at 0% tied on the first key and the alphabet
broke the tie. That is the wrong order on its own, and worse than it looks:
the dashboard queue keeps only the first few rows per source, so a printer
that was genuinely empty got truncated off the card while a fuller one held
its place. The card exists to answer "what do I walk out and change today" and
was answering it with the alphabet. Rows now sort by the emptiest cartridge on
the printer, ascending. Waste cartridges were already normalised to percent
remaining, so one ascending sort is worst-first for every supply type and the
critical flag falls out of it rather than needing its own key; the name only
breaks ties.
- **A permission became grantable to anonymous visitors because of how it was
spelled.** `guest_grantable_permissions()` opened any permission whose name
ended `.view` or `.read` to the guest role, on the reasoning that reading is
the safe default. That is true of a permission gating a list of asset numbers
and false of one gating bytes, and a suffix cannot tell them apart. Twenty-four
of eighty-five qualified, and nobody had decided any of them:
`applications.view` reached the SYSTEM-context PowerShell bodies and the
installer binaries a site pushes to its bays; `backups.view` returned the full
payload that the separate `backups.download` permission exists to protect;
`assets.view` gated a global search that queries the staff directory, the USB
history and the network inventory with no per-domain check of its own;
`audit.view` and `settings.view` are admin-category and were opened by naming
alone. Every entry is now DECLARED, by core or by the plugin that owns it,
having read what its routes actually return. Adding one is a code change,
which is the point - it puts the decision in a diff rather than in a suffix.
The two routes serving script bodies and installer packages moved to a new
`applications.download` permission, which is not grantable, so the catalogue
metadata can still be opened to a site's floor without the bytes going with it.
- **Deactivating an account did not end its sessions.** There is no token
denylist in this product, so the JWT user loader is the only place a
deactivated account can be stopped, and it did a bare lookup by id. Somebody
deactivated at 09:00 kept a working access token until it expired and a
refresh token that minted new ones for thirty days. `/api/auth/refresh` and
the API-token path already checked `isactive`; the loader now does too.
- **The login rate limit could be reset by a header the caller writes.**
`client_ip()` read the first `X-Forwarded-For` hop unconditionally, so
rotating it per request bought an unbounded budget, and every audit row
recorded the forged address. This walked around `TRUSTED_PROXY_COUNT`, which
exists to decide whether a proxy is really in front: the app deliberately
leaves ProxyFix off by default for exactly this reason, and then read the
header anyway. Resolution is now from `remote_addr`, which ProxyFix has
already filled from the header where a proxy is configured. Two plugins had
hand-rolled the same mistake for their own limiters and now share the helper,
which is on `shopdb.api` so the third one does not. **A site behind IIS must
set `TRUSTED_PROXY_COUNT`** or its whole floor shares one rate-limit bucket;
INSTALL-WINDOWS-IIS.md now says so.
- **What an operator approved and what an endpoint ran were two different
lookups.** Delivery descriptors for `run_script` and `update_app` are resolved
when the agent claims a command, not when the command is queued, and the
command row recorded no digest - the enqueue path resolved the descriptor
purely to validate it and threw the result away. Anyone able to replace a
script body or an installer package could do so in the gap, and the
re-resolved descriptor would then faithfully describe the new bytes: the agent
verifies correctly and runs something nobody approved, with the audit trail
naming the operator who queued the original. The approved digest is now pinned
on the command at enqueue and the claim refuses a descriptor that no longer
matches, failing the push instead of shipping it. Separately, an agent may
only finalize a command that is actually running, so a result cannot be posted
for a command that was never claimed nor overwrite a recorded failure with a
success.
- **The published site's API documentation page had no spec to show.**
`shopdb/core/api/docs.py` reads `docs/openapi.json` at runtime to serve
`/api/docs`, and publication excluded the whole of `docs/` because the prose