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:
85
CHANGELOG.md
85
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -536,6 +536,9 @@ export const dashboardApi = {
|
||||
},
|
||||
navigation() {
|
||||
return api.get('/dashboard/navigation')
|
||||
},
|
||||
capabilities() {
|
||||
return api.get('/dashboard/capabilities')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
109
frontend/src/components/AssetDetailTabs.vue
Normal file
109
frontend/src/components/AssetDetailTabs.vue
Normal 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>
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 || []
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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')
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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})")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
61
tests/test_core/test_deactivated_user.py
Normal file
61
tests/test_core/test_deactivated_user.py
Normal 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
|
||||
@@ -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'}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
94
tests/test_core/test_search_domain_permissions.py
Normal file
94
tests/test_core/test_search_domain_permissions.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
50
tests/test_no_hidden_tests.py
Normal file
50
tests/test_no_hidden_tests.py
Normal 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).'
|
||||
)
|
||||
@@ -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.
|
||||
|
||||
87
tests/test_plugins/test_endpoint_applications_model.py
Normal file
87
tests/test_plugins/test_endpoint_applications_model.py
Normal 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
|
||||
151
tests/test_plugins/test_endpoint_delivery_descriptor.py
Normal file
151
tests/test_plugins/test_endpoint_delivery_descriptor.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
101
tests/test_plugins/test_supplies_card_ordering.py
Normal file
101
tests/test_plugins/test_supplies_card_ordering.py
Normal 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']
|
||||
Reference in New Issue
Block a user