Securing the dashboard

Using the dashboard — its pages, mounting under a sub-path, CSP, plain HTTP: Dashboard guide.

Full guide: Authentication & authorization — Devise, Warden, Sorcery, Basic auth, API tokens, route gates, role-based read/write splits, the CSRF model, and what isn't gated. This page is the quick reference plus the web-extension surface.

The Wurk dashboard is a mountable Rails engine. It ships no session layer of its own — the first thing every production deploy does is gate it behind the host app's existing authentication, and Wurk gives you two hooks to do that without writing a custom Rack middleware.

Hook Use it for API
Wurk::Web.use Drop in any Rack auth middleware (Devise/Warden, Rack::Auth::Basic, a custom guard) in front of the dashboard. Wurk::Web.use(Middleware, *args, &block)
`Wurk::Web.configure { \ c\ c.authorization { … } }` A per-request (env, method, path) -> truthy/falsey check (e.g. role gating GET vs POST). see § Authorization hook

Both are Sidekiq-compatible: Sidekiq::Web.use(...) is aliased to Wurk::Web.use, so an existing Sidekiq initializer keeps working on the one-line gem swap.

The two compose. Wurk::Web.use middleware runs first (outermost), so by the time the authorization block runs, the host middleware has already populated env (e.g. env['warden']).

Scope: these hooks only affect requests routed under the engine mount. The host app's own controllers are untouched.

Wurk::Web.use

Call it once at boot, from an initializer. The chain is built on the first request and memoized, so register before the app starts serving.

# config/initializers/wurk.rb
Wurk::Web.use(MyAuthMiddleware, some: "arg") { |env| ... }

*args and the optional block pass straight through to the middleware's new. Multiple calls stack outermost-first. A middleware that returns a 401/403/redirect short-circuits the request — the dashboard never sees it.

Recipes

The worked middleware for each auth stack lives in one place — Authentication & authorization § Wurk::Web.use:

Authorization hook

For role logic that depends on the HTTP method or path (e.g. support staff may view but only admins may retry/kill), use the per-request hook instead of (or alongside) a use middleware:

Wurk::Web.configure do |c|
  c.authorization do |env, method, _path|
    user = env["warden"]&.user
    method == "GET" ? (user&.support? || user&.admin?) : user&.admin?
  end
end

A falsey return short-circuits to 403. path is engine-relative (/api/stats, not the host's absolute /wurk/api/stats), matching Sidekiq's contract. Path-scoped examples and how the SPA learns its own capability: Authentication & authorization § authorization.

Read-only mode

To ship a viewer-only deploy (e.g. a public demo) with no Ruby config, set WURK_WEB_READ_ONLY=1 — every mutating request 403s and the SPA hides destructive actions. Equivalent in Ruby:

Wurk::Web.configure { |c| c.read_only = true }

The banner message, the string coercion read_only= accepts, and what read-only does not protect: Authentication & authorization § Read-only mode.

Custom tabs / Web extensions

Third-party gems (sidekiq-cron, sidekiq-unique-jobs, sidekiq-status, …) add their own dashboard tabs through Sidekiq::Web::Config#register_extension (alias register). Wurk implements that surface under the Sidekiq::Web / Wurk::Web alias, so requiring those gems works unchanged:

register_extension(extclass, name:, tab:, index:, …) mirrors Sidekiq: tab is the label, index the path, name the asset namespace (tab/index may be arrays, zipped). Both call styles work — class-level, or inside a configure block:

Sidekiq::Web.register(MyGem::Web, name: "unique_jobs", tab: "Locks", index: "locks")

Sidekiq::Web.configure do |c|
  c.register_extension(MyGem::Web, name: "unique_jobs", tab: "Locks", index: "locks")
  c.tabs["Expiry"] = "expiry"          # the tabs hash (label => path) is directly mutable
  c.custom_job_info_rows << MyGem::Row # collected for job-detail rows
  c.app_url = "https://myapp.example"
end

A registered tab whose path isn't one Wurk already renders natively surfaces in the left-nav (read from GET /api/meta). Clicking it opens an in-dashboard Extension page that renders the extension's view natively: the extension's Sinatra-style routes and ERB views are executed server-side and the resulting static HTML is embedded in an open ShadowRoot. Extension styles (including stylesheet links) stay in that tree instead of selecting dashboard headings, buttons or navigation; dashboard element/class rules do not enter it. A local baseline styles tables, links and forms, resets inherited presentation, and intentionally uses the dashboard's theme tokens for typography and colors. It does not import dashboard utility/component CSS. This is style isolation, not a security sandbox for untrusted HTML.

HTML is inserted with innerHTML: inline and external <script> tags remain inert, and rendering never recreates executable scripts. Extension-internal links and GET/POST forms still use SPA interception; modified clicks and links opening a new tab retain browser behavior. custom_job_info_rows render as extra rows in the job-detail modal.

How native rendering works. Sidekiq extensions declare routes in registered(app) (app.get "/locks" do … erb :index end) and ship ERB templates under root_dir/views. Wurk lazily calls registered(app) on the first rendering request for that extension, then caches the captured routes and helpers for subsequent requests — no Sinatra involved.

The app is a Wurk::Web::Extension::App. Its supported router DSL follows Sidekiq's authoritative §25.5:

  • route(*methods, path, &block) registers one route per method — the same path and block under each listed HTTP method ("GET", "POST", …; methods are upcased, so Symbols work too). The verb helpers head, get, post, put, patch, and delete each register a single method with the same path/block contract.
  • match(env) returns a Wurk::Web::Extension::Action for the first registered route whose method and path match the request, or nil on a method/path miss. Matching does not execute the route block; the block runs when the action does. Named path captures are available through route_params(:name), whose keys are Symbols.

For example, one route can serve both GET and POST:

module MyGem::Web
  def self.registered(app)
    app.route("GET", "POST", "/locks/:digest") do
      @digest = route_params(:digest)
      erb :index
    end
  end
end

This supports the extension App DSL, not the upstream Sidekiq::Web::Router, Sidekiq::Web::Route, or Sidekiq::Web::Action constants. Sidekiq::WebHelpers and Sidekiq::Paginator are the helpers extension views render with, a working subset of §25.7 rather than all of it. Rendering and security constraints remain the same:

  • GET/POST /<mount>/ext/<name>/<subpath> matches the extension's route (route params like /locks/:digest included), runs the block in a WebHelpers-compatible context (h, t, truncate, root_path, asset_path, redis, params, redirect, erb, …), and returns the rendered HTML. Links and forms inside the view are intercepted by the SPA so navigation stays in-page; redirect (e.g. delete → list) is followed transparently.
  • GET /<mount>/ext-assets/<name>/<file> serves files from the extension's asset_paths (with Cache-Control from cache_for, path-traversal rejected), so a view's <link href="<%= asset_path('app.css') %>"> works.
  • Locale strings load from root_dir/locales/en.yml and resolve through the t helper; missing keys degrade to the humanized key (DeleteAll reads Delete All), never raise.
  • erb :_paging and erb :_job_info are built in, and erb honours locals:.
  • CSRF follows Sidekiq's model: non-GET extension requests must be same-origin per Sec-Fetch-Site. In the engine mount, a plain-HTTP origin that gets no such header is checked by Origin/Referer instead; the standalone Sidekiq::Web rack app stays strict (§ CSRF). Cross-site requests are denied.
  • Tabs added by bare tabs["Label"] = "path" mutation have no extension to render; those fall back to an Extension page that iframes the tab's own path (if the host mounts something reachable there, it renders in the frame). This existing bare-tab path remains available for host-mounted views needing client-side code, with an open-in-new-tab link; there is no new registration flag or change to the server API.
  • Calling register/register_extension (or mutating tabs) does not eagerly execute registered(app). If that callback raises a StandardError during first-request capture, Wurk caches the failure and does not retry the callback or serve partially captured routes. First and repeated rendering requests report it through the error handlers with context web-extension-registration and return a sanitized 500 body, Extension render error (see server logs), rather than exposing exception details or letting the error escape.

Scope note. This renders extensions registered against wurk's Sidekiq::Web alias. Gems that literally require "sidekiq" still can't load against wurk (wurk intentionally does not depend on — or install — the sidekiq gem; the consumer app shouldn't either, since wurk's native unique-jobs / cron / batches / limiter replace the common ecosystem gems). A require "sidekiq" compatibility shim is tracked, deliberately deferred, in #204.

Language, theme, and timezone

Not security settings, but the other three per-visitor things a host shapes server-side. config.web.offered_locales is the supported set, not a default: the dashboard negotiates it against the visitor's Accept-Language header (negotiated_locale in Wurk::DashboardController, backed by Wurk::Web::LocaleNegotiator). config.web.default_locale is the fallback used when nothing in that header matches the offered set, and config.web.default_theme is the palette default. All three are read server-side and injected ahead of the shell's pre-paint script, so a visitor's first load already renders in the right language and palette instead of flashing the wrong one. Timezone has no server-side hint at all — it's resolved entirely client-side from the browser's own zone, or a visitor's own override. Full reference, including precedence order and every option: Configuration → Dashboard configuration.

mount Sidekiq::Web

The stock install line works in a Rails app. mount Sidekiq::Web => "/sidekiq" serves registered extension routes at their own paths, as a bare page, and hands every other path to the engine. So it shows the same dashboard as mount Wurk::Engine, with the same gates: Sidekiq::Web.use middleware, the authorization hook and read-only mode run on both sides, and anything a host wraps around Sidekiq::Web runs first. The machine API is not served under this mount; mount Wurk::Engine or mount Wurk::API serves it.

run Sidekiq::Web outside Rails serves extension routes and the probe and stats routes only. Anything else is a 404 whose body names the fix, plus one WARN in the log.

Probe and stats routes

Every mount (mount Wurk::Engine, mount Sidekiq::Web, and run Sidekiq::Web outside Rails) answers Sidekiq Web's machine-readable routes with upstream's shapes, so an uptime check or script written against /sidekiq/stats keeps working. They sit behind the same gates as the dashboard.

Request Answer
HEAD / 200 after one LLEN queue:default; an error when Redis is unreachable
GET /stats JSON: sidekiq (processed, failed, busy, processes, enqueued, scheduled, retries, dead, default_latency), redis (redis_version, uptime_in_days, connected_clients, used_memory_human, used_memory_peak_human, store_name, store_version), server_utc_time
GET /stats/queues JSON object, queue name to size, largest first
GET /dashboard/stats 302 to /stats

The dashboard's own JSON (/api/stats) is a different, SPA-shaped payload.

What the dashboard costs Redis

The pages a dashboard polls read Redis in a fixed number of round trips, however deep the page or however many queues there are (bench/dashboard_paging.rb gates the counts):

Read Round trips
Landing payload (/api/stats, each SSE tick), queue list (/api/queues) 2
One queue's gauges and a page of its jobs; a page of retries, scheduled or dead 1
A page of batches or flows 2

Open SSE streams in one process share one read per second, so ten tabs cost what one does. Listing rows carry no backtrace and no batch jid lists: a job's backtrace is read when its detail opens, and a batch's failed and dead jids on its own page (list rows count them instead, as failed_count and dead_count).

The Limits page checks that each registered limiter still has its metadata (one EXISTS per limiter) at most once a minute per process, rather than on every page; on a writable dashboard that check also drops the expired names from lmtr-list. Between checks, a limiter whose metadata expired still counts in the total and is left off its page, and a name found expired that registers again shows up at the next check.