diff --git a/CLAUDE.md b/CLAUDE.md
index dfe42ddb0fa6..c3c6ae80f1ab 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -559,6 +559,14 @@ means **two components may never share a basename**, at any depth.
- Three modes: none, simple (file-based users.yml), forward-proxy (e.g., Authelia)
- JWT tokens for simple auth with configurable TTL
- User file location: `./data/users.yml` or `./data/users.yaml`
+- **Roles and filters are separate checks, and a new route needs both.** Roles say what a
+ user may do; a per-user `filter` says which containers. An empty `roles:` means `all`, so
+ holding every role never means admin. Per-container routes resolve through
+ `h.resolveLabels(r)`. Anything reaching past the containers a user can see (host-wide or
+ instance-wide actions, setup, settings, anything that hands out a credential) must also
+ refuse `h.restrictedUser(r)`, as `pruneHost` and `setupCanWrite` do. The `notifications`
+ and `cloud` roles are the documented, tested exceptions; don't add another without
+ documenting it in `docs/guide/authentication/simple.md`.
### Testing
diff --git a/docs/de/guide/authentication/simple.md b/docs/de/guide/authentication/simple.md
index 88fa626251d5..6b5716d5393e 100644
--- a/docs/de/guide/authentication/simple.md
+++ b/docs/de/guide/authentication/simple.md
@@ -1,7 +1,7 @@
---
title: Einfache Authentifizierung
description: Richte die einfache Authentifizierung von Dozzle mit einer users.yml ein und lege mit Filtern und Rollen fest, welche Docker-Container jeder Benutzer sieht und was er tun darf.
-sourceHash: 42be19df819b
+sourceHash: 6669a5ecbae2
---
# Einfache Authentifizierung
@@ -137,6 +137,8 @@ users:
In diesem Beispiel hat der Benutzer `admin` keinen Filter und sieht damit alle Container. Der Benutzer `guest` sieht nur Container mit dem Label `com.example.app`. Das ist nützlich, um den Zugriff auf bestimmte Container einzuschränken.
+Ein Benutzer mit Filter kann unabhängig von seinen Rollen das Setup nicht ändern, weil es die gesamte Instanz betrifft.
+
> [!NOTE]
> Filter können auch [global](/de/guide/filters) mit dem Flag `--filter` gesetzt werden. Dieses Flag gilt für alle Benutzer. Hat ein Benutzer einen eigenen Filter, überschreibt dieser den globalen Filter.
diff --git a/docs/es/guide/authentication/simple.md b/docs/es/guide/authentication/simple.md
index e2a5a290bf51..f7837f4ea21b 100644
--- a/docs/es/guide/authentication/simple.md
+++ b/docs/es/guide/authentication/simple.md
@@ -1,7 +1,7 @@
---
title: Autenticación simple
description: Configura la autenticación simple de Dozzle con un archivo users.yml y limita con filtros y roles qué contenedores Docker ve cada usuario y qué puede hacer con ellos.
-sourceHash: 42be19df819b
+sourceHash: 6669a5ecbae2
---
# Autenticación simple
@@ -137,6 +137,8 @@ users:
En este ejemplo, el usuario `admin` no tiene filtro, así que ve todos los contenedores. El usuario `guest` solo ve los contenedores con la etiqueta `com.example.app`. Esto es útil para restringir el acceso a contenedores concretos.
+Un usuario con un filtro no puede cambiar la configuración inicial, sean cuales sean sus roles, porque eso afecta a toda la instancia.
+
> [!NOTE]
> Los filtros también se pueden definir [globalmente](/es/guide/filters) con el flag `--filter`. Ese flag se aplica a todos los usuarios. Si un usuario tiene un filtro definido, este tiene prioridad sobre el global.
diff --git a/docs/fr/guide/authentication/simple.md b/docs/fr/guide/authentication/simple.md
index c52ebf3567a7..d632ef3adabc 100644
--- a/docs/fr/guide/authentication/simple.md
+++ b/docs/fr/guide/authentication/simple.md
@@ -1,7 +1,7 @@
---
title: Authentification simple
description: Configurez l'authentification simple de Dozzle avec un fichier users.yml, et limitez par des filtres et des rôles les conteneurs Docker que voit chaque utilisateur et ce qu'il peut faire.
-sourceHash: 42be19df819b
+sourceHash: 6669a5ecbae2
---
# Authentification simple
@@ -137,6 +137,8 @@ users:
Dans cet exemple, l'utilisateur `admin` n'a aucun filtre, il voit donc tous les conteneurs. L'utilisateur `guest` ne voit que les conteneurs portant le label `com.example.app`. C'est pratique pour restreindre l'accès à certains conteneurs.
+Un utilisateur ayant un filtre ne peut pas modifier la configuration, quels que soient ses rôles, car cela affecte toute l'instance.
+
> [!NOTE]
> Les filtres peuvent aussi être définis [globalement](/fr/guide/filters) avec le flag `--filter`. Ce flag s'applique à tous les utilisateurs. Si un utilisateur a un filtre défini, celui-ci remplace le filtre global.
diff --git a/docs/guide/authentication/simple.md b/docs/guide/authentication/simple.md
index afb7c90dd63f..7cad4c746248 100644
--- a/docs/guide/authentication/simple.md
+++ b/docs/guide/authentication/simple.md
@@ -136,6 +136,8 @@ users:
In this example, the `admin` user has no filter, so they can see all containers. The `guest` user can only see containers with the label `com.example.app`. This is useful for restricting access to specific containers.
+A user with a filter can't change setup, whatever their roles, because it affects the whole instance.
+
> [!NOTE]
> Filters can also be set [globally](/guide/filters) with the `--filter` flag. This flag is applied to all users. If a user has a filter set, it will override the global filter.
diff --git a/docs/zh/guide/authentication/simple.md b/docs/zh/guide/authentication/simple.md
index 22b43bcc534c..04c2e604de4a 100644
--- a/docs/zh/guide/authentication/simple.md
+++ b/docs/zh/guide/authentication/simple.md
@@ -1,7 +1,7 @@
---
title: 简单模式身份验证
description: 使用 users.yml 文件配置 Dozzle 的简单模式身份验证,并通过过滤器和角色限制每个用户能看到哪些 Docker 容器、能执行哪些操作。
-sourceHash: 42be19df819b
+sourceHash: 6669a5ecbae2
---
# 简单模式身份验证
@@ -137,6 +137,8 @@ users:
在这个例子中,`admin` 用户没有过滤器,因此能看到所有容器。`guest` 用户只能看到带有 `com.example.app` 标签的容器。这样就能把访问限制在特定容器上。
+设置了过滤器的用户无论拥有什么角色,都无法更改设置,因为这会影响整个实例。
+
> [!NOTE]
> 过滤器也可以用 `--filter` 参数[全局设置](/zh/guide/filters)。该参数对所有用户生效。如果某个用户设置了自己的过滤器,则会覆盖全局过滤器。
diff --git a/internal/web/filtered_user_admin_test.go b/internal/web/filtered_user_admin_test.go
new file mode 100644
index 000000000000..f7b5dd03a0d1
--- /dev/null
+++ b/internal/web/filtered_user_admin_test.go
@@ -0,0 +1,102 @@
+package web
+
+import (
+ "context"
+ "crypto/tls"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "testing/fstest"
+ "time"
+
+ "github.com/amir20/dozzle/internal/auth"
+ "github.com/amir20/dozzle/internal/hostservice"
+ "github.com/go-chi/chi/v5"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// No Remote-Roles header means every role, the same as an empty roles key in
+// users.yml, so the filter is all that separates these two.
+var (
+ filteredUser = []string{"Remote-User", "guest", "Remote-Filter", "label=com.example.app"}
+ unfiltered = []string{"Remote-User", "amir"}
+)
+
+func proxyConfig() *Config {
+ return &Config{Base: "/", Mode: "server",
+ Authorization: Authorization{
+ Provider: FORWARD_PROXY,
+ Authorizer: auth.NewForwardProxyAuth("Remote-User", "Remote-Email", "Remote-Name", "Remote-Filter", "Remote-Roles"),
+ },
+ Setup: SetupConfig{StartedAt: time.Now()},
+ }
+}
+
+func proxyAgentsHandler(hosts *fakeAgentHosts) *chi.Mux {
+ manager := hostservice.NewRetriableClientManager(nil, nil, time.Second, tls.Certificate{})
+ hosts.MultiHostService = hostservice.NewMultiHostService(manager, time.Second)
+ return createRouter(&handler{
+ hostService: hosts,
+ content: fstest.MapFS{"index.html": {Data: []byte("index page")}},
+ config: proxyConfig(),
+ })
+}
+
+func TestSetup_FilteredUserCannotWrite(t *testing.T) {
+ setupTestEnv(t, true)
+ h := proxyAgentsHandler(&fakeAgentHosts{connected: map[string]string{}})
+
+ writes := []struct{ method, path, body string }{
+ {"POST", "/api/setup/agent-cert", ""},
+ {"PATCH", "/api/setup/config", `{"enableShell":true}`},
+ {"PATCH", "/api/setup/config", `{"autoPrune":{"mode":"daily","time":"03:00"}}`},
+ {"POST", "/api/setup/restart", ""},
+ {"POST", "/api/setup/agents", `{"address":"nas:7007"}`},
+ {"DELETE", "/api/setup/agents", `{"endpoint":"nas:7007"}`},
+ }
+ for _, write := range writes {
+ rr := doSetup(h, write.method, write.path, write.body, filteredUser...)
+ assert.Equal(t, http.StatusForbidden, rr.Code, "%s %s %s", write.method, write.path, write.body)
+ assert.NotContains(t, rr.Body.String(), "PRIVATE KEY")
+ }
+
+ // Writing the roles out changes nothing: the filter is what disqualifies.
+ explicit := append([]string{"Remote-Roles", "all"}, filteredUser...)
+ assert.Equal(t, http.StatusForbidden, doSetup(h, "POST", "/api/setup/agent-cert", "", explicit...).Code)
+
+ rr := doSetup(h, "GET", "/api/setup", "", filteredUser...)
+ require.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
+ var state setupState
+ require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &state))
+ assert.False(t, state.CanWrite)
+ assert.False(t, state.CanWriteUpdates)
+}
+
+func TestSetup_UnfilteredUserStillWrites(t *testing.T) {
+ setupTestEnv(t, true)
+ h := proxyAgentsHandler(&fakeAgentHosts{connected: map[string]string{}})
+
+ rr := doSetup(h, "POST", "/api/setup/agent-cert", "", unfiltered...)
+ require.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
+ var pair setupAgentCertResponse
+ require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &pair))
+ assert.Contains(t, pair.Key, "PRIVATE KEY")
+
+ assert.Equal(t, http.StatusNoContent, doSetup(h, "PATCH", "/api/setup/config", `{"enableShell":true}`, unfiltered...).Code)
+}
+
+func Test_setupCanWrite_EveryLoginProvider(t *testing.T) {
+ for _, provider := range []AuthProvider{SIMPLE, OIDC, FORWARD_PROXY} {
+ h := &handler{config: &Config{Authorization: Authorization{Provider: provider}}}
+ canWrite := func(user auth.User) bool {
+ req := httptest.NewRequest(http.MethodPost, "/api/setup/agent-cert", nil)
+ return h.setupCanWrite(req.WithContext(auth.WithUser(context.Background(), user)))
+ }
+
+ assert.True(t, canWrite(auth.User{Roles: auth.All}), string(provider))
+ assert.False(t, canWrite(auth.User{Roles: auth.All, ContainerLabels: devLabels}), string(provider))
+ assert.False(t, canWrite(auth.User{Roles: auth.Actions}), string(provider))
+ }
+}
diff --git a/internal/web/setup.go b/internal/web/setup.go
index 7d15fcb63380..5d557dbe52bc 100644
--- a/internal/web/setup.go
+++ b/internal/web/setup.go
@@ -155,9 +155,10 @@ func (h *handler) setupCanWrite(r *http.Request) bool {
return h.setupWindowOpen()
}
user := auth.UserFromContext(r.Context())
- // Role.Has matches any bit, so compare the whole mask: setup is for
- // someone who holds every role.
- return user != nil && user.Roles&auth.All == auth.All
+ // Setup is for someone who holds every role and sees every container. An
+ // empty roles key means all, so the roles alone can't tell an admin from a
+ // user confined by a filter, and setup applies to the whole instance.
+ return user != nil && user.Roles&auth.All == auth.All && !h.restrictedUser(r)
}
// setupCanWriteUpdates is setupCanWrite for the auto-update settings. Without