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 @@ + + + + + diff --git a/frontend/src/components/PluginAssetPanels.vue b/frontend/src/components/PluginAssetPanels.vue index d5b8ab92..9782f2af 100644 --- a/frontend/src/components/PluginAssetPanels.vue +++ b/frontend/src/components/PluginAssetPanels.vue @@ -2,8 +2,8 @@ -