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