gh-wj
209 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
21e658ce5f |
Remove the Swagger try-it page
It shipped 1.77 MB of vendored assets to every site - swagger-ui-bundle.min.js
alone is larger than Redoc - in order to provide an Execute button that only the
development config ever enabled. Production config had it off, testing had it
off hard, and when off the routes were never bound at all. The staticdocs
directory drops from 2.9 MB to 1.1 MB, and there is one fewer vendored
dependency to track for licences and advisories.
What replaces it: an agent has the MCP server's shopdb_call, and a human has
Redoc for reading plus the 324 harvested examples in the spec for a curl that is
mostly copy-paste. The read-only page was always the one that mattered.
The tests this leaves behind are the interesting part. That file was named for
try-it but half of it proved things about the page that stays, above all that
nothing it loads comes from the internet, which is what a CDN reference costs on
an air-gapped box. Those are kept and the file is renamed for what it covers.
Pointing the air-gap test at redoc.standalone.js immediately failed: the bundle
still carried a trailing sourceMappingURL for a .map that was never vendored,
so every devtools open was a guaranteed 404 with no internet to fall back on.
That was visible in a browser console and nobody had chased it, because the test
had only ever been parametrised over the two Swagger files. The pragma is gone
and the assertion now checks the TRAILING pragma specifically: Redoc's own code
contains that string three more times, in a data: URI it assembles and in a
worker blob, and a blunt "not in body" assertion fails on correct code.
A first attempt to strip it with a line-anchored pattern removed 507 KB, because
the bundle is minified onto long lines. Hence the anchored, byte-exact removal
and the assertion that can tell a directive from source.
(cherry picked from commit
|
||
|
|
1e42356aca |
Make the API usable by an agent, and stop the contract going stale
Someone writing a script against ShopDB had two options: read a 705 KB spec, or
guess. The spec is the reference, not an index, and handing an agent the whole
thing means either truncating it or spending a context window on 479 operations
nobody asked about.
Three additions, each feeding the next.
/api/docs/operations.txt is the index: every operation on one line with its
method, path, tag, auth and summary, at around 70 KB for all 486. A caller can
hold the entire API at once, find the four operations it needs, and only then
fetch their schemas.
/api/docs/openapi.json?tag=... narrows the spec to one of the 41 tag groups.
Components are kept intact deliberately: pruning the schema graph to match would
leave references resolving to nothing, and a generator that fails confusingly is
worse than a file that is larger than strictly necessary.
tools/mcp/ is an MCP server built on both. Five tools rather than 483, because a
tool per operation is unusable - clients degrade past a few dozen and models
start picking the wrong one. It reads everything from the live instance at call
time, so enabling a plugin adds operations it can already reach with no change
here. The token is the security boundary and the README says so plainly: every
operation the credential permits is reachable, so exploratory work wants a
read-scoped token.
The rest of this is about the contract not drifting, which is what makes any of
the above trustworthy.
Two CI jobs:
- api-contract runs the parity gates and then regenerates the spec and fails if
anything changed. Those gates already existed and already worked - they caught
both routes added here, unprompted - but nothing ran them except by hand, and
the committed spec had silently drifted by three operations, including two
setup endpoints added the day before.
- migrations-mysql is the job the header comment has been claiming for some
time while not existing. It described "the REAL multi-site deploy path" and
"what catches an Alembic regression on MySQL before it ships", and there was
no such job. That is worse than no comment: it is the false confidence that
let a migration ship which could not build a schema on a fresh MySQL database.
The suite runs on SQLite, and SQLite accepts a foreign key onto a table that
does not exist.
CONTRIBUTING.md now says to run gen_openapi.py --refresh after changing a route,
and that a route the sweep cannot classify belongs in PUBLIC_ENDPOINTS with a
reason rather than hand-edited into the inventory, which the next refresh would
overwrite. The new index route is declared there alongside the spec it indexes.
(cherry picked from commit
|
||
|
|
39aaa5d0cb |
Ship the API docs the container never had, and theme them like the product
/api/docs was broken in every Docker deployment, including the air-gapped ones.
docs.py resolves openapi.json and llms.txt from the repo root, and the image
copies shopdb, plugins, migrations, scripts and wsgi.py but never docs. The page
rendered the Redoc shell and then answered 404 fetching its own specification.
Only the two files the app actually serves are copied; the rest of that tree is
prose with no business in a runtime image.
The theme was invented colours that matched nothing. It now maps the product's
own tokens, and reads the theme the way the app does: the application stores an
explicit light/dark choice and stamps data-theme, so choosing light on a
dark-desktop kiosk used to give a dark documentation page. The operating system
setting is the fallback, exactly as it is in the app.
Light-mode primary, success and error are a shade darker than the product
tokens, because those tokens are designed as fills and fail contrast as text on
the nested panels where type names and required labels sit. Measured with a
browser harness rather than judged by eye: every text node in both themes now
clears WCAG AA, from an initial 5603 reported failures - most of which were the
harness itself ignoring alpha, which is its own lesson about trusting a number.
Four Redoc elements carry hardcoded colours with no theme key and keep targeted
rules, selected by tag and attribute rather than by generated class hashes that
change on every build.
The build now compiles the Python. Docker never imports the application, so a
syntax error sails through every layer and surfaces as a crash-looping
container; that happened, four restarts, while working on this file.
Redoc's upstream licence file was never vendored although its bundle header
points at it. The placeholder added here says exactly that rather than inventing
the third-party notices it is supposed to carry, and the dead redocly.com badge
is hidden: the licence wants the notice in the distribution, not a rendered
link that a site with no internet cannot follow.
(cherry picked from commit
|
||
|
|
0eed80f3b9 |
Create endpointlogcollections in the chain that owns it
A fresh install could not build its schema. Core revision 7d51_endpoint_logcollections creates endpointlogcollections with a foreign key onto endpointcommands, but PLUGIN_TABLE_OWNERS lists both tables as owned by the endpoints plugin, and only the plugin's 0002 revision creates endpointcommands. Core migrations run before any plugin chain, and a plugin chain only runs once that plugin is enabled, which cannot happen before the schema exists. So there is no ordering in which the core revision can succeed on an empty database. MySQL rejects it with 1824, 'Failed to open the referenced table endpointcommands'. That is the first command the README gives an adopting site, on the branch that exists for sites to adopt. Nothing caught it because the suite runs on sqlite:///:memory: and SQLite accepts a foreign key onto a table that does not exist. The failure is MySQL-only and therefore invisible to every test. Fix: - endpoints revision 0025 creates the table in the plugin chain, after 0002 has created its foreign key target. Guarded, so it is a no-op on sites that already applied the core revision. - 7d51 skips when endpointcommands is absent, which is exactly the fresh install case 0025 then covers. Its downgrade is guarded to match. - EXPECTED_HEAD_REVISION records the new endpoints head. Verified against real MySQL 8.4, not SQLite: on an empty database the README sequence now completes, 7d51 stamps without creating anything, and enabling the endpoints plugin builds endpointlogcollections with endpointlogcollections_ibfk_2 pointing at endpointcommands. |
||
|
|
be1eb9b366 |
Let the API docs follow the system theme
The app has a dark theme. /api/docs was the one page that stayed white when the rest of the site went dark, which is exactly the page somebody has open beside the app while they work. Redoc does not read prefers-color-scheme, and the <redoc spec-url> element it was using takes no theme at all - so the page now initialises through Redoc.init, which does. Light keeps the default look; dark names the colours Redoc otherwise derives from the background and gets wrong: body text, the sidebar, and the right-hand sample panel, which came out dark-on-dark. It re-initialises on a live scheme change rather than restyling, because Redoc bakes its colours into styled-components at init - a CSS override reaches the chrome and misses the content. The listener has the addListener fallback for the older MediaQueryList, since a shop-floor kiosk browser is exactly where this gets read. Nothing new is loaded: same vendored bundle, same spec, no font, no CDN. A test pins that, because theming is the kind of change that quietly introduces one. The try-it page is left light. Swagger UI 5 ships no dark theme, so it would mean vendoring a community stylesheet that fights upstream on every bump - and it is a development tool that is off in production anyway. |
||
|
|
76f085e224 |
Serve a spec that knows where the instance is mounted
/api/docs on the production site handed out
https://<host>/api/apitokens for an app that lives at /shopdb. Copy that URL
and it 404s; Swagger's try-it fired at the same wrong address.
The mount is a DEPLOYMENT fact and the spec is a BUILD artifact.
gen_openapi.py writes servers: [{"url": "/"}] because at generation time nobody
knows where the app will be hung, and wsgi.py only moves MOUNT_PATH from
PATH_INFO into SCRIPT_NAME when the process starts. Served as written, the file
was right for a root install and wrong for every subpath one - which is how
production runs.
The endpoint now sets `servers` per request from request.script_root: empty at
root, /shopdb under the subpath method, and correct behind a proxy that sets the
prefix. The paths themselves stay mount-free, because the prefix belongs in the
server entry and duplicating it into each path would double it.
The file is about a megabyte, so it is parsed once and cached by mtime, and each
request gets a shallow copy with only `servers` replaced - two requests arriving
on different mounts cannot race on one dict.
A test pins both cases and the fact that paths stay unprefixed. Planting a wrong
server URL fails it.
|
||
|
|
f9d6b57f0f |
Move the documentation to the line that ships, and correct it against the code
The docs lived on one lineage and the fleet code on another. That is why six pages described a shared fleet key as how an agent authenticates, why COLLECTOR-AUTH had to warn that the per-PC credential code was not in the repository it sat in, and why ADR-019 recorded a decision whose implementation was somewhere else. Documentation that cannot see the code it documents goes stale by construction, and no amount of editing fixes that. So the corpus moves here: 84 files, 56 pages, 20 ADRs, the proposals, INDEX.md, llms.txt and both gates. Filenames are unchanged, because a wiki page name is derived from the filename and a rename breaks every link anybody saved. THE POINT OF THE MOVE IS THE CORRECTION IT ENABLES. Every caveat that said "not in this tree" was false the moment the files landed beside the code, so each was rewritten from what is actually here: - COLLECTOR-AUTH gained the real enroll request body, the two bounds on the unauthenticated route (per-IP rate limit, pending cap with stale rows dropped before a real PC is refused), and the rejected pending reason that binds to the endpoint identity rather than the nonce. - ADR-019 now describes an implementation that is present, including its tests, and says plainly that what remains is the FLEET migration, not the code. - proposals/device-identity claimed phase 1 was unbuilt. plugins/computers declares deviceid on the collector schema and prefers it over the older fallbacks. Corrected in the page, its summary and the index. WINDOWS/IIS IS NOW DOCUMENTED AS THE LEGACY PATH, per the product decision, and carefully: it is what production runs today. Every Windows page opens by saying it is supported, not for a new site, and where a new site should go instead. No procedure was cut - an operator on an IIS box at 2am needs these pages to work exactly as well tomorrow. MIGRATE-TOPOLOGY stops being a footnote and becomes the off-ramp, which is why it nearly doubled: what was there did not match how the Docker stack really works here. The Docker material that already lived on this line was kept and given ownership rather than buried under the ported Windows-centric prose: DEPLOY now describes the four services, the migrate service that runs the upgrade chain before api starts, TLS terminated by nginx, and the plugin count corrected from thirteen to seventeen - a number both lineages had wrong. ROADMAP is rewritten from evidence rather than memory: what has shipped, read out of the changelog, the ADRs and the manifests, and ahead of it exactly two items - finalize a distributed release and polish the setup wizard. PROJECT-MAP is generated and was stale; the GENERATOR was what was wrong. --check passes now. API-REFERENCE is retired to a pointer. /api/docs serves 573 operations with derived auth, query parameters and harvested examples, and a hand-written page competing with that is a liability. The file stays so its wiki page and its inbound links survive. AND THE MOVE TURNED ON A SCRUB THAT HAD NEVER RUN HERE. test_docs_publishable keys on docs/*.md, which this line did not have, so it skipped - its own comment predicted what would surface. Porting the corpus activated it and it found 48 lines that must not reach a published wiki: branch names abbreviating the site, assistant-tooling paths, the site name in two deployment pages, and an employee ID in a Windows path that the scrub does not even look for. |
||
|
|
68567d9a27 |
Add a try-it API page, and stop the docs page reaching for the internet
/api/docs renders the spec with Redoc, which is read-only by design. The thing people mean when they ask for Swagger is firing a request from the page, so Swagger UI now sits beside it at /api/docs/try, reading the same spec - one contract, two renderers. It is OFF outside development and 404s rather than rendering a disabled page: a production box holds live fleet data, and a docs page that can POST to it is a different object from a docs page that cannot. Vendored, not linked. swagger-ui-bundle.min.js and swagger-ui.css are served from staticdocs/ beside the existing Redoc bundle, because production has no internet. validatorUrl is null: left unset, Swagger UI hands the spec to the public swagger validator to render a badge, which here is a request that cannot succeed and elsewhere would ship the internal API surface off the box. THE VENDORED REDOC BUNDLE WAS ALREADY REACHING THE INTERNET, found while proving this page does not. redoc.standalone.js fetched cdn.redoc.ly/redoc/logo-mini.svg for the "API docs by Redocly" mark, so every load of /api/docs on the air-gapped server has been waiting on a request that cannot complete. Replaced with an inline transparent GIF. Three gate holes closed, each one a dodge that worked: - the page is built by interpolating url_for output into a JS string literal, unescaped. SCRIPT_NAME is proxy-controlled and ProxyFix honours X-Forwarded-Prefix, so a mount path carrying a quote closed the literal and executed what followed. The spec URL is now json.dumps'd and the rest HTML-escaped; the injected quote renders as \" and is inert - the "no internet" assertion on the page scanned for https?://, so <script src="//cdn.jsdelivr.net/..."> - the form a copy-paste from any CDN's own documentation produces - matched nothing and passed - the same assertion on the vendored files was a five-host CDN allowlist. An upstream bump to any other host walked through it, and the one real leak in that directory was on no list. It now matches the SHAPES a browser fetches - @import, css url(), src/href, @font-face - whatever the host, ignoring the data: URIs this CSS is full of And the Redoc page's link to the try-it view is built with url_for, which raises BuildError when those routes are not mounted - every production box. Made unconditional it would 500 the docs page in exactly the deployment nobody tests locally, and no test caught that. One does now. |
||
|
|
5aa748cab5 |
Publish examples and query parameters, and stop the harvest leaking secrets
The spec described 573 operations with a schema each and almost nothing a
reader could copy: 12 operations carried an example, and no operation described
a query parameter at all, because a Flask handler's query string is invisible to
url_map. A route whose real interface is ?code= documented nothing.
Both are now derived from evidence rather than written by hand.
QUERY PARAMETERS come from each view's AST - request.args.get, the subscript
form, getlist, and this repo's own helpers - and a read that does not resolve to
a literal name is not emitted. 0 to 87 operations, 350 parameter objects. The
enumerations some handlers validate (sort=assetnumber|hostname) are deliberately
NOT published: deriving the accepted set reliably needs more than a literal
read, and a wrong enum is exactly the invented contract this work avoids.
EXAMPLES are harvested from the test suite, which already sends real bodies and
asserts real responses, so they are transcripts rather than inventions. 12 to
about 324. The harvest is re-runnable and optional; generation works without it.
THE HARVEST PUBLISHED TWO REAL CREDENTIALS, and that is the important part of
this commit. A bootstrap token and a kiosk setup code reached docs/openapi.json
inside URL values - a file served at /api/docs on production and copied to a
wiki. Neither scrubber layer could see them: the keys are 'scripturl' and 'url',
which name no secret, and the opaque-value rule excludes ':' and '/' on purpose
so that example URLs survive. Nothing was committed.
Four changes came out of it:
- a URL-credential rule that scrubs the parameter VALUE and keeps the URL, so
?t= is redacted and ?page=2&perpage=50 still reads as an example
- redaction runs on the way OUT as well as on the way in. Harvested examples
live in the inventory and a refresh preserves them, so a scrubber fixed after
a harvest protects only future harvests while the stored copy keeps
publishing. The published file can no longer carry a secret the current rules
would catch, whatever the inventory holds
- the leak gate now holds its own patterns instead of importing the generator's.
Checked with the scrubber's own rules it could only ever assert that redact()
ran, and it was green while both tokens sat in the file. It now knows the
product's token prefixes, a URL-credential shape, and a crude entropy
backstop. Replanting the exact credential that shipped fails it
- a DELETE only declares a body when the HANDLER reads one, derived from its
AST. A transcript proves the client sent something: the suite posts {} to
routes that ignore it, and that published "this route reads a JSON body" on
DELETE /api/endpoints/agentkey, whose handler never calls get_json.
249 operations still carry no example, because no test exercises them with a
JSON body. The honest fix is a test, not a hand-written example, and the next
harvest picks each one up for free.
|
||
|
|
f16b463736 |
Stop a settings test depending on which tests ran before it
CI failed one test that passes here: "no such table: settings", seeding a
Setting into a database with no tables. The test was not wrong about the
product, it was asking the process-wide plugin_manager to do the work.
create_app() re-points that singleton, and eight test modules build their own
app, so it belongs to whichever ran last on the xdist worker.
_seed_plugin_settings then opens `with self._app.app_context()` on a different
app's in-memory SQLite. A sibling symptom is the registry coming back None
("'NoneType' object has no attribute 'get_all'"). Both land on whichever test
follows, never on the module that caused them, so they move with the split -
green on one machine, red on another.
The test now builds a manager for itself, sets _app and _db, and calls
_seed_plugin_settings directly. That is the routine `flask plugin upgrade-all`
runs, so what is under test has not changed, and the assertion no longer depends
on worker order. init_app is deliberately not called: it re-registers the plugin
blueprints and a second call on one app raises "The name 'backups' is already
registered".
This does NOT fix the underlying fragility, and that is a separate decision.
Running tests/test_plugins serially today gives 115 failed and 542 errors, from
the same cause; under -n auto it is nearly invisible because modules land in
separate processes. The repair is to stop create_app mutating a module-level
singleton, and it belongs in its own change rather than riding along here.
|
||
|
|
8fc6df8f0a |
Describe the API this branch actually serves
docs/openapi.json is served live at /api/docs on the production box and documented none of the routes added since 2026-09-11. It is generated from docs/api-inventory.json, which is not tracked on this branch, so the generator had no input - and tests/test_api_inventory_parity.py skipped itself for the same reason. No route here had ever been checked against the published contract. The inventory is now generated from this branch's own url_map rather than carried over from elsewhere, with each route's auth derived from the decorators and gates on its view rather than guessed. A route whose gate cannot be classified is marked for review, not quietly called public. gen_openapi.py can refresh it from the live app, so this does not rot again, and human-authored prose survives a refresh. The parity test now RUNS instead of skipping. The thing that mattered more than the missing pages: nine operations were published with "security: []" - the machine-readable claim that no credentials are needed - while their handlers require one. Two of them serve the bootstrap installer and the agent package. A generated client, a firewall plan or anything else reading the spec would have been told those are anonymous. Required auth is now expressed as required, and a token-or-network gate is expressed as optional auth with the network half named in the description, which is what it actually is. A test re-derives auth from the live app and fails when the committed inventory disagrees, because the parity check only ever compared route lists: a wrong auth string passed everything. test_openapi_spec.py asserted that optional auth was the majority of the surface, a premise the guest-role and permission model has made false. It now holds the property that matters - an optional-auth route is expressed as optional - rather than a count that was true once. gen_openapi.py also loads .env before building the app, like every other script that builds one, or a plain python run falls back to the hardcoded localhost URL. |
||
|
|
85f69038d8 |
Check the things the gates were reporting green without checking
Four gates that were not gating. NO MIGRATION COLUMN NAME HAD EVER BEEN CHECKED. The naming gate matched db.Column attribute assignments only, so every column a migration creates has bypassed the lowercase-concatenated rule since the gate was written. It now reads migrations too, in the shapes they are actually written: sa.Column in create_table and add_column, alter_column's new_column_name, the batch equivalents, the keyword form, and a literal with a leading capital or a string prefix. Run against the tree it finds nothing, which is now a fact rather than an artefact of the pattern. THE ADR-015 SITE-LITERAL SCAN DID NOT REACH THE C# AGENT, twice over: agent/ was not in the directory list, and a file-level --exclude alongside the --include list changes which files grep matches at all, so the .cs include was not doing what it looked like it was doing. A hostname or base URL compiled into the agent would have shipped to another site with no CI signal. NO PLUGIN MIGRATION WAS CHECKED FOR TOUCHING SOMEBODY ELSE'S TABLES. ADR-008 says plugin chains own plugin schema and the core chain is reserved for core tables; the endpoints chain broke that, added and then dropped columns on the core assets table, and nothing would have stopped the next one. The scan walks every plugin chain, resolves each DDL op to its table, and fails when that table belongs to core or to another plugin - including through op.execute and through a bind, which is this repo's dominant idiom and the shape a check keyed on the receiver name would have missed entirely. Statements are classified one at a time, so a leading DML verb cannot hide trailing DDL. The two historical crossings are allowlisted by plugin, file, op, table AND column, so the shipped history stays green and anything new goes red. AND THE GATE RUNNER ITSELF PASSED WITHOUT RUNNING ANYTHING. In a checkout with no venv, `python` is often not on PATH; the pytest line ends in `|| true`, so the run died 127, the report was one command-not-found line, every gate matched nothing, and it still printed "All gates ran." and exited 0. That exit code is what everything else trusts. It now refuses to run without an interpreter that can import pytest. Recorded rather than fixed, with the reasoning in both files: the publishability scrub keys on docs/*.md, so prose that lives only in docs/wiki-drafts/ does not turn it on. Whether those drafts are internal or headed for publication is a question about what they are for, not a bug to close by rewriting them. |
||
|
|
2f98188feb |
Let a site close the legacy collector routes, once it can afford to
Turning "Shared key allowed" off refused the key on the endpoints plugin and left four core collector routes open to it. Anything still holding the old key could keep rewriting logged-in user, last boot time, serial number and installed app inventory for ANY hostname, while the admin believed the shared key was off. Data integrity only - no command execution, no credential issuance - but it is a hole in a boundary the product advertises as closed. It cannot simply be closed. Both GE-Enforce reporters still authenticate with that key, so refusing it fleet-wide would stop asset and printer reporting everywhere, and on an air-gapped site mid-migration that is the wrong order of operations. collector.legacysharedkeyrefused therefore DEFAULTS TO TODAY'S BEHAVIOUR, and its description names exactly what turning it on breaks and when it should be turned on: after the reporters move to per-PC credentials, not before. A legacy route used with the shared key now logs a warning, so a site can find out who still depends on the key before flipping anything. On a stock Windows deployment that warning may name the proxy rather than the caller; the token prefix in the same line is the identifier to go by. The switch is seeded with the core defaults and has a control on the authentication settings page. Undeclared, it was a row that did not exist, and this UI has no generic key/value editor, so seeding alone would have rendered nothing for an operator to click. Two contract declarations corrected while in here: notifications.alertscope was declared db.JSON in the model while the migration that creates it ships TEXT, and those migrations state the constraint in their own docstrings: no JSON columns. The model now matches the shipped column rather than the schema being bent to the model. usb and printedparts both import a name that arrived in contract 0.35.0 while declaring core_version floors of 0.1.0 and 0.16.0, so the loader would have accepted them on a core that cannot satisfy them - an ImportError at blueprint registration instead of a refusal with a reason. Both floors now say what the plugin actually needs. |
||
|
|
7ac1ee3077 |
Refuse a kiosk caller wearing an address that belongs to the path
Both kiosks admit a check-in or check-out with no login when the caller sits in a configured network. The subtraction that makes that safe was missing. A reverse proxy normally sits ON the plant network a site enters in the allowlist. When the proxy forwards nothing - misconfigured, or something bypassed it - the request arrives wearing the proxy's own address, which matches the allowlist, while carrying no evidence whatever about who sent it. That is the one case that has to refuse, and it was being admitted. The previous fail-closed test only looked closed because the proxy in it happened to sit outside the range. Addresses that belong to the path are now subtracted before the allowlist is consulted: loopback always, since no site's kiosk is the server itself, plus whatever the site declares as its proxies. USB and printedparts get the same treatment - they had diverged for no reason anybody chose. Three more, same surface: A range that admits everybody can no longer be saved by accident. 0.0.0.0/0 and ::/0 are refused outright, anything wider than a site plausibly needs is refused unless an explicit override is set, and the refusal says why. The kiosk token no longer travels in a URL. The provisioning link carries a single-use setup code redeemed on first open, so the token stays out of browser history, referrers and proxy logs. The route is declared public deliberately: the secret is the code, the way a password-reset link is the secret. The badge-to-name lookup is bounded. It answers without a login and is now reachable from a whole network rather than one screen, so a source that keeps missing gets refused rather than being allowed to walk the directory. On by default, budget and kill switch are settings. All four new settings carry controls on their settings pages. A setting that can stop the kiosk, or widen it, must not be reachable only by hand-editing rows. The installer's client-IP advice is corrected: it described the opposite of what the shipped waitress flags do, and named only GE-Enforce as the thing that breaks when the rule is absent, when the two kiosk allowlists depend on it too. |
||
|
|
987f5c0d0f |
Stop the one-line install handing a standard user a way in
The bootstrap command staged its script into C:\Windows\Temp and then ran it elevated. That directory grants Users create-file with CREATOR OWNER full control, so somebody without admin rights can pre-plant the exact path the elevated run is about to execute. It now stages under Program Files, creates the directory with an explicit DACL when it is missing, and refuses to run a staged file that is not owned by an admin principal. A failed hardening is loud rather than silent: the run stops instead of carrying on with a file it could not protect. The token moved out of the query string into a header. In a query string it lands verbatim in the reverse proxy and IIS logs, which is a credential written to a file nobody thinks of as a secret store. The query form is still accepted, because bays provisioned before this change are holding install commands that use it, and it logs that it was used - without logging the token - so a site can see who still needs a re-issued command before turning it off with plugin.endpoints.bootstrapquerytoken. Two smaller things this also closes: A request with no token at all answered 400 naming the header, which told an anonymous caller the state of that switch and contradicted the module's own promise that anything unusable answers 404. No token is now 404. The 400 is kept for a token genuinely presented in the query form while the query form is off, which is a caller who needs to know their command is stale. The rollout documentation was republishing the vulnerable one-liner verbatim, so a tech following the runbook reinstated exactly what this fixes. Both documents now carry the current command. Also corrects the operator-facing copy for the token, which named a query argument and a header that do not exist: it is ?t= and X-Endpoint-Bootstrap-Token, per bootstrap.py's own constants. Anybody following the old text got a 404 with no reason given. Both switches added in this range are now declared with the other plugin settings and reachable from their settings page. Read through Setting.get but never declared, they were rows that did not exist until someone wrote one by hand, which on an air-gapped box means the feature was unreachable. |
||
|
|
3f80425f30 |
Make an admin's refusal stick, and let a revoke actually deny
Three holes in the enrollment path, all of the same shape: a decision the admin made did not survive contact with a PC that simply asked again. REJECTION WAS UNDONE BY A NEW NONCE. A rejected request re-armed itself as a fresh cycle when the PC came back with a different nonce, and with the shared key and auto-enroll on it was then issued the credential the admin had just refused. Rejection now binds to the endpoint identity - hostname plus BIOS serial - not to the nonce, so the PC stays refused however often it reinstalls. An admin clears it deliberately, through the approve path that already exists; a genuinely different PC is untouched. THE PENDING QUEUE COULD BE FILLED UNTIL IT REFUSED EVERY NEW PC. /enroll takes no auth by design - a bay that has never spoken to the server has nothing to authenticate with - so anything that can reach the server could sit at the cap and lock out real enrollments fleet-wide. Rows nobody has touched for seven days now age out before the cap refuses anyone, and the count of what went is audited. Per-identity dedup and the cap itself were already there and are left alone. REVOKE DID NOT STOP A LIVE AGENT. It fell back to the shared key and the server admitted it again, so revoking a compromised PC changed nothing while that key still worked. plugin.endpoints.revokeddeniessharedkey makes a revoked host refused on the shared key too. That switch DEFAULTS OFF, and that is the important part. Turning it on would silence any bay revoked for a key rotation, and would break the documented revoke-then-rollback path to an older agent - on an air-gapped site, mid migration, that is a fleet outage. A never-enrolled bay is never affected either way: with no credential row at all the shared key still works, which is the whole un-migrated fleet. The checkbox sits next to the other two enrollment switches and says what turning it on costs, because a setting that can stop the floor should not be reachable only through the API. |
||
|
|
fe4f44f855 |
Lock the shared fleet key away from the people standing at the bay
The generated install.ps1 wrote the collector key to HKLM\SOFTWARE\GE\ShopDB and left it at the DACL the key inherits, which grants BUILTIN\Users read. Any operator logged on to any bay could read the fleet key, and that key still authenticates an un-enrolled PC: it claims queued commands and downloads script bodies and installer packages. The per-PC credential work exists to retire that key, but it stays until every consumer is off it, so meanwhile what matters is who can read it. Protect-HklmShopdbKey breaks inheritance, DROPS the inherited entries rather than copying them - copying is what silently preserves the Users read - and states SYSTEM and Administrators by SID in both registry views. By SID, so a localised Windows still matches. Everything that legitimately reads this key is SYSTEM (the service, the GE-Enforce reporters) or an administrator (the adoption tool, Set-ShopDBServerPins.ps1). The tray reads no registry at all - it talks to the service over the named pipe - so nothing loses access. It runs for the key-less package too, because ServerPins live in the same key and the registry is authoritative for pinning: a standard user must not be able to rewrite the pins either. A bay that cannot take the ACL still installs. It says so, loudly, rather than failing the install or staying quiet, because an agent that is not installed is a worse outcome than a key that is still readable on that one machine. Note what this does NOT fix: bays that already carry the key keep their inherited DACL until they are reinstalled or upgraded through a package built after this change. |
||
|
|
35d4fcd41c |
Report whether a returned drive was wiped and scanned
The USB audit answered who had which drive and when, but not whether it came back wiped or virus-scanned. That is the question the record is kept to answer: removable-media custody is CMMC evidence, and custody without the state of the media on return is half the story. Both stores already held the values - the external checkinoutlog in sanitized and scanned_viruses, the app's own usbcheckouts in waswiped and scannedviruses - and neither mode selected them. Both now report them, in the JSON, the CSV and the column list, so the two directory modes still answer in one voice. Two things the rendering has to get right: - Only a return carries them. The wipe and the scan happen when the drive comes back, so a check-out row shows blanks rather than implying a fact about an event that had not happened yet. - Unknown is not "no". Both stores keep these nullable deliberately: a null means nobody wrote it down, which is a different claim from a drive that came back unwiped. Rendering an unrecorded return as "No" would put an assertion in an audit record that no one ever made, so unknown stays blank and reads as the gap it is. A test holds that, because "no" is the tempting default. auditflag lives in selfhosted.py rather than routes.py: routes already imports selfhosted, so the helper had to sit on that side or the import would be circular. |
||
|
|
0f56653345 |
Say when a kiosk screen is not set up, and hand out the setup link
Two failures from the same root: an administrator cannot see the kiosk token, and a screen that was provisioned wrongly does not say so. The settings API masks every key whose name contains 'token', so the settings page shows '********'. The real value is visible for one moment - after Generate, before Save - and an administrator who copies the field at any other time provisions a screen with the literal mask. The kiosk then answers 404, exactly as it answers a caller it has never heard of, because the route deliberately does not advertise itself. Nothing anywhere says what went wrong. - Each kiosk gains a permission-gated provisioning-link route that returns the real setup URL, token included, to somebody who already holds settings.edit. The mask stays: a token has no business in an ordinary settings list. The copy step just stops depending on catching the one moment it is on screen. Revealing it writes an audit row, since that is when the value left the database. Both settings pages get a "Copy setup link" button, which falls back to showing the link when the clipboard is refused - it is, over plain http and in locked-down browsers. - The USB kiosk asks the server on load whether this screen may be used, and says plainly when it may not. It rendered a keypad and only failed once somebody had typed their SSO, which reads as broken rather than as not set up. It cannot guess: the enabled flag is public and only says the site has a kiosk, and a caller admitted by network holds no token to look for, so the parts kiosk's localStorage check would warn exactly the callers the allowlist exists to serve. The probe is a badge-less session request - the gate runs before any badge handling, so refused is 404 and admitted is the validation error, with no lookup and nothing recorded. A test pins that ordering, because reordering it would start telling admitted screens they are not set up. Verified in a browser, not only in tests: provisioning with the mask lands on the refused screen, a correct token gets the drive list and survives a plain revisit, and a tokenless caller inside the allowlist gets straight in. |
||
|
|
da91a29155 |
Admit kiosk callers by network position, not only by device token
The device token assumes a handful of fixed screens an administrator provisions once. That is not how these kiosks are used: most people open the page on their own PC, and there is no login, so per-browser provisioning would mean touching every PC on the floor and losing it again on the next reimage or cleared profile. A screen in that state did not look refused either - the USB kiosk rendered a working page whose every call answered 404. Both kiosks now admit a caller on either ground, the same shape the GE-Enforce client endpoints already use: the per-device token, or a source address inside an allowlist. Wall screens keep the token and are not confined to the allowlist; everyone else is admitted by where they are on the network. Still gated on the site's enable flag, so nothing opens by itself, and both allowlists ship empty - token-only until a site enters its ranges. - shopdb.api gains ip_in_cidrs and request_ip_allowlisted (contract 0.35.0). On the surface because both plugins need the identical gate, and a CIDR parser copied into two of them is where a fail-open creeps in later. They fail closed on an empty setting, an unparseable caller address and a malformed entry, and they read remote_addr rather than raw X-Forwarded-For - so the IIS rewrite rule is what makes them meaningful, which the module says at length. - Separate settings per kiosk, usb_kiosk_allowed_cidrs and printedparts_kiosk_allowed_cidrs. One must not grant the other, for the same reason the tokens are separate. - Both settings pages get a picker listing the network plugin's subnets with their VLANs. It stores resolved CIDR strings, not subnet ids: the setting has to work at a site that does not run that plugin and must not break when an inventory row is deleted. Entries matching no subnet are shown rather than hidden, since a stale one left by an inventory edit looks exactly like a hand-typed range. - USBKiosk says it was not admitted instead of pretending to work, which PartsKiosk already did. - The USB settings page keyed text-vs-boolean off the token alone. Adding a second text setting to the boolean path would have saved it as the word 'true' - the bug that replaced the kiosk token and 404'd every provisioned screen. Both load and save now key off a list, with a spec on it. The trade, stated plainly in the admin UI: anyone on an allowed network can check a drive in or out against a badge number they guess, and the record carries that person's name. Badges are attribution, not authorization. Keeping the ranges narrow is what bounds it; login is the alternative and was ruled out because these users do not sign in. |
||
|
|
1a6ac67cab |
Say what a plugin settings tab can actually do, and catch a declaration that cannot work
Finding F12 of the architectural review, checked against the code and found worse than reported: the dynamic component import cannot be rewritten by the bundler, so the built file ships the unresolved path and a plugin tab answers 404 in every production build, staged or not. It works only under the dev server. Nothing shipping is affected, because the one plugin that declares a tab also registers the same page as an ordinary route, and that is the one sites reach. - The ADR draft now carries a "Constraints as implemented" section: only the model catalog hub registers plugin tabs, registration happens when that hub mounts so a tab is not deep-linkable and does not survive a refresh, and the build finding above with the bundler's own warning. The two ways out are stated as the open decision rather than taken. - The hook's docstring reads as instructions to a plugin author, and its example no longer names a path that does not exist at build time. - A route declared for a hub that cannot render it is dropped with an error naming the plugin, the hub and the hubs that do work, and the same is logged at startup. Enabling the plugin still succeeds: a tab nobody can reach is no reason to take a working backend offline. - Tests hold the two halves together: no bundled plugin may declare a hub the frontend does not implement, and the list in the server must equal the hubs that really register routes, so the two cannot drift apart again. |
||
|
|
066a19052b |
Act on the wj architectural review: races, limits, pool and contract gaps
Findings F2, F4, F5, F6, F7, F8, F9, F10, F11 and F13 from wj-architectural-review.md. F1/F1a (the non-sargable LOWER pattern) and F3 (the warranty sync holding a pooled connection) are being handled separately and are untouched here. - A collector check-in that raced another for the same PC lost everything: the unique index on the application fingerprint raised, nothing caught it, and the whole check-in rolled back, discarding state, events and hardware, answering the agent with form copy meant for a person. The insert now runs in a savepoint, reconciles what the other transaction already wrote, one row per savepoint so a second collision skips a row rather than the batch, and the check-in commits with a warning. This is the shape the delete side of the same function already had. - Enrollment is deliberately unauthenticated, so it now carries a per-address rate limit and a cap on pending requests, and the cap is a setting an admin can see and change. A flood can no longer bury the approval queue or grow the table without bound. - The fleet alert poll no longer formats debug strings on every request. - BusinessUnit is on the contract surface (0.34.0), so the endpoints plugin stops reaching into a core table with raw SQL. - A test walks every asset type descriptor and proves the endpoint it names really refuses a caller without the declared permission. - The plugin settings routes now need a permission; they were telling anyone which modules a site runs and where its components live. - The connection pool is sized explicitly with a short timeout, and the arithmetic against the server's connection ceiling is written next to the worker count instead of being left to defaults nobody chose. - Fleet alerts carry both the correct lowercase keys and the old camelCase pair. Trays older than 1.7.0 match keys case-sensitively and would have gone quiet on every PC not yet upgraded; the old pair goes when they are. - The dev server proxy points at localhost again, with environment variables for the remote-box workflow, and one developer's address is out of the server certificate page. |
||
|
|
2557d1967d |
Make the one-line install survive Defender, and approval leave a PC that works
All three found testing the bootstrap installer on Windows. - The published command used the irm-pipe-iex form, which Microsoft Defender detects as Trojan:Win32/Commando.A!ml and kills before it runs, every time, on a default install. The command now downloads the script to a file, runs it, and removes it in a finally block, which Defender does not object to. The UI also offers the script as a plain download for a tech who would rather save it. - Approving a request only linked the asset, so a PC whose pending reason was "unknown hostname" - which is every PC installed this way - kept getting 403 on every call afterwards, because its computer record had no hostname to match the credential against. Approval now stamps the hostname when the record has none, refuses with a 409 naming both when the record has a different one, and overwrites only when the admin confirms. The Credentials tab offers that confirmation. - A hostname that resolves to no record now says so instead of reading as an impostor, while still refusing the request. - While waiting for approval the agent retried on the 15-minute telemetry cadence, so a PC approved immediately enrolled a quarter of an hour later and the installer always ended on "still pending". It now retries about every minute for the first ten minutes, then every five to thirty minutes, then every fifteen, with jitter. A rate limit or server error still uses the slower backoff. |
||
|
|
ca57b7f728 |
Install an agent on a one-off PC from a single command
An admin generates a short-lived token in Settings > Endpoints > Endpoints and
copies the command it produces. A tech runs that one line in an elevated
PowerShell on a PC with no agent:
powershell -ExecutionPolicy Bypass -c "irm https://<server>/shopdb/api/
endpoints/siteagent/bootstrap.ps1?t=<token> | iex"
The script refuses to run unelevated, refuses a PC that already has the agent
(a managed bay gets it from GE-Enforce), downloads the package the existing
builder produces, checks both binaries against the hashes the server baked in,
installs with NO shared key, and reports what happened. The PC then asks to
enroll and waits for an admin to approve it, which the script watches for
locally for two minutes before exiting.
- New endpointbootstraptokens table and migration
endpoints0024bootstraptokens: hash only, expiry, use count, revoke.
- Admin routes to generate, list and revoke tokens; the secret is shown once.
- Two token-only routes: the script, and the package (which spends a use).
Anything wrong with the token answers 404 rather than confirming it exists,
both are rate limited per address and both are audited with the caller.
- The package builder is now shared rather than duplicated, and a bootstrap
package can never carry a collector key.
|
||
|
|
8f10c09a76 |
Always pin the server, let the registry own pins, and survive a revoke mid-upgrade
Found in the Windows VM re-test. - A CA-flagged server certificate (common with a plain openssl self-signed cert) produced an installer package with no pin at all, so agent 1.20 refused the server. The package now always carries the SPKI pin of the certificate PCs receive; the CA flag only skips the trusted-root import. When an uploaded CA-flagged certificate differs from the server's own, both pins are included. The build response carries X-ShopDB-Build-Warning and the installer logs what older agents need. - Pins written into appsettings.json by the installer were merged with the registry, so removing a pin from HKLM never took effect. When the ServerPins value exists in the registry it is now authoritative, even when empty; appsettings pins apply only when it is absent. Set-ShopDBServerPins.ps1 -Clear deletes the value; new -None writes an empty value (no pins). - A script or package download sent with a credential revoked moments earlier got 401 CREDENTIAL_INVALID and failed the command. The agent now retries that download once with the shared key and records the fallback in the result. |
||
|
|
1c2ba677c3 |
Let key-less PCs ask to enroll, and let an admin retry a trust-blocked PC
Found in the Windows VM re-test. - A PC installed without the shared key got 401 from POST /api/endpoints/enroll while the shared key was allowed, so no request was recorded and no admin could approve it. Enroll now never refuses: a valid shared key only decides auto-issue on a clean match; no key or an invalid key creates or updates the one pending request for that hostname and serial. An approved request still issues only to its own nonce. - A PC blocked with "server certificate not trusted by this PC" could never be queued again after trust was fixed, because the blocker comes from its last failed upgrade. Naming the PC in a wave now queues it when trust is its only blocker, and the response lists it under "retried"; "next n" still skips it. The Migration table lets such a PC be selected. - counts.pending on GET /enrollment counts pending requests, including ones for unknown hostnames; counts.pendingcomputers keeps the per-computer figure. |
||
|
|
22d06abb70 |
Show per-PC credentials across the admin interface
- Endpoints (shared key) page: the key is presented as what PCs use until they enroll, with enrollment progress, a link to Enrollment, rewritten guidance, and rotate/revoke confirmations that say how many PCs are not enrolled yet. - Settings cards describe the server certificate as what PCs actually receive, on IIS or Docker; Enrollment has its own icon (the icon set lacked the one the Server Certificate card already asked for). - Server Certificate page checks the certificate the site presents at its public address and compares its pin with the certificate file, warning when they differ. The host comes from site settings only; the check is a TLS handshake, never an HTTP request. - Fleet list: Credential column and filter (enrolled, pending, re-enroll blocked, shared key, not reporting), computed in the list query. - PC detail: credential state, prefix and dates for fleet readers, state only for others, with admin Revoke (optionally blocking re-enrollment) and Allow re-enrollment. - Dashboard: pending enrollment requests, and PCs blocked from re-enrolling for more than seven days. - Runbook: where to find each of these in the app. |
||
|
|
70d5531b4d |
Make rollback, first upgrades and pin changes work as tested on Windows
Found in an end-to-end test on a Windows 11 VM against a self-signed server.
- Rollback: a 1.20 agent re-enrolls within a second of an un-enroll, so an
older agent pushed afterwards arrived on an enrolled PC and was refused.
Revoke now takes {"blockreenroll": true, "reason": ...}; a blocked PC is
never issued a credential and keeps working on the shared key until
POST .../assets/<assetid>/allow-reenroll. The Enrollment page offers the
block when un-enrolling and shows blocked PCs with an allow button.
- First upgrade of old agents: agents before 1.20 check the server
certificate when downloading the upgrade, so a store wave failed on PCs that
do not trust a self-signed server while the Migration tab said ready. A
failed download now marks the PC blocked with "server certificate not
trusted by this PC". New Install-ShopDBServerCertificate.ps1 (GE-Enforce,
SYSTEM) adds a non-CA server certificate to the trusted roots after checking
its thumbprint; the server certificate page offers a .cer download and the
SHA-1 thumbprint.
- The generated installer writes ServerPins (with an optional next pin for a
planned rotation), can leave the shared key out so the PC waits for
approval, never touches an existing credential, and skips the trusted-root
import for a CA certificate.
- The agent re-reads server pins about every 60 seconds and recycles pooled
connections within 2 minutes, so a pin change needs no restart.
- An alert can only be acknowledged by a PC it targets.
- A wave no longer counts a PC as failed after a later wave upgraded it.
- The scripts report a pin mismatch only when the pin really is missing, and
otherwise log the real error.
- Runbook: trust step before the first wave, rollback order, pin re-read,
verifying the pin against the live URL; agent contract updated.
|
||
|
|
7480e4fe3a |
Give each endpoint its own credential and fix the review findings on the server
Per-PC credentials (replaces the 09-14 client-certificate attempt): - POST /api/endpoints/enroll issues a credential to a PC that matches exactly one computer by hostname and BIOS serial and has none yet. Anything else (unknown or duplicate hostname, serial mismatch, already enrolled) waits as a request an admin approves or rejects. The agent's persisted nonce binds an approved request to the PC that asked. - One auth helper serves every agent route. With X-Endpoint-Credential the asset comes from the credential and a request naming another PC gets 403 ASSET_MISMATCH. The shared key still works for PCs that have not enrolled, is refused (401 SHARED_KEY_REFUSED) for any PC that has, and can be switched off. A revoked credential gets 401 CREDENTIAL_INVALID so the agent re-enrolls. - The core collector accepts the credential for the computers, printers and endpoints collectors through two new plugin hooks exposed on shopdb.api (contract 0.33.0), and refuses the shared key for enrolled PCs. - Admin API: enrollment list, approve, reject, revoke (un-enroll), settings. Migration API: per-PC readiness with blockers, and upgrade waves that package the agent once and queue the existing agent upgrade per PC. - Migration 0023 creates endpointcredentials, endpointenrollrequests and endpointmigrationwaves, drops the certificate columns from assets where test databases have them, and renames app_fingerprint to appfingerprint. 0021 and 0022 keep their ids but no longer alter the core assets table. - Removed: client_cert.py, certificate renewal, the unauthenticated /test/client-cert route, nginx client certificate forwarding and the agent certificate script. The client-cert header could be forged on IIS, the key was readable by users, and the shared key remained a fallback. - GET /settings/server-cert returns the SPKI SHA-256 pin for GE-Enforce. Review fixes: - report_command_result used g without importing it; every result was a 500. - collect_log refuses setup answer files, appsettings, agent directories and key files at enqueue. - Fleet alerts: alerttimeout and alertfleet agree with the migration, the scope is validated and a malformed scope no longer reaches the whole fleet, and the ack race no longer raises NameError. - A refused break-glass override leaves no hold and no audit row. - Agent upgrade checks locks and permissions before repackaging, and writes the package atomically. - Server certificate upload validates the pair before replacing files; generation no longer builds an openssl config from user input; audit rows are actually written. - Health evaluation runs in a savepoint so a caught error cannot lose the check-in. - Required-app policies are found by appid, survive renames, and deleting an app deactivates policies that still name it. The UI and API now say such a policy only takes effect inside a business policy. - Backfill handles duplicate fingerprints and rolls back a failed batch. - Plugin settings routes skip a plugin whose hook raises. - The setup token is no longer printed to the log. - The naming gate checks top-level files again and handles odd file names. - Removed root scratch scripts, one of which carried a token. - Rewrote the fleet alert scheduling and app delta tests on real fixtures. |
||
|
|
5cd5332397 |
Add agent fingerprinting, client certificate auth, and security audit documentation
This commit adds: - Database migrations for app_delta fingerprints and client certificates - Scripts for certificate generation and fingerprint backfilling - Comprehensive security audit reports and findings tracking - Implementation documentation for certificate authentication - Test utilities for certificate renewal and fingerprinting - GE-ENFORCE collector fix documentation Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> |
||
|
|
ca90969eb4 |
Fix fleet alerts sent immediately instead of respecting scheduled starttime
Bug Fix: Fleet alert scheduling regression
Problem:
- User sets notification to run at 3:30 PM (future time)
- Fleet alert sent to agents immediately at 3:10 PM instead of waiting
- Agents see notification 20 minutes early
Root Cause:
- GET /api/endpoints/alerts query filtered for:
- alertfleet = True
- isactive = True
- endtime is NULL OR endtime > now (not expired)
- Not acknowledged
- MISSING CHECK: starttime is NULL OR starttime <= now (has started)
- Result: Any notification with future starttime appeared immediately
Solution:
- Added starttime filter to fleet alert query (routes.py:4151-4153)
- Query now checks:
db.or_(
Notification.starttime.is_(None),
Notification.starttime <= now
)
- Notifications with future starttime excluded until that time
Behavior After Fix:
- Notification with no starttime: appears immediately (existing behavior)
- Notification with past starttime: appears immediately (existing behavior)
- Notification with future starttime: waits until that time (FIXED)
- Notification with past endtime: never appears (existing behavior)
Testing:
- tests/test_plugins/test_endpoints_fleet_alert_scheduling.py
- test_fleet_alert_respects_future_starttime (regression test)
- test_fleet_alert_with_no_starttime_appears_immediately
- test_fleet_alert_past_starttime_appears
- test_fleet_alert_expired_does_not_appear
Timeline Example:
- 3:10 PM: User creates notification with starttime=3:30 PM, alertfleet=True
- 3:15 PM: Agent polls /api/endpoints/alerts -> empty (starttime not reached)
- 3:35 PM: Agent polls again -> alert appears (starttime has passed)
Impact:
- Agents poll every 60 seconds, so scheduled alerts appear within 1 minute of starttime
- No database migration needed (starttime column already exists)
- Backwards compatible (NULL starttime still means immediate)
Related:
- Notification.is_current property already checks starttime (models/notification.py:162-169)
- Shopfloor board respects starttime correctly
- Only fleet alert endpoint had this regression
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
|
||
|
|
a186ba1136 |
Add plugins.get_plugin_settings_routes to PUBLIC_ENDPOINTS
New endpoint must answer unauthenticated pages (kiosks, TV displays) so they can load plugin routes before login. Gate test now passes. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> |
||
|
|
8be1c2379f |
Add one-click agent self-upgrade from the endpoint detail page
An operator could see that an endpoint was behind the published build but had no way to act on it. update_app resolves against Application rows, and the agent lives in the version store, so there was nothing to push. Bridge the two: the staged build is packaged and registered as a reserved Application row, and a managed PowerShell script is seeded to install it. The endpoint downloads that package over the existing collector-authenticated app-package route, so no parallel delivery path is introduced. The new route prepares the delivery and then DELEGATES to enqueue_endpoint_command as a digest-pinned run_script. It deliberately does not implement its own enqueue: a second path would duplicate, and eventually drift from, the endpoint lock, the Configuration Policy gate, stale-command reaping and the at-most-one-in-flight rule. A fleet-wide agent swap is the last action that should skip a quarantine check. Tests pin that delegation rather than only the happy path. Because it lands as run_script, the caller needs endpoints.runscript on top of endpoints.agentupdate. That is intended: this delivers arbitrary SYSTEM PowerShell by the same mechanism as any other script push. The audit event is written only on an actual 201, so a refused push does not record an upgrade that never happened. Replacing the agent is self-modifying the control plane - the process receiving the command is the one being swapped. The script therefore re-launches itself detached and exits immediately, because the agent runs run_script as a direct child and holds its stdout/stderr pipes while waiting on it. Expect the command to report queued rather than success: the process that would report completion is stopped mid-run. Judge the result by the version the endpoint reports afterward. Tray relaunch does not use the installer's `explorer.exe <path>` trick. That works in install-shopdbagent.ps1 only because the installer runs as the user - note its own `-ne "SYSTEM"` guard. This script always runs as SYSTEM in session 0, where the hand-off reaches no shell and exits 0, so Start-Process reported success for a relaunch that never happened. It also cannot descend into the session with a token-duplicating launch: UserContextSpool documents that WTSQueryUserToken is blocked by endpoint protection on these hosts. Instead a one-shot scheduled task targets the pinned ShopFloor account, matching how the user-context spool already resolves that user, and the tray must run as that account or the mapped-drive probe reports the wrong (empty) drive table. The relaunch is verified by polling for the process rather than assumed, and stays best-effort: the Run key still starts the tray at next logon, and a late tray is not worth rolling back a good agent upgrade. panel_status now carries agentbehind/agentversion/agentcurrent so the UI gates the action on a flag rather than parsing "(behind X)" out of a label. The button is confirmed by typing UPDATE, naming both versions and the service interruption, matching how run_script and system_restart are gated. Verified on live endpoints: upgraded 1.13.0.0 and 1.15.0.0 machines to 1.16.0.0. run_script delivery, digest fetch and system context all predate 1.13.0.0, so no minimum agent version is declared for this path. |
||
|
|
d32293ccb2 |
Auto-register staged agent builds as a pushable application
Gate collect_log on a minimum agent version and bridge the agent version
store to the applications table so a staged build can actually be pushed.
update_app resolves an install source from the applications table alone; it
cannot see agentstore. A build could therefore be staged and current while no
application row existed at all, which is what the fleet was in: the agent was
unpushable. Staging is now the single source of truth - it packages the
current build and upserts the row in the same request, so the row cannot
describe a build that is no longer staged.
PARTIAL BUILD ADVISORY
This is steps 1-4 of a longer rollout and is deliberately incomplete:
- There is no push trigger yet. The registered package is resolvable and
verifiable, but nothing enqueues an upgrade; the "behind current build"
indicator is still display-only.
- The upgrade template script, its script row, and the enqueue route
(endpoints.agentupdate) are not written.
- Blocking constraint for the next slice: run_script executes
powershell.exe -File <script> with NO argument channel, so a delivered
upgrade script cannot be passed BaseUrl/CollectorKey and must either
source them itself or run_script must be extended first.
Nothing here touches a live endpoint. Staging only changes what a future
push would contain.
Details:
- Package members carry a fixed timestamp. zipfile stamps the current mtime
by default, so identical input produced a different archive digest on
every call. update_app pins a download by SHA-256, so a drifting hash
would rewrite installersha256 out from under an endpoint mid-download and
fail its verification.
- All three routes that can change 'current' re-register (upload,
adopt-legacy, rollback). Covering only upload would leave the row pinned
to the build an operator just rolled away from, and the push would ship
it anyway - silent drift.
- Registration cannot fail an upload. The binary is staged and audited by
the time it runs, so a packaging problem is reported in the response
payload rather than raised; returning an error would tell the operator a
successful upload was rejected.
- The package is written to a temp name and os.replace'd, so an interrupted
write cannot leave a truncated archive at the path the row advertises.
- The row is isinstallable but hidden: machine-managed, and it must not
appear in the software catalogue beside real licensed applications.
- Added application_package_location() to the shopdb.api contract surface
(lazy, matching collector_authorized) returning the package directory and
URL prefix together. agentregistry reached into shopdb.core internals,
violating the plugin import contract; sourcing both from one place also
stops the write location and installpath from drifting apart.
collect_log is refused at enqueue below its minimum agent version, with
numeric comparison - string ordering misplaces 1.9.0.0 against 1.16.0. The
refusal is mirrored in the UI so a command slot is not spent on a call that
is guaranteed to fail.
Test isolation: create_app('testing') resolves instance_path to the real repo
instance/ directory, so agent upload tests write into the developer's actual
store. The new tests redirect both the store and package output into tmp_path
and include a guard test that fails if that isolation regresses. NOTE: the
pre-existing test_endpoints_agentupload.py still has this leak and deposits
1.5.0.0 and 1.6.3 into instance/siteagent/ on a full run - not fixed here.
Suite: 2834 passed, 11 skipped. The two failures (test_docs_publishable, and
a symlink scan needing Windows privileges) were confirmed pre-existing by
stashing this work and reproducing them.
|
||
|
|
b49fb60c0c |
Add log collection, script output capture, and timestamp fixes
Log Collector: a new collect_log action reads a plaintext log file from a known path and returns its tail for viewing and download. Rides the existing endpoints.updateapp grant rather than inventing one, since collecting a log is the same trust level as pushing an application. Collected logs land in their own table with per-asset retention of 20 instead of overloading the command audit row, and the agent refuses UNC, traversal, wildcards, directories and non-text content independently of the server check. collect_log is POLICY_EXEMPT. Every existing Configuration Policy predates the action and so cannot name it, and the intersection in ALLOWED_ACTIONS_FOR drops anything unnamed - so every policied endpoint refused log collection outright, including for admins, who saw a permission-shaped error for a grant they held. Exemption from policy is not exemption from permission: the elevated grant still applies, and a regression test asserts both halves so a future edit cannot quietly loosen the grant while "fixing" the policy gate. Script output capture: successful runs can return output, gated per script by captureoutput. Failures always returned it; success never did, which left a green badge as the only evidence a run produced. Timestamps: Activity and Operator Reports rendered date-only because the shared formatter's 'date' branch drops the clock time. Run Scripts additionally labels its timestamp, since a bare time meant "completed" on a terminal row but "queued" on an in-flight one - indistinguishable, and the wrong reading. Agent 1.16.0. Minor: collect_log is a new action name, so an older agent asked for it fails closed on the unknown-action default. |
||
|
|
494b837bae |
Make alertscope actually target fleet alerts
alertscope was stored, serialised and editable in the authoring form but
never read when alerts were handed to an endpoint, so every fleet alert
went to every machine regardless of what was entered. The control implied
a capability nothing enforced.
get_fleet_alerts now resolves the polling endpoint's facts and filters
each alert through its scope. Supported keys: hostname, assettype,
computertype, location, businessunit. A key may hold one value or a list
(any match), matching is case-insensitive since these are operator-typed
names, and multiple keys narrow together.
Unrecognised keys are ignored rather than treated as match-nothing. A
typo like {"hostnam": "BAY-01"} would otherwise mute the alert fleet-wide
and look identical to a delivery failure; sending wider than intended is
the safe direction for something whose job is to warn people. A garbled
non-dict scope falls back to the whole fleet for the same reason. The
real fix for typos belongs at authoring time, not delivery.
BusinessUnit is not exported from shopdb.api, so its name is resolved by
table rather than importing the model and breaking the plugin import
contract.
Ten tests cover the narrowing cases and the fail-open behaviour.
|
||
|
|
e0a7a51d46 |
Fix fleet alert server surface inherited from wj
The wj merge brought in a half-built fleet alert feature: server-side UI behaviour still being stood up over a delivery proof of concept. It left five red tests. This clears them and fixes a real hole underneath. The cross-machine guard never ran. acknowledge_fleet_alert read .assetid off _resolve_asset_for_hostname, which returns the id itself, so the check raised AttributeError on every call and answered 500. That guard is what stops one endpoint acknowledging another's alert, and the collector key is shared across the fleet, so until now any bay could clear an alert on another machine's behalf. Compare the id directly. Gate both alert routes the way the rest of the agent channel is gated. They are collector-key authenticated, not JWT+role, because a shop floor bay has no user session - the same shape as claim_next_command and upload_operator_report. Declared in the authz allowlists with the reason, rather than inventing a permission no agent could hold. Settle the delivery contract ahead of the durable agent work. The poll response is authoritative for a host: every alert that should be live on it right now. An alert absent from the feed is the agent's instruction to retire the pending toast, which is how a server-side close or cancel reaches a machine already displaying one. Acknowledgement is terminal per product decision, so a close after an ack is a no-op and the agent can retry a queued ack safely. A toast that merely timed out is not an acknowledgement. Stop reaching past the plugin contract surface for the mailer. One import was a plain redirect; the other pulled a private helper to pre-check recipients, duplicating what send_alert already does. It also returned early when no email recipients existed, which silently suppressed the independent webhook leg - misconfigured SMTP was dropping Teams alerts. Register NotificationAcknowledgement in get_models() and give the notifications plugin ownership of notificationacknowledgements, then move the expected head to notifications0006fleetalerts. Add the coverage whose absence let the .assetid bug ship. Thirteen tests over gating, authoritative-feed withdrawal, terminal and idempotent acknowledgement, per-asset scoping, and the cross-machine guard. Five of them fail against the pre-fix handler. Remaining suite failures are pre-existing and local: the symlink scan needs elevation, and the docs check wants files absent from this checkout. |
||
|
|
d6163f54a4 |
Stop scripts reporting success after doing nothing
A run_script command was judged solely on PowerShell's exit code, and PowerShell treats most errors as non-terminating: it writes the error, runs the next line, and still exits zero. The account-provisioning script reported succeeded having provisioned nothing, and because no output was captured there was no evidence to contradict the verdict. Scripts now carry an errormode. Strict prepends $ErrorActionPreference = 'Stop' so the first error ends the run and the exit code means what it says. It is opt-in per script rather than global because bodies authored against the permissive semantics rely on them - tolerating a failing cleanup, probing with a cmdlet that errors when a thing is absent, looping past a bad item - and would start aborting midway, leaving endpoints half-configured. The default is unchanged, so every existing script keeps running exactly as before. The prologue is applied after digest verification, so the SHA-256 still validates the bytes the server delivered. stdout/stderr is captured on both paths: redirected streams for SYSTEM, a transcript file under the ShopFloor profile for user-session runs, since CreateProcessAsUser cannot inherit the service's pipes across the session boundary. Output is always logged locally and returned to the server only on failure, so routine output stays on the machine that produced it instead of filling command history. Also adds the optional per-script working directory, which the same surfaces needed, and a collapsible explainer on the scripts page - the dropdown is not self-explanatory, and an operator who does not know about non-terminating errors has no reason to ever leave the default. Agent 1.14.0. An older agent ignoring errormode keeps today's behaviour. |
||
|
|
fd18099199 |
Gate the script library on the grant that pushes scripts
The script library was gated on applications.*, so anyone holding the software-delivery grant could rewrite the bytes of a script that a separately trusted operator later pushed to a bay. The push was gated on endpoints.runscript; the CONTENT of what got pushed was not. An installer is a vendor artefact, but a script here is arbitrary PowerShell this site wrote that the agent runs as SYSTEM, so the write and the push must require the same trust. All six script routes now require endpoints.runscript, including the body download - reading the bytes is not a lesser act than listing them. Core names the permission as a string rather than importing it from the endpoints plugin so the module stays importable with the plugin absent. This tightens access and there is deliberately no backfill: the operator scope reaches applications.create/edit, so operator-scope holders lose the library. Granting them endpoints.runscript on upgrade would undo the change and hand every operator remote SYSTEM code execution, since the same grant gates the push. A test pins that no prebuilt scope confers it. Adds the System Scripts settings page and card. The route requires the permission rather than admin, because the grant is meant for non-admin operators and the sibling admin gate would lock them out of the library the grant exists for. The settings icon map gains terminal and key; the agent-key card had been asking for key since it shipped and silently drawing a puzzle piece. The library had no test coverage before this. |
||
|
|
0b5546bcef |
Cap plugin revision ids at the 32 chars MySQL stores
Rolling the containers failed on strict MySQL: the endpoints chain could not stamp its new head with "Data too long for column version_num". Plugin version tables declare version_num VARCHAR(32) and the new revision id was 33 chars. Nothing in the suite caught it. The tests run on SQLite, which does not enforce declared column widths, so an over-long id passes locally and fails only on a real deploy - after the migration body has run and only the stamp fails. The rollback was clean here, but the failure lands at the worst possible point. Shorten the id and assert the limit in the test that already parses every plugin revision, so the next over-long id fails on a laptop instead of during a roll. Core is deliberately not covered: its alembic_version is VARCHAR(128), and it already carries ids longer than 32 that are correct. |
||
|
|
8f115ca330 |
Split shipped monitor bundle into per-monitor policies
The product shipped one Configuration Policy carrying every shopfloor app monitor, wrapped in a shipped Site Policy that applied it to every endpoint. That bundled two decisions a site cannot separate: which monitors exist, and which endpoints get them. A site that wanted eDNC on one PC type and MTConnect on another had to delete the shipped policy and rebuild both halves. Ship one Configuration Policy per monitor with no PC type attribution, and stop shipping a Site Policy at all. Composition is a site decision; the product now supplies parts, not an assembled opinion. Shipped Configuration Policies become editable and deletable. Provenance stays a source tag rather than a lock, which is safe because seeding is insert-if- absent and no reconciling seeder exists, so a site edit is never stomped. The stale comments claiming shipped rows were read-only and reconciled described behavior the code never had. |
||
|
|
68ddf87d3b |
Gate operator reports behind their own grant
Operator report screenshots are an uncontrolled capture of whatever was on the operator's screen when they filed the report, so they can carry part numbers, customer names, or other shopfloor content. The rest of endpoints.view is uniform machine-collected telemetry. Reading a PC's CPU load does not imply a right to read someone's screen, so the reports and their images move to endpoints.operatorreports. The image route is the real control point, not the panel: the panel only decides whether a tab is drawn, while the bytes stay reachable by URL to anyone who can guess an id. Both GET routes now require the new grant. Migration 7d47 backfills the grant to everyone holding endpoints.view so nobody loses a panel they were already using; tightening from there is a per-account decision in the user editor. The grant is in the scope denylist, so it is a per-user direct grant and Reporter cannot hold it. Also adds test_user_editor_checkboxes_match_the_server_action_list. The SCOPE_ENDPOINT_ACTIONS comment claimed a sync guard kept the Vue checkbox list aligned with the Python tuple, but no such test existed. The lists had already drifted: endpoints.operatorreports reached a running container with no way to grant it through the UI. The guard catches drift in both directions - a name only in Python ships the feature dark, and a name only in Vue is worse, since the admin ticks a box and believes access was granted while the server refuses to store it. |
||
|
|
0134eaa219 |
Hard delete applications, nest installer packages, detect PE versions
Soft delete left orphaned executables on disk and let a deleted name collide with a fresh import, so delete is now a hard delete that removes the associated files. The destructive paths (delete, revert-to-record) require a typed OK because neither can be undone. Installers keep their filename at import and nest per application/version, which stops repeat imports doubling up. Version now comes from the same PE detection the endpoints agent uses; when no version can be read the caller is asked for one rather than silently storing a blank. Applications also surface Version and a monitorable toggle, with launch path and process name required only while monitorable is ticked. Fleet shows the PC alias, matching PC Details. |
||
|
|
79545db96e |
Add a Delete action to the applications list and withdraw the app policy
The Applications page had no delete control even though the endpoint and the API client both already existed, so the gap was UI-only. The button is gated on applications.delete via hasPermission rather than on the admin role, mirroring the backend guard: admins bypass it anyway, and a non-admin role granted the permission should get the button instead of a control that would only 403. Deleting also withdraws the generated install policy. Delete here is a soft delete, and the policy is a separate row that did not follow, so a deactivated application kept installing itself across the fleet with nothing on screen to explain why. A customised policy is still left alone, on the same ownership terms as unticking the box. |
||
|
|
aa9dafc5ca |
Add application-driven policy generation with an acknowledgement gate
Ticking "Required on all PCs" now generates a configuration policy that installs the application on every non-server endpoint it matches, rather than only flagging a software-compliance report row. Because that save is a fleet-wide deployment, it requires explicit acknowledgement. The gate is enforced in the API, not only in the form: a confirmation dialog that can be sidestepped by POSTing directly is decoration, not a safeguard. It fires only on the transition INTO the required state, since re-saving an already-required application deploys nothing new. Generated policies carry a new 'autoapp' source. They stay editable, and hand-editing one transfers ownership to 'local' so it survives a later uncheck; 'shipped' remains read-only. Existing required applications are not migrated retroactively - policies are only created on save. Also adds the monitorable fields that the shipped-policy breakup will build on, and repairs a mangled non-ASCII byte in copilot-instructions.md that was failing the style gate. |
||
|
|
31dc9d49e1 |
Add an All PCs policy scope that excludes servers
The policy builder only offered "Matches everything" (the `true` scope node), which also targets servers. Authoring an estate-wide policy almost always means "every PC an operator sits at", and a reboot or script push against a server is a materially different event. Adds an `allpcs` scope node that matches every endpoint except those whose computer type is server-designated. Server designation reads the computer type's ICON slug, which is nominally a UI hint and is now load-bearing; it was chosen over matching the type NAME (a site can rename "Server") and over a new isserver column (needs a migration). It is isolated behind SERVER_ICON so promoting it to a real flag later is a one-place change. The `true` node is retained and still matches servers, so existing policies keep their exact meaning rather than silently re-scoping. It stays visible in the dropdown only when editing a policy that already uses it; new policies default to `allpcs`. Covered by tests/test_plugins/test_computers_policy_allpcs.py, verified against a planted canary so the server-exclusion assertions are known to bite. |
||
|
|
5de6a2b170 |
Seed plugin permissions and relink scopes on plugin upgrade
on_install/on_enable fire only on a state transition, so a permission declared by a later plugin version never reached an already-installed site through them. Until someone ran flask seed permissions by hand the name did not exist, and any route guarding on it refused every caller. Settings already had sync_all_plugin_settings() on the upgrade path for exactly this reason; permissions now have the counterpart. The scope relink is the half that actually bites. Creating the Permission row only makes the name exist - the prebuilt scopes derive their grants from the catalog, so a permission seeded without a following Role.seedscopes() belongs to nobody but admin. The plugin installs clean and its UI is invisible to every non-admin, which reads as a broken plugin rather than a seeding gap. Ordering matches flask seed permissions: seedscopes only links rows that already exist, so it runs last. Additive, so a site's deliberate role edits survive the deploy, and best-effort per plugin so one bad plugin cannot abort a deploy. Test deletes an existing plugin permission, runs upgrade-all, and asserts both that the row returns and that it is granted to the technician scope. Verified it fails without the fix rather than passing vacuously. |
||
|
|
4c1d96b6ba |
Bump plugin contract to 0.32.0 and register new plugin tables
Exports caller_is_role on the plugin authorization surface. Additive name, so a minor bump. Scoped personal tokens are always refused by it, matching require_role, which stops a plugin from accidentally letting a scoped token inherit role authority from its owner. Registers the tables added by the endpoint lock and policy hold work (endpointlocks, policyactionholds) plus the endpoint health tables in the alembic template, so plugin migrations see them and the ownership guard does not report them as unclaimed. |
||
|
|
0e43ac6b8d |
Add prebuilt user scopes and guard invalid Reporter grants
A newly created user held no roles and no permissions, so the sidebar filtered every nav item away and the account appeared broken. Adds three prebuilt scopes (Reporter, Operator, Technician) derived from the live permission catalog by verb rather than hand-listed, so a new plugin permission lands in the right scope automatically instead of silently going ungranted. users.view is granted to all three since a user who cannot see the directory cannot collaborate; users.create/edit/delete stay admin-only. Role.seedscopes() is additive and idempotent, seeded via flask seed permissions, and preserves custom grants on rerun. Reporter guard: the auth model is additive across admin, direct grants and roles, so Reporter cannot be expressed as merely fewer grants - a direct endpoint-action grant would silently re-arm a read-only account. Rather than change the global permission predicate and alter every route's semantics, _check_scope_grants() rejects the invalid combination at write time on both create and update. The update path validates the effective post-update state, so demoting a Technician to Reporter is refused while a stale endpoint grant remains rather than quietly leaving a read-only user with write authority. Validation runs before the User object is constructed. Assigning user.roles first put the relationship into the session autoflush path while the User was still unattached; nothing broke because the refusal returns before db.session.add(), but it left a trap where any query added below that point would autoflush a half-built user and surface later as an orphan row. Verified with -W error::sqlalchemy.exc.SAWarning: 25 passed. |
||
|
|
52c9d2b009 |
Add multi-action policy holds and exempt collect_now from policy
Adds PolicyActionHold so an admin can suspend specific actions on a computer without editing the policy itself, covering the break-glass case where a machine must be quiesced immediately. Holds cover multiple actions in one atomic submission rather than one row per action, so a partial failure cannot leave a half-applied hold. Duration is free-form with 0 meaning indefinite (migration 0006 makes expiresat nullable); the prior fixed ceiling was removed because an admin quiescing a misbehaving machine does not know in advance how long it needs, and a hold silently expiring is worse than one that must be cleared deliberately. The What This Does explainer reflects indefinite holds. collect_now exemption: refreshing status was denied on any endpoint with a non-empty effective policy even when the caller held the matching grant, because policy intersection silently removed the action. collect_now is a read-only status refresh with no side effects, so it now sits in POLICY_EXEMPT_ACTIONS alongside the other routine actions. Verified against a synthetic non-empty policy: operators can refresh status again while every write action stays gated. |