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
|
### 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.**
|
- **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
|
`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
|
`/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
|
// 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
|
// the worker; the command simply stays 'running' server-side until a later
|
||||||
// operator retry supersedes it.
|
// 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(
|
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(
|
using var response = await http.PostAsJsonAsync(
|
||||||
url, result, JsonOptions, token);
|
url, result, JsonOptions, token);
|
||||||
if (!response.IsSuccessStatusCode)
|
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}",
|
logger.LogInformation("Command {Id} finished succeeded={Succeeded}",
|
||||||
claim.EndpointCommandId, result.Succeeded);
|
claim.EndpointCommandId, result.Succeeded);
|
||||||
|
|
||||||
|
|||||||
@@ -185,6 +185,15 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "plugin-endpoints"
|
"name": "plugin-endpoints"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "core-scripts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "endpoints-delivery"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "endpoints-siteagent"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"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": {
|
"/api/dashboard/widgets": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"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() {
|
navigation() {
|
||||||
return api.get('/dashboard/navigation')
|
return api.get('/dashboard/navigation')
|
||||||
|
},
|
||||||
|
capabilities() {
|
||||||
|
return api.get('/dashboard/capabilities')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1083,6 +1083,21 @@ td.actions .btn + .btn {
|
|||||||
display: contents;
|
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 Cards */
|
||||||
.section-card {
|
.section-card {
|
||||||
background: var(--bg-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
|
<!-- 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
|
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. -->
|
on any asset detail page, so a plugin adds detail-page UI with no Vue. -->
|
||||||
<template v-for="panel in panels" :key="panel.id">
|
<template v-for="panel in groupedPanels" :key="panel.id">
|
||||||
<div v-if="panelVisible(panel)" class="section-card">
|
<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
|
<!-- 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
|
panel declares a status rollup, a green/yellow/red dot for the worst
|
||||||
row (a stopped service or a warning/critical event). --><h3
|
row (a stopped service or a warning/critical event). --><h3
|
||||||
@@ -149,7 +149,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import api from '../api'
|
import api from '../api'
|
||||||
import { colorStyle } from '@/utils/colorStyle'
|
import { colorStyle } from '@/utils/colorStyle'
|
||||||
import {
|
import {
|
||||||
@@ -183,10 +183,42 @@ function visibleRows(panel) {
|
|||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
assetid: { type: [Number, String], default: null },
|
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([])
|
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
|
// 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.
|
// than on the panel object because panels are replaced wholesale on refetch.
|
||||||
const tabs = ref({})
|
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)
|
// Let a parent detail page (for example PCDetail's header Refresh button)
|
||||||
// trigger a panel reload without duplicating the fetch plumbing.
|
// trigger a panel reload without duplicating the fetch plumbing.
|
||||||
defineExpose({ refresh: load })
|
defineExpose({ refresh: load, populatedGroups })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -411,7 +443,18 @@ defineExpose({ refresh: load })
|
|||||||
font-weight: 700;
|
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 { 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-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; }
|
.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) {
|
export function panelVisible(panel) {
|
||||||
if (panel.render === 'tabs') return tabSections(panel).length > 0
|
if (panel.render === 'tabs') return tabSections(panel).length > 0
|
||||||
if (panel.render === 'list') return rows(panel).length > 0 || !!panel.empty
|
if (panel.render === 'list') return rows(panel).length > 0 || !!panel.empty
|
||||||
if (panel.render === 'keyvalue') return keyvalueFields(panel).length > 0
|
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
|
if (panel.render === 'badge') return badges(panel).length > 0
|
||||||
return false
|
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)
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Global search is authenticated; offering the box to a signed-out
|
<!-- Drawn only for a caller who can actually search. The server
|
||||||
reader gives them a control that can only answer 401. -->
|
answers that, because a guest has no user object here and
|
||||||
<div v-if="authStore.isAuthenticated" class="sidebar-search">
|
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
|
<input
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -145,6 +148,10 @@ const routeViewKey = computed(() =>
|
|||||||
route.path.startsWith('/settings') ? '/settings' : route.path
|
route.path.startsWith('/settings') ? '/settings' : route.path
|
||||||
)
|
)
|
||||||
const searchQuery = ref('')
|
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 navItems = ref([])
|
||||||
const activeNotifications = ref([])
|
const activeNotifications = ref([])
|
||||||
const facilityName = ref('ShopDB')
|
const facilityName = ref('ShopDB')
|
||||||
@@ -289,6 +296,15 @@ onMounted(async () => {
|
|||||||
navItems.value = buildNavItems(defaultNav)
|
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 {
|
try {
|
||||||
const resp = await notificationsApi.active()
|
const resp = await notificationsApi.active()
|
||||||
activeNotifications.value = resp.data.data?.notifications || []
|
activeNotifications.value = resp.data.data?.notifications || []
|
||||||
|
|||||||
@@ -69,14 +69,24 @@
|
|||||||
<span class="assets-v">{{ stats.totalassets || 0 }}</span>
|
<span class="assets-v">{{ stats.totalassets || 0 }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Status, on one line, and explicitly labelled as status. In
|
<!-- Every status the total contains, not just In Use. These were
|
||||||
repair is muted at zero rather than given a card of its own to
|
counted server-side for the rollup and then thrown away, so a
|
||||||
announce that nothing is wrong. -->
|
reader could see one number and had no way to learn where the
|
||||||
<p class="assets-status">
|
rest of the total went. Retired assets in particular ARE in
|
||||||
<span class="assets-inuse">{{ stats.activeassets || 0 }}</span> in use
|
the total, which is not guessable. -->
|
||||||
<span aria-hidden="true"> / </span>
|
<div v-for="row in statusrows" :key="row.name" class="assets-row assets-sub">
|
||||||
<span :class="{ 'assets-zero': !stats.inrepair }">
|
<span class="assets-k assets-subk">{{ row.name }}</span>
|
||||||
{{ stats.inrepair || 0 }} in repair
|
<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>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
@@ -133,6 +143,17 @@ const summaryloaded = ref(false)
|
|||||||
// hold - and to what the guest role reaches, when nobody is signed in.
|
// hold - and to what the guest role reaches, when nobody is signed in.
|
||||||
const navitems = ref([])
|
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(
|
const openable = computed(
|
||||||
// The dashboard itself is not somewhere to go from the dashboard.
|
// The dashboard itself is not somewhere to go from the dashboard.
|
||||||
() => navitems.value.filter((item) => item.route && item.route !== '/'))
|
() => navitems.value.filter((item) => item.route && item.route !== '/'))
|
||||||
@@ -224,11 +245,18 @@ span.assets-k { color: var(--text); font-weight: 600; }
|
|||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assets-status {
|
/* Status rows sit UNDER the total, indented and quieter, because they break
|
||||||
margin: 0.5rem 0 0;
|
the total down rather than adding to it. Same weight as the type rows would
|
||||||
font-size: 0.85rem;
|
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);
|
color: var(--text-light);
|
||||||
}
|
}
|
||||||
.assets-inuse { color: var(--success); font-weight: 600; font-variant-numeric: tabular-nums; }
|
|
||||||
.assets-zero { color: var(--text-light); }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Link template</span>
|
<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>
|
<small class="muted">Placeholders: <code>{host}</code>, <code>{port}</code>, <code>{scheme}</code></small>
|
||||||
</label>
|
</label>
|
||||||
<label class="field checkbox">
|
<label class="field checkbox">
|
||||||
|
|||||||
@@ -84,8 +84,10 @@
|
|||||||
Hardware and the rest follow. Panels self-order among themselves by
|
Hardware and the rest follow. Panels self-order among themselves by
|
||||||
their declared position, and self-hide when a site has no plugin
|
their declared position, and self-hide when a site has no plugin
|
||||||
answering for this asset type. -->
|
answering for this asset type. -->
|
||||||
<div class="content-grid">
|
<AssetDetailTabs v-model="activegroup" :tabs="detailtabs" label="Computer detail sections" />
|
||||||
<PluginAssetPanels ref="assetPanels" :assetid="computer.assetid" />
|
|
||||||
|
<div v-show="activegroup === 'details'" class="content-grid">
|
||||||
|
<PluginAssetPanels ref="assetPanels" :assetid="computer.assetid" group="details" />
|
||||||
|
|
||||||
<!-- Left Column -->
|
<!-- Left Column -->
|
||||||
<div class="content-column">
|
<div class="content-column">
|
||||||
@@ -256,7 +258,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="section-card" v-if="installedApps.length > 0">
|
||||||
<h3 class="section-title">Installed Applications</h3>
|
<h3 class="section-title">Installed Applications</h3>
|
||||||
<div class="app-list">
|
<div class="app-list">
|
||||||
@@ -276,19 +299,20 @@
|
|||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Audit Footer -->
|
<!-- Audit Footer -->
|
||||||
@@ -305,7 +329,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
import { colorStyle } from "@/utils/colorStyle"
|
import { colorStyle } from "@/utils/colorStyle"
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { computersApi, applicationsApi, endpointsApi } from '@/api'
|
import { computersApi, applicationsApi, endpointsApi } from '@/api'
|
||||||
@@ -315,6 +339,7 @@ import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
|||||||
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
|
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
|
||||||
import AssetTriageBand from '@/components/AssetTriageBand.vue'
|
import AssetTriageBand from '@/components/AssetTriageBand.vue'
|
||||||
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
|
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
|
||||||
|
import AssetDetailTabs from '@/components/AssetDetailTabs.vue'
|
||||||
import AssetRelationships from '@/components/AssetRelationships.vue'
|
import AssetRelationships from '@/components/AssetRelationships.vue'
|
||||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||||
import { setRecordTitle } from '@/composables/pageTitle'
|
import { setRecordTitle } from '@/composables/pageTitle'
|
||||||
@@ -344,6 +369,37 @@ const hasRecordCheckin = computed(() => {
|
|||||||
})
|
})
|
||||||
const installedApps = ref([])
|
const installedApps = ref([])
|
||||||
const assetPanels = ref(null)
|
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 restarting = ref(false)
|
||||||
const refreshing = ref(false)
|
const refreshing = ref(false)
|
||||||
|
|
||||||
@@ -550,6 +606,7 @@ async function confirmRetire() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
/* PC-specific styles - shared styles are in global style.css */
|
/* PC-specific styles - shared styles are in global style.css */
|
||||||
|
|
||||||
/* Remote-access protocol links */
|
/* Remote-access protocol links */
|
||||||
|
|||||||
@@ -2013,3 +2013,26 @@ class ComputersPlugin(BasePlugin):
|
|||||||
('computers.edit', 'Edit computers', 'computers'),
|
('computers.edit', 'Edit computers', 'computers'),
|
||||||
('computers.delete', 'Delete 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 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
|
from .routes import endpoints_bp
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ def _build_readme():
|
|||||||
|
|
||||||
|
|
||||||
@endpoints_bp.route('/adoptiontool/package', methods=['POST'])
|
@endpoints_bp.route('/adoptiontool/package', methods=['POST'])
|
||||||
@require_role('admin')
|
@require_permission('endpoints.agentpackage')
|
||||||
def package_adoption_tool():
|
def package_adoption_tool():
|
||||||
"""Bundle the adoption tool as a downloadable ZIP.
|
"""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 hashlib
|
||||||
|
import hmac
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
@@ -941,6 +942,43 @@ _CLAIM_HOLD_SECONDS = 25
|
|||||||
_CLAIM_POLL_INTERVAL = 1.0
|
_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):
|
def _reap_stale_commands(asset_id):
|
||||||
"""Expire in-flight commands an agent will never finish for this endpoint.
|
"""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:
|
if application is None or not application.installpath:
|
||||||
return None, ('Application for appversionid {} has no installer package'
|
return None, ('Application for appversionid {} has no installer package'
|
||||||
.format(ident))
|
.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 {
|
return {
|
||||||
'source': 'version',
|
'source': 'version',
|
||||||
'appversionid': version.appversionid,
|
'appversionid': version.appversionid,
|
||||||
'appid': version.appid,
|
'appid': version.appid,
|
||||||
'appname': application.appname,
|
'appname': application.appname,
|
||||||
'version': version.version,
|
'version': version.version,
|
||||||
'installpath': '/api/endpoints/apppackage/{}'.format(version.appversionid),
|
'installpath': installpath,
|
||||||
'sha256': version.sha256,
|
'sha256': sha256,
|
||||||
'silentargs': version.silentargs,
|
'silentargs': version.silentargs,
|
||||||
}, None
|
}, None
|
||||||
|
|
||||||
@@ -1209,18 +1272,25 @@ def enqueue_endpoint_command(asset_id: int):
|
|||||||
# update_app pins a specific application version; verify it resolves to a
|
# 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
|
# real installer now so a broken push is rejected at enqueue rather than
|
||||||
# failing opaquely on the endpoint minutes later.
|
# 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':
|
if action == 'update_app':
|
||||||
_descriptor, resolve_error = _resolve_update_app(target)
|
descriptor, resolve_error = _resolve_update_app(target)
|
||||||
if resolve_error is not None:
|
if resolve_error is not None:
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR, resolve_error)
|
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
|
# 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
|
# uploaded body now so a broken push is rejected at enqueue rather than
|
||||||
# failing opaquely on the endpoint later.
|
# failing opaquely on the endpoint later.
|
||||||
if action == 'run_script':
|
if action == 'run_script':
|
||||||
_script, script_error = _resolve_run_script(target)
|
script, script_error = _resolve_run_script(target)
|
||||||
if script_error is not None:
|
if script_error is not None:
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR, script_error)
|
return error_response(ErrorCodes.VALIDATION_ERROR, script_error)
|
||||||
|
pinnedsha256 = (script or {}).get('sha256')
|
||||||
|
|
||||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||||
if state is None:
|
if state is None:
|
||||||
@@ -1252,6 +1322,7 @@ def enqueue_endpoint_command(asset_id: int):
|
|||||||
targetkind=targetkind if target else None,
|
targetkind=targetkind if target else None,
|
||||||
status=EndpointCommand.STATUS_PENDING,
|
status=EndpointCommand.STATUS_PENDING,
|
||||||
requestedby=getattr(current_user, 'userid', None),
|
requestedby=getattr(current_user, 'userid', None),
|
||||||
|
pinnedsha256=pinnedsha256,
|
||||||
)
|
)
|
||||||
db.session.add(command)
|
db.session.add(command)
|
||||||
|
|
||||||
@@ -1312,6 +1383,8 @@ def claim_next_command():
|
|||||||
# fail closed rather than 500 the poll.
|
# fail closed rather than 500 the poll.
|
||||||
if command.action == 'update_app':
|
if command.action == 'update_app':
|
||||||
descriptor, _resolve_error = _resolve_update_app(command.target)
|
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:
|
if descriptor is not None:
|
||||||
claim['app'] = descriptor
|
claim['app'] = descriptor
|
||||||
# run_script rides a resolved script descriptor: the agent gets a
|
# 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.
|
# agent fail closed rather than 500 the poll.
|
||||||
if command.action == 'run_script':
|
if command.action == 'run_script':
|
||||||
script_descriptor, _script_error = _resolve_run_script(command.target)
|
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:
|
if script_descriptor is not None:
|
||||||
claim['script'] = script_descriptor
|
claim['script'] = script_descriptor
|
||||||
return success_response(claim)
|
return success_response(claim)
|
||||||
@@ -1351,6 +1426,33 @@ def report_command_result(command_id: int):
|
|||||||
return error_response(ErrorCodes.NOT_FOUND,
|
return error_response(ErrorCodes.NOT_FOUND,
|
||||||
f'No command {command_id}', http_code=404)
|
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 {}
|
payload = request.get_json(silent=True) or {}
|
||||||
succeeded = bool(payload.get('succeeded'))
|
succeeded = bool(payload.get('succeeded'))
|
||||||
command.status = (EndpointCommand.STATUS_SUCCEEDED if succeeded
|
command.status = (EndpointCommand.STATUS_SUCCEEDED if succeeded
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ import zipfile
|
|||||||
|
|
||||||
from flask import request, current_app, send_file
|
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
|
from .routes import endpoints_bp
|
||||||
|
|
||||||
@@ -171,11 +172,11 @@ USAGE
|
|||||||
registers the tray to autostart for every user at logon and adds a
|
registers the tray to autostart for every user at logon and adds a
|
||||||
"ShopDB Agent" Start Menu shortcut for manual relaunch.
|
"ShopDB Agent" Start Menu shortcut for manual relaunch.
|
||||||
|
|
||||||
UNINSTALL
|
UNINSTALL
|
||||||
Run uninstall-shopdbagent.ps1 from an elevated PowerShell to fully remove the
|
Run uninstall-shopdbagent.ps1 from an elevated PowerShell to fully remove the
|
||||||
agent, tray autostart, Start Menu shortcut, and credentials.
|
agent, tray autostart, Start Menu shortcut, and credentials.
|
||||||
|
|
||||||
SECURITY
|
SECURITY
|
||||||
install.ps1 carries a live collector key in its default -CollectorKey value.
|
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
|
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.
|
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'])
|
@endpoints_bp.route('/siteagent', methods=['GET'])
|
||||||
@require_role('admin')
|
@require_permission('endpoints.agentpackage')
|
||||||
def get_siteagent():
|
def get_siteagent():
|
||||||
"""Report whether the site agent build has a prebuilt binary staged."""
|
"""Report whether the site agent build has a prebuilt binary staged."""
|
||||||
return success_response(_siteagent_status_payload())
|
return success_response(_siteagent_status_payload())
|
||||||
|
|
||||||
|
|
||||||
@endpoints_bp.route('/siteagent/package', methods=['POST'])
|
@endpoints_bp.route('/siteagent/package', methods=['POST'])
|
||||||
@require_role('admin')
|
@require_permission('endpoints.agentpackage')
|
||||||
def package_siteagent():
|
def package_siteagent():
|
||||||
"""Bundle the site agent as a downloadable ZIP.
|
"""Bundle the site agent as a downloadable ZIP.
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,17 @@ def upgrade():
|
|||||||
|
|
||||||
op.create_table(
|
op.create_table(
|
||||||
'endpointapplications',
|
'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('assetid', sa.Integer(), nullable=False),
|
||||||
sa.Column('displayname', sa.String(length=255), nullable=False),
|
sa.Column('displayname', sa.String(length=255), nullable=False),
|
||||||
sa.Column('displayversion', sa.String(length=128), nullable=True),
|
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.
|
# Who queued it (userid) for audit; the agent never sees this.
|
||||||
requestedby = db.Column(db.Integer, nullable=True)
|
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
|
# Agent-reported outcome. resultjson holds structured detail (for example
|
||||||
# the observed MTConnect running state); resultmessage is a short summary.
|
# the observed MTConnect running state); resultmessage is a short summary.
|
||||||
resultmessage = db.Column(db.Text, nullable=True)
|
resultmessage = db.Column(db.Text, nullable=True)
|
||||||
@@ -282,7 +295,16 @@ class EndpointApplication(db.Model):
|
|||||||
|
|
||||||
__tablename__ = 'endpointapplications'
|
__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
|
# The asset this install belongs to. Not unique: many applications per
|
||||||
# endpoint. CASCADE so removing the asset clears its SBOM snapshot.
|
# 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)
|
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):
|
class EndpointsPlugin(BasePlugin):
|
||||||
"""Endpoint RMM telemetry ingest plus read/dashboard surface."""
|
"""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
|
# including every logged-in username on the floor, because the
|
||||||
# panels and the list were gated on the same string.
|
# panels and the list were gated on the same string.
|
||||||
#
|
#
|
||||||
# It is deliberately NOT named '.view'. guest_grantable_permissions
|
# The name avoids '.view' for readability, but the NAME no longer
|
||||||
# opens any '.view' or '.read' permission to the guest role, so this
|
# decides anything: guest_grantable_permissions is a declared list
|
||||||
# name is what keeps the fleet roster off the public surface while
|
# now, and this plugin blesses endpoints.view alone below. Adding
|
||||||
# per-asset telemetry can still be granted.
|
# endpoints.fleet there would be what opens the roster, and the
|
||||||
|
# docstring on that hook says why not to.
|
||||||
('endpoints.fleet',
|
('endpoints.fleet',
|
||||||
'View the fleet-wide endpoint list and dashboards', 'endpoints'),
|
'View the fleet-wide endpoint list and dashboards', 'endpoints'),
|
||||||
# Issue an allowlisted command to an endpoint agent (for example the
|
# Issue an allowlisted command to an endpoint agent (for example the
|
||||||
@@ -153,6 +168,17 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
# heavier grant than a routine service/process toggle.
|
# heavier grant than a routine service/process toggle.
|
||||||
('endpoints.deploy',
|
('endpoints.deploy',
|
||||||
'Run staged scripts and push application installers to endpoints', 'endpoints'),
|
'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
|
# Collector service scope for the endpoint agent. A managed token
|
||||||
# scoped to ONLY this authorizes the endpoints collector and nothing
|
# scoped to ONLY this authorizes the endpoints collector and nothing
|
||||||
# else, so rotating/revoking it stops endpoint-agent traffic without
|
# else, so rotating/revoking it stops endpoint-agent traffic without
|
||||||
@@ -162,6 +188,21 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
'Submit endpoint agent telemetry (fleet reporting)', 'collector'),
|
'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]:
|
def get_settings_defaults(self) -> List[Dict]:
|
||||||
"""Settings owned by the endpoints agent-key kill switch.
|
"""Settings owned by the endpoints agent-key kill switch.
|
||||||
|
|
||||||
@@ -310,6 +351,9 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
'permission': 'endpoints.command',
|
'permission': 'endpoints.command',
|
||||||
'empty': 'No monitored services reported.',
|
'empty': 'No monitored services reported.',
|
||||||
'position': 12,
|
'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',
|
'id': 'endpoints-tracked-processes',
|
||||||
@@ -348,6 +392,8 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
'permission': 'endpoints.command',
|
'permission': 'endpoints.command',
|
||||||
'empty': 'No monitored processes reported.',
|
'empty': 'No monitored processes reported.',
|
||||||
'position': 13,
|
'position': 13,
|
||||||
|
# observed process state, not something you act on here
|
||||||
|
'group': 'monitors',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
# One feed instead of two cards. Events and commands answer the
|
# 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.',
|
'empty': 'Nothing recorded for this endpoint yet.',
|
||||||
'position': 20,
|
'position': 20,
|
||||||
|
# the event and command feed IS the activity tab
|
||||||
|
'group': 'activity',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'id': 'endpoints-interfaces',
|
'id': 'endpoints-interfaces',
|
||||||
@@ -410,6 +458,33 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
],
|
],
|
||||||
'empty': 'No network interfaces reported.',
|
'empty': 'No network interfaces reported.',
|
||||||
'position': 16,
|
'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
|
# Installed-software (SBOM) inventory. Low-priority reference
|
||||||
@@ -641,11 +716,18 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
if not displayname:
|
if not displayname:
|
||||||
warnings.append('skipped application with no displayname')
|
warnings.append('skipped application with no displayname')
|
||||||
continue
|
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(
|
db.session.add(EndpointApplication(
|
||||||
assetid=assetid,
|
assetid=assetid,
|
||||||
displayname=displayname[:255],
|
displayname=displayname[:255],
|
||||||
displayversion=(item.get('displayversion') or None),
|
displayversion=_clamp(item.get('displayversion'), 128),
|
||||||
publisher=(item.get('publisher') or None),
|
publisher=_clamp(item.get('publisher'), 255),
|
||||||
))
|
))
|
||||||
stored += 1
|
stored += 1
|
||||||
return stored
|
return stored
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from sqlalchemy.exc import IntegrityError
|
|||||||
from shopdb.api import (
|
from shopdb.api import (
|
||||||
db, cache, success_response, error_response, ErrorCodes, require_permission,
|
db, cache, success_response, error_response, ErrorCodes, require_permission,
|
||||||
require_any_permission,
|
require_any_permission,
|
||||||
authorized_service_token, Setting, Application,
|
authorized_service_token, Setting, Application, client_ip,
|
||||||
)
|
)
|
||||||
|
|
||||||
SHAREROOT_SETTING = 'geenforce_share_root'
|
SHAREROOT_SETTING = 'geenforce_share_root'
|
||||||
@@ -207,12 +207,14 @@ PAYLOAD_DOWNLOAD_RATELIMIT_WINDOW_SECONDS = 60
|
|||||||
|
|
||||||
|
|
||||||
def _client_ip():
|
def _client_ip():
|
||||||
"""Caller IP for rate limiting, honoring the first X-Forwarded-For hop
|
"""Caller IP for rate limiting.
|
||||||
(mirrors shopdb.core.api.auth._login_ip)."""
|
|
||||||
forwarded = request.headers.get('X-Forwarded-For')
|
Uses the shared helper, which resolves from remote_addr. The raw
|
||||||
if forwarded:
|
X-Forwarded-For hop is client-supplied, so keying a rate limit on it lets a
|
||||||
return forwarded.split(',')[0].strip()
|
caller rotate the header and get an unbounded budget - the same reasoning
|
||||||
return request.remote_addr or 'unknown'
|
_trusted_client_ip() above already applies to the allowlist.
|
||||||
|
"""
|
||||||
|
return client_ip(request) or 'unknown'
|
||||||
|
|
||||||
|
|
||||||
def _payload_max_bytes():
|
def _payload_max_bytes():
|
||||||
|
|||||||
@@ -74,6 +74,25 @@ class GuidesPlugin(BasePlugin):
|
|||||||
('guides.delete', 'Delete guides', 'guides'),
|
('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]:
|
def get_navigation_items(self) -> List[Dict]:
|
||||||
# Position 8, directly under Slides (7), rather than down in the
|
# Position 8, directly under Slides (7), rather than down in the
|
||||||
# Information section with the reference lists. A guide is something you
|
# 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.
|
# dashboard card and the toner report were both doing.
|
||||||
'levelid': printer.get('levelid'),
|
'levelid': printer.get('levelid'),
|
||||||
'iscritical': any(s['status'] == 'critical' for s in depleted),
|
'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': [{
|
'supplies': [{
|
||||||
'text': '{} {}%'.format(_shortsupplyname(supply.get('name')),
|
'text': '{} {}%'.format(_shortsupplyname(supply.get('name')),
|
||||||
supply.get('remaining')),
|
supply.get('remaining')),
|
||||||
@@ -2667,7 +2670,18 @@ def dashboard_supplies():
|
|||||||
} for supply in depleted],
|
} 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.
|
# 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.
|
# An empty card and a blind card must not look the same.
|
||||||
return success_response({'rows': rows,
|
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 flask_jwt_extended import jwt_required, get_jwt_identity, current_user
|
||||||
|
|
||||||
from shopdb.api import (
|
from shopdb.api import (
|
||||||
db, cache, Setting,
|
db, cache, Setting, client_ip,
|
||||||
success_response, error_response, ErrorCodes,
|
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
|
person has more addresses - but it stops an open endpoint being an easy
|
||||||
place to park data or fill a disk.
|
place to park data or fill a disk.
|
||||||
"""
|
"""
|
||||||
address = (request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
|
# Not the raw X-Forwarded-For hop: the caller sends that, so rotating it
|
||||||
or request.remote_addr or 'unknown')
|
# 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
|
bucket = int(_utcnow().timestamp()) // GUEST_CREATE_WINDOW_SECONDS
|
||||||
key = f'securesendguest:{address}:{bucket}'
|
key = f'securesendguest:{address}:{bucket}'
|
||||||
used = cache.get(key) or 0
|
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
|
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
|
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.
|
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]:
|
def get_settings_defaults(self) -> List[dict]:
|
||||||
"""The one switch this plugin owns.
|
"""The one switch this plugin owns.
|
||||||
|
|||||||
@@ -174,5 +174,13 @@ class WarrantyPlugin(BasePlugin):
|
|||||||
('warranty.delete', 'Delete warranties', 'warranty'),
|
('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:
|
def init_app(self, app: Flask, db_instance) -> None:
|
||||||
logger.info(f"Warranty plugin initialized (v{self.meta.version})")
|
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" }
|
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)" }
|
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
|
# 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).
|
# GE such as SFLD; never tree-delete a key we do not own).
|
||||||
foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64,
|
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
|
# 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
|
# serves its body by digest to the agent. Additive name on the surface, minor
|
||||||
# bump.
|
# 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
|
# Product release version (see ADR-007). The product version and the
|
||||||
# plugin-contract version above are distinct series with independent
|
# 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):
|
def user_lookup_callback(_jwt_header, jwt_data):
|
||||||
from .core.models import User
|
from .core.models import User
|
||||||
identity = jwt_data["sub"]
|
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
|
@app.after_request
|
||||||
def apply_security_headers(response):
|
def apply_security_headers(response):
|
||||||
|
|||||||
@@ -104,6 +104,12 @@ def collector_authorized(pluginname: str):
|
|||||||
# as script in this origin - see shopdb/utils/uploads.py.
|
# as script in this origin - see shopdb/utils/uploads.py.
|
||||||
from shopdb.utils.uploads import send_upload, harden_upload_response
|
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
|
# Import-mode helpers: preserve legacy timestamps during a bulk data import
|
||||||
from shopdb.utils.import_mode import (
|
from shopdb.utils.import_mode import (
|
||||||
apply_import_timestamps,
|
apply_import_timestamps,
|
||||||
@@ -346,6 +352,8 @@ __all__ = [
|
|||||||
# Serving uploads
|
# Serving uploads
|
||||||
'send_upload',
|
'send_upload',
|
||||||
'harden_upload_response',
|
'harden_upload_response',
|
||||||
|
'client_ip',
|
||||||
|
'strip_port',
|
||||||
# Import-mode helpers
|
# Import-mode helpers
|
||||||
'apply_import_timestamps',
|
'apply_import_timestamps',
|
||||||
'import_mode_active',
|
'import_mode_active',
|
||||||
|
|||||||
@@ -656,13 +656,17 @@ def upload_application_package(app_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@applications_bp.route('/package/<path:filename>', methods=['GET'])
|
@applications_bp.route('/package/<path:filename>', methods=['GET'])
|
||||||
@require_permission('applications.view')
|
@require_permission('applications.download')
|
||||||
def serve_application_package(filename):
|
def serve_application_package(filename):
|
||||||
"""Download an uploaded installer.
|
"""Download an uploaded installer.
|
||||||
|
|
||||||
Authenticated, unlike the image: this is licensed vendor software, and an
|
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
|
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.
|
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)
|
return send_upload(_apppackage_dir(), filename, as_attachment=True)
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,19 @@ def _count_by_type(assettype):
|
|||||||
return query.count()
|
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',
|
COUNTEDTYPES = ('machine', 'computer', 'network_device', 'printer',
|
||||||
'measuring_tool')
|
'measuring_tool')
|
||||||
|
|
||||||
@@ -69,6 +82,10 @@ def _countedassets():
|
|||||||
@require_permission('assets.view')
|
@require_permission('assets.view')
|
||||||
def get_dashboard():
|
def get_dashboard():
|
||||||
"""Get dashboard summary data (asset-based)."""
|
"""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')
|
machine_count = _count_by_type('machine')
|
||||||
pc_count = _count_by_type('computer')
|
pc_count = _count_by_type('computer')
|
||||||
network_count = _count_by_type('network_device')
|
network_count = _count_by_type('network_device')
|
||||||
@@ -99,6 +116,22 @@ def get_dashboard():
|
|||||||
'totalmeasuringtool': measuringtool_count,
|
'totalmeasuringtool': measuringtool_count,
|
||||||
'activeassets': status_dict.get('In Use', 0),
|
'activeassets': status_dict.get('In Use', 0),
|
||||||
'inrepair': status_dict.get('In Repair', 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
|
# Structured data
|
||||||
'counts': {
|
'counts': {
|
||||||
'machines': machine_count,
|
'machines': machine_count,
|
||||||
@@ -219,6 +252,38 @@ def get_navigation():
|
|||||||
return success_response(all_items)
|
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'])
|
@dashboard_bp.route('/widgets', methods=['GET'])
|
||||||
@require_permission('assets.view')
|
@require_permission('assets.view')
|
||||||
def get_widgets():
|
def get_widgets():
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ from shopdb.utils.authz import require_permission
|
|||||||
|
|
||||||
pluginui_bp = Blueprint('pluginui', __name__)
|
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):
|
def _collect(hookname):
|
||||||
"""Aggregate a data-only hook across enabled plugins.
|
"""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
|
Query param assetid is required. A panel matches when its assettypes list
|
||||||
contains the asset's type key or the wildcard '*'.
|
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)
|
assetid = request.args.get('assetid', type=int)
|
||||||
if not assetid:
|
if not assetid:
|
||||||
@@ -81,6 +95,9 @@ def asset_panels():
|
|||||||
for panel in _collect('get_asset_panels'):
|
for panel in _collect('get_asset_panels'):
|
||||||
assettypes = panel.get('assettypes') or []
|
assettypes = panel.get('assettypes') or []
|
||||||
if '*' in assettypes or (assettype and assettype in assettypes):
|
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.append(panel)
|
||||||
matched.sort(key=lambda panel: panel.get('position', 99))
|
matched.sort(key=lambda panel: panel.get('position', 99))
|
||||||
return success_response(matched)
|
return success_response(matched)
|
||||||
|
|||||||
@@ -196,11 +196,15 @@ def upload_script_body(script_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@scripts_bp.route('/body/<path:filename>', methods=['GET'])
|
@scripts_bp.route('/body/<path:filename>', methods=['GET'])
|
||||||
@require_permission('applications.view')
|
@require_permission('applications.download')
|
||||||
def download_script_body(filename: str):
|
def download_script_body(filename: str):
|
||||||
"""Operator-facing script body download (JWT/RBAC).
|
"""Operator-facing script body download (JWT/RBAC).
|
||||||
|
|
||||||
The endpoint agent uses the collector-authenticated plugin route instead;
|
The endpoint agent uses the collector-authenticated plugin route instead;
|
||||||
this one is for humans reviewing what a script does.
|
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)
|
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.api.settings import get_cached_settings
|
||||||
from shopdb.core.services.spellfix import Vocabulary
|
from shopdb.core.services.spellfix import Vocabulary
|
||||||
from shopdb.utils.responses import success_response
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -1233,22 +1233,42 @@ def global_search():
|
|||||||
results = []
|
results = []
|
||||||
search_term = f'%{query}%'
|
search_term = f'%{query}%'
|
||||||
|
|
||||||
# Run all search domains
|
# Every domain answers only to a caller who holds ITS permission.
|
||||||
results.extend(_search_applications(query, search_term))
|
#
|
||||||
results.extend(_search_knowledgebase(query, search_term))
|
# The route is gated on assets.view alone, and that used to be the whole
|
||||||
results.extend(_search_guides(query, search_term))
|
# check: one permission then queried twelve domains, so it read out of the
|
||||||
results.extend(_search_employees(query, search_term))
|
# staff directory, the USB checkout history, the notification log and the
|
||||||
results.extend(_search_assets(query, search_term))
|
# network inventory to anybody who could look up an asset number. The
|
||||||
results.extend(_search_measuringtools(query, search_term))
|
# SEARCH_DOMAINS settings are feature toggles a site sets, not authorization,
|
||||||
results.extend(_search_usbdevices(query, search_term))
|
# and were never doing this job.
|
||||||
results.extend(_search_printeditems(query, search_term))
|
#
|
||||||
results.extend(_search_customfields(query, search_term))
|
# caller_holds rather than require_permission: a search that refuses the
|
||||||
results.extend(_search_notifications(query, search_term))
|
# whole request because one domain is off-limits is useless. Each domain
|
||||||
results.extend(_search_hostnames(query, search_term))
|
# simply contributes nothing.
|
||||||
results.extend(_search_vendor_model_type(query, search_term))
|
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
|
# IP-specific searches. An address and the subnet it sits in are network
|
||||||
if classification['is_ip']:
|
# 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_by_ip(query, search_term))
|
||||||
results.extend(_search_subnets(query))
|
results.extend(_search_subnets(query))
|
||||||
|
|
||||||
|
|||||||
@@ -334,23 +334,32 @@ def download_import_template(tablename):
|
|||||||
|
|
||||||
|
|
||||||
def _existing_hostname_map():
|
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
|
Azure and Thor exports align on hostname (their displayName), while the
|
||||||
import engine keys assets on assetnumber. This lets the Azure adapter route
|
import engine keys assets on assetnumber. This lets those adapters route a
|
||||||
a matched device to its real asset so the import updates in place.
|
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']
|
assets = db.metadata.tables['assets']
|
||||||
computers = db.metadata.tables['computers']
|
computers = db.metadata.tables['computers']
|
||||||
query = sa_select(
|
query = sa_select(
|
||||||
computers.c.hostname, assets.c.assetnumber
|
computers.c.hostname, assets.c.assetid
|
||||||
).select_from(
|
).select_from(
|
||||||
computers.join(assets, computers.c.assetid == assets.c.assetid)
|
computers.join(assets, computers.c.assetid == assets.c.assetid)
|
||||||
).where(computers.c.hostname.isnot(None))
|
).where(computers.c.hostname.isnot(None))
|
||||||
mapping = {}
|
mapping = {}
|
||||||
for hostname, assetnumber in db.session.execute(query):
|
for hostname, assetid in db.session.execute(query):
|
||||||
if hostname:
|
if hostname:
|
||||||
mapping[hostname.strip().lower()] = assetnumber
|
mapping[hostname.strip().lower()] = assetid
|
||||||
return mapping
|
return mapping
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -351,19 +351,20 @@ def update_role(roleid: int):
|
|||||||
# Update permissions
|
# Update permissions
|
||||||
if 'permissions' in data and role.rolename != 'admin':
|
if 'permissions' in data and role.rolename != 'admin':
|
||||||
# The guest role is anyone with no account, so what it may hold is
|
# 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
|
# bounded to what core or a plugin has explicitly declared grantable.
|
||||||
# declared safe to do anonymously. Refused at the door rather than
|
# Refused at the door rather than warned about - a checkbox that
|
||||||
# warned about - a checkbox that quietly makes deleting an asset
|
# quietly makes deleting an asset anonymous is not a warning anybody
|
||||||
# anonymous is not a warning anybody reads twice.
|
# reads twice.
|
||||||
if role.rolename == Role.GUEST:
|
if role.rolename == Role.GUEST:
|
||||||
allowed = guest_grantable_permissions()
|
allowed = guest_grantable_permissions()
|
||||||
refused = sorted(set(data['permissions']) - allowed)
|
refused = sorted(set(data['permissions']) - allowed)
|
||||||
if refused:
|
if refused:
|
||||||
return error_response(
|
return error_response(
|
||||||
ErrorCodes.VALIDATION_ERROR,
|
ErrorCodes.VALIDATION_ERROR,
|
||||||
'The guest role cannot hold {}. A guest may be granted any '
|
'The guest role cannot hold {}. A guest may only be '
|
||||||
'view permission, and only writes a plugin has declared '
|
'granted permissions core or the owning plugin has '
|
||||||
'safe to do without an account.'.format(', '.join(refused)))
|
'declared grantable, which is a code change rather than a '
|
||||||
|
'setting.'.format(', '.join(refused)))
|
||||||
old_perms = [p.name for p in role.permissions]
|
old_perms = [p.name for p in role.permissions]
|
||||||
perms = Permission.query.filter(Permission.name.in_(data['permissions'])).all()
|
perms = Permission.query.filter(Permission.name.in_(data['permissions'])).all()
|
||||||
new_perms = [p.name for p in perms]
|
new_perms = [p.name for p in perms]
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ class Permission(db.Model):
|
|||||||
('assets.delete', 'Delete assets', 'assets'),
|
('assets.delete', 'Delete assets', 'assets'),
|
||||||
# Applications
|
# Applications
|
||||||
('applications.view', 'View applications', '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.create', 'Create applications', 'applications'),
|
||||||
('applications.edit', 'Edit applications', 'applications'),
|
('applications.edit', 'Edit applications', 'applications'),
|
||||||
('applications.delete', 'Delete applications', 'applications'),
|
('applications.delete', 'Delete applications', 'applications'),
|
||||||
@@ -152,28 +158,60 @@ def full_permission_catalog(include_disabled=False):
|
|||||||
return catalog
|
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():
|
def guest_grantable_permissions():
|
||||||
"""Which permissions the guest role may be granted.
|
"""Which permissions the guest role may be granted.
|
||||||
|
|
||||||
Reading is the safe default and covers almost everything a site wants to
|
Every entry is DECLARED, by core below or by a plugin through
|
||||||
open up: any `.view` or `.read` permission may be handed to guests.
|
get_guest_grantable_permissions(). Nothing qualifies by being spelled a
|
||||||
|
particular way.
|
||||||
|
|
||||||
A write is different - it lets somebody with no account change this
|
It used to: any name ending `.view` or `.read` was grantable, on the
|
||||||
database, and the audit trail can then say when and from where but never
|
reasoning that reading is the safe default. That is true of a permission
|
||||||
who. So a write has to be BLESSED by whoever owns it, through the plugin's
|
that gates a list of asset numbers and false of one that gates bytes, and
|
||||||
get_guest_grantable_permissions(). Secure Send is the case that exists:
|
the suffix cannot tell them apart. `applications.view` reached the
|
||||||
creating a one-view link for somebody is the whole point of the page, and
|
SYSTEM-context PowerShell bodies and the installer binaries;
|
||||||
the page has always been usable by people with no account here.
|
`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
|
So the rule is inverted: a permission is grantable when somebody wrote it
|
||||||
checkbox that quietly makes deleting an asset anonymous is not a warning
|
down, having looked at what it actually serves. Adding one is a code
|
||||||
anybody reads twice.
|
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
|
from flask import current_app, has_app_context
|
||||||
|
|
||||||
catalog = full_permission_catalog()
|
catalog = full_permission_catalog()
|
||||||
grantable = {name for name, _, _ in catalog
|
known = {name for name, _, _ in catalog}
|
||||||
if name.endswith('.view') or name.endswith('.read')}
|
grantable = {name for name in CORE_GUEST_GRANTABLE if name in known}
|
||||||
|
|
||||||
if not has_app_context():
|
if not has_app_context():
|
||||||
return grantable
|
return grantable
|
||||||
@@ -181,7 +219,6 @@ def guest_grantable_permissions():
|
|||||||
if not pm:
|
if not pm:
|
||||||
return grantable
|
return grantable
|
||||||
|
|
||||||
known = {name for name, _, _ in catalog}
|
|
||||||
for name, plugin in pm.get_all_plugins().items():
|
for name, plugin in pm.get_all_plugins().items():
|
||||||
if not pm.registry.is_enabled(name):
|
if not pm.registry.is_enabled(name):
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -427,12 +427,41 @@ class Resolver:
|
|||||||
def resolve(self, tablename, idcolumn, value):
|
def resolve(self, tablename, idcolumn, value):
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
# A number is taken at face value: exports round-trip, and some sites do
|
# An int is an id. It cannot be anything else.
|
||||||
# know their ids.
|
if isinstance(value, int):
|
||||||
if isinstance(value, int) or (isinstance(value, str) and value.strip().isdigit()):
|
|
||||||
return int(value)
|
return int(value)
|
||||||
|
|
||||||
label = _label_column(tablename)
|
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:
|
if not label:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"cannot look up %s by name - give the numeric %s instead" % (tablename, idcolumn))
|
"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]:
|
def get_guest_grantable_permissions(self) -> List[str]:
|
||||||
"""Permissions this plugin allows a site to grant to the GUEST role.
|
"""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
|
A guest is anyone with no account. EVERY permission of this plugin's
|
||||||
grantable without asking a plugin - reading is the safe default. This
|
that a site may grant one has to be named here, reads included.
|
||||||
hook is for the exception: a WRITE that is genuinely meant to be usable
|
|
||||||
by somebody who never signs in.
|
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
|
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
|
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
|
The bar for a read: could an anonymous visitor hold this, forever, and
|
||||||
be defensible in the audit log? If not, leave it out.
|
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 []
|
return []
|
||||||
|
|
||||||
@@ -363,8 +370,29 @@ class BasePlugin(ABC):
|
|||||||
# ('list' takes a 'map' of title/badge/meta
|
# ('list' takes a 'map' of title/badge/meta
|
||||||
# keys, rendered generically - see warranty)
|
# keys, rendered generically - see warranty)
|
||||||
'position': int, # order among panels
|
'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
|
Consumed by GET /api/pluginui/asset-panels?assetid=<id>, which returns
|
||||||
the panels matching that asset's type. Disabled plugins
|
the panels matching that asset's type. Disabled plugins
|
||||||
are skipped; a broken plugin is isolated in prod, re-raised in dev/test.
|
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,
|
matched (dashboard IP fallback), or used as a rate-limit key (login). Left in,
|
||||||
it makes every request look like a different client.
|
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
|
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.
|
bare 2001:db8::1 (many colons, no brackets) is returned untouched.
|
||||||
"""
|
"""
|
||||||
@@ -26,14 +43,11 @@ def strip_port(ipaddress):
|
|||||||
|
|
||||||
|
|
||||||
def client_ip(request_obj):
|
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
|
Resolved from remote_addr, which ProxyFix has already rewritten from
|
||||||
when neither is present (callers that need a non-null key coerce it).
|
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')
|
return strip_port(request_obj.remote_addr)
|
||||||
if forwarded:
|
|
||||||
raw = forwarded.split(',')[0].strip()
|
|
||||||
else:
|
|
||||||
raw = request_obj.remote_addr
|
|
||||||
return strip_port(raw)
|
|
||||||
|
|||||||
@@ -32,11 +32,18 @@ def ratelimit_on(app):
|
|||||||
|
|
||||||
|
|
||||||
def _bad_login(client, ip):
|
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(
|
return client.post(
|
||||||
'/api/auth/login',
|
'/api/auth/login',
|
||||||
json={'username': 'ghost', 'password': 'wrong'},
|
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
|
from shopdb.utils.clientip import strip_port, client_ip
|
||||||
|
|
||||||
@@ -34,12 +42,94 @@ class _FakeRequest:
|
|||||||
self.remote_addr = remote_addr
|
self.remote_addr = remote_addr
|
||||||
|
|
||||||
|
|
||||||
def test_client_ip_prefers_forwarded_first_hop_stripped():
|
def test_client_ip_ignores_the_forwarded_header():
|
||||||
request_obj = _FakeRequest(headers={'X-Forwarded-For': '10.0.0.5:51000, 10.0.0.1'},
|
"""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')
|
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'
|
assert client_ip(request_obj) == '10.0.0.5'
|
||||||
|
|
||||||
|
|
||||||
def test_client_ip_falls_back_to_remote_addr_stripped():
|
def test_client_ip_falls_back_to_remote_addr_stripped():
|
||||||
request_obj = _FakeRequest(headers={}, remote_addr='192.168.1.9:60000')
|
request_obj = _FakeRequest(headers={}, remote_addr='192.168.1.9:60000')
|
||||||
assert client_ip(request_obj) == '192.168.1.9'
|
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
|
# 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
|
from shopdb.core.models import guest_grantable_permissions
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
grantable = guest_grantable_permissions()
|
grantable = guest_grantable_permissions()
|
||||||
assert 'assets.view' in grantable
|
|
||||||
assert 'reports.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):
|
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 == []
|
assert guestrole.permissions == []
|
||||||
|
|
||||||
|
|
||||||
def test_the_api_allows_granting_a_guest_a_view(client, db, auth_headers,
|
def test_the_api_allows_granting_a_guest_a_declared_read(client, db,
|
||||||
|
auth_headers,
|
||||||
guestrole):
|
guestrole):
|
||||||
response = client.put('/api/users/roles/{}'.format(guestrole.roleid),
|
response = client.put('/api/users/roles/{}'.format(guestrole.roleid),
|
||||||
headers=auth_headers,
|
headers=auth_headers,
|
||||||
json={'permissions': ['audit.view']})
|
json={'permissions': ['reports.view']})
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert [p.name for p in guestrole.permissions] == ['audit.view']
|
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 == 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):
|
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']
|
rows = client.get('/api/users/roles', headers=auth_headers).get_json()['data']
|
||||||
guest = next(r for r in rows if r['rolename'] == 'guest')
|
guest = next(r for r in rows if r['rolename'] == 'guest')
|
||||||
assert guest['isguest'] is True
|
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 '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)
|
other = next((r for r in rows if r['rolename'] != 'guest'), None)
|
||||||
if other is not 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',
|
response = client.get('/api/auditlogs',
|
||||||
headers={'Authorization': 'Bearer not.a.token'})
|
headers={'Authorization': 'Bearer not.a.token'})
|
||||||
assert response.status_code == 422
|
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)
|
entries = _data(client, '/api/pluginui/asset-presentation', auth_headers)
|
||||||
assert 'measuring_tool' not in {e.get('assettype') for e in entries}
|
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',
|
'plugins.list_enabled_plugins',
|
||||||
'dashboard.get_navigation', # filtered per caller; a guest sees only
|
'dashboard.get_navigation', # filtered per caller; a guest sees only
|
||||||
# what the guest role can reach
|
# 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
|
'dashboard.health_check', # monitoring has no account
|
||||||
|
|
||||||
# Settings: the handler serves an unauthenticated caller only the public
|
# 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()
|
_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 endpointcommands queue plus the MTConnect state columns, 0003
|
||||||
# adds the generic command target/targetkind columns, and 0004 adds the
|
# adds the generic command target/targetkind columns, and 0004 adds the
|
||||||
# endpointapplications SBOM snapshot table.
|
# 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
|
# Plugins built after the cutover: their 0001 baseline really creates tables the
|
||||||
# core chain never owned.
|
# 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."""
|
"""Every asset panel targets computer assets only, with an endpoint."""
|
||||||
plugin = EndpointsPlugin()
|
plugin = EndpointsPlugin()
|
||||||
panels = plugin.get_asset_panels()
|
panels = plugin.get_asset_panels()
|
||||||
assert len(panels) == 5
|
assert len(panels) == 6
|
||||||
for panel in panels:
|
for panel in panels:
|
||||||
assert panel['assettypes'] == ['computer']
|
assert panel['assettypes'] == ['computer']
|
||||||
assert panel['endpoint'].startswith('/api/endpoints/asset/{assetid}')
|
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'
|
assert processes['permission'] == 'endpoints.command'
|
||||||
|
|
||||||
|
|
||||||
def test_recent_events_card_is_last_and_starts_collapsed():
|
def test_activity_card_starts_collapsed_and_sits_below_live_state():
|
||||||
"""Events is the lowest-priority card: forced to the bottom of the column
|
"""The activity feed is history, so it must not push live state down.
|
||||||
and collapsed by default."""
|
|
||||||
|
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()
|
plugin = EndpointsPlugin()
|
||||||
panels = plugin.get_asset_panels()
|
panels = plugin.get_asset_panels()
|
||||||
events = next(p for p in panels if p['id'] == 'endpoints-events')
|
activity = next(p for p in panels if p['id'] == 'endpoints-activity')
|
||||||
assert events['collapsedDefault'] is True
|
# NOT collapsed by default, unlike the events card it replaced. The feed
|
||||||
assert events['position'] == max(p['position'] for p in panels)
|
# 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():
|
def test_endpoints_dashboard_widgets_declared():
|
||||||
@@ -115,7 +129,9 @@ def test_endpoints_dashboard_widgets_declared():
|
|||||||
for widget in widgets:
|
for widget in widgets:
|
||||||
assert widget['render'] == 'exceptions'
|
assert widget['render'] == 'exceptions'
|
||||||
assert widget['empty'] == 'hide'
|
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():
|
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."""
|
rather than dropping them silently."""
|
||||||
from plugins.endpoints.plugin import KNOWN_PAYLOAD_KEYS
|
from plugins.endpoints.plugin import KNOWN_PAYLOAD_KEYS
|
||||||
|
|
||||||
for key in ('disks', 'bios', 'memory', 'processor', 'osinfo', 'domain',
|
for key in ('disks', 'bios', 'memory', 'processor', 'osinfo', 'domain'):
|
||||||
'applications'):
|
|
||||||
assert key not in KNOWN_PAYLOAD_KEYS
|
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():
|
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
|
from flask import Flask
|
||||||
|
|
||||||
application = Flask(__name__)
|
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()}
|
rules = {rule.rule for rule in application.url_map.iter_rules()}
|
||||||
assert any(rule.endswith('/asset/<int:asset_id>/telemetrymeta') for rule in 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):
|
def test_rate_limit_is_per_ip(client, app, auth_headers):
|
||||||
"""A second caller (different X-Forwarded-For) has its own budget: one IP
|
"""A second caller has its own budget: one IP being throttled must not
|
||||||
being throttled must not throttle everyone."""
|
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')
|
sha = _store(app, b'per-ip blob')
|
||||||
key = _fetch_key(client, auth_headers)
|
key = _fetch_key(client, auth_headers)
|
||||||
hot = {**key, 'X-Forwarded-For': '203.0.113.202'}
|
hot = {'REMOTE_ADDR': '203.0.113.202'}
|
||||||
cool = {**key, 'X-Forwarded-For': '203.0.113.203'}
|
cool = {'REMOTE_ADDR': '203.0.113.203'}
|
||||||
with _config(app, GEENFORCE_PAYLOAD_RATELIMIT_MAX=1,
|
with _config(app, GEENFORCE_PAYLOAD_RATELIMIT_MAX=1,
|
||||||
GEENFORCE_PAYLOAD_RATELIMIT_WINDOW_SECONDS=300):
|
GEENFORCE_PAYLOAD_RATELIMIT_WINDOW_SECONDS=300):
|
||||||
assert client.get(f'/api/geenforce/payload/{sha}',
|
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}',
|
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.
|
# Fresh IP still gets its first request through.
|
||||||
assert client.get(f'/api/geenforce/payload/{sha}',
|
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):
|
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