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) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-24 07:35:25 -04:00
parent 24cc5fd0f0
commit e80f3c70b6
4 changed files with 62 additions and 20 deletions
+7 -3
View File
@@ -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
+6 -1
View File
@@ -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
+40 -14
View File
@@ -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_<keyId>_<secret>` 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)
+9 -2
View File
@@ -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