fix(cloud): gate cloud endpoints behind a new cloud role (#4960)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Amir RaminfarandClaude Opus 5 authored and GitHub committed 2026-08-28 14:08:38 +00:00
1 parent bed7e7a226
commit 3794a3b637
12 files changed
+84 -17

No files matched your search

+1 -1
View File
@@ -13,7 +13,7 @@
<mdi:bell class="size-6" />
</router-link>
<CloudPopover />
<CloudPopover v-if="config.enableCloud" />
<router-link
:to="{ name: '/settings' }"
+1 -1
View File
@@ -1,7 +1,7 @@
<template>
<div class="flex flex-col gap-5 px-4 py-4 md:px-8">
<section class="flex items-center gap-4">
<CloudSearchInline class="hidden max-w-sm flex-1 md:flex" />
<CloudSearchInline v-if="config.enableCloud" class="hidden max-w-sm flex-1 md:flex" />
<Links class="ml-auto">
<template #more-items>
<Tag class="font-mono">{{ config.version }}</Tag>
+7 -1
View File
@@ -7,6 +7,12 @@ const cloudStatusError = ref<"auth" | "unavailable" | false>(false);
const isLoadingCloudStatus = ref(false);
async function fetchCloudConfig() {
// Cloud endpoints are gated by the cloud role, so a user without it would
// only ever get a 403 here. Skip the call and leave the state unlinked.
if (!config.enableCloud) {
cloudConfig.value = null;
return;
}
try {
const res = await fetch(withBase("/api/cloud/config"));
if (!res.ok) {
@@ -24,7 +30,7 @@ async function fetchCloudConfig() {
const initialLoad = fetchCloudConfig();
async function fetchCloudStatus() {
if (!cloudConfig.value?.linked) return;
if (!config.enableCloud || !cloudConfig.value?.linked) return;
isLoadingCloudStatus.value = true;
cloudStatusError.value = false;
try {
+1 -1
View File
@@ -71,7 +71,7 @@
</section>
<!-- CLOUD -->
<section class="flex flex-col gap-4">
<section class="flex flex-col gap-4" v-if="config.enableCloud">
<div>
<h2 class="text-xl font-semibold tracking-tight">{{ $t("cloud.title") }}</h2>
<p class="text-base-content/60 mt-1 text-sm">{{ $t("settings.cloud-desc") }}</p>
+1
View File
@@ -16,6 +16,7 @@ export interface Config {
enableShell: boolean;
enableDownload: boolean;
enableNotifications: boolean;
enableCloud: boolean;
disableAvatars: boolean;
releaseCheckMode: "automatic" | "manual";
user?: {
+4
View File
@@ -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
+4 -1
View File
@@ -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":
+4 -1
View File
@@ -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},
+20
View File
@@ -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")
+2
View File
@@ -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
}
+25
View File
@@ -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})))
}
+14 -11
View File
@@ -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 {