1
0
mirror of https://github.com/amir20/dozzle.git synced 2025-12-21 13:23:07 +01:00

feat: Add user roles (#4133)

This commit is contained in:
Dmitry Mazurov
2025-09-22 00:51:16 +03:00
committed by GitHub
parent 54799b420c
commit 5f24657b41
17 changed files with 207 additions and 79 deletions

View File

@@ -20,6 +20,7 @@ type proxyAuthContext struct {
headerEmail string
headerName string
headerFilter string
headerRoles string
}
func hashEmail(email string) string {
@@ -30,12 +31,13 @@ func hashEmail(email string) string {
return hex.EncodeToString(hash[:])
}
func NewForwardProxyAuth(userHeader, emailHeader, nameHeader, filterHeader string) *proxyAuthContext {
func NewForwardProxyAuth(userHeader, emailHeader, nameHeader, filterHeader, rolesHeader string) *proxyAuthContext {
return &proxyAuthContext{
headerUser: userHeader,
headerEmail: emailHeader,
headerName: nameHeader,
headerFilter: filterHeader,
headerRoles: rolesHeader,
}
}
@@ -46,7 +48,8 @@ func (p *proxyAuthContext) AuthMiddleware(next http.Handler) http.Handler {
if err != nil {
log.Fatal().Str("filter", r.Header.Get(p.headerFilter)).Msg("Failed to parse container filter")
}
user := newUser(r.Header.Get(p.headerUser), r.Header.Get(p.headerEmail), r.Header.Get(p.headerName), containerFilter)
userRoles := ParseRole(r.Header.Get(p.headerRoles))
user := newUser(r.Header.Get(p.headerUser), r.Header.Get(p.headerEmail), r.Header.Get(p.headerName), containerFilter, userRoles)
ctx := context.WithValue(r.Context(), remoteUser, user)
next.ServeHTTP(w, r.WithContext(ctx))
} else {

42
internal/auth/roles.go Normal file
View File

@@ -0,0 +1,42 @@
package auth
import (
"strings"
)
type Role int
const (
NONE Role = 0
Shell Role = 1 << iota
Actions
Download
)
const AllRole = Shell | Actions | Download
func ParseRole(commaValues string) Role {
if commaValues == "" {
return AllRole
}
var roles Role
for r := range strings.SplitSeq(commaValues, ",") {
role := strings.TrimSpace(strings.ToLower(r))
switch role {
case "shell":
roles |= Shell
case "actions":
roles |= Actions
case "download":
roles |= Download
case "none":
return NONE
}
}
return roles
}
func (roles Role) Has(role Role) bool {
return roles&role != 0
}

View File

@@ -38,7 +38,7 @@ func (a *simpleAuthContext) CreateToken(username, password string) (string, erro
return "", ErrInvalidCredentials
}
claims := map[string]interface{}{"username": user.Username, "email": user.Email, "name": user.Name, "filter": user.Filter}
claims := map[string]interface{}{"username": user.Username, "email": user.Email, "name": user.Name, "filter": user.Filter, "roles": user.RolesConfigured}
jwtauth.SetIssuedNow(claims)
if a.ttl > 0 {

View File

@@ -24,7 +24,9 @@ type User struct {
Name string `json:"name" yaml:"name"`
Password string `json:"-" yaml:"password"`
Filter string `json:"-" yaml:"filter"`
RolesConfigured string `json:"-" yaml:"roles"`
ContainerLabels container.ContainerLabels `json:"-" yaml:"-"`
Roles Role `json:"-" yaml:"-"`
}
func (u User) AvatarURL() string {
@@ -35,12 +37,13 @@ func (u User) AvatarURL() string {
return fmt.Sprintf("https://gravatar.com/avatar/%s?d=https%%3A%%2F%%2Fui-avatars.com%%2Fapi%%2F/%s/128", hashEmail(u.Email), url.QueryEscape(name))
}
func newUser(username, email, name string, labels container.ContainerLabels) User {
func newUser(username, email, name string, labels container.ContainerLabels, roles Role) User {
return User{
Username: username,
Email: email,
Name: name,
ContainerLabels: labels,
Roles: roles,
}
}
@@ -198,14 +201,18 @@ func UserFromContext(ctx context.Context) *User {
email := claims["email"].(string)
name := claims["name"].(string)
containerFilter := container.ContainerLabels{}
roles := AllRole
if filter, ok := claims["filter"].(string); ok {
containerFilter, err = container.ParseContainerFilter(filter)
if err != nil {
log.Fatal().Err(err).Str("filter", filter).Msg("Failed to parse container filter")
}
}
if role, ok := claims["roles"].(string); ok {
roles = ParseRole(role)
}
user := newUser(username, email, name, containerFilter)
user := newUser(username, email, name, containerFilter, roles)
return &user
}
return nil

View File

@@ -21,6 +21,7 @@ type Args struct {
AuthHeaderEmail string `arg:"--auth-header-email,env:DOZZLE_AUTH_HEADER_EMAIL" default:"Remote-Email" help:"sets the HTTP Header to use for email in Forward Proxy configuration."`
AuthHeaderName string `arg:"--auth-header-name,env:DOZZLE_AUTH_HEADER_NAME" default:"Remote-Name" help:"sets the HTTP Header to use for name in Forward Proxy configuration."`
AuthHeaderFilter string `arg:"--auth-header-filter,env:DOZZLE_AUTH_HEADER_FILTER" default:"Remote-Filter" help:"sets the HTTP Header to use for filtering in Forward Proxy configuration."`
AuthHeaderRoles string `arg:"--auth-header-roles,env:DOZZLE_AUTH_HEADER_ROLES" default:"Remote-Roles" help:"sets the HTTP Header to use for roles in Forward Proxy configuration."`
EnableActions bool `arg:"--enable-actions,env:DOZZLE_ENABLE_ACTIONS" default:"false" help:"enables essential actions on containers from the web interface."`
EnableShell bool `arg:"--enable-shell,env:DOZZLE_ENABLE_SHELL" default:"false" help:"enables shell access to containers from the web interface."`
DisableAvatars bool `arg:"--disable-avatars,env:DOZZLE_DISABLE_AVATARS" default:"false" help:"disables avatars for authenticated users."`

View File

@@ -11,11 +11,12 @@ import (
)
type GenerateCmd struct {
Username string `arg:"positional"`
Password string `arg:"--password, -p" help:"sets the password for the user"`
Name string `arg:"--name, -n" help:"sets the display name for the user"`
Email string `arg:"--email, -e" help:"sets the email for the user"`
Filter string `arg:"--user-filter" help:"sets the filter for the user. This can be a comma separated list of filters."`
Username string `arg:"positional"`
Password string `arg:"--password, -p" help:"sets the password for the user"`
Name string `arg:"--name, -n" help:"sets the display name for the user"`
Email string `arg:"--email, -e" help:"sets the email for the user"`
Filter string `arg:"--user-filter" help:"sets the filter for the user. This can be a comma separated list of filters."`
RolesConfigured string `arg:"--user-roles" help:"sets the roles for the user. This can be a comma separated list of roles."`
}
func (g *GenerateCmd) Run(args Args, embeddedCerts embed.FS) error {
@@ -27,11 +28,12 @@ func (g *GenerateCmd) Run(args Args, embeddedCerts embed.FS) error {
}
buffer := auth.GenerateUsers(auth.User{
Username: args.Generate.Username,
Password: args.Generate.Password,
Name: args.Generate.Name,
Email: args.Generate.Email,
Filter: args.Generate.Filter,
Username: args.Generate.Username,
Password: args.Generate.Password,
Name: args.Generate.Name,
Email: args.Generate.Email,
Filter: args.Generate.Filter,
RolesConfigured: args.Generate.RolesConfigured,
}, true)
if _, err := os.Stdout.Write(buffer.Bytes()); err != nil {

View File

@@ -14,11 +14,19 @@ func (h *handler) containerActions(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
userLabels := h.config.Labels
permit := true
if h.config.Authorization.Provider != NONE {
user := auth.UserFromContext(r.Context())
if user.ContainerLabels.Exists() {
userLabels = user.ContainerLabels
}
permit = user.Roles.Has(auth.Actions)
}
if !permit {
log.Warn().Msg("user is not permitted to perform actions on container")
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
}
containerService, err := h.hostService.FindContainer(hostKey(r), id, userLabels)

View File

@@ -20,7 +20,7 @@ func Test_createRoutes_proxy_missing_headers(t *testing.T) {
handler := createHandler(nil, afero.NewIOFS(fs), Config{Base: "/",
Authorization: Authorization{
Provider: FORWARD_PROXY,
Authorizer: auth.NewForwardProxyAuth("Remote-User", "Remote-Email", "Remote-Name", "Remote-Filter"),
Authorizer: auth.NewForwardProxyAuth("Remote-User", "Remote-Email", "Remote-Name", "Remote-Filter", "Remote-RolesConfigured"),
},
})
req, err := http.NewRequest("GET", "/", nil)
@@ -39,7 +39,7 @@ func Test_createRoutes_proxy_happy(t *testing.T) {
handler := createHandler(nil, afero.NewIOFS(fs), Config{Base: "/",
Authorization: Authorization{
Provider: FORWARD_PROXY,
Authorizer: auth.NewForwardProxyAuth("Remote-User", "Remote-Email", "Remote-Name", "Remote-Filter"),
Authorizer: auth.NewForwardProxyAuth("Remote-User", "Remote-Email", "Remote-Name", "Remote-Filter", "Remote-RolesConfigured"),
},
})
req, err := http.NewRequest("GET", "/", nil)

View File

@@ -23,11 +23,19 @@ func (h *handler) downloadLogs(w http.ResponseWriter, r *http.Request) {
}
userLabels := h.config.Labels
permit := true
if h.config.Authorization.Provider != NONE {
user := auth.UserFromContext(r.Context())
if user.ContainerLabels.Exists() {
userLabels = user.ContainerLabels
}
permit = user.Roles.Has(auth.Download)
}
if !permit {
log.Warn().Msg("user is not permitted to download logs from container")
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
}
now := time.Now()

View File

@@ -12,7 +12,6 @@ import (
"github.com/amir20/dozzle/internal/auth"
"github.com/amir20/dozzle/internal/profile"
"github.com/rs/zerolog/log"
)
@@ -45,12 +44,20 @@ func (h *handler) executeTemplate(w http.ResponseWriter, req *http.Request) {
user := auth.UserFromContext(req.Context())
if h.config.Authorization.Provider == NONE || user != nil {
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)
} else {
config["enableShell"] = h.config.EnableShell
config["enableActions"] = h.config.EnableActions
config["enableDownload"] = true
}
config["authProvider"] = h.config.Authorization.Provider
config["version"] = h.config.Version
config["hostname"] = h.config.Hostname
config["hosts"] = hosts
config["enableActions"] = h.config.EnableActions
config["enableShell"] = h.config.EnableShell
config["disableAvatars"] = h.config.DisableAvatars
config["releaseCheckMode"] = h.config.ReleaseCheckMode
}

View File

@@ -27,15 +27,22 @@ func (h *handler) attach(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
userLabels := h.config.Labels
permit := true
if h.config.Authorization.Provider != NONE {
user := auth.UserFromContext(r.Context())
if user.ContainerLabels.Exists() {
userLabels = user.ContainerLabels
}
permit = user.Roles.Has(auth.Shell)
}
if !permit {
log.Warn().Msg("user is not permitted to attach to container")
conn.WriteMessage(websocket.TextMessage, []byte("⛔ Access denied: attaching to this container is forbidden\r\n"))
return
}
containerService, err := h.hostService.FindContainer(hostKey(r), id, userLabels)
if err != nil {
log.Error().Err(err).Msg("error while trying to find container")
return
@@ -60,11 +67,19 @@ func (h *handler) exec(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
userLabels := h.config.Labels
permit := true
if h.config.Authorization.Provider != NONE {
user := auth.UserFromContext(r.Context())
if user.ContainerLabels.Exists() {
userLabels = user.ContainerLabels
}
permit = user.Roles.Has(auth.Shell)
}
if !permit {
log.Warn().Msg("user is not permitted to exec into container")
conn.WriteMessage(websocket.TextMessage, []byte("⛔ Access denied: attaching to this container is forbidden\r\n"))
return
}
containerService, err := h.hostService.FindContainer(hostKey(r), id, userLabels)