From e80f3c70b659cfb7554011ce44842b0e66ff3937 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 24 May 2026 07:35:25 -0400 Subject: [PATCH] docs: cover admin dashboard actions + API key Delete Update the design docs so they match the implemented Admin-only dashboard surface. GatewayDashboardDesign now documents the Close session / Kill worker controls and the new Delete action on revoked API keys, plus the ConfirmDialog gate for every destructive action. Sessions.md adds the SessionManager.KillWorkerAsync entry alongside CloseSessionAsync and explains the immediate-kill semantics. Authentication.md adds the IApiKeyAdminStore.DeleteAsync write path and the dashboard-delete-key audit event. DashboardInterfaceDesign drops the "read-only until admin workflows have a separate design" line in favor of the confirm-before-act invariant. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/Authentication.md | 10 ++++-- docs/DashboardInterfaceDesign.md | 7 ++++- docs/GatewayDashboardDesign.md | 54 +++++++++++++++++++++++--------- docs/Sessions.md | 11 +++++-- 4 files changed, 62 insertions(+), 20 deletions(-) diff --git a/docs/Authentication.md b/docs/Authentication.md index 4ad9f87..3fc0074 100644 --- a/docs/Authentication.md +++ b/docs/Authentication.md @@ -155,9 +155,9 @@ public static ApiKeyRecord Read(SqliteDataReader reader) ### Write paths -`SqliteApiKeyAdminStore` (`IApiKeyAdminStore`) implements administrative mutations: `CreateAsync` accepts an `ApiKeyCreateRequest`, `RevokeAsync` sets `revoked_utc` only when not already revoked, and `RotateAsync` replaces `secret_hash`, clears `last_used_utc`, and clears `revoked_utc` so a rotated key is immediately usable. +`SqliteApiKeyAdminStore` (`IApiKeyAdminStore`) implements administrative mutations: `CreateAsync` accepts an `ApiKeyCreateRequest`, `RevokeAsync` sets `revoked_utc` only when not already revoked, `RotateAsync` replaces `secret_hash`, clears `last_used_utc`, and clears `revoked_utc` so a rotated key is immediately usable, and `DeleteAsync` permanently removes a row but only when `revoked_utc IS NOT NULL` — active keys are untouched (returns false) so the revoke event lands in the audit log before the row disappears. -Because `RotateAsync` clears `revoked_utc`, rotating a previously revoked key reactivates it. The dashboard API Keys page therefore offers the Rotate (and Revoke) action only for keys whose status is `Active`; a revoked key shows no actions, so an operator cannot un-revoke a deliberately disabled key as a side effect of a rotation. +Because `RotateAsync` clears `revoked_utc`, rotating a previously revoked key reactivates it. The dashboard API Keys page therefore offers the Rotate (and Revoke) actions only for keys whose status is `Active`; revoked keys instead show a Delete action that calls `DeleteAsync`, so an operator can permanently remove a revoked row without ever risking un-revocation as a side effect of a rotation. ### Audit trail @@ -217,7 +217,11 @@ constraints remain fully unconstrained after migration. Key ids are restricted by the parser to ASCII letters, digits, periods, and hyphens so they remain safe to embed in the token format and in URL paths used by administrative tooling. The CLI is not the only management surface: the dashboard API Keys page -creates, rotates, and revokes keys through the same `IApiKeyAdminStore`. See +creates, rotates, revokes, and deletes (revoked-only) keys through the same +`IApiKeyAdminStore`. Every destructive dashboard action is gated by a +confirmation dialog and emits its own audit event +(`dashboard-create-key`, `dashboard-rotate-key`, `dashboard-revoke-key`, +`dashboard-delete-key`). See [Gateway Dashboard Design](./GatewayDashboardDesign.md#api-keys-page). ## Scope Serialization diff --git a/docs/DashboardInterfaceDesign.md b/docs/DashboardInterfaceDesign.md index 274c7fa..237fdd9 100644 --- a/docs/DashboardInterfaceDesign.md +++ b/docs/DashboardInterfaceDesign.md @@ -287,7 +287,12 @@ Use this checklist when applying the design to another project: - Use dashed bordered empty states for loading and no-data cases. - Use top-bordered sections for page groups instead of nested cards. - Centralize formatting and redaction outside Razor markup. -- Keep the dashboard read-only until admin workflows have a separate design. +- Hide every destructive admin affordance from viewers; render it only for + the `Admin` role and re-check the role server-side on every invocation. +- Route every destructive action (Close session, Kill worker, Rotate / + Revoke / Delete API key) through the shared `ConfirmDialog` component so + the operator always gets one explicit confirmation step before the call + reaches the service. ## Related Documentation diff --git a/docs/GatewayDashboardDesign.md b/docs/GatewayDashboardDesign.md index 158caad..1d442f3 100644 --- a/docs/GatewayDashboardDesign.md +++ b/docs/GatewayDashboardDesign.md @@ -241,10 +241,20 @@ Show: - active server handles and item counts if gateway shadow state has them, - latest faults, - last heartbeat payload, -- close/kill controls only if admin actions are later enabled. +- admin Close session / Kill worker controls (Admin role only). -For v1, details should be read-only unless an explicit admin action design is -added. +The Sessions list, the Workers list, and this details page all render the same +admin controls when the signed-in principal carries the `Admin` role; viewers +and the localhost-anonymous bypass see no action affordances and the server +re-checks the role on every invocation. Every destructive admin action is +gated by a confirmation dialog before it reaches `ISessionManager`. + +- **Close session** routes through `ISessionManager.CloseSessionAsync`: the + worker is asked to shut down gracefully and is killed only as a fallback if + shutdown fails. +- **Kill worker** routes through `ISessionManager.KillWorkerAsync`: the worker + is killed immediately with no graceful-shutdown attempt. The session is + removed from the registry and the open-session slot is released either way. ### Workers page @@ -346,29 +356,39 @@ for what each constraint means and how it is enforced on the gRPC path. #### Management actions -Create, Rotate, and Revoke controls render only when the signed-in user is -authorized. `DashboardApiKeyAuthorization.CanManage` requires an authenticated -principal carrying the `Admin` role claim (resolved at login from the user's -LDAP groups via `MxGateway:Dashboard:GroupToRole`). A `Viewer` role can read -the table but sees no action controls, and an anonymous localhost session -shows the same read-only view. +Create, Rotate, Revoke, and Delete controls render only when the signed-in +user is authorized. `DashboardApiKeyAuthorization.CanManage` requires an +authenticated principal carrying the `Admin` role claim (resolved at login +from the user's LDAP groups via `MxGateway:Dashboard:GroupToRole`). A +`Viewer` role can read the table but sees no action controls, and an +anonymous localhost session shows the same read-only view. - **Create** opens a dialog for the key id, display name, scope checkboxes (the `GatewayScopes` catalog), and the optional constraint fields: read and write subtrees, read and write tag globs, browse subtrees, max write classification, and the read-alarm-only / read-historized-only flags. - **Rotate** issues a new secret for an existing key id and invalidates the - old one. + old one. Active keys only — rotating a revoked key would un-revoke it, so + the button is not shown on revoked rows. - **Revoke** marks a key revoked; a revoked key cannot be un-revoked. +- **Delete** permanently removes a key row from the auth database, but only + when the key is already revoked. `IApiKeyAdminStore.DeleteAsync` rejects + active keys (returns false) so the revoke event lands in the audit log + before the row disappears. Revoked rows show a Delete button in place of + the previous "No actions" placeholder. + +Every destructive action (Rotate / Revoke / Delete) is gated by the shared +`ConfirmDialog` component before reaching the service; Create uses its own +form modal as the implicit confirmation step. Create and Rotate return the assembled `mxgw__` token **once**, in a one-time banner. It is never shown again, so the operator must copy it immediately. This mirrors the `apikey create-key` / `rotate-key` CLI. Every management action appends an `api_key_audit` entry -(`dashboard-create-key`, `dashboard-rotate-key`, `dashboard-revoke-key`) with -the key id and the caller's remote address. Secrets and pepper values are never -logged. +(`dashboard-create-key`, `dashboard-rotate-key`, `dashboard-revoke-key`, +`dashboard-delete-key`) with the key id and the caller's remote address. +Secrets and pepper values are never logged. ### Settings page @@ -540,7 +560,7 @@ The first dashboard slice implements: 2. local Bootstrap static assets. 3. dashboard configuration binding. 4. dashboard auth using LDAP bind + role-mapped HTTP-only cookie. -5. read-only `DashboardSnapshotService`. +5. `DashboardSnapshotService` projecting gateway state for read views. 6. home page with metric cards. 7. sessions page with active session table and session details. 8. workers page with worker table. @@ -550,6 +570,12 @@ The first dashboard slice implements: 12. route-mapping tests, disabled-dashboard tests, auth tests, and snapshot projection/redaction tests. +Subsequent slices added Admin-gated destructive actions: API-key +Create/Rotate/Revoke (and Delete on revoked keys), and session/worker +Close/Kill via `IDashboardSessionAdminService` → `ISessionManager`. Every +destructive action passes through the shared `ConfirmDialog` component +before reaching its service. + ## Related Documentation - [Dashboard Interface Design](./DashboardInterfaceDesign.md) diff --git a/docs/Sessions.md b/docs/Sessions.md index fb502ce..e5b8534 100644 --- a/docs/Sessions.md +++ b/docs/Sessions.md @@ -49,7 +49,14 @@ public void TransitionTo(SessionState nextState) ### SessionManager (ISessionManager) -`SessionManager` is the orchestrator. It exposes `OpenSessionAsync`, `TryGetSession`, `InvokeAsync`, `ReadEventsAsync`, `CloseSessionAsync`, `CloseExpiredLeasesAsync`, and `ShutdownAsync`. It composes `ISessionRegistry`, `ISessionWorkerClientFactory`, `GatewayMetrics`, and `GatewayOptions`. +`SessionManager` is the orchestrator. It exposes `OpenSessionAsync`, `TryGetSession`, `InvokeAsync`, `ReadEventsAsync`, `CloseSessionAsync`, `KillWorkerAsync`, `CloseExpiredLeasesAsync`, and `ShutdownAsync`. It composes `ISessionRegistry`, `ISessionWorkerClientFactory`, `GatewayMetrics`, and `GatewayOptions`. + +`CloseSessionAsync` and `KillWorkerAsync` are both end-of-life paths but differ in what they offer the worker: + +- `CloseSessionAsync` is the graceful path: it calls `GatewaySession.CloseAsync`, which asks the worker to shut down via `IWorkerClient.ShutdownAsync` and only kills the process as a fallback if shutdown fails. +- `KillWorkerAsync` is the forceful path used by the dashboard's admin Kill button: it calls `GatewaySession.KillWorker` directly, which kills the worker process immediately with no graceful-shutdown attempt and transitions the session to `Closed`. + +Both paths converge on the same registry/metrics cleanup, so the open-session slot is released and `mxgateway.sessions.closed` is incremented either way. Concurrency is bounded by a `SemaphoreSlim` initialized to `GatewayOptions.Sessions.MaxSessions`. Open requests that exceed the bound throw `SessionManagerException` with `SessionLimitExceeded` rather than queuing; the caller is expected to retry. @@ -220,7 +227,7 @@ if (_workerClient is not null) If both graceful shutdown and the kill fall-back fail, the original and kill exceptions are bundled into an `AggregateException` and surfaced as `SessionCloseStartedException`. `SessionManager.CloseSessionCoreAsync` then translates that into a `SessionManagerException` with `CloseFailed` and removes the session. -`GatewaySession.KillWorker` is the unconditional forced-close path used by shutdown when graceful close itself throws. +`GatewaySession.KillWorker` is the unconditional forced-close path used by shutdown when graceful close itself throws, and also by `SessionManager.KillWorkerAsync` — the explicit kill path that the dashboard's admin Kill button invokes. `KillWorkerAsync` skips `WorkerClient.ShutdownAsync` entirely, so `KillCount` increments while `ShutdownCount` does not; the session is then removed from the registry and the open-session slot is released, identical to the cleanup that follows a successful `CloseSessionAsync`. ## Shutdown Coordination