feat: add operator dashboard - #37
Merged
Merged
Conversation
The runtime writes seven tables and exposes none of them. An operator who wants to know why a message has not run reads SQL, and the only administration surfaces are two engine controllers and a handful of CLI commands. SolidObjects::Web is a mountable Rack application over those tables: instances and their committed state, the ready and claimed mailbox, reminders, effects, broadcasts, dead letters, and processes. It is a separate require, because a worker process must not carry a web stack. Authorization is the reason it is a router rather than a set of controllers. Every route declares the administration policy it needs and a route without one raises at load time, so a page added later cannot reach the actor tables before somebody says who may read it. The deny-by-default posture is enforced by construction rather than by remembering a filter. It changes only two things. Retrying a dead letter goes through DeadLetterManager, which is idempotent; a retry the mailbox refuses renders the reason rather than a 500. Pausing an instance sets paused_at so the activation manager stops claiming it, which is an operator brake and not a stop: a pass in flight finishes its turn and a synchronous caller waiting on that instance times out. Charts come from Chart.js on a CDN with a subresource integrity hash, and that host is the only external origin the policy names. A deployment without outbound network access vendors the file or turns charts off. Rack is now an explicit dependency at >= 3.1 because the dashboard writes lowercase response headers.
Greptile SummarySolidObjects::Web adds a separately loaded, mountable Rack operator dashboard for inspecting actor-runtime tables and performing narrowly authorized administrative actions.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains. Important Files Changed
Sequence DiagramsequenceDiagram
participant Operator
participant Rails as Rails Router / Session
participant Web as SolidObjects::Web
participant CSRF as CSRF Protection
participant Policy as Administration Policy
participant Runtime as Runtime Tables
Operator->>Rails: Dashboard request
Rails->>Web: Mounted Rack request with session
Web->>CSRF: Validate state-changing request
CSRF->>Policy: Forward accepted request
Policy-->>Web: Allow or deny route action/resource
alt Authorized
Web->>Runtime: Read data or perform narrow action
Runtime-->>Web: Result
Web-->>Operator: HTML, JSON, or redirect
else Denied
Web-->>Operator: 403 Forbidden
end
Reviews (2): Last reviewed commit: "fix: keep the CSRF secret for the sessio..." | Re-trigger Greptile |
Comment on lines
+67
to
+70
| # The session token is replaced whether or not this comparison | ||
| # succeeds, so a token cannot be replayed after it is spent. | ||
| session[:csrf] = SecureRandom.base64(TOKEN_BYTES) | ||
| matches?(token, stored) |
There was a problem hiding this comment.
Session rotation invalidates valid forms
When an operator submits one of several forms rendered from the same session secret, valid? replaces that secret before comparison, causing every other form already open on the page or in another tab to receive 403 until it is reloaded.
Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/solid_objects/web/csrf_protection.rb
Line: 67-70
Comment:
**Session rotation invalidates valid forms**
When an operator submits one of several forms rendered from the same session secret, `valid?` replaces that secret before comparison, causing every other form already open on the page or in another tab to receive 403 until it is reloaded.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.The dead-letter list renders one Retry form per row, and a browser keeps pages open in other tabs. Rotating the session secret on the first submission answered 403 to every other form the same page had already rendered, so retrying a second dead letter appeared to be forbidden until the operator reloaded. Single use is not what a CSRF token provides. It proves the request came from a page this session was served; the per-request mask is what keeps the value on the wire from repeating, which is the property rotation looked like it was adding. The mount check now forges a token of the right length so it reaches the comparison rather than being turned away by the length check, which is what a wrong-but-well-formed token would do.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
SolidObjects::Webis a mountable Rack dashboard over the runtime tables:instances and their committed state, the ready and claimed mailbox, reminders,
effects, broadcasts, dead letters, and processes.
It is a separate require. A worker process must not carry a web stack, and
test/integration/load_contract_test.rbrecords each new file as deliberatelydeferred.
API
New public surface:
SolidObjects::Web.call(the Rack entry point),.registerfor extension tabs, routes and view directories,.usefor Rackmiddleware, and
.chart_library_url/.chart_library_integrity.rackbecomes an explicit dependency at>= 3.1. It already arrives withAction Pack in every supported Rails version; the floor is stated because the
dashboard writes lowercase response headers, which Rack 3 requires. The
web/directory is added to the gemspec file list.
No migration. Nothing in the runtime path changed.
Security
Every route declares the administration policy it needs, and
Router#routeraises at load time on a route without one. A page added later therefore cannot
reach the actor tables before an application has said who may read it, so the
deny-by-default posture is enforced by construction rather than by remembering
a filter. An unconfigured mount returns 403 everywhere.
authorization_context:is the request object, which answersrequest,sessionandenv, so a policy can read the signed-in operator the way acontroller does.
docs/dashboard.mdlists the action and resource of everypage.
Other boundaries:
request without a valid one gets 403.
default-src 'self', a per-request nonce, and nounsafe-inline. TheChart.js CDN host is the only external origin named, and only when charts are
enabled. Chart data travels in a
data-attribute, not an inline script.actor type would otherwise close the attribute.
query string.
matcheswithsanitize_sql_like.Correctness
Two write actions, both narrow:
DeadLetterManager, so it is idempotent.A retry the mailbox refuses, such as an actor class that no longer exists,
renders the reason with 422 rather than failing the request.
paused_at, which the activation manager alreadyhonours. This is an operator brake, not a stop: a pass already in flight
finishes its turn, and a synchronous caller waiting on a paused instance
times out rather than receiving a result. Both are stated on the page and in
the docs.
Not included: bulk retry, because
DeadLetterManagerexposes no bulkoperation, and audit records of who pressed what.
docs/roadmap.mdrecordsboth.
Cost
The summary bar issues one grouped count per subsystem on every page, and each
list page counts its own relation to page it. Instance counts per actor type
are one grouped query on the dashboard only, bounded to twelve rows. Actor type
suggestions come from the registry rather than a
DISTINCTno adapter cananswer from an index. Process rows count activated instances in one grouped
query rather than one per row.
HEAD /exists so an uptime monitor need notload a whole page. The roadmap records that this was reasoned about rather than
benchmarked.
Validation
rakecovers Minitest, Standard Ruby, RuboCop, RBS generation and validation,Steep, and Brakeman; all clean. The 14 skips are the pre-existing PostgreSQL
and MySQL suites that skip on SQLite; this change adds none.
Coverage worth naming:
test/integration/web_mount_test.rbdrives the dashboard through a realRails router in a subprocess, not a mock environment: the mount supplies
SCRIPT_NAME, the Rails session middleware supplies the session CSRF needs,and a dashboard nested below the engine mount is only reachable because the
engine cascades a path it does not serve. It asserts a forged token is
refused and changes nothing, and that a valid one redirects to the
mount-prefixed path and applies the write.
format the same number identically.
Every significant behaviour was mutation tested: authorization, CSRF, HTML
escaping, pause, retry, filtering, paging, formatting, chart data and the chart
container each fail when the implementation is removed.
Three defects were found this way and fixed:
so a replacement page was unreachable.
built stack is memoized.
bare 500.
Not verified
A chart that grows on repeated redraws was reported during development. The
canvas is now in a dedicated sized container with
position: absolute, whichis what Chart.js requires and makes the resize feedback loop structurally
impossible. I could not reproduce the original growth headlessly at
devicePixelRatio: 2over 60 resize and update rounds, so the fix isstructural rather than confirmed against a reproduction.