Skip to content
Dashboard Authorization

Dashboard Authorization

The dashboard protects every Connect RPC with a per-action ui.Authorizer by default. Without an authorizer, RPCs fail closed unless you explicitly opt in with ui.WithInsecureAllowUnauthenticated() for local development or trusted networks. The dashboard does not impose an identity model. Your middleware authenticates the request, extracts whatever principal your application uses, and stores it on the request context. The authorizer receives that context and the requested dashboard action.

package main

import (
	"context"
	"errors"
	"net/http"

	jobs "github.com/jdziat/simple-durable-jobs/v4"
	"github.com/jdziat/simple-durable-jobs/v4/ui"
	"gorm.io/driver/sqlite"
	"gorm.io/gorm"
)

type Principal struct {
	UserID string
	Role   string
}

type DashboardAuthorizer struct{}

func (DashboardAuthorizer) Authorize(ctx context.Context, action ui.Action) error {
	principal, ok := ui.PrincipalFromContext(ctx)
	if !ok {
		return errors.New("missing principal")
	}
	user, ok := principal.(Principal)
	if !ok {
		return errors.New("invalid principal")
	}

	switch action {
	case ui.ActionViewJobs, ui.ActionViewJob, ui.ActionViewStats, ui.ActionWatchEvents:
		return nil
	case ui.ActionRetryJob, ui.ActionBulkRetryJobs:
		if user.Role == "operator" || user.Role == "admin" {
			return nil
		}
	case ui.ActionDeleteJob, ui.ActionBulkDeleteJobs, ui.ActionPurgeQueue:
		if user.Role == "admin" {
			return nil
		}
	case ui.ActionPauseJob, ui.ActionCancelJob, ui.ActionResumeJob,
		ui.ActionPauseQueue, ui.ActionResumeQueue:
		if user.Role == "operator" || user.Role == "admin" {
			return nil
		}
	}

	return errors.New("not allowed")
}

func main() {
	ctx := context.Background()
	db, err := gorm.Open(sqlite.Open("jobs.db?_journal_mode=WAL&_busy_timeout=5000&_txlock=immediate"), &gorm.Config{})
	if err != nil {
		panic(err)
	}
	store := jobs.NewGormStorage(db)
	if err := store.Migrate(ctx); err != nil {
		panic(err)
	}
	q := jobs.New(store)

	authMiddleware := func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			// Replace this with your real authentication and identity lookup.
			principal := Principal{UserID: "u_123", Role: "operator"}
			next.ServeHTTP(w, r.WithContext(ui.WithPrincipal(r.Context(), principal)))
		})
	}

	http.Handle("/jobs/", http.StripPrefix("/jobs", ui.Handler(
		store,
		ui.WithQueue(q),
		ui.WithMiddleware(authMiddleware),
		ui.WithAuthorizer(DashboardAuthorizer{}),
	)))
	http.ListenAndServe(":8080", nil)
}

Authorizer API

ui.Authorizer is:

type Authorizer interface {
	Authorize(ctx context.Context, action Action) error
}

Return nil to allow the action. Return a non-nil error to deny it. Plain errors become PermissionDenied Connect errors with the same message. If you return a *connect.Error, its code is preserved, so policies can return Unauthenticated, PermissionDenied, or another code deliberately.

Actions

The dashboard passes one of these actions for read RPCs:

ActionRPC
ui.ActionViewStatsGetStats, GetStatsHistory, ListQueues
ui.ActionViewJobsListJobs, ListScheduledJobs, ListWorkflows
ui.ActionViewJobGetJob, GetWorkflow
ui.ActionWatchEventsWatchEvents

There is no default action. A procedure that is not in one of the two tables on this page is denied outright with PermissionDenied, before your Authorizer is consulted and before the insecure opt-in is honoured — so an unmapped RPC never reaches your policy at all, under any configuration. If you upgrade and a newly-added RPC starts returning PermissionDenied, the fix is in the library’s procedure classification (actionForProcedure in ui/handler.go), not in your Authorizer; please file an issue. TestActionForProcedure_Exhaustive asserts every shipped JobsService procedure is classified, so this should not happen in a released build.

The dashboard passes one of these actions for mutating RPCs:

ActionRPC
ui.ActionRetryJobRetryJob
ui.ActionDeleteJobDeleteJob
ui.ActionBulkRetryJobsBulkRetryJobs
ui.ActionBulkDeleteJobsBulkDeleteJobs
ui.ActionPauseJobPauseJob
ui.ActionCancelJobCancelJob
ui.ActionResumeJobResumeJob
ui.ActionPauseQueuePauseQueue
ui.ActionResumeQueueResumeQueue
ui.ActionPurgeQueuePurgeQueue

Default Gate

ui.Handler fails closed by default: without ui.WithAuthorizer(...) or ui.WithInsecureAllowUnauthenticated(), all dashboard RPCs (reads and mutations) return PermissionDenied. This is an authorization gate only — it does not provide transport encryption, CSRF protection, or audit logging; operate the dashboard behind your own TLS and network controls. Static frontend assets remain public because they do not carry job payloads or metadata.

Use the insecure opt-in only for local development or a trusted network:

http.Handle("/jobs/", http.StripPrefix("/jobs", ui.Handler(
	store,
	ui.WithQueue(q),
	ui.WithInsecureAllowUnauthenticated(),
)))

ui.WithInsecureAllowUnauthenticatedWrites() remains available as a deprecated alias for ui.WithInsecureAllowUnauthenticated(). The alias now permits both reads and writes, matching the dashboard’s unified explicit opt-in.

ui.WithMiddleware(...) does not grant access by itself. Middleware is useful for authentication, logging, headers, and principal injection, but RPC access is controlled only by ui.WithAuthorizer(...) or the explicit insecure opt-in.

Origin Checks for Mutations

Mutating dashboard RPCs also check the browser Origin header. Requests with no Origin header are allowed so CLI and server-to-server Connect clients keep working. Browser requests with an Origin must be same-origin with the request host or match an explicit allow-list:

http.Handle("/jobs/", http.StripPrefix("/jobs", ui.Handler(
	store,
	ui.WithMiddleware(authMiddleware),
	ui.WithAuthorizer(DashboardAuthorizer{}),
	ui.WithAllowedOrigins("https://ops.example.com"),
)))

The embedded SPA is served from the same origin as the API, so it does not need this option. Cross-origin dashboard deployments do. If your authentication uses cookies or any automatically attached browser credential, keep this Origin check enabled and list only trusted dashboard origins.

Programmatic Reuse

The authorizer is only wired into the dashboard’s Connect RPC interceptor. Queue and storage APIs are unchanged, and no schema migration is involved.

If your application exposes programmatic admin operations, call the same authorizer before invoking operations such as Requeue, PauseJob, or CancelJob from your own handlers. That keeps the policy consistent without adding authorization parameters to queue or core storage methods.