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:
- Devise / Warden, including scoped variants
and the
routes.rbauthenticategate that needs no middleware at all. - Sorcery — and the same shape for Clearance,
Rodauth,
has_secure_password, or any hand-rolled session. - HTTP Basic auth —
Rack::Auth::Basicwith constant-time comparison. - API tokens / service-to-service.
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. 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 helpershead,get,post,put,patch, anddeleteeach register a single method with the same path/block contract.match(env)returns aWurk::Web::Extension::Actionfor the first registered route whose method and path match the request, ornilon a method/path miss. Matching does not execute the route block; the block runs when the action does. Named path captures are available throughroute_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/:digestincluded), runs the block in aWebHelpers-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'sasset_paths(withCache-Controlfromcache_for, path-traversal rejected), so a view's<link href="<%= asset_path('app.css') %>">works.- Locale strings load from
root_dir/locales/en.ymland resolve through thethelper; missing keys degrade to the humanized key (DeleteAllreadsDelete All), never raise. erb :_paginganderb :_job_infoare built in, anderbhonourslocals:.- CSRF follows Sidekiq's model: non-
GETextension requests must be same-origin perSec-Fetch-Site. In the engine mount, a plain-HTTP origin that gets no such header is checked byOrigin/Refererinstead; the standaloneSidekiq::Webrack 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 mutatingtabs) does not eagerly executeregistered(app). If that callback raises aStandardErrorduring 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 contextweb-extension-registrationand 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.