diff --git a/CHANGELOG.md b/CHANGELOG.md
index 061d8a2e..2b6b5691 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -128,6 +128,91 @@ ADR-007 and ADR-002.
### Fixed
+- **Global search answered twelve domains behind one permission.** The route is
+ gated on `assets.view`, and that was the entire check: looking up an asset
+ number also read out of the staff directory, the USB checkout history, the
+ notification log and the network inventory, none of which consult their own
+ permission. The `SEARCH_DOMAINS` settings are site feature toggles, not
+ authorization, and were never doing that job. Each domain now answers only to
+ a caller holding its permission - `employees.view` for the directory,
+ `usb.view` for checkouts, `network.view` for address and subnet matches,
+ `computers.view` for hostnames. Asset, custom-field and vendor/model results
+ stay on `assets.view`, since all three emit asset rows. The check is
+ `caller_holds` rather than `require_permission`: a search that refused the
+ whole request because one domain was off-limits would be useless, so a domain
+ the caller cannot see simply contributes nothing. This is also what makes
+ `assets.view` safe to hand to the guest role again, which is what lets a
+ signed-out visitor look up an asset number without also getting the staff
+ directory.
+
+- **The printer supplies card was ordered by printer name, so the emptiest
+ cartridge was not first and sometimes was not shown at all.** Rows sorted on
+ "is critical" and then alphabetically, and critical is everything at or below
+ 5% - so a cartridge at 5% and one at 0% tied on the first key and the alphabet
+ broke the tie. That is the wrong order on its own, and worse than it looks:
+ the dashboard queue keeps only the first few rows per source, so a printer
+ that was genuinely empty got truncated off the card while a fuller one held
+ its place. The card exists to answer "what do I walk out and change today" and
+ was answering it with the alphabet. Rows now sort by the emptiest cartridge on
+ the printer, ascending. Waste cartridges were already normalised to percent
+ remaining, so one ascending sort is worst-first for every supply type and the
+ critical flag falls out of it rather than needing its own key; the name only
+ breaks ties.
+
+- **A permission became grantable to anonymous visitors because of how it was
+ spelled.** `guest_grantable_permissions()` opened any permission whose name
+ ended `.view` or `.read` to the guest role, on the reasoning that reading is
+ the safe default. That is true of a permission gating a list of asset numbers
+ and false of one gating bytes, and a suffix cannot tell them apart. Twenty-four
+ of eighty-five qualified, and nobody had decided any of them:
+ `applications.view` reached the SYSTEM-context PowerShell bodies and the
+ installer binaries a site pushes to its bays; `backups.view` returned the full
+ payload that the separate `backups.download` permission exists to protect;
+ `assets.view` gated a global search that queries the staff directory, the USB
+ history and the network inventory with no per-domain check of its own;
+ `audit.view` and `settings.view` are admin-category and were opened by naming
+ alone. Every entry is now DECLARED, by core or by the plugin that owns it,
+ having read what its routes actually return. Adding one is a code change,
+ which is the point - it puts the decision in a diff rather than in a suffix.
+ The two routes serving script bodies and installer packages moved to a new
+ `applications.download` permission, which is not grantable, so the catalogue
+ metadata can still be opened to a site's floor without the bytes going with it.
+
+- **Deactivating an account did not end its sessions.** There is no token
+ denylist in this product, so the JWT user loader is the only place a
+ deactivated account can be stopped, and it did a bare lookup by id. Somebody
+ deactivated at 09:00 kept a working access token until it expired and a
+ refresh token that minted new ones for thirty days. `/api/auth/refresh` and
+ the API-token path already checked `isactive`; the loader now does too.
+
+- **The login rate limit could be reset by a header the caller writes.**
+ `client_ip()` read the first `X-Forwarded-For` hop unconditionally, so
+ rotating it per request bought an unbounded budget, and every audit row
+ recorded the forged address. This walked around `TRUSTED_PROXY_COUNT`, which
+ exists to decide whether a proxy is really in front: the app deliberately
+ leaves ProxyFix off by default for exactly this reason, and then read the
+ header anyway. Resolution is now from `remote_addr`, which ProxyFix has
+ already filled from the header where a proxy is configured. Two plugins had
+ hand-rolled the same mistake for their own limiters and now share the helper,
+ which is on `shopdb.api` so the third one does not. **A site behind IIS must
+ set `TRUSTED_PROXY_COUNT`** or its whole floor shares one rate-limit bucket;
+ INSTALL-WINDOWS-IIS.md now says so.
+
+- **What an operator approved and what an endpoint ran were two different
+ lookups.** Delivery descriptors for `run_script` and `update_app` are resolved
+ when the agent claims a command, not when the command is queued, and the
+ command row recorded no digest - the enqueue path resolved the descriptor
+ purely to validate it and threw the result away. Anyone able to replace a
+ script body or an installer package could do so in the gap, and the
+ re-resolved descriptor would then faithfully describe the new bytes: the agent
+ verifies correctly and runs something nobody approved, with the audit trail
+ naming the operator who queued the original. The approved digest is now pinned
+ on the command at enqueue and the claim refuses a descriptor that no longer
+ matches, failing the push instead of shipping it. Separately, an agent may
+ only finalize a command that is actually running, so a result cannot be posted
+ for a command that was never claimed nor overwrite a recorded failure with a
+ success.
+
- **The published site's API documentation page had no spec to show.**
`shopdb/core/api/docs.py` reads `docs/openapi.json` at runtime to serve
`/api/docs`, and publication excluded the whole of `docs/` because the prose
diff --git a/agent/ShopDbEndpointAgent.Core/CommandClient.cs b/agent/ShopDbEndpointAgent.Core/CommandClient.cs
index 296dc280..0dcc395c 100644
--- a/agent/ShopDbEndpointAgent.Core/CommandClient.cs
+++ b/agent/ShopDbEndpointAgent.Core/CommandClient.cs
@@ -64,10 +64,17 @@ public sealed class CommandClient : IDisposable
// result is logged and swallowed so a transient network blip does not crash
// the worker; the command simply stays 'running' server-side until a later
// operator retry supersedes it.
+ // hostname rides along so the server can check this agent owns the command
+ // it is finalizing. The shared collector key does not say who is calling,
+ // so without it any agent can post a result for any command id. The server
+ // treats the parameter as optional for older agents and enforces it when
+ // present, so shipping this ahead of a fleet update is safe.
public async Task ReportResultAsync(
- int commandId, CommandResult result, CancellationToken token)
+ int commandId, string hostname, CommandResult result,
+ CancellationToken token)
{
- var url = $"api/endpoints/commands/{commandId}/result";
+ var url = $"api/endpoints/commands/{commandId}/result?hostname="
+ + Uri.EscapeDataString(hostname);
using var response = await http.PostAsJsonAsync(
url, result, JsonOptions, token);
if (!response.IsSuccessStatusCode)
diff --git a/agent/ShopDbEndpointAgent.Core/CommandWorker.cs b/agent/ShopDbEndpointAgent.Core/CommandWorker.cs
index ec6dea0e..2d71232d 100644
--- a/agent/ShopDbEndpointAgent.Core/CommandWorker.cs
+++ b/agent/ShopDbEndpointAgent.Core/CommandWorker.cs
@@ -130,7 +130,8 @@ public sealed class CommandWorker : BackgroundService
};
}
- await client.ReportResultAsync(claim.EndpointCommandId, result, token);
+ await client.ReportResultAsync(
+ claim.EndpointCommandId, hostname, result, token);
logger.LogInformation("Command {Id} finished succeeded={Succeeded}",
claim.EndpointCommandId, result.Succeeded);
diff --git a/docs/openapi.json b/docs/openapi.json
index 077980da..3d20663c 100644
--- a/docs/openapi.json
+++ b/docs/openapi.json
@@ -185,6 +185,15 @@
},
{
"name": "plugin-endpoints"
+ },
+ {
+ "name": "core-scripts"
+ },
+ {
+ "name": "endpoints-delivery"
+ },
+ {
+ "name": "endpoints-siteagent"
}
],
"paths": {
@@ -2438,6 +2447,34 @@
}
}
},
+ "/api/dashboard/capabilities": {
+ "get": {
+ "tags": [
+ "core-platform"
+ ],
+ "summary": "Booleans describing what THIS caller may reach, so the app chrome can avoid drawing controls that would only answer...",
+ "description": "Booleans describing what THIS caller may reach, so the app chrome can avoid drawing controls that would only answer 401; cansearch reports whether the caller holds assets.view, which a signed-out visitor does when the guest role has been granted it\n\n**Auth:** none\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/dashboard/capabilities\n```",
+ "security": [],
+ "responses": {
+ "200": {
+ "description": "Success. Body is the success_response envelope: {status, data, meta}.",
+ "content": {
+ "application/json": {
+ "$ref": "#/components/schemas/SuccessEnvelope"
+ }
+ }
+ },
+ "default": {
+ "description": "Error. Body is the error envelope; the code and message are nested under data.error.",
+ "content": {
+ "application/json": {
+ "$ref": "#/components/schemas/ErrorEnvelope"
+ }
+ }
+ }
+ }
+ }
+ },
"/api/dashboard/widgets": {
"get": {
"tags": [
@@ -22680,6 +22717,768 @@
}
}
}
+ },
+ "/api/scripts": {
+ "get": {
+ "tags": [
+ "core-scripts"
+ ],
+ "summary": "List script catalog entries, active only by default",
+ "description": "List script catalog entries, active only by default\n\n**Auth:** jwt + require_permission applications.view\n\n**Params:** query: includeinactive (default false)\n\n**Example:**\n```\ncurl http://localhost:5001/api/scripts -H \"Authorization: Bearer $TOK\"\n```",
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success. Body is the success_response envelope: {status, data, meta}.",
+ "content": {
+ "application/json": {
+ "$ref": "#/components/schemas/SuccessEnvelope"
+ }
+ }
+ },
+ "default": {
+ "description": "Error. Body is the error envelope; the code and message are nested under data.error.",
+ "content": {
+ "application/json": {
+ "$ref": "#/components/schemas/ErrorEnvelope"
+ }
+ }
+ },
+ "401": {
+ "description": "Missing or invalid credentials."
+ },
+ "403": {
+ "description": "Authenticated, but not permitted."
+ }
+ }
+ },
+ "post": {
+ "tags": [
+ "core-scripts"
+ ],
+ "summary": "Create a script catalog entry; the body is uploaded separately so metadata and content version independently",
+ "description": "Create a script catalog entry; the body is uploaded separately so metadata and content version independently\n\n**Auth:** jwt + require_permission applications.create\n\n**Params:** body: name (required), description, runcontext (system|user, default system)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/scripts -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"name\":\"Restart spooler\",\"runcontext\":\"system\"}'\n```",
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success. Body is the success_response envelope: {status, data, meta}.",
+ "content": {
+ "application/json": {
+ "$ref": "#/components/schemas/SuccessEnvelope"
+ }
+ }
+ },
+ "default": {
+ "description": "Error. Body is the error envelope; the code and message are nested under data.error.",
+ "content": {
+ "application/json": {
+ "$ref": "#/components/schemas/ErrorEnvelope"
+ }
+ }
+ },
+ "401": {
+ "description": "Missing or invalid credentials."
+ },
+ "403": {
+ "description": "Authenticated, but not permitted."
+ }
+ },
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "additionalProperties": true,
+ "description": "body: name (required), description, runcontext (system|user, default system)"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/scripts/{script_id}": {
+ "get": {
+ "tags": [
+ "core-scripts"
+ ],
+ "summary": "Get one script entry with its metadata and run context",
+ "description": "Get one script entry with its metadata and run context\n\n**Auth:** jwt + require_permission applications.view\n\n**Params:** path: script_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/scripts/3 -H \"Authorization: Bearer $TOK\"\n```",
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success. Body is the success_response envelope: {status, data, meta}.",
+ "content": {
+ "application/json": {
+ "$ref": "#/components/schemas/SuccessEnvelope"
+ }
+ }
+ },
+ "default": {
+ "description": "Error. Body is the error envelope; the code and message are nested under data.error.",
+ "content": {
+ "application/json": {
+ "$ref": "#/components/schemas/ErrorEnvelope"
+ }
+ }
+ },
+ "401": {
+ "description": "Missing or invalid credentials."
+ },
+ "403": {
+ "description": "Authenticated, but not permitted."
+ },
+ "404": {
+ "description": "No such record."
+ }
+ },
+ "parameters": [
+ {
+ "name": "script_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ]
+ },
+ "put": {
+ "tags": [
+ "core-scripts"
+ ],
+ "summary": "Update a script entry's metadata; the stored body is untouched",
+ "description": "Update a script entry's metadata; the stored body is untouched\n\n**Auth:** jwt + require_permission applications.edit\n\n**Params:** path: script_id; body: name, description, runcontext, isactive\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/scripts/3 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"description\":\"Clears the print queue\"}'\n```",
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success. Body is the success_response envelope: {status, data, meta}.",
+ "content": {
+ "application/json": {
+ "$ref": "#/components/schemas/SuccessEnvelope"
+ }
+ }
+ },
+ "default": {
+ "description": "Error. Body is the error envelope; the code and message are nested under data.error.",
+ "content": {
+ "application/json": {
+ "$ref": "#/components/schemas/ErrorEnvelope"
+ }
+ }
+ },
+ "401": {
+ "description": "Missing or invalid credentials."
+ },
+ "403": {
+ "description": "Authenticated, but not permitted."
+ },
+ "404": {
+ "description": "No such record."
+ }
+ },
+ "parameters": [
+ {
+ "name": "script_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "additionalProperties": true,
+ "description": "path: script_id; body: name, description, runcontext, isactive"
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "core-scripts"
+ ],
+ "summary": "Remove a script catalog entry",
+ "description": "Remove a script catalog entry\n\n**Auth:** jwt + require_permission applications.delete\n\n**Params:** path: script_id\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/scripts/3 -H \"Authorization: Bearer $TOK\"\n```",
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success. Body is the success_response envelope: {status, data, meta}.",
+ "content": {
+ "application/json": {
+ "$ref": "#/components/schemas/SuccessEnvelope"
+ }
+ }
+ },
+ "default": {
+ "description": "Error. Body is the error envelope; the code and message are nested under data.error.",
+ "content": {
+ "application/json": {
+ "$ref": "#/components/schemas/ErrorEnvelope"
+ }
+ }
+ },
+ "401": {
+ "description": "Missing or invalid credentials."
+ },
+ "403": {
+ "description": "Authenticated, but not permitted."
+ },
+ "404": {
+ "description": "No such record."
+ }
+ },
+ "parameters": [
+ {
+ "name": "script_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ]
+ }
+ },
+ "/api/scripts/{script_id}/body": {
+ "post": {
+ "tags": [
+ "core-scripts"
+ ],
+ "summary": "Upload or replace the script body; stored on disk and hashed so a delivery can pin the exact bytes",
+ "description": "Upload or replace the script body; stored on disk and hashed so a delivery can pin the exact bytes\n\n**Auth:** jwt + require_permission applications.edit\n\n**Params:** path: script_id; multipart/form-data: file (the .ps1)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/scripts/3/body -H \"Authorization: Bearer $TOK\" -F 'file=@restart-spooler.ps1'\n```",
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success. Body is the success_response envelope: {status, data, meta}.",
+ "content": {
+ "application/json": {
+ "$ref": "#/components/schemas/SuccessEnvelope"
+ }
+ }
+ },
+ "default": {
+ "description": "Error. Body is the error envelope; the code and message are nested under data.error.",
+ "content": {
+ "application/json": {
+ "$ref": "#/components/schemas/ErrorEnvelope"
+ }
+ }
+ },
+ "401": {
+ "description": "Missing or invalid credentials."
+ },
+ "403": {
+ "description": "Authenticated, but not permitted."
+ },
+ "404": {
+ "description": "No such record."
+ }
+ },
+ "parameters": [
+ {
+ "name": "script_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "additionalProperties": true,
+ "description": "path: script_id; multipart/form-data: file (the .ps1)"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/scripts/body/{filename}": {
+ "get": {
+ "tags": [
+ "core-scripts"
+ ],
+ "summary": "Download a stored script body for review in the UI",
+ "description": "Download a stored script body for review in the UI\n\n**Auth:** jwt + require_permission applications.view\n\n**Params:** path: filename (as stored)\n\n**Example:**\n```\ncurl http://localhost:5001/api/scripts/body/restart-spooler.ps1 -H \"Authorization: Bearer $TOK\" -o restart-spooler.ps1\n```",
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success. Body is the success_response envelope: {status, data, meta}.",
+ "content": {
+ "application/json": {
+ "$ref": "#/components/schemas/SuccessEnvelope"
+ }
+ }
+ },
+ "default": {
+ "description": "Error. Body is the error envelope; the code and message are nested under data.error.",
+ "content": {
+ "application/json": {
+ "$ref": "#/components/schemas/ErrorEnvelope"
+ }
+ }
+ },
+ "401": {
+ "description": "Missing or invalid credentials."
+ },
+ "403": {
+ "description": "Authenticated, but not permitted."
+ },
+ "404": {
+ "description": "No such record."
+ }
+ },
+ "parameters": [
+ {
+ "name": "filename",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ]
+ }
+ },
+ "/api/endpoints/apppackage/{appversionid}": {
+ "get": {
+ "tags": [
+ "endpoints-delivery"
+ ],
+ "summary": "Agent-facing installer download for a pinned app version; serves the exact bytes the update_app descriptor pinned by...",
+ "description": "Agent-facing installer download for a pinned app version; serves the exact bytes the update_app descriptor pinned by SHA-256\n\n**Auth:** collector token (X-API-Key), endpoints scope\n\n**Params:** path: appversionid (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/apppackage/12 -H 'X-API-Key: $COLLECTOR_KEY' -o setup.exe\n```",
+ "security": [
+ {
+ "apiKeyAuth": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success. Body is the success_response envelope: {status, data, meta}.",
+ "content": {
+ "application/json": {
+ "$ref": "#/components/schemas/SuccessEnvelope"
+ }
+ }
+ },
+ "default": {
+ "description": "Error. Body is the error envelope; the code and message are nested under data.error.",
+ "content": {
+ "application/json": {
+ "$ref": "#/components/schemas/ErrorEnvelope"
+ }
+ }
+ },
+ "401": {
+ "description": "Missing or invalid credentials."
+ },
+ "403": {
+ "description": "Authenticated, but not permitted."
+ },
+ "404": {
+ "description": "No such record."
+ }
+ },
+ "parameters": [
+ {
+ "name": "appversionid",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ]
+ }
+ },
+ "/api/endpoints/apppackage/app/{app_id}": {
+ "get": {
+ "tags": [
+ "endpoints-delivery"
+ ],
+ "summary": "Agent-facing installer download resolved from the application rather than a specific version",
+ "description": "Agent-facing installer download resolved from the application rather than a specific version\n\n**Auth:** collector token (X-API-Key), endpoints scope\n\n**Params:** path: app_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/apppackage/app/4 -H 'X-API-Key: $COLLECTOR_KEY' -o setup.exe\n```",
+ "security": [
+ {
+ "apiKeyAuth": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success. Body is the success_response envelope: {status, data, meta}.",
+ "content": {
+ "application/json": {
+ "$ref": "#/components/schemas/SuccessEnvelope"
+ }
+ }
+ },
+ "default": {
+ "description": "Error. Body is the error envelope; the code and message are nested under data.error.",
+ "content": {
+ "application/json": {
+ "$ref": "#/components/schemas/ErrorEnvelope"
+ }
+ }
+ },
+ "401": {
+ "description": "Missing or invalid credentials."
+ },
+ "403": {
+ "description": "Authenticated, but not permitted."
+ },
+ "404": {
+ "description": "No such record."
+ }
+ },
+ "parameters": [
+ {
+ "name": "app_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ]
+ }
+ },
+ "/api/endpoints/apppackage/app/{app_id}/{filename}": {
+ "get": {
+ "tags": [
+ "endpoints-delivery"
+ ],
+ "summary": "As above, naming the stored file explicitly so the agent writes it back under the expected name",
+ "description": "As above, naming the stored file explicitly so the agent writes it back under the expected name\n\n**Auth:** collector token (X-API-Key), endpoints scope\n\n**Params:** path: app_id (int), filename\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/apppackage/app/4/setup.exe -H 'X-API-Key: $COLLECTOR_KEY' -o setup.exe\n```",
+ "security": [
+ {
+ "apiKeyAuth": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success. Body is the success_response envelope: {status, data, meta}.",
+ "content": {
+ "application/json": {
+ "$ref": "#/components/schemas/SuccessEnvelope"
+ }
+ }
+ },
+ "default": {
+ "description": "Error. Body is the error envelope; the code and message are nested under data.error.",
+ "content": {
+ "application/json": {
+ "$ref": "#/components/schemas/ErrorEnvelope"
+ }
+ }
+ },
+ "401": {
+ "description": "Missing or invalid credentials."
+ },
+ "403": {
+ "description": "Authenticated, but not permitted."
+ },
+ "404": {
+ "description": "No such record."
+ }
+ },
+ "parameters": [
+ {
+ "name": "app_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "filename",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ]
+ }
+ },
+ "/api/endpoints/scriptbody/{script_id}": {
+ "get": {
+ "tags": [
+ "endpoints-delivery"
+ ],
+ "summary": "Agent-facing script download for a run_script delivery, on the same key as the rest of the delivery path",
+ "description": "Agent-facing script download for a run_script delivery, on the same key as the rest of the delivery path\n\n**Auth:** collector token (X-API-Key), endpoints scope\n\n**Params:** path: script_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/scriptbody/3 -H 'X-API-Key: $COLLECTOR_KEY' -o run.ps1\n```",
+ "security": [
+ {
+ "apiKeyAuth": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success. Body is the success_response envelope: {status, data, meta}.",
+ "content": {
+ "application/json": {
+ "$ref": "#/components/schemas/SuccessEnvelope"
+ }
+ }
+ },
+ "default": {
+ "description": "Error. Body is the error envelope; the code and message are nested under data.error.",
+ "content": {
+ "application/json": {
+ "$ref": "#/components/schemas/ErrorEnvelope"
+ }
+ }
+ },
+ "401": {
+ "description": "Missing or invalid credentials."
+ },
+ "403": {
+ "description": "Authenticated, but not permitted."
+ },
+ "404": {
+ "description": "No such record."
+ }
+ },
+ "parameters": [
+ {
+ "name": "script_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ]
+ }
+ },
+ "/api/endpoints/asset/{asset_id}/applications": {
+ "get": {
+ "tags": [
+ "endpoints-delivery"
+ ],
+ "summary": "Installed-software snapshot the agent last reported for this endpoint, for the asset detail panel; a per-report...",
+ "description": "Installed-software snapshot the agent last reported for this endpoint, for the asset detail panel; a per-report snapshot rather than history\n\n**Auth:** jwt + require_permission endpoints.view\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/asset/313/applications -H \"Authorization: Bearer $TOK\"\n```",
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success. Body is the success_response envelope: {status, data, meta}.",
+ "content": {
+ "application/json": {
+ "$ref": "#/components/schemas/SuccessEnvelope"
+ }
+ }
+ },
+ "default": {
+ "description": "Error. Body is the error envelope; the code and message are nested under data.error.",
+ "content": {
+ "application/json": {
+ "$ref": "#/components/schemas/ErrorEnvelope"
+ }
+ }
+ },
+ "401": {
+ "description": "Missing or invalid credentials."
+ },
+ "403": {
+ "description": "Authenticated, but not permitted."
+ },
+ "404": {
+ "description": "No such record."
+ }
+ },
+ "parameters": [
+ {
+ "name": "asset_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ]
+ }
+ },
+ "/api/endpoints/siteagent": {
+ "get": {
+ "tags": [
+ "endpoints-siteagent"
+ ],
+ "summary": "Report whether a prebuilt agent binary is staged and can therefore be packaged",
+ "description": "Report whether a prebuilt agent binary is staged and can therefore be packaged\n\n**Auth:** jwt + require_role admin\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/siteagent -H \"Authorization: Bearer $TOK\"\n```",
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success. Body is the success_response envelope: {status, data, meta}.",
+ "content": {
+ "application/json": {
+ "$ref": "#/components/schemas/SuccessEnvelope"
+ }
+ }
+ },
+ "default": {
+ "description": "Error. Body is the error envelope; the code and message are nested under data.error.",
+ "content": {
+ "application/json": {
+ "$ref": "#/components/schemas/ErrorEnvelope"
+ }
+ }
+ },
+ "401": {
+ "description": "Missing or invalid credentials."
+ },
+ "403": {
+ "description": "Authenticated, but not permitted."
+ }
+ }
+ }
+ },
+ "/api/endpoints/siteagent/package": {
+ "post": {
+ "tags": [
+ "endpoints-siteagent"
+ ],
+ "summary": "Bundle the agent as a downloadable ZIP with a generated install.ps1 that provisions credentials, so a bay install needs...",
+ "description": "Bundle the agent as a downloadable ZIP with a generated install.ps1 that provisions credentials, so a bay install needs no hand editing\n\n**Auth:** jwt + require_role admin\n\n**Params:** body: baseurl, collectorkey, cadenceseconds, and the site TLS cert when one is readable\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/siteagent/package -H \"Authorization: Bearer $TOK\" -o shopdb-agent.zip\n```",
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success. Body is the success_response envelope: {status, data, meta}.",
+ "content": {
+ "application/json": {
+ "$ref": "#/components/schemas/SuccessEnvelope"
+ }
+ }
+ },
+ "default": {
+ "description": "Error. Body is the error envelope; the code and message are nested under data.error.",
+ "content": {
+ "application/json": {
+ "$ref": "#/components/schemas/ErrorEnvelope"
+ }
+ }
+ },
+ "401": {
+ "description": "Missing or invalid credentials."
+ },
+ "403": {
+ "description": "Authenticated, but not permitted."
+ }
+ },
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "additionalProperties": true,
+ "description": "body: baseurl, collectorkey, cadenceseconds, and the site TLS cert when one is readable"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/endpoints/adoptiontool/package": {
+ "post": {
+ "tags": [
+ "endpoints-siteagent"
+ ],
+ "summary": "Bundle the agentless adoption tool, for taking an inventory of a machine that will not run the service",
+ "description": "Bundle the agentless adoption tool, for taking an inventory of a machine that will not run the service\n\n**Auth:** jwt + require_role admin\n\n**Params:** body: as for the site agent package\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/adoptiontool/package -H \"Authorization: Bearer $TOK\" -o shopdb-adoption.zip\n```",
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success. Body is the success_response envelope: {status, data, meta}.",
+ "content": {
+ "application/json": {
+ "$ref": "#/components/schemas/SuccessEnvelope"
+ }
+ }
+ },
+ "default": {
+ "description": "Error. Body is the error envelope; the code and message are nested under data.error.",
+ "content": {
+ "application/json": {
+ "$ref": "#/components/schemas/ErrorEnvelope"
+ }
+ }
+ },
+ "401": {
+ "description": "Missing or invalid credentials."
+ },
+ "403": {
+ "description": "Authenticated, but not permitted."
+ }
+ },
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "additionalProperties": true,
+ "description": "body: as for the site agent package"
+ }
+ }
+ }
+ }
+ }
}
}
}
\ No newline at end of file
diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js
index e3eaa19d..6bc0f1b3 100644
--- a/frontend/src/api/index.js
+++ b/frontend/src/api/index.js
@@ -536,6 +536,9 @@ export const dashboardApi = {
},
navigation() {
return api.get('/dashboard/navigation')
+ },
+ capabilities() {
+ return api.get('/dashboard/capabilities')
}
}
diff --git a/frontend/src/assets/style.css b/frontend/src/assets/style.css
index 5038c546..7cf95e64 100644
--- a/frontend/src/assets/style.css
+++ b/frontend/src/assets/style.css
@@ -1083,6 +1083,21 @@ td.actions .btn + .btn {
display: contents;
}
+/* Full-width alternative to .content-grid, for a tab holding one long list
+ rather than short fact cards. An application inventory or an event feed
+ split into two balanced columns reads as two unrelated lists, and the eye
+ has to hunt for where the first one ended. */
+.detail-full {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ margin-bottom: 25px;
+}
+
+.detail-full > * {
+ min-width: 0;
+}
+
/* Section Cards */
.section-card {
background: var(--bg-card);
diff --git a/frontend/src/components/AssetDetailTabs.vue b/frontend/src/components/AssetDetailTabs.vue
new file mode 100644
index 00000000..1cb9e335
--- /dev/null
+++ b/frontend/src/components/AssetDetailTabs.vue
@@ -0,0 +1,109 @@
+
+
+