Securing the dashboard
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.
Devise / Warden
Devise builds on Warden, which is already in your middleware stack and has set
env['warden'] by the time a request reaches the mount. Gate with a tiny
middleware that bounces unauthenticated users to your login page:
# config/initializers/wurk.rb
class WurkAdminAuth
def initialize(app) = @app = app
def call(env)
warden = env["warden"]
user = warden&.user
return @app.call(env) if user&.admin?
warden&.authenticate!(scope: :user) # 401 → Devise failure app → /users/sign_in
[403, { "Content-Type" => "text/plain" }, ["Forbidden"]]
end
end
Wurk::Web.use(WurkAdminAuth)
Prefer to keep it in routes.rb? authenticate works too, and needs no
Wurk::Web.use:
# config/routes.rb
authenticate :user, ->(u) { u.admin? } do
mount Wurk::Engine => "/wurk"
end
Sorcery
Sorcery exposes its helpers on the controller, not on env, so check the
session directly in the middleware:
class WurkSorceryAuth
def initialize(app) = @app = app
def call(env)
user_id = env["rack.session"]&.[](:user_id)
admin = user_id && User.find_by(id: user_id)&.admin?
return @app.call(env) if admin
[302, { "Location" => "/login", "Content-Type" => "text/plain" }, ["Redirecting"]]
end
end
Wurk::Web.use(WurkSorceryAuth)
Plain HTTP Basic auth
For an internal tool, Rack::Auth::Basic is enough — no host-app coupling:
Wurk::Web.use(Rack::Auth::Basic, "Wurk") do |user, password|
ActiveSupport::SecurityUtils.secure_compare(user, ENV["WURK_USER"]) &
ActiveSupport::SecurityUtils.secure_compare(password, ENV["WURK_PASS"])
end
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.
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 non-GET request 403s and the SPA hides
destructive actions. Equivalent in Ruby:
Wurk::Web.configure { |c| c.read_only = true }
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 HTML is embedded in the page. 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 captures those routes at registration
and serves them Sidekiq-Web-compatibly — no Sinatra involved:
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, never raise. - CSRF matches Sidekiq's model: non-
GETextension requests must be same-origin perSec-Fetch-Site; 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). - Registration stays no-op-safe — a gem calling
register/register_extension(or mutatingtabs) never crashes boot.
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.