diff --git a/CHANGELOG.md b/CHANGELOG.md index 658422b..e1e4273 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +## Unreleased + +- Add `SolidObjects::Web`, a mountable Rack dashboard for the actor runtime. + It covers instances and their committed state, the ready and claimed + mailbox, reminders, effects, broadcasts, dead letters, and processes, with + actor-type and actor-id filtering, status filters, paging, and a polled + `GET /stats` endpoint. Mount it with + `mount SolidObjects::Web => "/solid_objects/dashboard"` after + `require "solid_objects/web"`; requiring the gem does not load it, so a + worker process carries no web stack. +- Authorize every dashboard route through `authorize_administration`. Each + route declares its own `action` and `resource`, and a route declared without + a policy raises at load time. The policy receives a context that answers + `request`, `session`, and `env`. +- Add two dashboard actions: an idempotent dead letter retry through + `SolidObjects.dead_letters.retry`, and instance pause/resume, which sets and + clears `paused_at` so the activation manager stops claiming that identity. A + retry the mailbox refuses, such as an actor class that no longer exists, + renders the reason with a 422 rather than failing the request. +- Draw instances per actor type, mailbox depth, and outbox and reminder status + with Chart.js, loaded from a CDN with a subresource integrity hash. The CDN + host is the only external origin the content security policy names. Point + `SolidObjects::Web.chart_library_url` at a vendored copy for a deployment + with no outbound network access, or set it to nil to render without charts. +- Add `SolidObjects::Web.register` for extension tabs, routes, and view + directories, and `SolidObjects::Web.use` for Rack middleware in front of the + dashboard. +- Add `rack` as an explicit dependency at `>= 3.1`, and package the `web/` + directory in the gem. + ## 0.13.0 - 2026-08-13 - **Breaking:** make observables invalidation-only by default. An ordinary diff --git a/Gemfile.lock b/Gemfile.lock index ee653c9..68e1420 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -7,6 +7,7 @@ PATH actionview (>= 8.0) activerecord (>= 8.0) activesupport (>= 8.0) + rack (>= 3.1) railties (>= 8.0) thor (>= 1.3) diff --git a/README.md b/README.md index 3d26f8a..964d7b6 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ tested, but the project does not yet claim production readiness. See - [State migrations](#state-migrations) - [Configuration](#configuration) - [Workers and operations](#workers-and-operations) +- [Dashboard](#dashboard) - [Database support](#database-support) - [Guarantees](#guarantees) - [When to use it](#when-to-use-it) @@ -1084,6 +1085,50 @@ an initializer. See the [operations guide](docs/operations.md) for monitoring, reconciliation, shutdown, retention, and backup guidance. +## Dashboard + +`SolidObjects::Web` is a Rack application that shows instances and their state, +the mailbox, reminders, effects, broadcasts, dead letters, and the registered +processes. Mount it inside the application routes, so the Rails session +middleware runs first: + +```ruby +# config/routes.rb +require "solid_objects/web" + +Rails.application.routes.draw do + mount SolidObjects::Web => "/solid_objects/dashboard" +end +``` + +It is not loaded by `require "solid_objects"`: a worker process must not carry +a web stack. The dashboard and the engine are separate mounts, so an +application that uses reactive ERB mounts both on different paths. + +Every page asks `authorize_administration` before its handler runs, and that +policy denies by default, so a mount alone exposes nothing. The block receives +the route's own `action:` and `resource:`, and an `authorization_context:` that +answers `request`, `session`, and `env`. + +The dashboard changes only two things. Retrying a dead letter goes through +`SolidObjects.dead_letters.retry`, which is idempotent. Pausing an instance +sets `paused_at` so the activation manager stops claiming that identity; a pass +already in flight finishes its turn, and a synchronous caller waiting on a +paused instance times out rather than receiving a result. + +The dashboard draws instances per actor type, mailbox depth, and outbox status +with Chart.js, loaded from a CDN with a subresource integrity hash. A +deployment with no outbound network access can vendor the file, or turn the +charts off: + +```ruby +SolidObjects::Web.chart_library_url = "/javascripts/chart.umd.min.js" +SolidObjects::Web.chart_library_integrity = nil +``` + +Read the [dashboard guide](docs/dashboard.md) for the full policy table, +extension registration, and query cost. + ## Database support Solid Objects supports: diff --git a/docs/authorization.md b/docs/authorization.md index fd5ca97..da1df7d 100644 --- a/docs/authorization.md +++ b/docs/authorization.md @@ -13,7 +13,7 @@ intentionally inert until the host application defines its trust boundary. | `authorize_query` | Attribute reads, declared queries, committed snapshots, scalar observable reads, initial component rendering, and every component refresh dependency | Explicit call context, the context passed to `solid_object`, or the request context resolved for a component refresh | Actor state or personalized projections can leak across users or tenants | | `authorize_destroy` | `reference.destroy` | Value passed as `authorization_context:` | Complete actor state, mailbox, reminders, and pending outboxes can be deleted | | `authorize_subscription` | Action Cable subscription to one actor stream | The `ActionCable::Connection` object | Clients can receive future observable updates for other actors | -| `authorize_administration` | Engine administration controllers, process inspection/cleanup/pruning, message pruning, and dead-letter inspection/retry | Rails controller or `{ source: "cli" }` | Operational metadata, arguments, errors, deletion, and retries become exposed or mutable | +| `authorize_administration` | Engine administration controllers, every `SolidObjects::Web` page, process inspection/cleanup/pruning, message pruning, and dead-letter inspection/retry | Rails controller, a `SolidObjects::Web` request that answers `request`/`session`/`env`, or `{ source: "cli" }` | Operational metadata, arguments, errors, deletion, and retries become exposed or mutable | Waiting again through `MessageReference#wait` reauthorizes the stored invocation as a message or query. Internal reminder, effect-callback, and @@ -154,6 +154,13 @@ configuration.authorize_administration = lambda do |authorization_context:, **| end ``` +That policy also denies every `SolidObjects::Web` page, which is the correct +result for a host whose only administration boundary is shell access. A policy +that opens the dashboard should separate reading from writing, because +`action` distinguishes them: `index` and `show` read, while `pause`, `resume`, +and `retry` change the runtime. The [dashboard guide](dashboard.md) lists the +action and resource of every page. + Run `bin/rails solid_objects:doctor` after configuration. Its neutral policy probe is deliberately conservative: a context-aware policy may correctly warn because it denies a `nil` context. diff --git a/docs/dashboard.md b/docs/dashboard.md new file mode 100644 index 0000000..395c9a5 --- /dev/null +++ b/docs/dashboard.md @@ -0,0 +1,200 @@ +# Operator dashboard + +`SolidObjects::Web` is a Rack application that shows what the actor runtime is +doing: instances and their state, the mailbox, reminders, effects, broadcasts, +dead letters, and the registered processes. It reads the same tables the +runtime writes, so it needs no separate store and no agent. + +It is deliberately not loaded by `require "solid_objects"`. A worker process +must not carry a web stack, and an application that never mounts the dashboard +must not pay for it. + +## Mounting + +```ruby +# config/routes.rb +require "solid_objects/web" + +Rails.application.routes.draw do + mount SolidObjects::Web => "/solid_objects/dashboard" +end +``` + +Mount it inside the application routes so the Rails session middleware runs +first. The dashboard needs a Rack session for CSRF protection and refuses a +state changing request without one. + +The dashboard and the engine are separate mounts. Mount the engine as well if +the application uses reactive ERB, and give each one its own path: + +```ruby +mount SolidObjects::Engine => "/solid_objects" +mount SolidObjects::Web => "/solid_objects/dashboard" +``` + +In a bare Rack application, supply the session middleware yourself: + +```ruby +use Rack::Session::Cookie, secret: ENV.fetch("SESSION_SECRET"), same_site: true +run SolidObjects::Web +``` + +## Authorization + +Every page asks `configuration.authorize_administration` before its handler +runs. That policy denies by default, so a mount alone exposes nothing. A route +declared without a policy raises at load time, which is why a new page cannot +reach the actor tables before an application has said who may read it. + +The block receives the route's own action and resource: + +| Page | `action` | `resource` | `resource_id` | +| --- | --- | --- | --- | +| Dashboard, `GET /stats`, `HEAD /` | `index` | `dashboard` | none | +| Instance list | `index` | `instances` | none | +| Instance detail | `show` | `instances` | instance id | +| Pause an instance | `pause` | `instances` | instance id | +| Resume an instance | `resume` | `instances` | instance id | +| Mailbox | `index` | `messages` | none | +| Message detail | `show` | `messages` | message id | +| Reminders | `index` | `reminders` | none | +| Effects | `index` | `effects` | none | +| Broadcasts | `index` | `broadcasts` | none | +| Dead letter list | `index` | `dead_letters` | none | +| Dead letter detail | `show` | `dead_letters` | dead letter id | +| Retry a dead letter | `retry` | `dead_letters` | dead letter id | +| Processes | `index` | `processes` | none | + +`authorization_context:` is the request object. It answers `request`, +`session`, and `env`, so a policy can read the signed-in operator the same way +a controller does: + +```ruby +SolidObjects.configure do |configuration| + configuration.authorize_administration = lambda do |action:, authorization_context:, **| + return false unless authorization_context.respond_to?(:session) + + operator = Operator.find_by(id: authorization_context.session[:operator_id]) + return false unless operator&.administrator? + + action == "index" || action == "show" || operator.may_write_runtime? + end +end +``` + +The command line reaches the same policy with `{ source: "cli" }` rather than +a request, which is why the example checks what the context answers before +reading a session from it. + +## Pages + +**Dashboard.** Totals per subsystem, the registered processes, and the most +recent dead letters. The summary bar appears on every page and can poll +`GET /stats` for the same numbers; nothing else on the page refreshes, because +a table that reloads under an operator who is reading it is worse than a stale +one. + +**Instances.** Filter by actor type and by an actor id substring. Each row +shows the lease state: `idle`, `activated`, `expired`, or `paused`. The detail +page shows committed state, the ready and claimed mailbox, message history, +reminders, effects, broadcasts, and dead letters for that identity. + +**Mailbox.** The ready and claimed messages across every identity, oldest +first. Mailbox lag on the summary bar is the age of the oldest message that is +already due, which is how far behind the workers are. + +**Reminders, effects, broadcasts, processes.** Status filtered lists. + +## Charts + +The dashboard draws three charts: instances per actor type, mailbox depth, and +a stacked view of effects, broadcasts, and reminders by status. Each canvas +carries its own numbers in a `data-chart-values` attribute, so the page needs +no inline script and no request to draw. Mailbox depth and the status chart +redraw when the Live poller reports new totals, because `/stats` already +carries those numbers. The instance chart does not: `/stats` does not group by +actor type, and adding that would put a `GROUP BY` on every poll. + +Chart.js comes from a CDN with a subresource integrity hash, so a compromised +CDN cannot substitute other code, and the CDN host is the only external origin +the content security policy names. + +A deployment with no outbound network access should vendor the file: + +```ruby +SolidObjects::Web.chart_library_url = "/javascripts/chart.umd.min.js" +SolidObjects::Web.chart_library_integrity = nil +``` + +A path below the mount is served from the dashboard's own asset directory and +needs no policy exception. Setting the URL to `nil` renders the dashboard +without charts and names no external origin at all. + +Set these before the first request. The middleware stack and the compiled +templates are built once and cached. + +**Dead letters.** The exception, its message, and its backtrace, with a retry +button. + +## Actions + +The dashboard changes only two things. + +**Retry a dead letter** goes through `SolidObjects.dead_letters.retry`, which +enqueues the original operation under an idempotency key. Pressing it twice +produces one message rather than two. + +A retry re-enters the mailbox, which refuses work the runtime cannot accept: an +actor class that no longer exists, a full mailbox, a payload over the cap. The +dashboard renders the dead letter again with the reason and a 422 status, +rather than failing the request. + +**Pause an instance** sets `paused_at`, and the activation manager stops +claiming that identity. Two consequences matter: + +- A pass already in flight finishes its turn. Pause is not a stop. +- A synchronous caller waiting on a paused instance times out rather than + receiving a result, because nothing will execute its message. + +Resume clears the column and the mailbox drains in sequence order. + +## Extensions + +An extension adds pages by declaring routes on the application class. Its +routes carry an authorization policy like every other route: + +```ruby +module Tenants + def self.registered(application) + application.get "/tenants", policy: { action: "index", resource: "tenants" } do + @tenants = Tenant.order(:name) + erb(:tenants) + end + end +end + +SolidObjects::Web.register( + Tenants, + tab: "Tenants", + path: "/tenants", + views: File.expand_path("../web/views", __dir__) +) +``` + +A registered view directory is searched before the packaged one, so an +application can replace a single page without forking the gem. A template +reads its arguments from `locals`, and a replacement `layout.erb` renders the +page it wraps with `locals.fetch(:content)`. + +Add Rack middleware in front of the dashboard with `SolidObjects::Web.use`, +for example to require HTTP basic authentication in an environment that has no +session-backed operator. + +## Cost + +The summary bar issues one grouped count per subsystem on every page, and each +list page counts its own relation to page it. That is a fixed set of indexed +aggregate queries, not a scan proportional to actor traffic, but it is not +free: do not put the dashboard behind an uptime monitor that loads the whole +page on an interval. `HEAD /` exists for that. It touches one table and +returns no body. diff --git a/docs/roadmap.md b/docs/roadmap.md index 6fab961..e29138c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -108,8 +108,25 @@ handing the block a raw Cable connection. - Backpressure: mailbox/payload/state/result caps and fair yields exist; distributed per-actor rate limits and global admission control do not. -- Administration: actor and dead-letter views plus policy hooks exist; richer - filtering, audit records, and bulk-safe tools do not. +- Administration: `SolidObjects::Web` is a mountable Rack dashboard covering + instances, mailbox, reminders, effects, broadcasts, dead letters, and + processes, with actor-type and actor-id filtering, status filters, paging, a + polled stats endpoint, Chart.js charts, and extension registration. The chart + library is fetched from a CDN with a subresource integrity hash, which a + deployment without outbound network access must replace with a vendored copy + or turn off. Every route declares its + own administration policy and a route declared without one raises at load + time, so the deny-by-default posture is enforced by construction rather than + by remembering to add a check. It changes only two things: an idempotent dead + letter retry and instance pause/resume. What does not exist is audit records + of who pressed what, and bulk-safe tools: retry is one dead letter at a time, + because `DeadLetterManager` exposes no bulk operation. Pause is an operator + brake and not a stop, since a pass already in flight finishes its turn and a + synchronous caller waiting on a paused instance times out. The page cost was + reasoned about rather than measured: the summary bar issues a fixed set of + indexed aggregate queries per page, which is why `HEAD /` exists for uptime + monitors, but no dashboard latency has been benchmarked against a large + table. ## Next milestones diff --git a/lib/solid_objects/web.rb b/lib/solid_objects/web.rb new file mode 100644 index 0000000..e8f43e0 --- /dev/null +++ b/lib/solid_objects/web.rb @@ -0,0 +1,222 @@ +# rbs_inline: enabled + +require "erb" +require "json" +require "securerandom" +require "uri" +require "rack" +require "rack/builder" +require "rack/static" + +require "solid_objects" +require "solid_objects/web/route" +require "solid_objects/web/router" +require "solid_objects/web/statistics" +require "solid_objects/web/paginator" +require "solid_objects/web/helpers" +require "solid_objects/web/action" +require "solid_objects/web/csrf_protection" +require "solid_objects/web/application" + +module SolidObjects + # An operator dashboard for the actor runtime, served as a Rack application: + # + # Rails.application.routes.draw do + # mount SolidObjects::Web => "/solid_objects" + # end + # + # It is deliberately not loaded by `require "solid_objects"`. A worker + # process must not carry a web stack, and an application that never mounts + # the dashboard must not pay for it. + # + # Every page asks `configuration.authorize_administration` first. That block + # denies by default, so a mount alone exposes nothing until an application + # states who may read it. + class Web + ROOT = File.expand_path("../../web", __dir__) + VIEWS = File.join(ROOT, "views") + ASSETS = File.join(ROOT, "assets") + + NONCE_KEY = "solid_objects.content_security_policy_nonce" + CSRF_TOKEN_KEY = "solid_objects.csrf_token" + NONCE_BYTES = 16 + ASSET_CACHE_SECONDS = 86_400 + TEMPLATE_NAME = /\A_?[a-z][a-z0-9_]*\z/ + + # The dashboard charts need a charting library, and this one is fetched + # from a public CDN with a subresource integrity hash, so a compromised CDN + # cannot substitute other code. A deployment with no outbound network + # access should vendor the file and point `chart_library_url` at it, or set + # that to nil to render the dashboard without charts. + CHART_LIBRARY_URL = "https://cdn.jsdelivr.net/npm/chart.js@4.5.0/dist/chart.umd.min.js" + CHART_LIBRARY_INTEGRITY = "sha384-XcdcwHqIPULERb2yDEM4R0XaQKU3YnDsrTmjACBZyfdVVqjh6xQ4/DCMd7XLcA6Y" + + DEFAULT_TABS = { + "Dashboard" => "/", + "Instances" => "/instances", + "Mailbox" => "/mailbox", + "Reminders" => "/reminders", + "Effects" => "/effects", + "Broadcasts" => "/broadcasts", + "Dead letters" => "/dead_letters", + "Processes" => "/processes" + }.freeze + + LOCK = Mutex.new + + class << self + # A path below the mount serves a vendored copy; an absolute URL is + # fetched from that host and is named in the content security policy. + # nil renders the dashboard without charts. + # @rbs @chart_library_url: String? + # @rbs @chart_library_integrity: String? + attr_writer :chart_library_url, :chart_library_integrity + + # @rbs () -> String? + def chart_library_url + defined?(@chart_library_url) ? @chart_library_url : CHART_LIBRARY_URL + end + + # @rbs () -> String? + def chart_library_integrity + defined?(@chart_library_integrity) ? @chart_library_integrity : CHART_LIBRARY_INTEGRITY + end + + # @rbs () -> bool + def charts? + !chart_library_url.nil? + end + + # The origin the content security policy has to allow. A vendored copy + # served from the mount has none, so the policy stays at 'self'. + # @rbs () -> String? + def chart_library_origin + url = chart_library_url + return nil unless url&.include?("//") + + uri = URI.parse(url) + return nil unless uri.scheme && uri.host + + "#{uri.scheme}://#{uri.host}" + rescue URI::InvalidURIError + nil + end + + # @rbs () -> Hash[String, String] + def tabs + @tabs ||= DEFAULT_TABS.dup + end + + # Searched in order, so an extension directory added first wins over the + # packaged one and an application can replace a single page. + # @rbs () -> Array[String] + def views + @views ||= [ VIEWS ] + end + + # @rbs () -> Array[[Array[untyped], Proc?]] + def middlewares + @middlewares ||= [] + end + + # The built stack is memoized, so a middleware added after the first + # request would otherwise be dropped without a word. + # @rbs (*untyped) ?{ () -> untyped } -> void + def use(*arguments, &block) + middlewares << [ arguments, block ] + LOCK.synchronize { @application = nil } + end + + # Adds pages to the dashboard. The extension receives the application + # class and declares its own routes on it, which means its routes carry + # an authorization policy like every other route. + # + # @rbs (untyped, tab: String, path: String, ?views: String?) -> void + def register(extension, tab:, path:, views: nil) + if views + self.views.unshift(views) + # A template compiled before this call resolved against the old + # search path, so a replacement view would never be reached. + LOCK.synchronize { @templates = {} } + end + tabs[tab] = path + extension.registered(Application) + end + + # @rbs (Symbol) -> untyped + def template(name) + LOCK.synchronize do + templates[name] ||= ERB.new(File.read(template_path(name)), trim_mode: "-") + end + end + + # @rbs () -> void + def reset! + LOCK.synchronize { @templates = {} } + @tabs = nil + @views = nil + @middlewares = nil + @application = nil + remove_instance_variable(:@chart_library_url) if defined?(@chart_library_url) + remove_instance_variable(:@chart_library_integrity) if defined?(@chart_library_integrity) + end + + # @rbs (Hash[String, untyped]) -> Array[untyped] + def call(env) + application.call(env) + end + + # @rbs () -> Web + def application + LOCK.synchronize { @application ||= new } + end + + private + + # @rbs () -> Hash[Symbol, untyped] + def templates + @templates ||= {} + end + + # A template name never comes from a request, and this keeps it that way + # rather than trusting every future caller to know it. + # @rbs (Symbol) -> String + def template_path(name) + raise ArgumentError, "invalid template name" unless name.to_s.match?(TEMPLATE_NAME) + + found = views.lazy.map { |directory| File.join(directory, "#{name}.erb") }.find { |path| File.exist?(path) } + found || raise(ArgumentError, "no template named #{name}") + end + end + + # @rbs (Hash[String, untyped]) -> Array[untyped] + def call(env) + env[NONCE_KEY] = SecureRandom.base64(NONCE_BYTES) + app.call(env) + end + + # @rbs () -> untyped + def app + @app ||= build + end + + private + + # @rbs () -> untyped + def build + assets = ASSETS + extra = self.class.middlewares + + ::Rack::Builder.new do + use ::Rack::Static, + urls: [ "/stylesheets", "/javascripts" ], + root: assets, + cascade: true, + header_rules: [ [ :all, { "cache-control" => "private, max-age=#{ASSET_CACHE_SECONDS}" } ] ] + extra.each { |arguments, block| use(*arguments, &block) } + use CsrfProtection + run Application.new + end + end + end +end diff --git a/lib/solid_objects/web/action.rb b/lib/solid_objects/web/action.rb new file mode 100644 index 0000000..abb3dd2 --- /dev/null +++ b/lib/solid_objects/web/action.rb @@ -0,0 +1,109 @@ +# rbs_inline: enabled + +require "erb" +require "rack/request" +require "rack/utils" + +module SolidObjects + class Web + # One request. A route handler runs inside an instance of this class, so a + # handler and the template it renders share the same helpers and the same + # request. + class Action + include Helpers + + # @rbs @env: Hash[String, untyped] + # @rbs @route: Route + # @rbs @captures: Hash[Symbol, String?] + # @rbs @request: untyped + # @rbs @layout_rendered: bool + # @rbs @response_status: Integer + + attr_reader :env, :route, :response_status + + # @rbs (env: Hash[String, untyped], route: Route) -> void + def initialize(env:, route:) + @env = env + @route = route + @captures = route.capture(env["PATH_INFO"].to_s) + @layout_rendered = false + @response_status = 200 + end + + # Sets the status a rendered page is served with. `halt` and `redirect` + # stop the handler, so a page that must render its own body and still + # report a failure needs this instead. + # @rbs (Integer) -> void + def status(code) + @response_status = code + end + + # @rbs () -> untyped + def request + @request ||= ::Rack::Request.new(env) + end + + # @rbs () -> Hash[untyped, untyped]? + def session + env["rack.session"] + end + + # @rbs (Symbol) -> String? + def route_params(key) + @captures[key] + end + + # @rbs (String) -> untyped + def url_params(key) + request.params[key] + end + + # @rbs () -> untyped + def call + instance_exec(&route.handler) + end + + # The layout is rendered once per request. The flag is raised before the + # page body runs so a partial the body renders returns its own fragment + # rather than a second whole page. The layout reads the page it wraps + # from `locals.fetch(:content)`. + # @rbs (Symbol, ?Hash[Symbol, untyped]) -> String + def erb(name, locals = {}) + return evaluate(Web.template(name), locals) if @layout_rendered + + @layout_rendered = true + content = evaluate(Web.template(name), locals) + evaluate(Web.template(:layout), { content: }) + end + + # @rbs (Integer, ?String) -> void + def halt(status, body = ::Rack::Utils::HTTP_STATUS_CODES.fetch(status, "Error")) + throw :halt, [ status, { "content-type" => "text/plain" }, [ body ] ] + end + + # @rbs (String) -> void + def redirect(path) + throw :halt, [ 302, { "location" => path_to(path) }, [] ] + end + + # @rbs (untyped) -> void + def json(payload) + throw :halt, [ + 200, + { "content-type" => "application/json", "cache-control" => "private, no-store" }, + [ JSON.generate(payload) ] + ] + end + + private + + # `locals` is a local variable of this method, so a template reads it by + # name, and every helper is reachable because the template runs against + # this object. + # @rbs (untyped, Hash[Symbol, untyped]) -> String + def evaluate(template, locals) + template.result(binding) + end + end + end +end diff --git a/lib/solid_objects/web/application.rb b/lib/solid_objects/web/application.rb new file mode 100644 index 0000000..26aeb4d --- /dev/null +++ b/lib/solid_objects/web/application.rb @@ -0,0 +1,239 @@ +# rbs_inline: enabled + +module SolidObjects + class Web + # The pages. Each route states the administration policy it needs, and + # `call` asks that policy before the handler runs, so a page cannot read + # the actor tables on behalf of an unauthorized request. + class Application + extend Router + + SCRIPT_PLACEHOLDER = "!script-src!" + CONTENT_SECURITY_POLICY = [ + "default-src 'self'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'none'", + "img-src 'self' data:", + "style-src 'self'", + "script-src #{SCRIPT_PLACEHOLDER}", + "connect-src 'self'", + "object-src 'none'" + ].join("; ").freeze + + MAILBOX_MEMBERSHIPS = %w[ready claimed].freeze + RECENT_LIMIT = 10 + CHART_TYPE_LIMIT = 12 + + head "/", policy: { action: "index", resource: "dashboard" } do + # The cheapest liveness check available: it proves the dashboard can + # reach the database the actors run on, and returns no body. + ReadyMessage.count + "" + end + + get "/", policy: { action: "index", resource: "dashboard" } do + @statistics = statistics.to_h + @processes = Process.order(last_heartbeat_at: :desc).limit(RECENT_LIMIT) + @dead_letters = DeadLetter.order(last_failed_at: :desc, id: :desc).limit(RECENT_LIMIT) + # The only chart that costs its own query, and the only one the poller + # cannot refresh, because the other two read what `/stats` already + # returns. Bounded so a runtime with many actor types draws a readable + # chart rather than every type it has ever seen. + @instances_by_type = Instance + .group(:actor_type) + .order(Arel.sql("COUNT(*) DESC")) + .limit(CHART_TYPE_LIMIT) + .count + erb(:dashboard) + end + + get "/stats", policy: { action: "index", resource: "dashboard" } do + json(statistics.to_h) + end + + get "/instances", policy: { action: "index", resource: "instances" } do + @paginator = paginate(filtered_instances) + # Suggestions come from the registry rather than a DISTINCT over the + # instances table, which no adapter can answer from an index. The field + # stays free text, so an actor type that is no longer registered is + # still reachable. + @actor_types = SolidObjects.registry.to_h.keys.sort + erb(:instances) + end + + get "/instances/:id", policy: { action: "show", resource: "instances" } do + @instance = find_instance + @ready_messages = @instance.messages + .where(id: ReadyMessage.select(:message_id)) + .order(sequence: :asc) + .limit(RECENT_LIMIT) + @claimed_messages = @instance.messages + .where(id: ClaimedMessage.select(:message_id)) + .order(sequence: :asc) + .limit(RECENT_LIMIT) + @recent_messages = @instance.messages.order(sequence: :desc).limit(RECENT_LIMIT) + @reminders = Reminder.where(instance_id: @instance.id).order(next_run_at: :asc).limit(RECENT_LIMIT) + @effects = Effect.where(instance_id: @instance.id).order(id: :desc).limit(RECENT_LIMIT) + @broadcasts = Broadcast.where(instance_id: @instance.id).order(id: :desc).limit(RECENT_LIMIT) + @dead_letters = DeadLetter.where(instance_id: @instance.id).order(last_failed_at: :desc).limit(RECENT_LIMIT) + erb(:instance) + end + + # Pausing stops the activation manager from claiming the instance again. + # A pass already in flight finishes its turn, and a synchronous caller + # waiting on this instance times out rather than being answered, so this + # is an operator brake and not a delivery guarantee. + post "/instances/:id/pause", policy: { action: "pause", resource: "instances" } do + instance = find_instance + instance.update!(paused_at: SolidObjects.database_adapter.database_now) + redirect("/instances/#{instance.id}") + end + + post "/instances/:id/resume", policy: { action: "resume", resource: "instances" } do + instance = find_instance + instance.update!(paused_at: nil) + redirect("/instances/#{instance.id}") + end + + get "/mailbox", policy: { action: "index", resource: "messages" } do + @membership = filter_value(MAILBOX_MEMBERSHIPS, default: "ready") + @paginator = paginate(mailbox_messages(@membership)) + erb(:mailbox) + end + + get "/messages/:id", policy: { action: "show", resource: "messages" } do + @message = Message.find_by(id: route_params(:id)) + halt(404) unless @message + erb(:message) + end + + get "/reminders", policy: { action: "index", resource: "reminders" } do + @status = filter_value(Statistics::REMINDER_STATUSES) + relation = Reminder.order(next_run_at: :asc, id: :asc) + @paginator = paginate(@status ? relation.where(status: @status) : relation) + erb(:reminders) + end + + get "/effects", policy: { action: "index", resource: "effects" } do + @status = filter_value(Statistics::EFFECT_STATUSES) + relation = Effect.order(id: :desc) + @paginator = paginate(@status ? relation.where(status: @status) : relation) + erb(:effects) + end + + get "/broadcasts", policy: { action: "index", resource: "broadcasts" } do + @status = filter_value(Statistics::BROADCAST_STATUSES) + relation = Broadcast.order(id: :desc) + @paginator = paginate(@status ? relation.where(status: @status) : relation) + erb(:broadcasts) + end + + get "/dead_letters", policy: { action: "index", resource: "dead_letters" } do + @paginator = paginate(DeadLetter.order(last_failed_at: :desc, id: :desc)) + erb(:dead_letters) + end + + get "/dead_letters/:id", policy: { action: "show", resource: "dead_letters" } do + @dead_letter = DeadLetter.find_by(id: route_params(:id)) + halt(404) unless @dead_letter + erb(:dead_letter) + end + + # A retry re-enters the mailbox, which refuses work the runtime cannot + # accept: an actor class that no longer exists, a full mailbox, a payload + # over the cap. The operator who pressed the button is told which, + # instead of being handed a bare 500 from the Rack handler. + post "/dead_letters/:id/retry", policy: { action: "retry", resource: "dead_letters" } do + SolidObjects.dead_letters.retry(route_params(:id).to_i, authorization_context: self) + redirect("/dead_letters") + rescue Unauthorized + raise + rescue SolidObjects::Error => error + @dead_letter = DeadLetter.find_by(id: route_params(:id)) + halt(404) unless @dead_letter + @error = "#{error.class.name.split("::").last}: #{error.message}" + status(422) + erb(:dead_letter) + end + + get "/processes", policy: { action: "index", resource: "processes" } do + @status = filter_value(Statistics::PROCESS_STATES) + relation = Process.order(last_heartbeat_at: :desc) + @paginator = paginate(@status ? relation.where(shutdown_state: @status) : relation) + # Counted in one grouped query rather than once per row, because this + # page is read while the runtime is already under load. + @activated_counts = Instance + .where(activation_owner_id: @paginator.records.map(&:id)) + .group(:activation_owner_id) + .count + erb(:processes) + end + + # @rbs (Hash[String, untyped]) -> Array[untyped] + def call(env) + route = self.class.match(env["REQUEST_METHOD"].to_s, env["PATH_INFO"].to_s) + return not_found unless route + + action = Action.new(env:, route:) + return forbidden unless authorized?(action) + + respond(action, catch(:halt) { action.call }) + rescue Unauthorized + forbidden + end + + private + + # @rbs (Action, untyped) -> Array[untyped] + def respond(action, result) + return result if result.is_a?(Array) + + [ action.response_status, page_headers(action.env), [ result.to_s ] ] + end + + # @rbs (Action) -> bool + def authorized?(action) + policy = action.route.policy + SolidObjects.configuration.authorize_administration.call( + action: policy.fetch(:action), + resource: policy.fetch(:resource), + resource_id: action.route_params(:id), + authorization_context: action + ) + end + + # @rbs (Hash[String, untyped]) -> Hash[String, String] + def page_headers(env) + { + "content-type" => "text/html; charset=utf-8", + "cache-control" => "private, no-store", + "content-security-policy" => content_security_policy(env), + "x-content-type-options" => "nosniff", + "referrer-policy" => "same-origin" + } + end + + # The chart host is named only when one is configured, so a deployment + # that vendors the library or turns charts off never advertises a third + # party origin it does not use. + # @rbs (Hash[String, untyped]) -> String + def content_security_policy(env) + sources = [ "'self'", "'nonce-#{env[Web::NONCE_KEY]}'", Web.chart_library_origin ].compact + CONTENT_SECURITY_POLICY.sub(SCRIPT_PLACEHOLDER, sources.join(" ")) + end + + # The cascade header lets a host application serve its own 404 for a path + # below the mount that the dashboard does not define. + # @rbs () -> Array[untyped] + def not_found + [ 404, { "content-type" => "text/plain", "x-cascade" => "pass" }, [ "Not Found" ] ] + end + + # @rbs () -> Array[untyped] + def forbidden + [ 403, { "content-type" => "text/plain" }, [ "Forbidden" ] ] + end + end + end +end diff --git a/lib/solid_objects/web/csrf_protection.rb b/lib/solid_objects/web/csrf_protection.rb new file mode 100644 index 0000000..02b0246 --- /dev/null +++ b/lib/solid_objects/web/csrf_protection.rb @@ -0,0 +1,130 @@ +# rbs_inline: enabled + +require "rack/request" +require "rack/utils" +require "securerandom" + +module SolidObjects + class Web + # A state changing request must carry the token of the session that asked + # for the form. The token a form receives is masked with a fresh one-time + # pad on every request, so the bytes on the wire differ each time and a + # compression side channel cannot recover the session token. + class CsrfProtection + SAFE_METHODS = %w[GET HEAD OPTIONS TRACE].freeze + TOKEN_BYTES = 32 + + MISSING_SESSION = <<~MESSAGE + SolidObjects::Web needs a Rack session for CSRF protection. + + Mount it inside the application routes so the Rails session middleware runs first: + + Rails.application.routes.draw do + mount SolidObjects::Web => "/solid_objects" + end + + In a bare Rack application, run a session middleware before it: + + use Rack::Session::Cookie, secret: ENV.fetch("SESSION_SECRET"), same_site: true + run SolidObjects::Web + MESSAGE + + # @rbs (untyped) -> void + def initialize(app) + @app = app + end + + # @rbs (Hash[String, untyped]) -> Array[untyped] + def call(env) + return forbidden unless accept?(env) + + session = session!(env) + session[:csrf] ||= SecureRandom.base64(TOKEN_BYTES) + env[Web::CSRF_TOKEN_KEY] = mask(session[:csrf]) + @app.call(env) + end + + private + + # @rbs (Hash[String, untyped]) -> bool + def accept?(env) + return true if SAFE_METHODS.include?(env["REQUEST_METHOD"]) + + valid?(env, ::Rack::Request.new(env).params["authenticity_token"]) + end + + # @rbs (Hash[String, untyped], String?) -> bool + def valid?(env, given) + return false if given.nil? || given.empty? + + session = session!(env) + stored = session[:csrf] + return false if stored.nil? + + token = decode(given) + return false unless token + + # The secret is not rotated here. A page renders one Retry form per + # dead letter, and a browser keeps pages open in other tabs, so + # spending the secret on the first submission would answer 403 to + # every other form already rendered. Single use is not what a CSRF + # token provides: it proves the request came from a page this session + # was served, and the per-request mask below is what keeps the value + # on the wire from repeating. + matches?(token, stored) + end + + # @rbs (String, String) -> bool + def matches?(token, stored) + candidate = case token.bytesize + when TOKEN_BYTES then token + when TOKEN_BYTES * 2 then unmask(token) + else return false + end + + ::Rack::Utils.secure_compare(candidate, decode(stored).to_s) + end + + # @rbs (String) -> String + def mask(token) + decoded = decode(token).to_s + pad = SecureRandom.random_bytes(decoded.bytesize) + encode(pad + exclusive_or(pad, decoded)) + end + + # @rbs (String) -> String + def unmask(masked) + half = masked.bytesize / 2 + exclusive_or(masked[0, half].to_s, masked[half..].to_s) + end + + # @rbs (String, String) -> String + def exclusive_or(left, right) + left.bytes.zip(right.bytes).map { |first, second| first ^ second.to_i }.pack("c*") + end + + # @rbs (String) -> String + def encode(token) + [ token ].pack("m0").tr("+/", "-_") + end + + # @rbs (String) -> String? + def decode(token) + decoded = token.tr("-_", "+/").unpack1("m0") + decoded.is_a?(String) ? decoded : nil + rescue ArgumentError + nil + end + + # @rbs (Hash[String, untyped]) -> Hash[untyped, untyped] + def session!(env) + env["rack.session"] || raise(MISSING_SESSION) + end + + # @rbs () -> Array[untyped] + def forbidden + [ 403, { "content-type" => "text/plain" }, [ "Forbidden" ] ] + end + end + end +end diff --git a/lib/solid_objects/web/helpers.rb b/lib/solid_objects/web/helpers.rb new file mode 100644 index 0000000..b178692 --- /dev/null +++ b/lib/solid_objects/web/helpers.rb @@ -0,0 +1,227 @@ +# rbs_inline: enabled + +require "json" +require "rack/utils" + +module SolidObjects + class Web + # The methods a view may call. Everything a template prints goes through + # `h`, because an actor id, an operation name, and an exception message are + # all application supplied strings that reach this page unchanged. + module Helpers + # Only these survive a page link. A filter an operator set stays set when + # they turn the page; anything else the query string carries does not + # come back. + FORWARDED_PARAMS = %w[actor_type actor_id status per_page].freeze + TRUNCATION_LIMIT = 2_000 + + # @rbs (untyped) -> String + def h(text) + ::Rack::Utils.escape_html(text.to_s) + end + + # @rbs () -> String + def root_path + env["SCRIPT_NAME"].to_s + end + + # @rbs (String) -> String + def path_to(path) + "#{root_path}#{path}" + end + + # @rbs () -> String + def current_path + request.path_info + end + + # @rbs (String) -> bool + def current_tab?(path) + return current_path == "/" if path == "/" + + current_path.start_with?(path) + end + + # @rbs () -> Hash[String, String] + def tabs + Web.tabs + end + + # @rbs () -> String? + def csp_nonce + env[Web::NONCE_KEY] + end + + # @rbs () -> String + def csrf_tag + %() + end + + # @rbs (String) -> String + def form_to(path) + %(