Merge origin/main (West Jeff) into wilm

Bring in the natural-key import fix, Metrics->Monitors rename, PC detail tabbing and panel groups, guest search grants, and the 2026-08-30 security fixes. Reconcile endpoint packaging/installer, migrations, contract version, and tests; keep the delivery uninstaller and 6-step installer alongside main's permission-based RBAC and pin-drift protection.
This commit is contained in:
benjamin.baker
2026-08-31 16:59:07 -04:00
59 changed files with 2859 additions and 180 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

View File

@@ -64,10 +64,17 @@ public sealed class CommandClient : IDisposable
// result is logged and swallowed so a transient network blip does not crash
// the worker; the command simply stays 'running' server-side until a later
// operator retry supersedes it.
// hostname rides along so the server can check this agent owns the command
// it is finalizing. The shared collector key does not say who is calling,
// so without it any agent can post a result for any command id. The server
// treats the parameter as optional for older agents and enforces it when
// present, so shipping this ahead of a fleet update is safe.
public async Task ReportResultAsync(
int commandId, CommandResult result, CancellationToken token)
int commandId, string hostname, CommandResult result,
CancellationToken token)
{
var url = $"api/endpoints/commands/{commandId}/result";
var url = $"api/endpoints/commands/{commandId}/result?hostname="
+ Uri.EscapeDataString(hostname);
using var response = await http.PostAsJsonAsync(
url, result, JsonOptions, token);
if (!response.IsSuccessStatusCode)

View File

@@ -130,7 +130,8 @@ public sealed class CommandWorker : BackgroundService
};
}
await client.ReportResultAsync(claim.EndpointCommandId, result, token);
await client.ReportResultAsync(
claim.EndpointCommandId, hostname, result, token);
logger.LogInformation("Command {Id} finished succeeded={Succeeded}",
claim.EndpointCommandId, result.Succeeded);

View File

@@ -185,6 +185,15 @@
},
{
"name": "plugin-endpoints"
},
{
"name": "core-scripts"
},
{
"name": "endpoints-delivery"
},
{
"name": "endpoints-siteagent"
}
],
"paths": {
@@ -2438,6 +2447,34 @@
}
}
},
"/api/dashboard/capabilities": {
"get": {
"tags": [
"core-platform"
],
"summary": "Booleans describing what THIS caller may reach, so the app chrome can avoid drawing controls that would only answer...",
"description": "Booleans describing what THIS caller may reach, so the app chrome can avoid drawing controls that would only answer 401; cansearch reports whether the caller holds assets.view, which a signed-out visitor does when the guest role has been granted it\n\n**Auth:** none\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/dashboard/capabilities\n```",
"security": [],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
}
}
}
},
"/api/dashboard/widgets": {
"get": {
"tags": [
@@ -22680,6 +22717,768 @@
}
}
}
},
"/api/scripts": {
"get": {
"tags": [
"core-scripts"
],
"summary": "List script catalog entries, active only by default",
"description": "List script catalog entries, active only by default\n\n**Auth:** jwt + require_permission applications.view\n\n**Params:** query: includeinactive (default false)\n\n**Example:**\n```\ncurl http://localhost:5001/api/scripts -H \"Authorization: Bearer $TOK\"\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
}
}
},
"post": {
"tags": [
"core-scripts"
],
"summary": "Create a script catalog entry; the body is uploaded separately so metadata and content version independently",
"description": "Create a script catalog entry; the body is uploaded separately so metadata and content version independently\n\n**Auth:** jwt + require_permission applications.create\n\n**Params:** body: name (required), description, runcontext (system|user, default system)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/scripts -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"name\":\"Restart spooler\",\"runcontext\":\"system\"}'\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
}
},
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "body: name (required), description, runcontext (system|user, default system)"
}
}
}
}
}
},
"/api/scripts/{script_id}": {
"get": {
"tags": [
"core-scripts"
],
"summary": "Get one script entry with its metadata and run context",
"description": "Get one script entry with its metadata and run context\n\n**Auth:** jwt + require_permission applications.view\n\n**Params:** path: script_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/scripts/3 -H \"Authorization: Bearer $TOK\"\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "script_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
},
"put": {
"tags": [
"core-scripts"
],
"summary": "Update a script entry's metadata; the stored body is untouched",
"description": "Update a script entry's metadata; the stored body is untouched\n\n**Auth:** jwt + require_permission applications.edit\n\n**Params:** path: script_id; body: name, description, runcontext, isactive\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/scripts/3 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"description\":\"Clears the print queue\"}'\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "script_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "path: script_id; body: name, description, runcontext, isactive"
}
}
}
}
},
"delete": {
"tags": [
"core-scripts"
],
"summary": "Remove a script catalog entry",
"description": "Remove a script catalog entry\n\n**Auth:** jwt + require_permission applications.delete\n\n**Params:** path: script_id\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/scripts/3 -H \"Authorization: Bearer $TOK\"\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "script_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/api/scripts/{script_id}/body": {
"post": {
"tags": [
"core-scripts"
],
"summary": "Upload or replace the script body; stored on disk and hashed so a delivery can pin the exact bytes",
"description": "Upload or replace the script body; stored on disk and hashed so a delivery can pin the exact bytes\n\n**Auth:** jwt + require_permission applications.edit\n\n**Params:** path: script_id; multipart/form-data: file (the .ps1)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/scripts/3/body -H \"Authorization: Bearer $TOK\" -F 'file=@restart-spooler.ps1'\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "script_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "path: script_id; multipart/form-data: file (the .ps1)"
}
}
}
}
}
},
"/api/scripts/body/{filename}": {
"get": {
"tags": [
"core-scripts"
],
"summary": "Download a stored script body for review in the UI",
"description": "Download a stored script body for review in the UI\n\n**Auth:** jwt + require_permission applications.view\n\n**Params:** path: filename (as stored)\n\n**Example:**\n```\ncurl http://localhost:5001/api/scripts/body/restart-spooler.ps1 -H \"Authorization: Bearer $TOK\" -o restart-spooler.ps1\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "filename",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/api/endpoints/apppackage/{appversionid}": {
"get": {
"tags": [
"endpoints-delivery"
],
"summary": "Agent-facing installer download for a pinned app version; serves the exact bytes the update_app descriptor pinned by...",
"description": "Agent-facing installer download for a pinned app version; serves the exact bytes the update_app descriptor pinned by SHA-256\n\n**Auth:** collector token (X-API-Key), endpoints scope\n\n**Params:** path: appversionid (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/apppackage/12 -H 'X-API-Key: $COLLECTOR_KEY' -o setup.exe\n```",
"security": [
{
"apiKeyAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "appversionid",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/api/endpoints/apppackage/app/{app_id}": {
"get": {
"tags": [
"endpoints-delivery"
],
"summary": "Agent-facing installer download resolved from the application rather than a specific version",
"description": "Agent-facing installer download resolved from the application rather than a specific version\n\n**Auth:** collector token (X-API-Key), endpoints scope\n\n**Params:** path: app_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/apppackage/app/4 -H 'X-API-Key: $COLLECTOR_KEY' -o setup.exe\n```",
"security": [
{
"apiKeyAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "app_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/api/endpoints/apppackage/app/{app_id}/{filename}": {
"get": {
"tags": [
"endpoints-delivery"
],
"summary": "As above, naming the stored file explicitly so the agent writes it back under the expected name",
"description": "As above, naming the stored file explicitly so the agent writes it back under the expected name\n\n**Auth:** collector token (X-API-Key), endpoints scope\n\n**Params:** path: app_id (int), filename\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/apppackage/app/4/setup.exe -H 'X-API-Key: $COLLECTOR_KEY' -o setup.exe\n```",
"security": [
{
"apiKeyAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "app_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "filename",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/api/endpoints/scriptbody/{script_id}": {
"get": {
"tags": [
"endpoints-delivery"
],
"summary": "Agent-facing script download for a run_script delivery, on the same key as the rest of the delivery path",
"description": "Agent-facing script download for a run_script delivery, on the same key as the rest of the delivery path\n\n**Auth:** collector token (X-API-Key), endpoints scope\n\n**Params:** path: script_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/scriptbody/3 -H 'X-API-Key: $COLLECTOR_KEY' -o run.ps1\n```",
"security": [
{
"apiKeyAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "script_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/api/endpoints/asset/{asset_id}/applications": {
"get": {
"tags": [
"endpoints-delivery"
],
"summary": "Installed-software snapshot the agent last reported for this endpoint, for the asset detail panel; a per-report...",
"description": "Installed-software snapshot the agent last reported for this endpoint, for the asset detail panel; a per-report snapshot rather than history\n\n**Auth:** jwt + require_permission endpoints.view\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/asset/313/applications -H \"Authorization: Bearer $TOK\"\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "asset_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/api/endpoints/siteagent": {
"get": {
"tags": [
"endpoints-siteagent"
],
"summary": "Report whether a prebuilt agent binary is staged and can therefore be packaged",
"description": "Report whether a prebuilt agent binary is staged and can therefore be packaged\n\n**Auth:** jwt + require_role admin\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/siteagent -H \"Authorization: Bearer $TOK\"\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
}
}
}
},
"/api/endpoints/siteagent/package": {
"post": {
"tags": [
"endpoints-siteagent"
],
"summary": "Bundle the agent as a downloadable ZIP with a generated install.ps1 that provisions credentials, so a bay install needs...",
"description": "Bundle the agent as a downloadable ZIP with a generated install.ps1 that provisions credentials, so a bay install needs no hand editing\n\n**Auth:** jwt + require_role admin\n\n**Params:** body: baseurl, collectorkey, cadenceseconds, and the site TLS cert when one is readable\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/siteagent/package -H \"Authorization: Bearer $TOK\" -o shopdb-agent.zip\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
}
},
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "body: baseurl, collectorkey, cadenceseconds, and the site TLS cert when one is readable"
}
}
}
}
}
},
"/api/endpoints/adoptiontool/package": {
"post": {
"tags": [
"endpoints-siteagent"
],
"summary": "Bundle the agentless adoption tool, for taking an inventory of a machine that will not run the service",
"description": "Bundle the agentless adoption tool, for taking an inventory of a machine that will not run the service\n\n**Auth:** jwt + require_role admin\n\n**Params:** body: as for the site agent package\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/adoptiontool/package -H \"Authorization: Bearer $TOK\" -o shopdb-adoption.zip\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
}
},
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "body: as for the site agent package"
}
}
}
}
}
}
}
}

View File

@@ -536,6 +536,9 @@ export const dashboardApi = {
},
navigation() {
return api.get('/dashboard/navigation')
},
capabilities() {
return api.get('/dashboard/capabilities')
}
}

View File

@@ -1083,6 +1083,21 @@ td.actions .btn + .btn {
display: contents;
}
/* Full-width alternative to .content-grid, for a tab holding one long list
rather than short fact cards. An application inventory or an event feed
split into two balanced columns reads as two unrelated lists, and the eye
has to hunt for where the first one ended. */
.detail-full {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 25px;
}
.detail-full > * {
min-width: 0;
}
/* Section Cards */
.section-card {
background: var(--bg-card);

View File

@@ -0,0 +1,109 @@
<template>
<!-- The tab strip for an asset detail page. Presentational only: it owns the
strip and the keyboard model, never the content, so every detail page
gets the same behaviour without the strip knowing what an asset is.
It sits BELOW the hero and triage band on purpose. Those answer "is this
thing OK", which must not be behind a click - the strip partitions the
detail underneath and is not navigation. That distinction is what makes
tabs worth having here; a strip that duplicated the sidebar was tried and
rejected. -->
<div class="adt" role="tablist" :aria-label="label">
<button
v-for="(tab, index) in tabs"
:key="tab.key"
:id="`adt-${uid}-${tab.key}`"
class="adt-tab"
:class="{ 'adt-on': tab.key === modelValue }"
role="tab"
type="button"
:aria-selected="tab.key === modelValue ? 'true' : 'false'"
:aria-controls="`adtp-${uid}-${tab.key}`"
:tabindex="tab.key === modelValue ? 0 : -1"
:ref="el => setRef(el, index)"
@click="$emit('update:modelValue', tab.key)"
@keydown="onKey($event, index)"
>
{{ tab.label }}
<span v-if="tab.count != null" class="adt-count">{{ tab.count }}</span>
</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
// [{ key, label, count? }] - the caller decides which tabs exist, so a page
// can drop a tab it has nothing for rather than offering an empty one.
tabs: { type: Array, required: true },
modelValue: { type: String, required: true },
label: { type: String, default: 'Detail sections' },
})
const emit = defineEmits(['update:modelValue'])
// Ids have to be unique per instance: a page may show two of these, and
// duplicate ids would break the aria-controls pairing.
const uid = Math.random().toString(36).slice(2, 8)
const els = ref([])
function setRef(el, index) { if (el) els.value[index] = el }
// Roving tabindex, per the WAI-ARIA tabs pattern: arrows move between tabs,
// Home and End jump to the ends. Without this a keyboard user tabs through
// every tab button to reach the panel.
function onKey(event, index) {
const last = props.tabs.length - 1
let next = null
if (event.key === 'ArrowRight') next = index === last ? 0 : index + 1
else if (event.key === 'ArrowLeft') next = index === 0 ? last : index - 1
else if (event.key === 'Home') next = 0
else if (event.key === 'End') next = last
if (next === null) return
event.preventDefault()
emit('update:modelValue', props.tabs[next].key)
els.value[next]?.focus()
}
</script>
<style scoped>
.adt {
display: flex;
gap: 2px;
/* overflow-y MUST be stated. Per the spec, when one axis is not `visible`
the other computes from `visible` to `auto` - so `overflow-x: auto` alone
silently makes this scrollable vertically too, and the -1px margin the
tabs use to sit on the border is enough to trip it. The result is a tiny
vertical scrollbar inside the strip. */
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
border-bottom: 1px solid var(--border);
margin-bottom: 1.25rem;
}
.adt-tab {
/* Never shrink: a squeezed tab wraps its label, which grows the strip and is
what horizontal scrolling exists to avoid. */
flex: none;
font: inherit;
font-size: 0.875rem;
font-weight: 500;
white-space: nowrap;
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
padding: 0.7rem 0.9rem;
color: var(--text-light);
cursor: pointer;
}
.adt-tab:hover { color: var(--text); }
.adt-on { color: var(--primary); border-bottom-color: var(--primary); }
.adt-tab:focus-visible { outline: 2px solid var(--primary); outline-offset: -3px; border-radius: 3px; }
.adt-count {
font-size: 0.75rem;
color: var(--text-light);
margin-left: 0.35rem;
font-variant-numeric: tabular-nums;
}
</style>

View File

@@ -2,8 +2,8 @@
<!-- Generic renderer for ADR-010 get_asset_panels (Path A). A plugin declares
a panel as JSON (title, endpoint, render mode, field map); this renders it
on any asset detail page, so a plugin adds detail-page UI with no Vue. -->
<template v-for="panel in panels" :key="panel.id">
<div v-if="panelVisible(panel)" class="section-card">
<template v-for="panel in groupedPanels" :key="panel.id">
<div v-if="panelVisible(panel)" class="section-card" :class="{ 'pap-span': panel.render === 'table' }">
<!-- collapsible panels get a clickable header with a caret and, when the
panel declares a status rollup, a green/yellow/red dot for the worst
row (a stopped service or a warning/critical event). --><h3
@@ -149,7 +149,7 @@
</template>
<script setup>
import { ref, watch } from 'vue'
import { ref, computed, watch } from 'vue'
import api from '../api'
import { colorStyle } from '@/utils/colorStyle'
import {
@@ -183,10 +183,42 @@ function visibleRows(panel) {
const props = defineProps({
assetid: { type: [Number, String], default: null },
// Which detail-page tab to render. Null renders every panel, which is what
// the detail pages that have not adopted tabs still do - so this stays
// additive and no existing page changes behaviour.
group: { type: String, default: null },
})
const panels = ref([])
// The groups this build renders. A panel in any other group would be fetched,
// held, and shown on no tab at all.
//
// The server normalises group before we see it, so a version-matched pair
// never lands here. A MISMATCHED pair does: deploy a new frontend against a
// server still serving an older vocabulary and every panel in a group that
// has since been renamed or removed silently disappears. That is what happened
// to tracked services when it moved out of 'actions' - the backend still said
// 'actions', this build had no such tab, and the card was simply gone with
// nothing logged. The same applies to the 'metrics' to 'monitors' rename.
// Fall back the same way the server does rather than drop it.
const RENDERED_GROUPS = ['details', 'monitors', 'software', 'activity']
function groupOf(panel) {
return RENDERED_GROUPS.includes(panel.group) ? panel.group : 'details'
}
const groupedPanels = computed(() =>
props.group ? panels.value.filter((p) => groupOf(p) === props.group) : panels.value
)
// Which groups actually have a panel with data, so a parent can hide a tab
// rather than offering an empty one.
const populatedGroups = computed(() => {
const seen = new Set()
panels.value.forEach((p) => { if (panelVisible(p)) seen.add(groupOf(p)) })
return seen
})
// Which tab each 'tabs' panel is showing, keyed by panel id. Kept here rather
// than on the panel object because panels are replaced wholesale on refetch.
const tabs = ref({})
@@ -334,7 +366,7 @@ watch(() => props.assetid, load, { immediate: true })
// Let a parent detail page (for example PCDetail's header Refresh button)
// trigger a panel reload without duplicating the fetch plumbing.
defineExpose({ refresh: load })
defineExpose({ refresh: load, populatedGroups })
</script>
<style scoped>
@@ -411,7 +443,18 @@ defineExpose({ refresh: load })
font-weight: 700;
}
.pap-table-wrap { overflow-x: auto; }
/* A table panel SPANS BOTH COLUMNS rather than scrolling inside one.
Network interfaces is the case that forced this: adapter, address, MAC,
speed and state at half page width either squeezed every cell to three
lines or handed the reader a horizontal scrollbar, and a scrollbar for
data that simply needs room is a layout failure, not a feature. column-span
is the multi-column layout's own answer, so the short fact cards stay two-up
around it.
The wrapper keeps overflow-x as a floor for the genuinely too-wide case on
a narrow viewport, where nothing else can help. */
.pap-span { column-span: all; }
.pap-table-wrap { overflow-x: auto; max-width: 100%; }
.pap-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
.pap-table th, .pap-table td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--border); }
.pap-badges { display: flex; flex-wrap: wrap; gap: 0.5rem; }

View File

@@ -246,11 +246,17 @@ export function rowAction(panel, item) {
}
}
// A panel declaring `empty` is asking to stay visible with nothing in it and
// say so - "No installed software reported." is an answer, where an absent
// card is a question. `list` honoured that and `table` did not, for no reason
// anybody wrote down, so a table panel with an empty message still vanished.
// It matters more now that a panel can be the only thing in a tab: the tab
// itself disappears with it.
export function panelVisible(panel) {
if (panel.render === 'tabs') return tabSections(panel).length > 0
if (panel.render === 'list') return rows(panel).length > 0 || !!panel.empty
if (panel.render === 'keyvalue') return keyvalueFields(panel).length > 0
if (panel.render === 'table') return rows(panel).length > 0
if (panel.render === 'table') return rows(panel).length > 0 || !!panel.empty
if (panel.render === 'badge') return badges(panel).length > 0
return false
}

View File

@@ -327,3 +327,16 @@ describe('facet chips on a card that shows a health dot', () => {
expect(facetedRows(services, ['Start Pending']).length).toBe(1)
})
})
describe('panelVisible and empty', () => {
it('keeps a table panel that declares an empty message', () => {
// It is the only card in the Software tab, so hiding it hides the tab and
// the reader cannot tell "nothing reported" from "feature missing".
const panel = { render: 'table', _data: { items: [] }, empty: 'No installed software reported.' }
expect(panelVisible(panel)).toBe(true)
})
it('still hides a table panel with nothing to say', () => {
expect(panelVisible({ render: 'table', _data: { items: [] } })).toBe(false)
})
})

View File

@@ -11,9 +11,12 @@
</div>
</div>
<!-- Global search is authenticated; offering the box to a signed-out
reader gives them a control that can only answer 401. -->
<div v-if="authStore.isAuthenticated" class="sidebar-search">
<!-- Drawn only for a caller who can actually search. The server
answers that, because a guest has no user object here and
hasPermission would say no for the very visitor the guest grant
exists to serve. Members who lack assets.view lose a box that only
ever answered 401 to them. -->
<div v-if="cansearch" class="sidebar-search">
<input
v-model="searchQuery"
type="text"
@@ -145,6 +148,10 @@ const routeViewKey = computed(() =>
route.path.startsWith('/settings') ? '/settings' : route.path
)
const searchQuery = ref('')
// Starts false so the box is never drawn before the server has said the
// caller can use it. A flash of a control that then vanishes reads worse than
// one that arrives a moment late.
const cansearch = ref(false)
const navItems = ref([])
const activeNotifications = ref([])
const facilityName = ref('ShopDB')
@@ -289,6 +296,15 @@ onMounted(async () => {
navItems.value = buildNavItems(defaultNav)
}
try {
const caps = await dashboardApi.capabilities()
cansearch.value = !!caps.data.data?.cansearch
} catch (err) {
// Leave the box hidden. Guessing from isAuthenticated here would put it
// back in front of members who cannot search, which is the thing this
// replaced.
}
try {
const resp = await notificationsApi.active()
activeNotifications.value = resp.data.data?.notifications || []

View File

@@ -69,14 +69,24 @@
<span class="assets-v">{{ stats.totalassets || 0 }}</span>
</div>
<!-- Status, on one line, and explicitly labelled as status. In
repair is muted at zero rather than given a card of its own to
announce that nothing is wrong. -->
<p class="assets-status">
<span class="assets-inuse">{{ stats.activeassets || 0 }}</span> in use
<span aria-hidden="true"> / </span>
<span :class="{ 'assets-zero': !stats.inrepair }">
{{ stats.inrepair || 0 }} in repair
<!-- Every status the total contains, not just In Use. These were
counted server-side for the rollup and then thrown away, so a
reader could see one number and had no way to learn where the
rest of the total went. Retired assets in particular ARE in
the total, which is not guessable. -->
<div v-for="row in statusrows" :key="row.name" class="assets-row assets-sub">
<span class="assets-k assets-subk">{{ row.name }}</span>
<span class="assets-v assets-subv">{{ row.count }}</span>
</div>
<!-- What the total covers. Asked twice what the number meant, which
is the signal a label is underspecified. -->
<p class="assets-caption">
Active records only<span v-if="stats.dualpathcollapsed">, dual-bay
pairs counted once</span>.
<span v-if="stats.inactiveexcluded">
{{ stats.inactiveexcluded }} deactivated record{{ stats.inactiveexcluded === 1 ? '' : 's' }}
excluded.
</span>
</p>
</section>
@@ -133,6 +143,17 @@ const summaryloaded = ref(false)
// hold - and to what the guest role reaches, when nobody is signed in.
const navitems = ref([])
// Every status in the total, biggest first. Sorted rather than fixed-order
// because the statuses are seed data a site can change - hardcoding "In Use,
// In Repair, Retired" would silently omit a status somebody added.
const statusrows = computed(() => {
const statuses = stats.value.statuses || {}
return Object.keys(statuses)
.map((name) => ({ name, count: statuses[name] }))
.filter((row) => row.count > 0)
.sort((a, b) => b.count - a.count)
})
const openable = computed(
// The dashboard itself is not somewhere to go from the dashboard.
() => navitems.value.filter((item) => item.route && item.route !== '/'))
@@ -224,11 +245,18 @@ span.assets-k { color: var(--text); font-weight: 600; }
border-bottom: none;
}
.assets-status {
margin: 0.5rem 0 0;
font-size: 0.85rem;
/* Status rows sit UNDER the total, indented and quieter, because they break
the total down rather than adding to it. Same weight as the type rows would
read as more things to add up. */
.assets-sub { border-bottom: none; padding: 0.15rem 0 0.15rem 0.75rem; }
.assets-subk { color: var(--text-light); font-size: 0.85rem; }
a.assets-subk:hover { text-decoration: none; }
.assets-subv { font-weight: 500; font-size: 0.85rem; color: var(--text-light); }
.assets-caption {
margin: 0.6rem 0 0;
font-size: 0.78rem;
line-height: 1.45;
color: var(--text-light);
}
.assets-inuse { color: var(--success); font-weight: 600; font-variant-numeric: tabular-nums; }
.assets-zero { color: var(--text-light); }
</style>

View File

@@ -69,7 +69,7 @@
</label>
<label class="field">
<span>Link template</span>
<input v-model="form.linktemplate" type="text" maxlength="255" placeholder="vnc://{host}:{port}" />
<input v-model="form.linktemplate" type="text" maxlength="255" placeholder="scheme://{host}:{port}" />
<small class="muted">Placeholders: <code>{host}</code>, <code>{port}</code>, <code>{scheme}</code></small>
</label>
<label class="field checkbox">

View File

@@ -84,8 +84,10 @@
Hardware and the rest follow. Panels self-order among themselves by
their declared position, and self-hide when a site has no plugin
answering for this asset type. -->
<div class="content-grid">
<PluginAssetPanels ref="assetPanels" :assetid="computer.assetid" />
<AssetDetailTabs v-model="activegroup" :tabs="detailtabs" label="Computer detail sections" />
<div v-show="activegroup === 'details'" class="content-grid">
<PluginAssetPanels ref="assetPanels" :assetid="computer.assetid" group="details" />
<!-- Left Column -->
<div class="content-column">
@@ -256,7 +258,28 @@
</div>
</div>
<!-- Installed Applications -->
<!-- Custom Fields -->
<CustomFieldsSection :assetid="computer.assetid" />
<!-- All relationships (controls, defaultprinter, ...) -->
<AssetRelationships v-if="computer.assetid" :assetid="computer.assetid" />
<!-- Notes -->
<div class="section-card" v-if="computer.notes">
<h3 class="section-title">Notes</h3>
<p class="notes-text">{{ computer.notes }}</p>
</div>
</div>
</div>
<!-- Software and Activity run FULL WIDTH, not in the two-column flow the
Details tab uses. Both are long lists rather than short fact cards:
an application inventory or an event feed split into balanced columns
reads as two unrelated lists, and the eye has to find where the first
one ended. -->
<div v-show="activegroup === 'software'" class="detail-full">
<PluginAssetPanels :assetid="computer.assetid" group="software" />
<div>
<div class="section-card" v-if="installedApps.length > 0">
<h3 class="section-title">Installed Applications</h3>
<div class="app-list">
@@ -276,21 +299,22 @@
</router-link>
</div>
</div>
<!-- Custom Fields -->
<CustomFieldsSection :assetid="computer.assetid" />
<!-- All relationships (controls, defaultprinter, ...) -->
<AssetRelationships v-if="computer.assetid" :assetid="computer.assetid" />
<!-- Notes -->
<div class="section-card" v-if="computer.notes">
<h3 class="section-title">Notes</h3>
<p class="notes-text">{{ computer.notes }}</p>
</div>
</div>
</div>
<!-- Monitors and Activity are entirely plugin-contributed: what is being
watched on the box, the service state and its controls, and the event
feed all belong to the endpoints plugin, and core knows nothing about
them.
Monitors keeps the two-column flow because it is short cards. -->
<div v-show="activegroup === 'monitors'" class="content-grid">
<PluginAssetPanels :assetid="computer.assetid" group="monitors" />
</div>
<div v-show="activegroup === 'activity'" class="detail-full">
<PluginAssetPanels :assetid="computer.assetid" group="activity" />
</div>
<!-- Audit Footer -->
<div class="audit-footer">
<span>Created {{ formatDate(computer.createddate) }}<template v-if="computer.createdby"> by {{ computer.createdby }}</template></span>
@@ -305,7 +329,7 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, watch, onMounted } from 'vue'
import { colorStyle } from "@/utils/colorStyle"
import { useRoute, useRouter } from 'vue-router'
import { computersApi, applicationsApi, endpointsApi } from '@/api'
@@ -315,6 +339,7 @@ import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
import AssetTriageBand from '@/components/AssetTriageBand.vue'
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import AssetDetailTabs from '@/components/AssetDetailTabs.vue'
import AssetRelationships from '@/components/AssetRelationships.vue'
import { useIdentifierFlags } from '@/composables/identifierSettings'
import { setRecordTitle } from '@/composables/pageTitle'
@@ -344,6 +369,37 @@ const hasRecordCheckin = computed(() => {
})
const installedApps = ref([])
const assetPanels = ref(null)
// Which tab is showing. Always opens on Details: it holds what the asset IS,
// which is the answer somebody arriving from a search or a link came for.
const activegroup = ref('details')
// Only offer a tab that has something in it. An empty tab is worse than an
// absent one - it reads as broken rather than as "this site does not collect
// that". Details always shows because core fills it regardless of plugins.
//
// populatedGroups comes from PluginAssetPanels, which knows which panels
// actually returned data; Software is additionally true when core has an
// installed-applications list of its own.
const detailtabs = computed(() => {
const filled = assetPanels.value?.populatedGroups ?? new Set()
const has = (key) => filled.has ? filled.has(key) : false
const tabs = [{ key: 'details', label: 'Details' }]
// Monitors sits second: what is being watched on this box is the question
// asked right after "what is it", and well before its software inventory.
if (has('monitors')) tabs.push({ key: 'monitors', label: 'Monitors' })
if (has('software') || installedApps.value.length > 0) {
tabs.push({ key: 'software', label: 'Software', count: installedApps.value.length || null })
}
if (has('activity')) tabs.push({ key: 'activity', label: 'Activity' })
return tabs
})
// A tab can disappear on refetch - a plugin gets disabled, telemetry stops
// answering. Fall back to Details rather than leaving the page showing nothing.
watch(detailtabs, (tabs) => {
if (!tabs.some((tab) => tab.key === activegroup.value)) activegroup.value = 'details'
})
const restarting = ref(false)
const refreshing = ref(false)
@@ -550,6 +606,7 @@ async function confirmRetire() {
</script>
<style scoped>
/* PC-specific styles - shared styles are in global style.css */
/* Remote-access protocol links */

View File

@@ -2013,3 +2013,26 @@ class ComputersPlugin(BasePlugin):
('computers.edit', 'Edit computers', 'computers'),
('computers.delete', 'Delete computers', 'computers'),
]
def get_guest_grantable_permissions(self) -> List[str]:
"""Declared because a site chose it: the floor looks PCs up by number.
KNOWN GAP, tracked separately. The computer serializer is
BaseModel.to_dict(), so it publishes every column - serialnumber, the
Azure device and object ids - and the detail route resolves
accessmethods into launch links, each built from that protocol's own
linktemplate, plus the full Communication row. A guest granted this
gets all of it, on the LIST route as well, so it is one request rather
than one per asset.
Do not name schemes here. The templates are site-editable rows in
accessprotocols, so what a link looks like is data, not a constant -
the shipped VNC row is com.realvnc.vncviewer.connect://, not vnc://,
and an earlier version of this docstring said otherwise for months.
The fix is a field allowlist on Computer.to_dict() plus suppressing the
resolved links unless the caller holds a remote-access permission. Do
not widen this hook until that lands; a site enabling guest access
today should weigh it.
"""
return ['computers.view']

View File

@@ -23,7 +23,7 @@ import zipfile
from flask import request, current_app, send_file
from shopdb.api import error_response, ErrorCodes, require_role
from shopdb.api import error_response, ErrorCodes, require_permission
from .routes import endpoints_bp
@@ -66,7 +66,7 @@ def _build_readme():
@endpoints_bp.route('/adoptiontool/package', methods=['POST'])
@require_role('admin')
@require_permission('endpoints.agentpackage')
def package_adoption_tool():
"""Bundle the adoption tool as a downloadable ZIP.

View File

@@ -7,6 +7,7 @@ These routes are the human-facing read surface, gated by user JWT + RBAC.
"""
import hashlib
import hmac
import json
import os
import time
@@ -941,6 +942,43 @@ _CLAIM_HOLD_SECONDS = 25
_CLAIM_POLL_INTERVAL = 1.0
def _pin_holds(command, descriptor):
"""Does the descriptor still carry the digest this command was queued for?
True when nothing was pinned (actions that deliver no content), or when the
descriptor is missing entirely - a vanished script or version already sends
the command through with no descriptor so the agent fails closed, and that
path is not a content swap.
"""
if not command.pinnedsha256:
return True
if descriptor is None:
return True
return hmac.compare_digest(
str(descriptor.get('sha256') or ''), command.pinnedsha256)
def _fail_on_pin_drift(command, action):
"""Refuse a claim whose content changed after the operator approved it."""
command.status = EndpointCommand.STATUS_FAILED
command.resultmessage = (
'Refused: the {} content changed after this command was queued'
.format(action))
command.completedat = _naive_utcnow()
db.session.commit()
current_app.logger.warning(
'Endpoint command %s (%s, asset %s) refused: pinned sha256 %s no '
'longer matches the resolved descriptor',
command.endpointcommandid, action, command.assetid,
command.pinnedsha256)
AuditLog.log('refused', 'EndpointCommand',
entityid=command.endpointcommandid,
entityname=action,
details='content digest changed after enqueue')
db.session.commit()
return ('', 204)
def _reap_stale_commands(asset_id):
"""Expire in-flight commands an agent will never finish for this endpoint.
@@ -1097,14 +1135,39 @@ def _resolve_update_app(target):
if application is None or not application.installpath:
return None, ('Application for appversionid {} has no installer package'
.format(ident))
# PIN THE FILE THAT IS ACTUALLY SERVED.
#
# /api/endpoints/apppackage/<appversionid> resolves the version to its
# APPLICATION and serves application.installpath - there is no
# version-specific artifact behind that route. So version.sha256 pinned
# a digest that need not describe the bytes delivered, and where it was
# null the descriptor carried no digest at all and the agent had nothing
# to verify against. Deriving it from the application, exactly as the
# app branch does, makes the pin describe the delivery.
sha256 = (application.installersha256
or _backfill_app_installer_sha256(application))
if not sha256:
return None, ('Application for appversionid {} has an installer '
'package with no recorded SHA-256, so the delivery '
'cannot be verified'.format(ident))
# Carry the real filename so the agent's Path.GetExtension routes an MSI
# through msiexec. Without it every MSI push failed, because this branch
# ended the URL at the id and the extension was simply absent.
filename = _hosted_package_filename(application)
installpath = '/api/endpoints/apppackage/{}'.format(version.appversionid)
if filename:
installpath = '/api/endpoints/apppackage/app/{}/{}'.format(
application.appid, filename)
return {
'source': 'version',
'appversionid': version.appversionid,
'appid': version.appid,
'appname': application.appname,
'version': version.version,
'installpath': '/api/endpoints/apppackage/{}'.format(version.appversionid),
'sha256': version.sha256,
'installpath': installpath,
'sha256': sha256,
'silentargs': version.silentargs,
}, None
@@ -1209,18 +1272,25 @@ def enqueue_endpoint_command(asset_id: int):
# update_app pins a specific application version; verify it resolves to a
# real installer now so a broken push is rejected at enqueue rather than
# failing opaquely on the endpoint minutes later.
# Digest of the content this push was APPROVED against. Kept on the row so
# the claim path can refuse a descriptor that has changed since; see
# EndpointCommand.pinnedsha256.
pinnedsha256 = None
if action == 'update_app':
_descriptor, resolve_error = _resolve_update_app(target)
descriptor, resolve_error = _resolve_update_app(target)
if resolve_error is not None:
return error_response(ErrorCodes.VALIDATION_ERROR, resolve_error)
pinnedsha256 = (descriptor or {}).get('sha256')
# run_script pins a ShopDB-hosted script; verify it resolves to a real
# uploaded body now so a broken push is rejected at enqueue rather than
# failing opaquely on the endpoint later.
if action == 'run_script':
_script, script_error = _resolve_run_script(target)
script, script_error = _resolve_run_script(target)
if script_error is not None:
return error_response(ErrorCodes.VALIDATION_ERROR, script_error)
pinnedsha256 = (script or {}).get('sha256')
state = EndpointState.query.filter_by(assetid=asset_id).first()
if state is None:
@@ -1252,6 +1322,7 @@ def enqueue_endpoint_command(asset_id: int):
targetkind=targetkind if target else None,
status=EndpointCommand.STATUS_PENDING,
requestedby=getattr(current_user, 'userid', None),
pinnedsha256=pinnedsha256,
)
db.session.add(command)
@@ -1312,6 +1383,8 @@ def claim_next_command():
# fail closed rather than 500 the poll.
if command.action == 'update_app':
descriptor, _resolve_error = _resolve_update_app(command.target)
if not _pin_holds(command, descriptor):
return _fail_on_pin_drift(command, 'update_app')
if descriptor is not None:
claim['app'] = descriptor
# run_script rides a resolved script descriptor: the agent gets a
@@ -1322,6 +1395,8 @@ def claim_next_command():
# agent fail closed rather than 500 the poll.
if command.action == 'run_script':
script_descriptor, _script_error = _resolve_run_script(command.target)
if not _pin_holds(command, script_descriptor):
return _fail_on_pin_drift(command, 'run_script')
if script_descriptor is not None:
claim['script'] = script_descriptor
return success_response(claim)
@@ -1351,6 +1426,33 @@ def report_command_result(command_id: int):
return error_response(ErrorCodes.NOT_FOUND,
f'No command {command_id}', http_code=404)
# An agent may only finalize ITS OWN command. The collector key is shared
# fleet-wide, so without this any agent could post a result for any command
# id - marking an install succeeded on a machine that never ran it, which
# writes a false compliance record. Binding to the claimed hostname is a
# floor, not a fix: the credential still does not prove which endpoint is
# calling, and only a per-endpoint credential closes that.
# Optional because the shipped agent does not send it yet; enforced when it
# does, so an updated fleet gets the check without a flag day.
hostname = (request.args.get('hostname') or '').strip()
if hostname:
caller_asset_id = _resolve_asset_for_hostname(hostname)
if caller_asset_id != command.assetid:
return error_response(
ErrorCodes.FORBIDDEN,
'This command belongs to another endpoint', http_code=403)
# Only a claimed command can be finalized. Needs no agent change, and it
# closes two things the hostname check cannot: reporting on a command that
# was never claimed, and overwriting a terminal result - which would let a
# recorded failure be replaced by a success.
if command.status != EndpointCommand.STATUS_RUNNING:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Command {} is {}, not running'.format(
command_id, command.status),
http_code=409)
payload = request.get_json(silent=True) or {}
succeeded = bool(payload.get('succeeded'))
command.status = (EndpointCommand.STATUS_SUCCEEDED if succeeded

View File

@@ -31,7 +31,8 @@ import zipfile
from flask import request, current_app, send_file
from shopdb.api import success_response, error_response, ErrorCodes, require_role
from shopdb.api import (success_response, error_response, ErrorCodes,
require_permission)
from .routes import endpoints_bp
@@ -166,16 +167,16 @@ FILES
USAGE
1. Copy this whole folder to the target endpoint.
2. Right-click install.ps1 and Run with PowerShell as administrator. It
writes the server URL and collector key to HKLM\\SOFTWARE\\GE\\ShopDB,
copies the agent into Program Files, and starts the service. It also
registers the tray to autostart for every user at logon and adds a
"ShopDB Agent" Start Menu shortcut for manual relaunch.
writes the server URL and collector key to HKLM\\SOFTWARE\\GE\\ShopDB,
copies the agent into Program Files, and starts the service. It also
registers the tray to autostart for every user at logon and adds a
"ShopDB Agent" Start Menu shortcut for manual relaunch.
UNINSTALL
Run uninstall-shopdbagent.ps1 from an elevated PowerShell to fully remove the
agent, tray autostart, Start Menu shortcut, and credentials.
UNINSTALL
Run uninstall-shopdbagent.ps1 from an elevated PowerShell to fully remove the
agent, tray autostart, Start Menu shortcut, and credentials.
SECURITY
SECURITY
install.ps1 carries a live collector key in its default -CollectorKey value.
Treat this package as a secret. The key is not recoverable from the ShopDB
server after it was generated; regenerate the key in the UI to revoke it.
@@ -456,14 +457,14 @@ def _siteagent_status_payload():
@endpoints_bp.route('/siteagent', methods=['GET'])
@require_role('admin')
@require_permission('endpoints.agentpackage')
def get_siteagent():
"""Report whether the site agent build has a prebuilt binary staged."""
return success_response(_siteagent_status_payload())
@endpoints_bp.route('/siteagent/package', methods=['POST'])
@require_role('admin')
@require_permission('endpoints.agentpackage')
def package_siteagent():
"""Bundle the site agent as a downloadable ZIP.

View File

@@ -38,7 +38,17 @@ def upgrade():
op.create_table(
'endpointapplications',
sa.Column('endpointapplicationid', sa.Integer(), nullable=False),
# BigInteger, not Integer. This is a SNAPSHOT table: every check-in
# deletes the endpoint's rows and reinserts them, so the surrogate key
# burns through values at (endpoints x installed apps) per report and
# never reuses them. A 32-bit key exhausts on MySQL - a few hundred
# endpoints reporting a few hundred apps every 15 minutes reaches 2.1
# billion inside a couple of years, and the failure is an insert that
# stops working forever. SQLite has no such ceiling, so no test would
# have caught it. Changed while the table is still unreleased.
sa.Column('endpointapplicationid',
sa.BigInteger().with_variant(sa.Integer, 'sqlite'),
nullable=False),
sa.Column('assetid', sa.Integer(), nullable=False),
sa.Column('displayname', sa.String(length=255), nullable=False),
sa.Column('displayversion', sa.String(length=128), nullable=True),

View File

@@ -0,0 +1,51 @@
"""Pin the approved content digest on an endpoint command.
Delivery descriptors (run_script, update_app) are re-resolved when the agent
claims a command, not when an operator queues it. Between those two moments the
underlying script body or installer package can be replaced by anyone who can
edit the applications catalog, and the descriptor's SHA-256 would then describe
the NEW bytes: the agent verifies correctly and runs something the operator
never approved.
pinnedsha256 records the digest as it stood at enqueue. The claim path compares
and refuses the command if it has moved, so a swap fails the push instead of
shipping it.
Null for actions that deliver no content, and null on rows queued before this
migration - those are treated as unpinned and claim as before.
Idempotent: checks the live schema first so a db.create_all() site re-runs clean
on strict MySQL 8.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'endpoints0005cmdpin'
down_revision = 'endpoints0004apps'
branch_labels = None
depends_on = None
def _has_column(table_name, column_name):
from sqlalchemy import inspect
inspector = inspect(op.get_bind())
if not inspector.has_table(table_name):
return False
return any(col['name'] == column_name
for col in inspector.get_columns(table_name))
def upgrade():
if not _has_column('endpointcommands', 'pinnedsha256'):
op.add_column('endpointcommands',
sa.Column('pinnedsha256', sa.String(length=64),
nullable=True))
def downgrade():
if _has_column('endpointcommands', 'pinnedsha256'):
op.drop_column('endpointcommands', 'pinnedsha256')

View File

@@ -172,6 +172,19 @@ class EndpointCommand(db.Model):
# Who queued it (userid) for audit; the agent never sees this.
requestedby = db.Column(db.Integer, nullable=True)
# The content digest as it stood WHEN AN OPERATOR APPROVED THIS PUSH.
#
# Delivery descriptors are re-resolved at claim time, minutes or hours
# later, so without this the bytes an operator approved and the bytes the
# agent runs are two different lookups against mutable content. Whoever can
# replace a script body or an installer package could swap it in the gap
# and the digest would faithfully describe the new bytes.
#
# Set at enqueue for run_script and update_app; null for actions that
# deliver no content. The claim path refuses a command whose descriptor no
# longer matches, so a swap fails the command instead of shipping.
pinnedsha256 = db.Column(db.String(64), nullable=True)
# Agent-reported outcome. resultjson holds structured detail (for example
# the observed MTConnect running state); resultmessage is a short summary.
resultmessage = db.Column(db.Text, nullable=True)
@@ -282,7 +295,16 @@ class EndpointApplication(db.Model):
__tablename__ = 'endpointapplications'
endpointapplicationid = db.Column(db.Integer, primary_key=True)
# BigInteger on MySQL, Integer on SQLite. This table is rewritten wholesale
# on every check-in, so the key never stops climbing and a 32-bit column
# eventually stops accepting inserts - see the migration for the arithmetic.
#
# The variant is not decoration: SQLite only autoincrements a column typed
# exactly INTEGER PRIMARY KEY, so a plain BigInteger makes every insert fail
# with a NOT NULL violation on the test database. Nothing caught that,
# because no test inserts into this table.
endpointapplicationid = db.Column(
db.BigInteger().with_variant(db.Integer, 'sqlite'), primary_key=True)
# The asset this install belongs to. Not unique: many applications per
# endpoint. CASCADE so removing the asset clears its SBOM snapshot.

View File

@@ -78,6 +78,20 @@ def _as_json(value):
return json.dumps(value, separators=(',', ':'), sort_keys=True)
def _clamp(value, length):
"""A string bounded to its column width, or None.
Ingest writes whatever an endpoint reports. A registry value longer than its
column is not a data-quality nuisance on MySQL: in strict mode it raises
Data too long and fails the whole check-in transaction, so one machine with
a malformed Uninstall entry stops reporting telemetry and events entirely.
"""
if value is None:
return None
text = str(value).strip()
return text[:length] or None
class EndpointsPlugin(BasePlugin):
"""Endpoint RMM telemetry ingest plus read/dashboard surface."""
@@ -131,10 +145,11 @@ class EndpointsPlugin(BasePlugin):
# including every logged-in username on the floor, because the
# panels and the list were gated on the same string.
#
# It is deliberately NOT named '.view'. guest_grantable_permissions
# opens any '.view' or '.read' permission to the guest role, so this
# name is what keeps the fleet roster off the public surface while
# per-asset telemetry can still be granted.
# The name avoids '.view' for readability, but the NAME no longer
# decides anything: guest_grantable_permissions is a declared list
# now, and this plugin blesses endpoints.view alone below. Adding
# endpoints.fleet there would be what opens the roster, and the
# docstring on that hook says why not to.
('endpoints.fleet',
'View the fleet-wide endpoint list and dashboards', 'endpoints'),
# Issue an allowlisted command to an endpoint agent (for example the
@@ -152,8 +167,19 @@ class EndpointsPlugin(BasePlugin):
# running a staged script or pushing an installer is a strictly
# heavier grant than a routine service/process toggle.
('endpoints.deploy',
'Run staged scripts and push application installers to endpoints', 'endpoints'),
# Collector service scope for the endpoint agent. A managed token
'Run staged scripts and push application installers to endpoints', 'endpoints'),
# Packaging the agent is a heavier grant than deploying to one, and
# it is not the same job either: the generated ZIP carries a live
# collector key stamped into its install script, so whoever holds
# this can mint a distributable artefact containing the fleet-wide
# command-channel credential. It was require_role('admin'), which
# this codebase otherwise reserves for the few genuinely
# role-shaped checks - and a role cannot be granted to one person
# for one job the way a permission can.
('endpoints.agentpackage',
'Generate and download an agent or adoption-tool package '
'(contains a live collector key)', 'endpoints'),
# Collector service scope for the endpoint agent. A managed token
# scoped to ONLY this authorizes the endpoints collector and nothing
# else, so rotating/revoking it stops endpoint-agent traffic without
# touching computers/geenforce collectors. Minted from the settings
@@ -162,6 +188,21 @@ class EndpointsPlugin(BasePlugin):
'Submit endpoint agent telemetry (fleet reporting)', 'collector'),
]
def get_guest_grantable_permissions(self) -> List[str]:
"""Declared because a site chose it: live status on a PC page.
endpoints.view only. NOT endpoints.fleet - that is the roster, and the
whole reason the two were split.
KNOWN GAP, tracked separately: the per-asset panels this opens are
keyed on a small integer asset id and the status panel emits the
logged-in username, so walking ids reconstructs most of the roster the
split exists to protect. Bounding or rate-limiting the per-asset panels
is the fix. Until then a site enabling guest access should know the
split is a speed bump here, not a wall.
"""
return ['endpoints.view']
def get_settings_defaults(self) -> List[Dict]:
"""Settings owned by the endpoints agent-key kill switch.
@@ -310,6 +351,9 @@ class EndpointsPlugin(BasePlugin):
'permission': 'endpoints.command',
'empty': 'No monitored services reported.',
'position': 12,
# Service state is what is being watched; the Start/Stop
# controls belong beside it, not in a tab of their own.
'group': 'monitors',
},
{
'id': 'endpoints-tracked-processes',
@@ -348,6 +392,8 @@ class EndpointsPlugin(BasePlugin):
'permission': 'endpoints.command',
'empty': 'No monitored processes reported.',
'position': 13,
# observed process state, not something you act on here
'group': 'monitors',
},
{
# One feed instead of two cards. Events and commands answer the
@@ -391,6 +437,8 @@ class EndpointsPlugin(BasePlugin):
},
'empty': 'Nothing recorded for this endpoint yet.',
'position': 20,
# the event and command feed IS the activity tab
'group': 'activity',
},
{
'id': 'endpoints-interfaces',
@@ -410,6 +458,33 @@ class EndpointsPlugin(BasePlugin):
],
'empty': 'No network interfaces reported.',
'position': 16,
# adapter state is watched, not something you act on
'group': 'monitors',
},
{
# Installed-software (SBOM) inventory. Raw discovered list for
# now: no managed-application linkage is surfaced yet.
#
# NOT collapsible. It used to be, and started collapsed, because
# it was low-priority reference data sitting near the bottom of
# one long page. It is now the Software tab's whole reason to
# exist, and somebody who clicked that tab has already said what
# they came for - making them click again to see it is a step
# that asks a question already answered.
'id': 'endpoints-applications',
'title': 'Installed software',
'assettypes': ['computer'],
'endpoint': '/api/endpoints/asset/{assetid}/applications',
'render': 'table',
'columns': [
{'key': 'displayname', 'label': 'Name'},
{'key': 'displayversion', 'label': 'Version'},
{'key': 'publisher', 'label': 'Publisher'},
],
'empty': 'No installed software reported.',
'position': 30,
# the installed-software inventory
'group': 'software',
},
{
# Installed-software (SBOM) inventory. Low-priority reference
@@ -641,11 +716,18 @@ class EndpointsPlugin(BasePlugin):
if not displayname:
warnings.append('skipped application with no displayname')
continue
# TRUNCATE EVERY STRING, not just the name. On MySQL in strict
# mode an over-long value raises Data too long and takes down the
# WHOLE check-in transaction, so one machine with a malformed
# Uninstall registry entry stops reporting entirely - telemetry,
# events and all - until somebody uninstalls the offender. SQLite
# does not enforce declared lengths, so no test here would ever see
# it. displayname was already clamped; these two were not.
db.session.add(EndpointApplication(
assetid=assetid,
displayname=displayname[:255],
displayversion=(item.get('displayversion') or None),
publisher=(item.get('publisher') or None),
displayversion=_clamp(item.get('displayversion'), 128),
publisher=_clamp(item.get('publisher'), 255),
))
stored += 1
return stored

View File

@@ -21,7 +21,7 @@ from sqlalchemy.exc import IntegrityError
from shopdb.api import (
db, cache, success_response, error_response, ErrorCodes, require_permission,
require_any_permission,
authorized_service_token, Setting, Application,
authorized_service_token, Setting, Application, client_ip,
)
SHAREROOT_SETTING = 'geenforce_share_root'
@@ -207,12 +207,14 @@ PAYLOAD_DOWNLOAD_RATELIMIT_WINDOW_SECONDS = 60
def _client_ip():
"""Caller IP for rate limiting, honoring the first X-Forwarded-For hop
(mirrors shopdb.core.api.auth._login_ip)."""
forwarded = request.headers.get('X-Forwarded-For')
if forwarded:
return forwarded.split(',')[0].strip()
return request.remote_addr or 'unknown'
"""Caller IP for rate limiting.
Uses the shared helper, which resolves from remote_addr. The raw
X-Forwarded-For hop is client-supplied, so keying a rate limit on it lets a
caller rotate the header and get an unbounded budget - the same reasoning
_trusted_client_ip() above already applies to the allowlist.
"""
return client_ip(request) or 'unknown'
def _payload_max_bytes():

View File

@@ -74,6 +74,25 @@ class GuidesPlugin(BasePlugin):
('guides.delete', 'Delete guides', 'guides'),
]
def get_guest_grantable_permissions(self) -> List[str]:
"""A procedure is for whoever is standing at the machine.
Guides are written to be followed on the floor, by people who often
have no account and no reason to get one. The nav item and the API
route both already reason about a signed-out reader holding this, so
the grant was intended from the start; it qualified under the old
.view suffix rule and is written down here now that a grant has to be
declared.
What it reaches is bounded by the routes, not by this: list_guides
returns published guides only, matching search against the PUBLISHED
revision so draft wording cannot leak through a hit, and a guest can
never take the author branches because a guest has no user id. Guide
history stays with the editors. Publishing, editing and deleting are
separate permissions and none of them is grantable.
"""
return ['guides.view']
def get_navigation_items(self) -> List[Dict]:
# Position 8, directly under Slides (7), rather than down in the
# Information section with the reference lists. A guide is something you

View File

@@ -2659,6 +2659,9 @@ def dashboard_supplies():
# dashboard card and the toner report were both doing.
'levelid': printer.get('levelid'),
'iscritical': any(s['status'] == 'critical' for s in depleted),
# The emptiest cartridge on this printer, which is what decides the
# row's place in the queue. depleted is sorted and non-empty here.
'worstremaining': depleted[0]['remaining'],
'supplies': [{
'text': '{} {}%'.format(_shortsupplyname(supply.get('name')),
supply.get('remaining')),
@@ -2667,7 +2670,18 @@ def dashboard_supplies():
} for supply in depleted],
})
rows.sort(key=lambda r: (not r['iscritical'], r['printername']))
# Emptiest first. This sorted on iscritical then NAME, 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 decided which came first. That is bad ordering
# on its own and worse than it looks: the dashboard queue takes only the
# first few rows per source, so the printer that is actually empty was
# being truncated away while a fuller one held its place.
#
# remaining is already normalised for waste cartridges (a full waste bin
# reads as low remaining), so one ascending sort is worst-first for every
# supply type, and iscritical falls out of it rather than needing its own
# key. Name only breaks ties.
rows.sort(key=lambda r: (r['worstremaining'], r['printername']))
# Envelope rather than a bare list, so the card can say WHY it has no rows.
# An empty card and a blind card must not look the same.
return success_response({'rows': rows,

View File

@@ -37,7 +37,7 @@ from flask import current_app
from flask_jwt_extended import jwt_required, get_jwt_identity, current_user
from shopdb.api import (
db, cache, Setting,
db, cache, Setting, client_ip,
success_response, error_response, ErrorCodes,
)
@@ -117,8 +117,9 @@ def _guest_ratelimited():
person has more addresses - but it stops an open endpoint being an easy
place to park data or fill a disk.
"""
address = (request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
or request.remote_addr or 'unknown')
# Not the raw X-Forwarded-For hop: the caller sends that, so rotating it
# gives an unbounded budget and the limit stops being a limit.
address = client_ip(request) or 'unknown'
bucket = int(_utcnow().timestamp()) // GUEST_CREATE_WINDOW_SECONDS
key = f'securesendguest:{address}:{bucket}'
used = cache.get(key) or 0

View File

@@ -85,8 +85,14 @@ class ToolsPlugin(BasePlugin):
A guest-created link records no creator. The audit trail says when and
from where, and never who. That is the cost, and it is why this is the
only write in the product offered to guests at all.
tools.view rides along because tools.secrets without it is a page a
guest holds permission to use and no way to reach: it gates the nav
entry, and the browser tools behind it (codes, conversions) run client
side and touch no data at all. Declared explicitly rather than inferred
from the .view suffix, which is what the grantable rule now asks for.
"""
return ['tools.secrets']
return ['tools.secrets', 'tools.view']
def get_settings_defaults(self) -> List[dict]:
"""The one switch this plugin owns.

View File

@@ -174,5 +174,13 @@ class WarrantyPlugin(BasePlugin):
('warranty.delete', 'Delete warranties', 'warranty'),
]
def get_guest_grantable_permissions(self) -> List[str]:
"""Coverage dates and vendor for an asset somebody is standing at.
Nothing here is a credential and nothing reaches a supplier portal; the
provider lookups run server-side with keys the caller never sees.
"""
return ['warranty.view']
def init_app(self, app: Flask, db_instance) -> None:
logger.info(f"Warranty plugin initialized (v{self.meta.version})")

View File

@@ -122,6 +122,7 @@ if (Test-Path $shortcutPath) {
try { Remove-Item -Path $shortcutPath -Force -ErrorAction Stop; Write-Host " removed Start Menu shortcut $shortcutPath" }
catch { Write-Warning " could not remove Start Menu shortcut: $($_.Exception.Message)" }
}
# Drop the parent GE key only if we emptied it (some sites nest siblings under
# GE such as SFLD; never tree-delete a key we do not own).
foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64,

View File

@@ -98,7 +98,13 @@ from .plugins import plugin_manager
# model internals. The endpoints plugin resolves a delivered script by name and
# serves its body by digest to the agent. Additive name on the surface, minor
# bump.
__contract_version__ = '0.27.0'
# 0.28.0: added client_ip and strip_port to shopdb.api so a plugin does not
# hand-roll caller-IP extraction. Two plugins had, and both read the first
# X-Forwarded-For hop, which the caller sends - so a rate limit keyed on it was
# defeated by rotating the header. The helper resolves from remote_addr, which
# ProxyFix has already filled from the header when TRUSTED_PROXY_COUNT says a
# proxy is really in front. Additive names on the surface, minor bump.
__contract_version__ = '0.29.0'
# Product release version (see ADR-007). The product version and the
# plugin-contract version above are distinct series with independent
@@ -211,7 +217,14 @@ def create_app(config_name: str = None) -> Flask:
def user_lookup_callback(_jwt_header, jwt_data):
from .core.models import User
identity = jwt_data["sub"]
return db.session.get(User, int(identity))
user = db.session.get(User, int(identity))
# Deactivating an account has to end its sessions. There is no token
# denylist, so without this check the access token stays good until it
# expires and the refresh token keeps minting new ones for 30 days.
# /auth/refresh and the API-token path already check isactive.
if user is None or not user.isactive:
return None
return user
@app.after_request
def apply_security_headers(response):

View File

@@ -104,6 +104,12 @@ def collector_authorized(pluginname: str):
# as script in this origin - see shopdb/utils/uploads.py.
from shopdb.utils.uploads import send_upload, harden_upload_response
# Caller IP for rate limiting and audit. On the surface so a plugin does not
# reimplement it: the obvious hand-rolled version reads X-Forwarded-For, which
# the caller supplies, so a rate limit keyed on it can be rotated away to
# nothing. See shopdb/utils/clientip.py.
from shopdb.utils.clientip import client_ip, strip_port
# Import-mode helpers: preserve legacy timestamps during a bulk data import
from shopdb.utils.import_mode import (
apply_import_timestamps,
@@ -346,6 +352,8 @@ __all__ = [
# Serving uploads
'send_upload',
'harden_upload_response',
'client_ip',
'strip_port',
# Import-mode helpers
'apply_import_timestamps',
'import_mode_active',

View File

@@ -656,13 +656,17 @@ def upload_application_package(app_id: int):
@applications_bp.route('/package/<path:filename>', methods=['GET'])
@require_permission('applications.view')
@require_permission('applications.download')
def serve_application_package(filename):
"""Download an uploaded installer.
Authenticated, unlike the image: this is licensed vendor software, and an
open URL would publish it to anything that can reach the site. Always sent
as an attachment so a browser saves it rather than trying to render it.
Gated on applications.download, not applications.view: a guest may be
granted the metadata permission, and "an open URL would publish it" is just
as true of a URL any anonymous visitor can reach.
"""
return send_upload(_apppackage_dir(), filename, as_attachment=True)

View File

@@ -38,6 +38,19 @@ def _count_by_type(assettype):
return query.count()
def _inactive_excluded():
"""Counted-type assets held back by isactive, i.e. soft-deleted.
Reported so the page can account for itself. A total that silently drops
rows is the reason somebody compares it with a list page and finds a
difference they cannot explain.
"""
return (db.session.query(Asset).join(AssetType)
.filter(Asset.isactive == False,
AssetType.assettype.in_(COUNTEDTYPES))
.count())
COUNTEDTYPES = ('machine', 'computer', 'network_device', 'printer',
'measuring_tool')
@@ -69,6 +82,10 @@ def _countedassets():
@require_permission('assets.view')
def get_dashboard():
"""Get dashboard summary data (asset-based)."""
# Imported here, as in the helpers above: the dualpath service reaches back
# into models, so a module-level import is circular.
from shopdb.core.services.dualpath import dualpath_single_machine_enabled
machine_count = _count_by_type('machine')
pc_count = _count_by_type('computer')
network_count = _count_by_type('network_device')
@@ -99,6 +116,22 @@ def get_dashboard():
'totalmeasuringtool': measuringtool_count,
'activeassets': status_dict.get('In Use', 0),
'inrepair': status_dict.get('In Repair', 0),
# What the totals actually cover, so the page can say so rather than
# leaving a reader to work it out from a number that surprised them.
# Every count here filters isactive, and the dual-path collapse is a
# per-site toggle - asserting it unconditionally would be false at a
# site that has it off.
'countsactiveonly': True,
'dualpathcollapsed': bool(dualpath_single_machine_enabled()),
# Every status, not just the two that had callers. The counts were
# already built for the rollup above and then thrown away, so a reader
# could see "In Use" and had no way to learn where the rest went.
'statuses': status_dict,
# A DIFFERENT thing to a status, and the one that makes a total
# disagree with a list page. isactive is the soft-delete flag: these
# rows are excluded from every count above, so they are in no status
# bucket and cannot be found by adding the buckets up.
'inactiveexcluded': _inactive_excluded(),
# Structured data
'counts': {
'machines': machine_count,
@@ -219,6 +252,38 @@ def get_navigation():
return success_response(all_items)
@dashboard_bp.route('/capabilities', methods=['GET'])
def get_capabilities():
"""What THIS caller can reach, for chrome that would otherwise only 401.
The sidebar search box was gated on being signed in, because a box that
can only answer 401 is worse than no box. That stopped being the right
test once the guest role could be granted assets.view: a signed-out
visitor at a machine may now search for the asset number in front of them,
and the box stayed hidden from exactly the person it was re-opened for.
The client cannot answer this itself. Its hasPermission getter reads the
signed-in user's permission list, and a guest has no user at all, so
asking it about a guest returns false for everyone. The navigation route
above has the same problem and solves it the same way: the server knows
what the guest role holds, so the server decides.
Public, and deliberately so - it is the pre-login call that decides
whether the box is drawn. It reports only booleans about the caller, never
what the guest role holds in general, so it says nothing to an anonymous
reader that the rendered page does not already.
"""
from flask_jwt_extended import verify_jwt_in_request
from shopdb.utils.authz import caller_holds
verify_jwt_in_request(optional=True)
return success_response({
# Global search is gated on assets.view, and each domain behind it now
# gates on its own permission, so holding this reaches assets alone.
'cansearch': caller_holds('assets.view'),
})
@dashboard_bp.route('/widgets', methods=['GET'])
@require_permission('assets.view')
def get_widgets():

View File

@@ -19,6 +19,14 @@ from shopdb.utils.authz import require_permission
pluginui_bp = Blueprint('pluginui', __name__)
# The tabs an asset detail page offers, and so the only values a panel's
# 'group' may take. Kept here rather than in the frontend because the server
# resolves the group before the client sees it - see asset_panels below.
#
# 'details' is first and is the fallback: it holds what an asset IS, which is
# what a panel declaring nothing almost always contributes.
ASSET_PANEL_GROUPS = ('details', 'monitors', 'software', 'activity')
def _collect(hookname):
"""Aggregate a data-only hook across enabled plugins.
@@ -67,6 +75,12 @@ def asset_panels():
Query param assetid is required. A panel matches when its assettypes list
contains the asset's type key or the wildcard '*'.
Every panel comes back carrying a 'group', naming the detail-page tab it
belongs to. It is resolved HERE rather than in the client so a plugin that
predates tabs, or names a group that does not exist, still lands somewhere a
person can see instead of vanishing from a page that only renders known
groups. Unknown and absent both mean 'details'.
"""
assetid = request.args.get('assetid', type=int)
if not assetid:
@@ -81,6 +95,9 @@ def asset_panels():
for panel in _collect('get_asset_panels'):
assettypes = panel.get('assettypes') or []
if '*' in assettypes or (assettype and assettype in assettypes):
panel = dict(panel)
group = str(panel.get('group') or '').strip().lower()
panel['group'] = group if group in ASSET_PANEL_GROUPS else 'details'
matched.append(panel)
matched.sort(key=lambda panel: panel.get('position', 99))
return success_response(matched)

View File

@@ -196,11 +196,15 @@ def upload_script_body(script_id: int):
@scripts_bp.route('/body/<path:filename>', methods=['GET'])
@require_permission('applications.view')
@require_permission('applications.download')
def download_script_body(filename: str):
"""Operator-facing script body download (JWT/RBAC).
The endpoint agent uses the collector-authenticated plugin route instead;
this one is for humans reviewing what a script does.
Gated on applications.download, not applications.view: this serves the
SYSTEM-context PowerShell a site pushes to its bays, and a guest may be
granted the metadata permission.
"""
return send_upload(_script_dir(), filename, as_attachment=True)

View File

@@ -18,7 +18,7 @@ from shopdb.core.models import (
from shopdb.core.api.settings import get_cached_settings
from shopdb.core.services.spellfix import Vocabulary
from shopdb.utils.responses import success_response
from shopdb.utils.authz import require_permission
from shopdb.utils.authz import require_permission, caller_holds
logger = logging.getLogger(__name__)
@@ -1233,22 +1233,42 @@ def global_search():
results = []
search_term = f'%{query}%'
# Run all search domains
results.extend(_search_applications(query, search_term))
results.extend(_search_knowledgebase(query, search_term))
results.extend(_search_guides(query, search_term))
results.extend(_search_employees(query, search_term))
results.extend(_search_assets(query, search_term))
results.extend(_search_measuringtools(query, search_term))
results.extend(_search_usbdevices(query, search_term))
results.extend(_search_printeditems(query, search_term))
results.extend(_search_customfields(query, search_term))
results.extend(_search_notifications(query, search_term))
results.extend(_search_hostnames(query, search_term))
results.extend(_search_vendor_model_type(query, search_term))
# Every domain answers only to a caller who holds ITS permission.
#
# The route is gated on assets.view alone, and that used to be the whole
# check: one permission then queried twelve domains, so it read out of the
# staff directory, the USB checkout history, the notification log and the
# network inventory to anybody who could look up an asset number. The
# SEARCH_DOMAINS settings are feature toggles a site sets, not authorization,
# and were never doing this job.
#
# caller_holds rather than require_permission: a search that refuses the
# whole request because one domain is off-limits is useless. Each domain
# simply contributes nothing.
domains = [
('assets.view', _search_assets),
# Emit asset results routed to an asset page, so they belong to the
# same grant as the assets themselves.
('assets.view', _search_customfields),
('assets.view', _search_vendor_model_type),
('applications.view', _search_applications),
('kb.view', _search_knowledgebase),
('guides.view', _search_guides),
('employees.view', _search_employees),
('measuringtools.view', _search_measuringtools),
('usb.view', _search_usbdevices),
('printedparts.view', _search_printeditems),
('notifications.view', _search_notifications),
('computers.view', _search_hostnames),
]
for permission, search in domains:
if caller_holds(permission):
results.extend(search(query, search_term))
# IP-specific searches
if classification['is_ip']:
# IP-specific searches. An address and the subnet it sits in are network
# inventory, and knowing what answers on 10.20.x is a different question
# from looking up an asset number.
if classification['is_ip'] and caller_holds('network.view'):
results.extend(_search_by_ip(query, search_term))
results.extend(_search_subnets(query))

View File

@@ -334,23 +334,32 @@ def download_import_template(tablename):
def _existing_hostname_map():
"""Case-insensitive hostname -> assetnumber map for existing computers.
"""Case-insensitive hostname -> assetid map for existing computers.
Azure device-group exports align on hostname (their displayName), while the
import engine keys assets on assetnumber. This lets the Azure adapter route
a matched device to its real asset so the import updates in place.
Azure and Thor exports align on hostname (their displayName), while the
import engine keys assets on assetnumber. This lets those adapters route a
matched device to its real asset so the import updates in place.
THE ID, NOT THE ASSETNUMBER, and that is not a style choice. The resolver
takes any all-digit value at face value as a literal id rather than looking
it up, so an assetnumber like `0600` resolved to asset 600 - a different
asset entirely, or none. This site numbers operations 0NNN, so those
assetnumbers are real and common: one of them pointed a PC at a printer's
record, and another at an id that does not exist, which is the foreign-key
error an Azure import died on. Handing over the id the resolver is going to
treat it as makes the passthrough correct by construction.
"""
assets = db.metadata.tables['assets']
computers = db.metadata.tables['computers']
query = sa_select(
computers.c.hostname, assets.c.assetnumber
computers.c.hostname, assets.c.assetid
).select_from(
computers.join(assets, computers.c.assetid == assets.c.assetid)
).where(computers.c.hostname.isnot(None))
mapping = {}
for hostname, assetnumber in db.session.execute(query):
for hostname, assetid in db.session.execute(query):
if hostname:
mapping[hostname.strip().lower()] = assetnumber
mapping[hostname.strip().lower()] = assetid
return mapping

View File

@@ -351,19 +351,20 @@ def update_role(roleid: int):
# Update permissions
if 'permissions' in data and role.rolename != 'admin':
# The guest role is anyone with no account, so what it may hold is
# bounded: reads, plus the handful of writes a plugin has explicitly
# declared safe to do anonymously. Refused at the door rather than
# warned about - a checkbox that quietly makes deleting an asset
# anonymous is not a warning anybody reads twice.
# bounded to what core or a plugin has explicitly declared grantable.
# Refused at the door rather than warned about - a checkbox that
# quietly makes deleting an asset anonymous is not a warning anybody
# reads twice.
if role.rolename == Role.GUEST:
allowed = guest_grantable_permissions()
refused = sorted(set(data['permissions']) - allowed)
if refused:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'The guest role cannot hold {}. A guest may be granted any '
'view permission, and only writes a plugin has declared '
'safe to do without an account.'.format(', '.join(refused)))
'The guest role cannot hold {}. A guest may only be '
'granted permissions core or the owning plugin has '
'declared grantable, which is a code change rather than a '
'setting.'.format(', '.join(refused)))
old_perms = [p.name for p in role.permissions]
perms = Permission.query.filter(Permission.name.in_(data['permissions'])).all()
new_perms = [p.name for p in perms]

View File

@@ -46,6 +46,12 @@ class Permission(db.Model):
('assets.delete', 'Delete assets', 'assets'),
# Applications
('applications.view', 'View applications', 'applications'),
# Deliberately NOT '.view': reading the catalogue and pulling the bytes
# are different acts. A script body is SYSTEM-context PowerShell and an
# installer is a vendor binary, so the routes that serve them are gated
# here rather than on the metadata permission a site may hand to guests.
('applications.download',
'Download application installers and script bodies', 'applications'),
('applications.create', 'Create applications', 'applications'),
('applications.edit', 'Edit applications', 'applications'),
('applications.delete', 'Delete applications', 'applications'),
@@ -152,28 +158,60 @@ def full_permission_catalog(include_disabled=False):
return catalog
# Core permissions a site MAY grant the guest role. Declared, not derived.
# Each one has been read against what its routes actually return.
#
# Deliberately absent, with the reason, so nobody re-adds them by reflex:
# settings.view - every setting row, and secret masking is a substring test
# audit.view - usernames, addresses, and the before/after of every change
# users.view - gates nothing; list_users is require_role('admin')
CORE_GUEST_GRANTABLE = {
# Aggregate counts only; no row-level data reaches the caller.
'reports.view',
# Catalogue metadata. Safe only because the bytes moved to
# applications.download, which is not grantable.
'applications.view',
# Assets, and the global search over them. Safe only because search now
# gates each domain on its own permission: holding this reaches assets and
# nothing else, where it used to reach the staff directory and the USB,
# notification and network domains too.
'assets.view',
}
def guest_grantable_permissions():
"""Which permissions the guest role may be granted.
Reading is the safe default and covers almost everything a site wants to
open up: any `.view` or `.read` permission may be handed to guests.
Every entry is DECLARED, by core below or by a plugin through
get_guest_grantable_permissions(). Nothing qualifies by being spelled a
particular way.
A write is different - it lets somebody with no account change this
database, and the audit trail can then say when and from where but never
who. So a write has to be BLESSED by whoever owns it, through the plugin's
get_guest_grantable_permissions(). Secure Send is the case that exists:
creating a one-view link for somebody is the whole point of the page, and
the page has always been usable by people with no account here.
It used to: any name ending `.view` or `.read` was grantable, on the
reasoning that reading is the safe default. That is true of a permission
that gates a list of asset numbers and false of one that gates bytes, and
the suffix cannot tell them apart. `applications.view` reached the
SYSTEM-context PowerShell bodies and the installer binaries;
`backups.view` returned the full payload the separate backups.download
permission exists to protect; `assets.view` gated a global search that
queries the staff directory. None of those were decided. They were named.
Everything else is refused at the door rather than warned about, because a
checkbox that quietly makes deleting an asset anonymous is not a warning
anybody reads twice.
So the rule is inverted: a permission is grantable when somebody wrote it
down, having looked at what it actually serves. Adding one is a code
change, which is the point - it puts the decision in a diff rather than in
a suffix.
Naming one here does not open it. It makes the checkbox available in the
role grid; a site still has to tick it.
The bar, for a read: could an anonymous visitor hold this, forever, and
would the site be content with everything it returns? For a write, add
that the act can never be attributed to anybody.
"""
from flask import current_app, has_app_context
catalog = full_permission_catalog()
grantable = {name for name, _, _ in catalog
if name.endswith('.view') or name.endswith('.read')}
known = {name for name, _, _ in catalog}
grantable = {name for name in CORE_GUEST_GRANTABLE if name in known}
if not has_app_context():
return grantable
@@ -181,7 +219,6 @@ def guest_grantable_permissions():
if not pm:
return grantable
known = {name for name, _, _ in catalog}
for name, plugin in pm.get_all_plugins().items():
if not pm.registry.is_enabled(name):
continue

View File

@@ -427,12 +427,41 @@ class Resolver:
def resolve(self, tablename, idcolumn, value):
if value is None:
return None
# A number is taken at face value: exports round-trip, and some sites do
# know their ids.
if isinstance(value, int) or (isinstance(value, str) and value.strip().isdigit()):
# An int is an id. It cannot be anything else.
if isinstance(value, int):
return int(value)
label = _label_column(tablename)
# An ALL-DIGIT STRING IS AMBIGUOUS, and used to be taken at face value
# as an id. That is wrong wherever a site's natural keys are numeric,
# and this one numbers operations 0NNN: assetnumber `0600` was read as
# id 600, which is a different asset - a printer, in the case that
# found this - or none at all, which surfaced as a foreign-key error
# partway through an Azure device import.
#
# The label wins when one matches. Somebody writing `0600` in a column
# keyed on assetnumber means the asset called 0600, and silently
# binding a row to an unrelated record is far worse than the error.
# Fall through to the id reading only when no label matches, which
# keeps round-tripped exports and sites that do know their ids working.
if isinstance(value, str) and value.strip().isdigit():
if label:
key = (tablename, value.strip().lower())
if key in self._cache:
return self._cache[key]
table = db.metadata.tables[tablename]
row = db.session.execute(
table.select().where(
db.func.lower(db.func.trim(table.c[label]))
== value.strip().lower())
).first()
if row is not None:
found = getattr(row, idcolumn)
self._cache[key] = found
return found
return int(value)
if not label:
raise ValueError(
"cannot look up %s by name - give the numeric %s instead" % (tablename, idcolumn))

View File

@@ -107,17 +107,24 @@ class BasePlugin(ABC):
def get_guest_grantable_permissions(self) -> List[str]:
"""Permissions this plugin allows a site to grant to the GUEST role.
A guest is anyone with no account. Any `.view` or `.read` permission is
grantable without asking a plugin - reading is the safe default. This
hook is for the exception: a WRITE that is genuinely meant to be usable
by somebody who never signs in.
A guest is anyone with no account. EVERY permission of this plugin's
that a site may grant one has to be named here, reads included.
Reads used to be automatic: anything ending `.view` or `.read` was
grantable. That treats a name as a decision, and it went wrong in the
obvious way - permissions that gated installer bytes, backup payloads
and a search across the staff directory were all anonymous-grantable
because of how they were spelled. So the plugin that owns a permission
now says, having looked at what its routes return.
Naming one here does not open it. It makes the checkbox available in
the role grid; a site still has to tick it. Return [] (the default) and
none of this plugin's writes can ever be made anonymous.
nothing of this plugin's can be made anonymous.
The bar: could this action be attributed to nobody, forever, and still
be defensible in the audit log? If not, leave it out.
The bar for a read: could an anonymous visitor hold this, forever, and
would the site be content with everything the routes return - every
column of every serializer, not just the ones the UI happens to show?
For a write, add that the act can never be attributed to anybody.
"""
return []
@@ -363,8 +370,29 @@ class BasePlugin(ABC):
# ('list' takes a 'map' of title/badge/meta
# keys, rendered generically - see warranty)
'position': int, # order among panels
'group': str, # OPTIONAL. Which tab on the detail page:
# 'details' | 'monitors' | 'software'
# | 'activity'. Omitted means 'details'.
}
ON 'group'. The detail pages are tabbed, and a panel says which tab it
belongs to rather than the page listing panels by id - a page that knew
its plugins' panels by name would defeat the point of the hook.
Omitting it is the correct answer for most panels and keeps every
existing plugin working untouched: an unknown or absent group resolves
to 'details', which is the tab that holds what an asset IS. Reach for
another group only when the panel is the thing that tab exists for -
what is being watched on the box, an inventory listing, or a history
feed.
There is deliberately no group for controls. A panel that acts on the
machine belongs beside the state it acts on: tracked services read as
state first and a Start/Stop second, so they live in 'monitors' with
the rest of what is being watched. Splitting the buttons away from
the state they change made a tab that held one card and answered no
question on its own.
Consumed by GET /api/pluginui/asset-panels?assetid=<id>, which returns
the panels matching that asset's type. Disabled plugins
are skipped; a broken plugin is isolated in prod, re-raised in dev/test.

View File

@@ -6,6 +6,23 @@ every connection - so it must be stripped before the value is stored (audit),
matched (dashboard IP fallback), or used as a rate-limit key (login). Left in,
it makes every request look like a different client.
X-Forwarded-For is NOT read here. A client can send that header itself, so
trusting it unconditionally hands any caller a free identity: the login rate
limit and the account lockout are keyed on this value, and the audit log
records it. Reading it directly also walked around TRUSTED_PROXY_COUNT, which
exists precisely to decide whether a proxy is really in front (see the ProxyFix
block in shopdb/__init__.py).
The trusted case is already handled upstream. When TRUSTED_PROXY_COUNT is set,
ProxyFix resolves X-Forwarded-For into remote_addr before the request reaches a
view, so remote_addr is the real client. When it is unset, remote_addr is the
peer that actually connected. Either way remote_addr is the trustworthy source,
which is why this module only strips the port from it.
A site behind IIS or nginx MUST set TRUSTED_PROXY_COUNT (1 for a single proxy),
or every request will key on the proxy's own address and the per-client rate
limit becomes one shared bucket.
Port stripping is IPv6-aware: a bracketed [2001:db8::1]:443 loses its port; a
bare 2001:db8::1 (many colons, no brackets) is returned untouched.
"""
@@ -26,14 +43,11 @@ def strip_port(ipaddress):
def client_ip(request_obj):
"""First-hop client IP behind one proxy, port stripped.
"""Client IP, port stripped, from the connecting peer.
Honors the first X-Forwarded-For hop, else remote_addr. Returns None only
when neither is present (callers that need a non-null key coerce it).
Resolved from remote_addr, which ProxyFix has already rewritten from
X-Forwarded-For when TRUSTED_PROXY_COUNT says a proxy is really in front.
Returns None only when remote_addr is absent (callers that need a non-null
key coerce it).
"""
forwarded = request_obj.headers.get('X-Forwarded-For')
if forwarded:
raw = forwarded.split(',')[0].strip()
else:
raw = request_obj.remote_addr
return strip_port(raw)
return strip_port(request_obj.remote_addr)

View File

@@ -32,11 +32,18 @@ def ratelimit_on(app):
def _bad_login(client, ip):
"""Attempt a login for a non-existent user from a given source IP."""
"""Attempt a login for a non-existent user from a given source IP.
The caller is set through REMOTE_ADDR, the peer that actually connected,
because that is what the limiter keys on. It used to be sent as
X-Forwarded-For, which meant these tests changed identity using the very
header a real attacker would rotate to escape the limit - so the suite
could not have told the difference.
"""
return client.post(
'/api/auth/login',
json={'username': 'ghost', 'password': 'wrong'},
headers={'X-Forwarded-For': ip},
environ_base={'REMOTE_ADDR': ip},
)

View File

@@ -1,4 +1,12 @@
"""strip_port / client_ip normalization (ARR forwards clientip:port)."""
"""client_ip: which address the app treats as the caller, and port normalization.
The answer is remote_addr. X-Forwarded-For is written by the caller, and the
login rate limit, the account lockout and the audit log all key on this value,
so honoring the header let a caller rotate it for an unbounded budget and an
untraceable trail. Where a proxy really is in front, TRUSTED_PROXY_COUNT turns
on ProxyFix and the header arrives already resolved into remote_addr - ports
included, because ARR forwards clientip:port.
"""
from shopdb.utils.clientip import strip_port, client_ip
@@ -34,12 +42,94 @@ class _FakeRequest:
self.remote_addr = remote_addr
def test_client_ip_prefers_forwarded_first_hop_stripped():
request_obj = _FakeRequest(headers={'X-Forwarded-For': '10.0.0.5:51000, 10.0.0.1'},
remote_addr='127.0.0.1')
def test_client_ip_ignores_the_forwarded_header():
"""The caller sends X-Forwarded-For, so it cannot decide who the caller is.
This test used to assert the opposite. The login rate limit, the account
lockout and the audit log all key on client_ip, so honoring the header let
a caller rotate it for an unbounded budget and an untraceable trail. Where
a proxy really is in front, TRUSTED_PROXY_COUNT turns on ProxyFix and the
header reaches us already resolved into remote_addr.
"""
request_obj = _FakeRequest(
headers={'X-Forwarded-For': '10.0.0.5:51000, 10.0.0.1'},
remote_addr='127.0.0.1')
assert client_ip(request_obj) == '127.0.0.1'
def test_client_ip_strips_a_port_proxyfix_passed_through():
"""ARR writes clientip:port into the header and ProxyFix copies it verbatim
into remote_addr, so the port still has to come off."""
request_obj = _FakeRequest(headers={}, remote_addr='10.0.0.5:51000')
assert client_ip(request_obj) == '10.0.0.5'
def test_client_ip_falls_back_to_remote_addr_stripped():
request_obj = _FakeRequest(headers={}, remote_addr='192.168.1.9:60000')
assert client_ip(request_obj) == '192.168.1.9'
# ---------------------------------------------------------------------------
# Through a real request, and through the login limiter that keys on this.
# ---------------------------------------------------------------------------
def test_the_forwarded_header_does_not_decide_the_client_ip(app):
from shopdb.utils.clientip import client_ip
with app.test_request_context(
'/', headers={'X-Forwarded-For': '10.0.0.99'},
environ_base={'REMOTE_ADDR': '203.0.113.7'}):
from flask import request
assert client_ip(request) == '203.0.113.7'
def test_the_peer_address_decides_the_client_ip(app):
from shopdb.utils.clientip import client_ip
with app.test_request_context(
'/', environ_base={'REMOTE_ADDR': '203.0.113.8'}):
from flask import request
assert client_ip(request) == '203.0.113.8'
def test_a_forwarded_port_is_still_stripped(app):
"""IIS ARR writes "clientip:port" and ProxyFix passes it through verbatim,
so the port has to come off remote_addr or every request looks like a new
client."""
from shopdb.utils.clientip import client_ip
with app.test_request_context(
'/', environ_base={'REMOTE_ADDR': '203.0.113.9:54321'}):
from flask import request
assert client_ip(request) == '203.0.113.9'
def test_rotating_the_forwarded_header_does_not_reset_the_login_limiter(app,
client,
db):
"""The budget follows the connection, not a header the caller writes.
Sprayed across many usernames on purpose: the per-account lockout bounds
guessing at ONE account, so the IP limiter is the only thing standing
between a caller and unlimited attempts spread across the directory. That
is what rotating the header used to defeat.
"""
caller = {'REMOTE_ADDR': '203.0.113.50'}
previous = dict(app.config)
app.config['AUTH_RATELIMIT_ENABLED'] = True
app.config['AUTH_RATELIMIT_MAX'] = 3
try:
seen = set()
for hop in range(10):
response = client.post(
'/api/auth/login',
headers={'X-Forwarded-For': '198.51.100.{}'.format(hop)},
environ_base=caller,
json={'username': 'nobody{}'.format(hop), 'password': 'wrong'})
seen.add(response.status_code)
if response.status_code == 429:
break
finally:
app.config['AUTH_RATELIMIT_ENABLED'] = previous.get(
'AUTH_RATELIMIT_ENABLED', False)
app.config['AUTH_RATELIMIT_MAX'] = previous.get('AUTH_RATELIMIT_MAX', 30)
assert 429 in seen, 'rotating X-Forwarded-For bought an unbounded budget'

View File

@@ -0,0 +1,61 @@
"""Deactivating an account ends 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. It did a bare lookup by id, which
meant deactivating somebody left their access token working until it expired and
their refresh token minting new ones for thirty days. /auth/refresh and the API
token path already checked isactive; the loader did not.
"""
from werkzeug.security import generate_password_hash
def _login(client, username='deactivateme', password='testpass'):
response = client.post('/api/auth/login',
json={'username': username, 'password': password})
assert response.status_code == 200, response.get_json()
return response.get_json()['data']
def _make_user(db, username='deactivateme'):
from shopdb.core.models import User
user = User(
username=username,
email=f'{username}@test.local',
passwordhash=generate_password_hash('testpass'),
)
db.session.add(user)
db.session.commit()
return user
def test_a_live_token_stops_working_once_the_account_is_deactivated(client, db):
user = _make_user(db)
tokens = _login(client)
headers = {'Authorization': 'Bearer {}'.format(tokens['access_token'])}
assert client.get('/api/auth/me', headers=headers).status_code == 200
user.isactive = False
db.session.commit()
assert client.get('/api/auth/me', headers=headers).status_code == 401
def test_a_deactivated_account_cannot_refresh(client, db):
user = _make_user(db, 'refreshme')
tokens = _login(client, 'refreshme')
headers = {'Authorization': 'Bearer {}'.format(tokens['refresh_token'])}
user.isactive = False
db.session.commit()
assert client.post('/api/auth/refresh', headers=headers).status_code == 401
def test_an_active_account_is_unaffected(client, db):
"""The loader must not reject everybody; isactive defaults true."""
_make_user(db, 'stillhere')
tokens = _login(client, 'stillhere')
headers = {'Authorization': 'Bearer {}'.format(tokens['access_token'])}
assert client.get('/api/auth/me', headers=headers).status_code == 200

View File

@@ -98,12 +98,61 @@ def test_a_site_with_no_guest_role_behaves_as_it_always_did(client, db):
# The guardrail
# ---------------------------------------------------------------------------
def test_a_view_permission_is_grantable(app):
def test_a_declared_permission_is_grantable(app):
from shopdb.core.models import guest_grantable_permissions
with app.app_context():
grantable = guest_grantable_permissions()
assert 'assets.view' in grantable
assert 'reports.view' in grantable
assert 'applications.view' in grantable
def test_a_view_permission_is_not_grantable_merely_for_being_a_view(app):
"""The suffix stopped deciding. These are all '.view' and none is declared.
settings.view gates every setting row; audit.view the before/after of every
change; users.view nothing at all. Each was anonymous-grantable purely
because of how it was spelled.
assets.view is deliberately NOT in this list. It was, while global search
answered twelve domains behind it; now that search gates each domain on its
own permission the grant reaches assets alone, and the floor needs it to
look an asset number up without signing in.
"""
from shopdb.core.models import guest_grantable_permissions
with app.app_context():
grantable = guest_grantable_permissions()
for name in ('settings.view', 'audit.view', 'users.view'):
assert name not in grantable, name
def test_the_guest_features_that_already_shipped_stay_grantable(app):
"""Both were granted on a live site before a grant had to be declared.
Inverting the rule to explicit declaration dropped every permission that
had qualified by its .view suffix, including two the product ships guest
flows for: guides, whose nav item and list route both reason about a
signed-out reader, and the tools page, without which a guest holding
tools.secrets cannot reach Secure Send at all.
A site that granted them under the old rule was then unable to save the
guest role AT ALL - the API refuses the whole request when any submitted
permission is undeclared, and the grid resubmits what the role already
holds. That deadlock is what this guards against, so anyone narrowing the
grantable set again has to look at what is already granted in the field.
"""
from shopdb.core.models import guest_grantable_permissions
with app.app_context():
grantable = guest_grantable_permissions()
assert 'guides.view' in grantable
assert 'tools.view' in grantable
def test_the_bytes_behind_applications_are_not_grantable(app):
"""applications.view is grantable only because the bytes moved off it."""
from shopdb.core.models import guest_grantable_permissions
with app.app_context():
grantable = guest_grantable_permissions()
assert 'applications.download' not in grantable
def test_a_write_permission_is_not_grantable(app):
@@ -127,13 +176,29 @@ def test_the_api_refuses_to_grant_a_guest_a_write(client, db, auth_headers,
assert guestrole.permissions == []
def test_the_api_allows_granting_a_guest_a_view(client, db, auth_headers,
guestrole):
def test_the_api_allows_granting_a_guest_a_declared_read(client, db,
auth_headers,
guestrole):
response = client.put('/api/users/roles/{}'.format(guestrole.roleid),
headers=auth_headers,
json={'permissions': ['reports.view']})
assert response.status_code == 200
assert [p.name for p in guestrole.permissions] == ['reports.view']
def test_the_api_refuses_an_undeclared_read(client, db, auth_headers,
guestrole):
"""This test used to grant guests audit.view and pass, which is the bug.
audit.view ends in '.view' and gates usernames, addresses and the
before/after of every change in the database.
"""
response = client.put('/api/users/roles/{}'.format(guestrole.roleid),
headers=auth_headers,
json={'permissions': ['audit.view']})
assert response.status_code == 200
assert [p.name for p in guestrole.permissions] == ['audit.view']
assert response.status_code == 400
assert 'audit.view' in response.get_json()['data']['error']['message']
assert guestrole.permissions == []
def test_the_guest_role_cannot_be_deleted(client, auth_headers, guestrole):
@@ -155,8 +220,9 @@ def test_the_role_list_says_what_a_guest_may_hold(client, auth_headers,
rows = client.get('/api/users/roles', headers=auth_headers).get_json()['data']
guest = next(r for r in rows if r['rolename'] == 'guest')
assert guest['isguest'] is True
assert 'assets.view' in guest['grantable']
assert 'reports.view' in guest['grantable']
assert 'assets.delete' not in guest['grantable']
assert 'audit.view' not in guest['grantable']
other = next((r for r in rows if r['rolename'] != 'guest'), None)
if other is not None:
@@ -188,3 +254,39 @@ def test_a_bad_token_is_still_refused(client, db, guestrole):
response = client.get('/api/auditlogs',
headers={'Authorization': 'Bearer not.a.token'})
assert response.status_code == 422
# ---------------------------------------------------------------------------
# What the chrome is allowed to draw
# ---------------------------------------------------------------------------
def test_a_guest_is_not_offered_search_before_the_grant(client, guestrole):
"""The box is hidden until somebody opens the door, not by default."""
body = client.get('/api/dashboard/capabilities').get_json()['data']
assert body['cansearch'] is False
def test_granting_a_guest_assets_view_offers_them_search(client, db, guestrole):
"""The point of the whole exercise: a signed-out visitor standing at a
machine gets the box, so they can look up the number in front of them."""
grant(db, guestrole, 'assets.view')
body = client.get('/api/dashboard/capabilities').get_json()['data']
assert body['cansearch'] is True
def test_a_member_without_assets_view_is_not_offered_search(client, db,
member_headers):
"""It used to be drawn for anyone signed in, and answered 401 to the ones
who could not use it."""
body = client.get('/api/dashboard/capabilities',
headers=member_headers).get_json()['data']
assert body['cansearch'] is False
def test_capabilities_does_not_report_what_the_guest_role_holds(client, db,
guestrole):
"""It answers about the CALLER only. A signed-out reader learns whether
their own box is drawn, not the shape of the site's guest policy."""
grant(db, guestrole, 'assets.view')
body = client.get('/api/dashboard/capabilities').get_json()['data']
assert set(body) == {'cansearch'}

View File

@@ -142,3 +142,86 @@ def test_asset_presentation_skip_disabled_plugin(app, client, auth_headers, monk
entries = _data(client, '/api/pluginui/asset-presentation', auth_headers)
assert 'measuring_tool' not in {e.get('assettype') for e in entries}
# =============================================================================
# asset-panel groups (which detail-page tab a panel lands in)
# =============================================================================
def test_every_panel_comes_back_with_a_group(app, client, db, auth_headers,
monkeypatch):
"""The client filters by group, so a panel without one must not vanish.
Resolution happens on the server precisely so a plugin written before tabs
existed still lands somewhere a person can see.
"""
from shopdb.core.api.pluginui import ASSET_PANEL_GROUPS
pm = app.extensions['plugin_manager']
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
assetid = _make_asset(db)
panels = _data(client, '/api/pluginui/asset-panels?assetid=%d' % assetid,
auth_headers)
assert panels, 'no panels matched, so this test proves nothing'
for panel in panels:
assert panel.get('group') in ASSET_PANEL_GROUPS, panel.get('id')
def test_an_undeclared_group_defaults_to_details(app, client, db, auth_headers,
monkeypatch):
"""Omitting group is the documented answer for most panels."""
pm = app.extensions['plugin_manager']
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
plugin = next(iter(pm.get_all_plugins().values()))
monkeypatch.setattr(plugin, 'get_asset_panels', lambda: [{
'id': 'nogroup', 'title': 'No group', 'assettypes': ['*'],
'endpoint': '/api/whatever', 'render': 'keyvalue', 'position': 1,
}])
assetid = _make_asset(db)
panels = _data(client, '/api/pluginui/asset-panels?assetid=%d' % assetid,
auth_headers)
panel = next(p for p in panels if p['id'] == 'nogroup')
assert panel['group'] == 'details'
def test_a_group_nobody_defined_falls_back_rather_than_disappearing(
app, client, db, auth_headers, monkeypatch):
"""A typo must not drop the panel off every tab.
The page renders known groups only, so an unrecognised value that survived
to the client would be a panel that exists and is never shown anywhere.
"""
pm = app.extensions['plugin_manager']
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
plugin = next(iter(pm.get_all_plugins().values()))
monkeypatch.setattr(plugin, 'get_asset_panels', lambda: [{
'id': 'typo', 'title': 'Typo', 'assettypes': ['*'],
'endpoint': '/api/whatever', 'render': 'keyvalue', 'position': 1,
'group': 'montiors',
}])
assetid = _make_asset(db)
panels = _data(client, '/api/pluginui/asset-panels?assetid=%d' % assetid,
auth_headers)
panel = next(p for p in panels if p['id'] == 'typo')
assert panel['group'] == 'details'
def test_group_matching_is_not_case_or_space_sensitive(app, client, db,
auth_headers, monkeypatch):
"""'Monitors ' is obviously meant to be monitors; do not send it to details."""
pm = app.extensions['plugin_manager']
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
plugin = next(iter(pm.get_all_plugins().values()))
monkeypatch.setattr(plugin, 'get_asset_panels', lambda: [{
'id': 'shouty', 'title': 'Shouty', 'assettypes': ['*'],
'endpoint': '/api/whatever', 'render': 'keyvalue', 'position': 1,
'group': ' Monitors ',
}])
assetid = _make_asset(db)
panels = _data(client, '/api/pluginui/asset-panels?assetid=%d' % assetid,
auth_headers)
panel = next(p for p in panels if p['id'] == 'shouty')
assert panel['group'] == 'monitors'

View File

@@ -28,6 +28,10 @@ PUBLIC_ENDPOINTS = {
'plugins.list_enabled_plugins',
'dashboard.get_navigation', # filtered per caller; a guest sees only
# what the guest role can reach
'dashboard.get_capabilities', # booleans about the CALLER, used to decide
# whether the search box is drawn at all;
# it must answer a signed-out visitor or
# the guest grant cannot reach the chrome
'dashboard.health_check', # monitoring has no account
# Settings: the handler serves an unauthenticated caller only the public

View File

@@ -0,0 +1,94 @@
"""Global search answers each domain only to a caller who holds ITS permission.
The route is gated on assets.view, and that used to be the entire check. One
permission then queried twelve domains, so anyone who could look up an asset
number also read out of the staff directory, the USB checkout history, the
notification log and the network inventory.
That is why assets.view could not be handed to guests, which cost the floor its
search. Gating each domain separately is what makes the grant safe again: it
reaches assets and nothing else.
"""
from unittest.mock import patch
import pytest
from shopdb.core.models import Permission, Role
def _grant_guest(db, *names):
guest = Role.guest()
guest.permissions = [p for p in Permission.query.filter(
Permission.name.in_(names)).all()]
db.session.commit()
@pytest.fixture
def guestrole(db):
"""The seeded guest role, holding nothing."""
Permission.seed()
Role.seedguest()
db.session.commit()
return Role.guest()
def _search(client, term='a'):
response = client.get('/api/search?q={}'.format(term))
assert response.status_code == 200, response.get_json()
return response.get_json()['data']
FAKE_EMPLOYEE = [{
'First_Name': 'Ada', 'Last_Name': 'Smith', 'SSO': 508123,
'Team': 'Machining', 'Role': 'Operator',
}]
def _with_employee_directory():
"""The HR directory is an external database and is unreachable in tests, so
the domain silently returned nothing and a leak test over it proved nothing.
Patched here so there is something real to leak."""
return patch('shopdb.core.api.search._employee_rows',
return_value=FAKE_EMPLOYEE)
def test_assets_view_alone_does_not_reach_the_staff_directory(client, db,
guestrole):
"""The finding this exists for: ?q=smith returned employee records to a
signed-out visitor holding only assets.view."""
_grant_guest(db, 'assets.view')
with _with_employee_directory():
data = _search(client, 'smith')
assert 'employee' not in {row['type'] for row in data['results']}
def test_employees_view_does_reach_it(client, db, guestrole):
"""The other direction, so the gate is not passing by refusing everything."""
_grant_guest(db, 'assets.view', 'employees.view')
with _with_employee_directory():
data = _search(client, 'smith')
employees = [row for row in data['results'] if row['type'] == 'employee']
assert len(employees) == 1
assert employees[0]['title'] == 'Ada Smith'
def test_search_still_answers_a_guest_with_assets_view(client, db, guestrole):
"""The regression that prompted this: guests lost search entirely.
assets.view left the grantable set because of the leak above, which took
the floor's asset lookup with it.
"""
_grant_guest(db, 'assets.view')
response = client.get('/api/search?q=press')
assert response.status_code == 200
def test_a_guest_with_nothing_granted_is_refused(client, db, guestrole):
_grant_guest(db)
assert client.get('/api/search?q=press').status_code in (401, 403)
def test_assets_view_is_grantable_again(app):
from shopdb.core.models import guest_grantable_permissions
with app.app_context():
assert 'assets.view' in guest_grantable_permissions()

View File

@@ -335,3 +335,29 @@ def test_reconcile_still_binds_normalized_exact(db):
_db.session.rollback()
def test_a_numeric_assetnumber_is_read_as_a_name_not_an_id(db):
"""This site numbers operations 0NNN, so numeric natural keys are real.
An all-digit string used to be taken at face value as an id. `0600` became
id 600 - a different asset, or none - which is how an Azure device import
died on a foreign key partway through, and how a PC could instead have been
bound silently to a printer's record. The label wins when one matches.
"""
resolver = Resolver()
seed_reference(resolver)
import_csv('assets', 'assetnumber,assettypeid\n0600,Machine\n',
resolver=resolver, commit=True)
realid = _db.session.execute(_db.text(
"select assetid from assets where assetnumber = '0600'")).scalar()
# Force the ambiguity: an id that is NOT the one named 0600.
assert resolver.resolve('assets', 'assetid', '0600') == realid
def test_a_number_naming_nothing_is_still_read_as_an_id(db):
"""Round-tripped exports and sites that know their ids keep working."""
resolver = Resolver()
seed_reference(resolver)
assert resolver.resolve('assets', 'assetid', '999999') == 999999

View File

@@ -0,0 +1,50 @@
"""No test file may sit where pytest will not collect it.
pytest.ini sets `testpaths = tests`, so a test file anywhere else is never run
by `pytest` and never run in CI. It still passes review, still gets maintained,
and still reports nothing.
That is not hypothetical. plugins/endpoints/tests/test_plugin.py arrived with a
merge carrying 55 lines of delivery assertions and was collected ZERO times; run
directly, five of its tests failed. plugins/printedparts/tests/test_plugin.py had
been sitting uncollected for longer. Both are now under tests/.
The failure mode is the worst kind: the suite is green, the coverage is imaginary,
and the authorisation on a route can be deleted without anything going red.
"""
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
# Where pytest actually looks (pytest.ini: testpaths = tests).
COLLECTED = REPO / 'tests'
# Trees that legitimately hold test-shaped files pytest must not collect:
# vendored dependencies, build output, and the frontend's own vitest specs.
IGNORED = ('node_modules', 'venv', '.git', 'dist', '__pycache__',
'site-packages', '.plugins-staged', 'frontend')
# The Windows installer stages a whole copy of the app under here. It is build
# output, not source, and the export excludes it - caught by this test on its
# first run, which is the sort of thing the ignore list exists for.
IGNORED_TREES = ('deploy/windows/installer/bundle',)
def test_every_test_file_lives_where_pytest_looks():
stray = []
for path in REPO.rglob('test_*.py'):
if any(part in IGNORED for part in path.parts):
continue
relative = path.relative_to(REPO).as_posix()
if any(relative.startswith(tree) for tree in IGNORED_TREES):
continue
if COLLECTED in path.parents:
continue
stray.append(path.relative_to(REPO))
assert not stray, (
'These test files are never collected, because pytest.ini limits\n'
'collection to tests/. They pass review and report nothing:\n '
+ '\n '.join(str(p) for p in sorted(stray))
+ '\n\nMove them under tests/ (tests/test_plugins/ for plugin tests).'
)

View File

@@ -94,7 +94,7 @@ EXPECTED_HEAD_REVISION['warranty'] = 'warranty0002proof'
# adds the endpointcommands queue plus the MTConnect state columns, 0003
# adds the generic command target/targetkind columns, and 0004 adds the
# endpointapplications SBOM snapshot table.
EXPECTED_HEAD_REVISION['endpoints'] = 'endpoints0004apps'
EXPECTED_HEAD_REVISION['endpoints'] = 'endpoints0005cmdpin'
# Plugins built after the cutover: their 0001 baseline really creates tables the
# core chain never owned.

View File

@@ -0,0 +1,87 @@
"""The installed-software snapshot table: it must actually accept inserts.
This table is rewritten wholesale on every endpoint check-in - delete the
asset's rows, insert the current inventory - so its surrogate key climbs at
(endpoints x installed apps) per report and never reuses a value. On MySQL a
32-bit key exhausts and inserts stop working permanently.
Widening it to BigInteger broke every insert on SQLite, because SQLite only
autoincrements a column typed exactly INTEGER PRIMARY KEY. Neither fault was
caught by the suite, for the same reason: nothing inserted into this table at
all. This does.
"""
from shopdb.api import db
from plugins.endpoints.models.endpoint import EndpointApplication
def test_rows_insert_and_the_key_autoincrements(app):
with app.app_context():
db.create_all()
first = EndpointApplication(assetid=1, displayname='Probe', displayversion='1.0')
second = EndpointApplication(assetid=1, displayname='Probe 2', displayversion='2.0')
db.session.add_all([first, second])
db.session.commit()
assert first.endpointapplicationid is not None
assert second.endpointapplicationid is not None
assert second.endpointapplicationid != first.endpointapplicationid
def test_the_key_is_wide_enough_for_a_table_that_is_rewritten_every_cycle(app):
"""BIGINT on MySQL, INTEGER on SQLite - both halves matter.
Dropping the variant breaks SQLite inserts; dropping the BigInteger breaks
MySQL years later, which is worse because it is silent until it is not.
"""
from sqlalchemy.dialects import mysql, sqlite
with app.app_context():
column = EndpointApplication.__table__.c.endpointapplicationid
assert 'BIGINT' in str(column.type.compile(dialect=mysql.dialect()))
assert 'INTEGER' in str(column.type.compile(dialect=sqlite.dialect()))
def test_over_long_registry_values_are_clamped_not_fatal(app):
"""One malformed Uninstall entry must not cost the whole check-in.
On MySQL in strict mode an over-long value raises Data too long and fails the
transaction, taking telemetry and events down with it - so that endpoint goes
silent until somebody uninstalls the offending program. SQLite does not
enforce declared lengths, so the suite would never have shown it.
"""
from plugins.endpoints.plugin import EndpointsPlugin
with app.app_context():
db.create_all()
warnings = []
stored = EndpointsPlugin()._replace_applications(
assetid=4242,
applications=[{
'displayname': 'A' * 400,
'displayversion': 'V' * 400,
'publisher': 'P' * 400,
}],
warnings=warnings,
)
db.session.commit()
assert stored == 1
row = EndpointApplication.query.filter_by(assetid=4242).one()
assert len(row.displayname) == 255
assert len(row.displayversion) == 128
assert len(row.publisher) == 255
def test_a_blank_value_becomes_null_rather_than_an_empty_string(app):
from plugins.endpoints.plugin import EndpointsPlugin
with app.app_context():
db.create_all()
EndpointsPlugin()._replace_applications(
assetid=4243,
applications=[{'displayname': 'Thing', 'publisher': ' '}],
warnings=[],
)
db.session.commit()
row = EndpointApplication.query.filter_by(assetid=4243).one()
assert row.publisher is None

View File

@@ -0,0 +1,151 @@
"""An update_app delivery must pin the bytes it actually delivers.
The agent verifies the downloaded file against the descriptor's sha256 and fails
closed on a mismatch - which is worthless if the descriptor carries no digest,
and misleading if it carries a digest for a different file.
Both were true of the version: branch. It pinned AppVersion.sha256, but
/api/endpoints/apppackage/<appversionid> resolves the version to its application
and serves application.installpath, so the digest described an artifact that
route never sends; where the version had no sha256 the descriptor carried none
at all and the agent had nothing to check.
It also ended the URL at the id, so an MSI arrived with no extension and the
agent's Path.GetExtension could not route it through msiexec.
"""
import pytest
from shopdb.api import db
from shopdb.core.models import Application, AppVersion
@pytest.fixture
def hosted_app(app, request):
# Unique per test: appname is unique-constrained and the database outlives
# a single test here, so a fixed name collides on the second use.
with app.app_context():
db.create_all()
application = Application(
appname='Widget-%s' % request.node.name[-24:],
installpath='/api/applications/package/widget-setup.msi',
installersha256='a' * 64,
)
db.session.add(application)
db.session.commit()
version = AppVersion(appid=application.appid, version='2.1', sha256=None)
db.session.add(version)
db.session.commit()
yield application.appid, version.appversionid
def test_a_version_delivery_carries_a_digest_even_when_the_version_has_none(app, hosted_app):
from plugins.endpoints.api.routes import _resolve_update_app
appid, appversionid = hosted_app
with app.app_context():
descriptor, error = _resolve_update_app(f'version:{appversionid}')
assert error is None, error
# Not None: this is the whole point. A blank pin makes the agent fail open.
assert descriptor['sha256'] == 'a' * 64
def test_a_version_delivery_keeps_the_installer_extension(app, hosted_app):
"""Without the filename an MSI is not routed through msiexec."""
from plugins.endpoints.api.routes import _resolve_update_app
appid, appversionid = hosted_app
with app.app_context():
descriptor, _ = _resolve_update_app(f'version:{appversionid}')
assert descriptor['installpath'].endswith('.msi')
def test_a_package_with_no_recorded_digest_is_refused_not_delivered_unverified(app):
"""Refusing to enqueue beats delivering something nothing can verify."""
from plugins.endpoints.api.routes import _resolve_update_app
with app.app_context():
db.create_all()
application = Application(appname='Unhashed-%s' % id(app),
installpath='/api/applications/package/x.exe')
db.session.add(application)
db.session.commit()
version = AppVersion(appid=application.appid, version='1.0', sha256=None)
db.session.add(version)
db.session.commit()
descriptor, error = _resolve_update_app(f'version:{version.appversionid}')
assert descriptor is None
assert 'SHA-256' in error
# ---------------------------------------------------------------------------
# The digest is pinned when an operator approves the push, not when the agent
# claims it. Between those two moments the bytes behind a descriptor can be
# replaced by anyone who can edit the catalog, and the re-resolved descriptor
# would faithfully describe the NEW bytes.
# ---------------------------------------------------------------------------
def test_an_unpinned_command_still_claims(app):
"""Actions that deliver no content, and rows queued before the pin existed."""
from plugins.endpoints.api.routes import _pin_holds
from plugins.endpoints.models import EndpointCommand
with app.app_context():
command = EndpointCommand(assetid=1, action='mtconnect_start',
pinnedsha256=None)
assert _pin_holds(command, None) is True
assert _pin_holds(command, {'sha256': 'b' * 64}) is True
def test_a_matching_digest_claims(app):
from plugins.endpoints.api.routes import _pin_holds
from plugins.endpoints.models import EndpointCommand
with app.app_context():
command = EndpointCommand(assetid=1, action='run_script',
pinnedsha256='c' * 64)
assert _pin_holds(command, {'sha256': 'c' * 64}) is True
def test_a_swapped_digest_does_not_claim(app):
"""The finding this exists for: approve one script, ship another.
An operator queues run_script against a reviewed body. Before the agent's
long poll returns, somebody with applications.edit replaces the body and
the digest is restamped. Without the pin the claim re-resolves, hands the
agent the attacker's digest, and the agent verifies it correctly.
"""
from plugins.endpoints.api.routes import _pin_holds
from plugins.endpoints.models import EndpointCommand
with app.app_context():
command = EndpointCommand(assetid=1, action='run_script',
pinnedsha256='c' * 64)
assert _pin_holds(command, {'sha256': 'd' * 64}) is False
def test_a_vanished_descriptor_is_not_treated_as_a_swap(app):
"""A deleted script already sends the command through with no descriptor so
the agent fails closed; that path must not be reported as tampering."""
from plugins.endpoints.api.routes import _pin_holds
from plugins.endpoints.models import EndpointCommand
with app.app_context():
command = EndpointCommand(assetid=1, action='run_script',
pinnedsha256='c' * 64)
assert _pin_holds(command, None) is True
def test_enqueue_records_the_digest_it_approved(app, hosted_app):
"""The resolved descriptor used to be discarded at enqueue (`_descriptor`)."""
from plugins.endpoints.api.routes import _resolve_update_app
appid, appversionid = hosted_app
with app.app_context():
descriptor, error = _resolve_update_app(str(appversionid))
assert error is None
# This is the value the enqueue path now writes to pinnedsha256.
assert descriptor['sha256'] == 'a' * 64

View File

@@ -69,7 +69,7 @@ def test_endpoints_asset_panels_are_computer_scoped():
"""Every asset panel targets computer assets only, with an endpoint."""
plugin = EndpointsPlugin()
panels = plugin.get_asset_panels()
assert len(panels) == 5
assert len(panels) == 6
for panel in panels:
assert panel['assettypes'] == ['computer']
assert panel['endpoint'].startswith('/api/endpoints/asset/{assetid}')
@@ -96,14 +96,28 @@ def test_services_and_processes_are_split_into_two_filtered_cards():
assert processes['permission'] == 'endpoints.command'
def test_recent_events_card_is_last_and_starts_collapsed():
"""Events is the lowest-priority card: forced to the bottom of the column
and collapsed by default."""
def test_activity_card_starts_collapsed_and_sits_below_live_state():
"""The activity feed is history, so it must not push live state down.
Renamed from the events card when events and commands merged into one feed.
It is no longer the LAST panel - installed software sits below it - so the
assertion is that it ranks below every live-state panel rather than being
the maximum, which would break again the next time a reference panel is
added underneath.
"""
plugin = EndpointsPlugin()
panels = plugin.get_asset_panels()
events = next(p for p in panels if p['id'] == 'endpoints-events')
assert events['collapsedDefault'] is True
assert events['position'] == max(p['position'] for p in panels)
activity = next(p for p in panels if p['id'] == 'endpoints-activity')
# NOT collapsed by default, unlike the events card it replaced. The feed
# surfaces critical events, and a card that hides the one thing worth seeing
# is the reason the health dot and the visible rows disagreed. Collapsible,
# so a reader can put it away.
assert activity.get('collapsible') is True
assert activity.get('collapsedDefault') is not True
live = [p['position'] for p in panels
if p['id'] in ('endpoints-status', 'endpoints-tracked-services',
'endpoints-tracked-processes', 'endpoints-interfaces')]
assert activity['position'] > max(live)
def test_endpoints_dashboard_widgets_declared():
@@ -115,7 +129,9 @@ def test_endpoints_dashboard_widgets_declared():
for widget in widgets:
assert widget['render'] == 'exceptions'
assert widget['empty'] == 'hide'
assert widget['permission'] == 'endpoints.view'
# The fleet-wide cards read the whole roster, so they are gated on
# endpoints.fleet, not the per-endpoint endpoints.view a guest may hold.
assert widget['permission'] == 'endpoints.fleet'
def test_endpoints_navigation_item_points_at_fleet_view():
@@ -209,10 +225,13 @@ def test_new_hardware_telemetry_keys_are_logged_not_silently_known():
rather than dropping them silently."""
from plugins.endpoints.plugin import KNOWN_PAYLOAD_KEYS
for key in ('disks', 'bios', 'memory', 'processor', 'osinfo', 'domain',
'applications'):
for key in ('disks', 'bios', 'memory', 'processor', 'osinfo', 'domain'):
assert key not in KNOWN_PAYLOAD_KEYS
# 'applications' USED to be in this list. It is persisted now - that is what
# the installed-software work added - so it belongs in the known set, and
# asserting its absence would fail the moment the feature it tests works.
assert 'applications' in KNOWN_PAYLOAD_KEYS
def test_telemetrymeta_route_is_registered_for_freshness_gating():
@@ -221,7 +240,12 @@ def test_telemetrymeta_route_is_registered_for_freshness_gating():
from flask import Flask
application = Flask(__name__)
application.register_blueprint(EndpointsPlugin().get_blueprint())
# A url_prefix is REQUIRED here: the fleet list registers as route(''), which
# Flask rejects as a bare rule and accepts under a prefix. That is deliberate
# - CONTRIBUTING says API routes carry no trailing slash - so the test has to
# mount the blueprint the way the app does.
application.register_blueprint(EndpointsPlugin().get_blueprint(),
url_prefix='/api/endpoints')
rules = {rule.rule for rule in application.url_map.iter_rules()}
assert any(rule.endswith('/asset/<int:asset_id>/telemetrymeta') for rule in rules)

View File

@@ -107,21 +107,47 @@ def test_payload_download_is_rate_limited(client, app, auth_headers):
def test_rate_limit_is_per_ip(client, app, auth_headers):
"""A second caller (different X-Forwarded-For) has its own budget: one IP
being throttled must not throttle everyone."""
"""A second caller has its own budget: one IP being throttled must not
throttle everyone.
Callers are distinguished by REMOTE_ADDR, the peer that actually connected,
which is what ProxyFix fills from X-Forwarded-For when a proxy is really in
front of this instance.
"""
sha = _store(app, b'per-ip blob')
key = _fetch_key(client, auth_headers)
hot = {**key, 'X-Forwarded-For': '203.0.113.202'}
cool = {**key, 'X-Forwarded-For': '203.0.113.203'}
hot = {'REMOTE_ADDR': '203.0.113.202'}
cool = {'REMOTE_ADDR': '203.0.113.203'}
with _config(app, GEENFORCE_PAYLOAD_RATELIMIT_MAX=1,
GEENFORCE_PAYLOAD_RATELIMIT_WINDOW_SECONDS=300):
assert client.get(f'/api/geenforce/payload/{sha}',
headers=hot).status_code == 200
headers=key, environ_base=hot).status_code == 200
assert client.get(f'/api/geenforce/payload/{sha}',
headers=hot).status_code == 429
headers=key, environ_base=hot).status_code == 429
# Fresh IP still gets its first request through.
assert client.get(f'/api/geenforce/payload/{sha}',
headers=cool).status_code == 200
headers=key, environ_base=cool).status_code == 200
def test_rate_limit_cannot_be_reset_by_forging_a_forwarded_header(
client, app, auth_headers):
"""The budget follows the connection, not a header the caller writes.
The limiter used to key on the first X-Forwarded-For hop, so rotating that
header per request bought an unbounded budget and the limit stopped being
a limit.
"""
sha = _store(app, b'forged header blob')
key = _fetch_key(client, auth_headers)
caller = {'REMOTE_ADDR': '203.0.113.210'}
with _config(app, GEENFORCE_PAYLOAD_RATELIMIT_MAX=1,
GEENFORCE_PAYLOAD_RATELIMIT_WINDOW_SECONDS=300):
assert client.get(f'/api/geenforce/payload/{sha}', headers=key,
environ_base=caller).status_code == 200
for hop in ('198.51.100.1', '198.51.100.2', '198.51.100.3'):
spoofed = {**key, 'X-Forwarded-For': hop}
assert client.get(f'/api/geenforce/payload/{sha}', headers=spoofed,
environ_base=caller).status_code == 429
def test_rate_limit_can_be_disabled(client, app, auth_headers):

View File

@@ -0,0 +1,101 @@
"""The supplies card must put the emptiest cartridge first.
It sorted on iscritical, then on PRINTER NAME. Critical is everything at or
below 5%, so a cartridge at 5% and one at 0% tied on the first key and the
alphabet decided the order.
That is bad ordering on its own, and worse than it looks: the dashboard queue
takes only the first few rows per source, so a printer that is genuinely empty
was truncated out of the card while a fuller one held its place. The card
existed to answer "what do I walk out and change today" and was answering it
with the alphabet.
"""
from unittest.mock import patch
import pytest
def _call(client, auth_headers):
response = client.get('/api/printers/dashboard/supplies',
headers=auth_headers)
assert response.status_code == 200
return response.get_json()['data']
@pytest.fixture(autouse=True)
def _clear_cache(app):
from shopdb.extensions import cache
cache.delete('printers_low_supplies')
yield
cache.delete('printers_low_supplies')
def _printer(printerid, name, remaining, status='critical'):
return {
'printerid': printerid,
'printername': name,
'assetnumber': 'A{}'.format(printerid),
'location': 'Shop floor',
'mapx': None, 'mapy': None, 'levelid': None,
'supplies': [{
'name': 'Black Toner', 'level': remaining, 'remaining': remaining,
'status': status, 'iswaste': False, 'isdrum': False,
'partnumbers': [], 'lastseen': None, 'color': 'black',
'supplytype': 'toner',
}],
}
def _patched(rows):
return patch('plugins.printers.api.asset_routes._get_low_supplies_data',
return_value={'printers': rows,
'summary': {'total_checked': len(rows),
'low': 0, 'critical': len(rows)}})
def test_the_empty_cartridge_comes_before_the_nearly_empty_one(client,
auth_headers):
"""'Alpha' at 5% used to precede 'Zulu' at 0% purely on the name."""
with _patched([_printer(1, 'Alpha', 5), _printer(2, 'Zulu', 0)]):
data = _call(client, auth_headers)
order = [row['printername'] for row in data['rows']]
assert order == ['Zulu', 'Alpha']
def test_rows_are_ordered_by_how_empty_they_are(client, auth_headers):
with _patched([_printer(1, 'Alpha', 4),
_printer(2, 'Bravo', 0),
_printer(3, 'Charlie', 2),
_printer(4, 'Delta', 5)]):
data = _call(client, auth_headers)
remaining = [row['worstremaining'] for row in data['rows']]
assert remaining == sorted(remaining)
assert remaining == [0, 2, 4, 5]
def test_a_zero_percent_printer_survives_the_queue_truncation(client,
auth_headers):
"""The dashboard queue keeps only the first few rows per source.
With five fuller printers whose names sort earlier, the empty one used to
fall off the end of the card entirely.
"""
rows = [_printer(index, name, 5) for index, name in enumerate(
['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo'], start=1)]
rows.append(_printer(6, 'Zulu', 0))
with _patched(rows):
data = _call(client, auth_headers)
# QUEUEPERSOURCE is 5 on the frontend; first place is what matters here.
assert data['rows'][0]['printername'] == 'Zulu'
assert [r['printername'] for r in data['rows'][:5]].count('Zulu') == 1
def test_the_name_still_breaks_a_tie(client, auth_headers):
with _patched([_printer(1, 'Bravo', 3), _printer(2, 'Alpha', 3)]):
data = _call(client, auth_headers)
assert [row['printername'] for row in data['rows']] == ['Alpha', 'Bravo']