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:
@@ -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
42
internal/auth/roles.go
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user