{{ $t("settings.cloud-desc") }}
diff --git a/assets/stores/config.ts b/assets/stores/config.ts index 845e2767..a7687253 100644 --- a/assets/stores/config.ts +++ b/assets/stores/config.ts @@ -16,6 +16,7 @@ export interface Config { enableShell: boolean; enableDownload: boolean; enableNotifications: boolean; + enableCloud: boolean; disableAvatars: boolean; releaseCheckMode: "automatic" | "manual"; user?: { diff --git a/docs/guide/authentication.md b/docs/guide/authentication.md index 6857a309..1beab030 100644 --- a/docs/guide/authentication.md +++ b/docs/guide/authentication.md @@ -179,6 +179,7 @@ Dozzle supports the following roles: | `actions` | `dozzle_actions` | Start, stop and restart containers. The instance also needs `--enable-actions`. | | `download` | `dozzle_download` | Download container logs as a file. | | `notifications` | `dozzle_notifications` | Create and edit notification rules and destinations. | +| `cloud` | `dozzle_cloud` | Link, unlink and configure Dozzle Cloud. | | `all` | `dozzle_all` | Every role above. This is the default when `roles` is empty. | | `none` | `dozzle_none` | No roles. Logs are still viewable, subject to the user's filter. Overrides anything else. | @@ -187,6 +188,9 @@ Roles are separated by commas or pipes (`shell,actions` or `shell|actions`), and > [!WARNING] > Notification rules are instance wide. A rule matches containers by expression, not by the user's filter, so a user with the `notifications` role can create a rule for containers their filter otherwise hides and receive those log lines at a destination they control. Only grant it to users you trust with every container on the instance. +> [!WARNING] +> Dozzle Cloud is also instance wide. Linking stores a single API key that repoints alert dispatch, log streaming and tool execution at one cloud account, and cloud tools run with the instance filter rather than the linking user's filter. A user with the `cloud` role can link the instance to their own cloud account and see every container through it, or unlink an existing connection. Only grant it to users you trust with every container on the instance. + Any role can be prefixed with `^` to exclude it. Exclusions are applied last, so order doesn't matter: ```yaml diff --git a/internal/auth/roles.go b/internal/auth/roles.go index 2b795b6e..4687b074 100644 --- a/internal/auth/roles.go +++ b/internal/auth/roles.go @@ -15,9 +15,10 @@ const ( Actions Download Notifications + Cloud ) -const All = Shell | Actions | Download | Notifications +const All = Shell | Actions | Download | Notifications | Cloud // ParseRole parses a comma-separated string of roles and returns the corresponding Role. // Roles prefixed with ^ are excluded after all other roles are applied, so "all,^shell" @@ -61,6 +62,8 @@ func ParseRole(input string) Role { bits = Download case "notifications", "dozzle_notifications": bits = Notifications + case "cloud", "dozzle_cloud": + bits = Cloud case "all", "dozzle_all": bits = All case "none", "dozzle_none": diff --git a/internal/auth/roles_test.go b/internal/auth/roles_test.go index c7009548..53df06bf 100644 --- a/internal/auth/roles_test.go +++ b/internal/auth/roles_test.go @@ -16,7 +16,9 @@ func TestParseRole(t *testing.T) { {"Single download role", "download", Download}, {"Single notifications role", "notifications", Notifications}, {"Single dozzle_notifications role", "dozzle_notifications", Notifications}, - {"All includes notifications", "all", Shell | Actions | Download | Notifications}, + {"Single cloud role", "cloud", Cloud}, + {"Single dozzle_cloud role", "dozzle_cloud", Cloud}, + {"All includes notifications and cloud", "all", Shell | Actions | Download | Notifications | Cloud}, {"None role", "none", None}, {"All role", "all", All}, @@ -41,6 +43,7 @@ func TestParseRole(t *testing.T) { {"Roles with spaces", "shell , actions , download", Shell | Actions | Download}, {"Dozzle roles with comma", "dozzle_shell,dozzle_actions", Shell | Actions}, {"Mixed dozzle and regular", "shell,dozzle_actions,download", Shell | Actions | Download}, + {"Cloud with other roles", "shell,cloud", Shell | Cloud}, // Multiple roles with pipe separator {"Shell and actions with pipe", "shell|actions", Shell | Actions}, diff --git a/internal/web/cloud.go b/internal/web/cloud.go index 5f8a2a18..595bd5eb 100644 --- a/internal/web/cloud.go +++ b/internal/web/cloud.go @@ -8,6 +8,7 @@ import ( "os" "time" + "github.com/amir20/dozzle/internal/auth" "github.com/amir20/dozzle/internal/notification" "github.com/amir20/dozzle/internal/notification/dispatcher" "github.com/rs/zerolog/log" @@ -21,6 +22,25 @@ type exchangeTokenResponse struct { ExpiresAt *string `json:"expiresAt,omitempty"` } +// requireCloudRole gates every Dozzle Cloud endpoint. Linking is an +// instance-wide operation: it stores a single API key that repoints alert +// dispatch, log streaming and tool execution at one cloud account, and the +// tool path runs with the instance filter rather than the caller's, so a +// label-restricted user must not be able to link, unlink or reconfigure it. +func (h *handler) requireCloudRole(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if h.config.Authorization.Provider != NONE { + user := auth.UserFromContext(r.Context()) + if user == nil || !user.Roles.Has(auth.Cloud) { + log.Warn().Msg("user is not permitted to manage cloud") + http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) + return + } + } + next.ServeHTTP(w, r) + }) +} + func (h *handler) cloudCallback(w http.ResponseWriter, r *http.Request) { token := r.URL.Query().Get("token") from := r.URL.Query().Get("from") diff --git a/internal/web/index.go b/internal/web/index.go index 5546f677..feb3afe4 100644 --- a/internal/web/index.go +++ b/internal/web/index.go @@ -77,12 +77,14 @@ func (h *handler) executeTemplate(w http.ResponseWriter, req *http.Request) { config["enableActions"] = h.config.EnableActions config["enableDownload"] = true config["enableNotifications"] = true + config["enableCloud"] = true if user != nil { config["enableShell"] = h.config.EnableShell && user.Roles.Has(auth.Shell) config["enableActions"] = h.config.EnableActions && user.Roles.Has(auth.Actions) config["enableDownload"] = user.Roles.Has(auth.Download) config["enableNotifications"] = user.Roles.Has(auth.Notifications) + config["enableCloud"] = user.Roles.Has(auth.Cloud) config["user"] = user } diff --git a/internal/web/label_filter_test.go b/internal/web/label_filter_test.go index 6f8c94db..6ca851ae 100644 --- a/internal/web/label_filter_test.go +++ b/internal/web/label_filter_test.go @@ -143,3 +143,28 @@ func Test_requireNotificationsRole(t *testing.T) { // Unset roles parse to All, which is what a user with only a filter gets. assert.Equal(t, http.StatusTeapot, serve(auth.User{Roles: auth.All, ContainerLabels: devLabels})) } + +func Test_requireCloudRole(t *testing.T) { + h := restrictedHandler(t) + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTeapot) }) + + serve := func(ctx context.Context) int { + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/cloud/callback?token=abc", nil).WithContext(ctx) + h.requireCloudRole(next).ServeHTTP(rr, req) + return rr.Code + } + withUser := func(user auth.User) context.Context { + return auth.WithUser(context.Background(), user) + } + + // A filtered user without the role must not be able to link the instance. + assert.Equal(t, http.StatusForbidden, serve(withUser(auth.User{Roles: auth.Download, ContainerLabels: devLabels}))) + assert.Equal(t, http.StatusForbidden, serve(withUser(auth.User{Roles: auth.None}))) + // No user at all (auth enabled) fails closed. + assert.Equal(t, http.StatusForbidden, serve(context.Background())) + + assert.Equal(t, http.StatusTeapot, serve(withUser(auth.User{Roles: auth.Cloud}))) + // Unset roles parse to All, so existing users keep cloud access. + assert.Equal(t, http.StatusTeapot, serve(withUser(auth.User{Roles: auth.All, ContainerLabels: devLabels}))) +} diff --git a/internal/web/routes.go b/internal/web/routes.go index d4668351..ea190c49 100644 --- a/internal/web/routes.go +++ b/internal/web/routes.go @@ -215,17 +215,20 @@ func createRouter(h *handler) *chi.Mux { r.Get("/releases", h.getReleases) // Cloud API - r.Get("/cloud/status", h.cloudStatus) - r.Get("/cloud/search/logs", h.cloudSearchLogs) - r.Get("/cloud/alerts", h.cloudAlerts) - r.Get("/cloud/config", h.cloudConfig) - r.Patch("/cloud/config", h.updateCloudConfig) - r.Delete("/cloud/config", h.deleteCloudConfig) - r.Post("/cloud/feedback", h.cloudFeedback) - // Cloud callback handles the OAuth-style code exchange. It must stay - // authenticated so an unauthenticated attacker cannot force-link the - // instance to their own cloud account via SetCloudConfig. - r.Get("/cloud/callback", h.cloudCallback) + r.Route("/cloud", func(r chi.Router) { + r.Use(h.requireCloudRole) + r.Get("/status", h.cloudStatus) + r.Get("/search/logs", h.cloudSearchLogs) + r.Get("/alerts", h.cloudAlerts) + r.Get("/config", h.cloudConfig) + r.Patch("/config", h.updateCloudConfig) + r.Delete("/config", h.deleteCloudConfig) + r.Post("/feedback", h.cloudFeedback) + // Cloud callback handles the OAuth-style code exchange. It must stay + // authenticated so an unauthenticated attacker cannot force-link the + // instance to their own cloud account via SetCloudConfig. + r.Get("/callback", h.cloudCallback) + }) // MCP (Model Context Protocol) endpoint if h.config.EnableMCP {