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

@@ -15,7 +15,7 @@
<KeyShortcut char="k" :modifiers="['shift', 'meta']" />
</a>
</li>
<li>
<li v-if="enableDownload">
<a :href="downloadUrl" download> <octicon:download-24 /> {{ $t("toolbar.download") }} </a>
</li>
<li v-if="!historical">
@@ -167,7 +167,7 @@ import LogAnalytics from "../LogViewer/LogAnalytics.vue";
import Terminal from "@/components/Terminal.vue";
const { showSearch } = useSearchFilter();
const { enableActions, enableShell } = config;
const { enableActions, enableShell, enableDownload } = config;
const { streamConfig, hasComplexLogs, levels } = useLoggingContext();
const showDrawer = useDrawer();

View File

@@ -15,7 +15,7 @@
<KeyShortcut char="k" :modifiers="['shift', 'meta']" />
</a>
</li>
<li>
<li v-if="enableDownload">
<a :href="downloadUrl" download> <octicon:download-24 /> {{ $t("toolbar.download") }} </a>
</li>
<li>
@@ -88,7 +88,7 @@
<script lang="ts" setup>
const { showSearch } = useSearchFilter();
const { enableDownload } = config;
const clear = defineEmit();
const { streamConfig, showHostname, showContainerName, containers } = useLoggingContext();

View File

@@ -12,6 +12,7 @@ export interface Config {
authProvider: "simple" | "none" | "forward-proxy";
enableActions: boolean;
enableShell: boolean;
enableDownload: boolean;
disableAvatars: boolean;
releaseCheckMode: "automatic" | "manual";
user?: {

View File

@@ -28,6 +28,7 @@ users:
# Generate with docker run -it --rm amir20/dozzle generate --name Admin --email me@email.net --password secret admin
password: $2a$11$9ho4vY2LdJ/WBopFcsAS0uORC0x2vuFHQgT/yBqZyzclhHsoaIkzK
filter:
roles:
```
Dozzle uses `email` to generate avatars using [Gravatar](https://gravatar.com/). It is optional. The password is hashed using `bcrypt` which can be generated using `docker run amir20/dozzle generate`.
@@ -139,15 +140,43 @@ In this example, the `admin` user has no filter, so they can see all containers.
> [!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.
### Setting specific roles for users
Dozzle allows assigning roles to users. Roles define what actions a user can perform on containers. Roles are configured in the users.yml file.
```yaml
users:
admin:
email:
name: Admin
password: $2a$11$9ho4vY2LdJ/WBopFcsAS0uORC0x2vuFHQgT/yBqZyzclhHsoaIkzK
roles:
guest:
email:
name: Guest
password: $2a$11$9ho4vY2LdJ/WBopFcsAS0uORC0x2vuFHQgT/yBqZyzclhHsoaIkzK
roles: shell
```
In this example, the `admin` user has no roles specified, so they have full access to all container actions. The `guest` user has the shell role, meaning they can only open a shell in the containers. Roles make it easy to control and restrict what users can do in Dozzle.
Dozzle supports the following roles:
- **shell** - allows attach and exec in the container.
- **actions** - allows performing container actions (start, stop, restart).
- **download** - allows downloading container logs.
- **none** - denies all actions.
## Generating users.yml
Dozzle has a built-in `generate` command to generate `users.yml`. Here is an example:
```sh
docker run -it --rm amir20/dozzle generate admin --password password --email test@email.net --name "John Doe" --user-filter name=foo > users.yml
docker run -it --rm amir20/dozzle generate admin --password password --email test@email.net --name "John Doe" --user-filter name=foo --user-roles shell > users.yml
```
In this example, `admin` is the username. Email and name are optional but recommended to display accurate avatars. `docker run -it --rm amir20/dozzle generate --help` displays all options. The `--user-filter` flag is a comma-separated list of filters.
In this example, `admin` is the username. Email and name are optional but recommended to display accurate avatars. `docker run -it --rm amir20/dozzle generate --help` displays all options. The `--user-filter` flag is a comma-separated list of filters. The `--user-roles` flag is a comma-separated list of roles.
## Forward Proxy
@@ -179,6 +208,7 @@ In this mode, Dozzle expects the following headers:
- `Remote-Email` to map to the user's email address. This email is also used to find the right [Gravatar](https://gravatar.com/) for the user.
- `Remote-Name` to be a display name like `John Doe`
- `Remote-Filter` to be a comma-separated list of filters allowed for user.
- `Remote-Roles` to be a comma-separated list of roles allowed for user.
### Setting up Dozzle with Authelia

View File

@@ -6,27 +6,29 @@ title: Environment Variables and Subcommands
Configurations can be done with flags or environment variables. The table below outlines all supported options and their respective env vars.
| Flag | Env Variable | Default |
| ---------------------- | --------------------------- | -------------- |
| `--addr` | `DOZZLE_ADDR` | `:8080` |
| `--base` | `DOZZLE_BASE` | `/` |
| `--hostname` | `DOZZLE_HOSTNAME` | `""` |
| `--level` | `DOZZLE_LEVEL` | `info` |
| `--auth-provider` | `DOZZLE_AUTH_PROVIDER` | `none` |
| `--auth-header-user` | `DOZZLE_AUTH_HEADER_USER` | `Remote-User` |
| `--auth-header-email` | `DOZZLE_AUTH_HEADER_EMAIL` | `Remote-Email` |
| `--auth-header-name` | `DOZZLE_AUTH_HEADER_NAME` | `Remote-Name` |
| `--enable-actions` | `DOZZLE_ENABLE_ACTIONS` | `false` |
| `--enable-shell` | `DOZZLE_ENABLE_SHELL` | `false` |
| `--disable-avatars` | `DOZZLE_DISABLE_AVATARS` | `false` |
| `--filter` | `DOZZLE_FILTER` | `""` |
| `--no-analytics` | `DOZZLE_NO_ANALYTICS` | `false` |
| `--mode` | `DOZZLE_MODE` | `server` |
| `--release-check-mode` | `DOZZLE_RELEASE_CHECK_MODE` | `automatic` |
| `--remote-host` | `DOZZLE_REMOTE_HOST` | |
| `--remote-agent` | `DOZZLE_REMOTE_AGENT` | |
| `--timeout` | `DOZZLE_TIMEOUT` | `10s` |
| `--namespace` | `DOZZLE_NAMESPACE` | `""` |
| Flag | Env Variable | Default |
|------------------------|-----------------------------|-----------------|
| `--addr` | `DOZZLE_ADDR` | `:8080` |
| `--base` | `DOZZLE_BASE` | `/` |
| `--hostname` | `DOZZLE_HOSTNAME` | `""` |
| `--level` | `DOZZLE_LEVEL` | `info` |
| `--auth-provider` | `DOZZLE_AUTH_PROVIDER` | `none` |
| `--auth-header-user` | `DOZZLE_AUTH_HEADER_USER` | `Remote-User` |
| `--auth-header-email` | `DOZZLE_AUTH_HEADER_EMAIL` | `Remote-Email` |
| `--auth-header-name` | `DOZZLE_AUTH_HEADER_NAME` | `Remote-Name` |
| `--auth-header-filter` | `DOZZLE_AUTH_HEADER_FILTER` | `Remote-Filter` |
| `--auth-header-roles` | `DOZZLE_AUTH_HEADER_ROLES` | `Remote-Roles` |
| `--enable-actions` | `DOZZLE_ENABLE_ACTIONS` | `false` |
| `--enable-shell` | `DOZZLE_ENABLE_SHELL` | `false` |
| `--disable-avatars` | `DOZZLE_DISABLE_AVATARS` | `false` |
| `--filter` | `DOZZLE_FILTER` | `""` |
| `--no-analytics` | `DOZZLE_NO_ANALYTICS` | `false` |
| `--mode` | `DOZZLE_MODE` | `server` |
| `--release-check-mode` | `DOZZLE_RELEASE_CHECK_MODE` | `automatic` |
| `--remote-host` | `DOZZLE_REMOTE_HOST` | |
| `--remote-agent` | `DOZZLE_REMOTE_AGENT` | |
| `--timeout` | `DOZZLE_TIMEOUT` | `10s` |
| `--namespace` | `DOZZLE_NAMESPACE` | `""` |
> [!TIP]
> Some flags like `--remote-host` or `--remote-agent` can be used multiple times. For example, `--remote-agent 167.99.1.1:7007 --remote-agent 167.99.1.2:7007` or comma-separated `DOZZLE_REMOTE_AGENT=167.99.1.1:7007,167.99.1.2:7007`.
@@ -36,16 +38,18 @@ Configurations can be done with flags or environment variables. The table below
Dozzle supports generating `users.yml` file. This file is used to authenticate users. Here is an example:
```sh
docker run -it --rm amir20/dozzle generate admin --password password --email test@email.net --name "John Doe" > users.yml
docker run -it --rm amir20/dozzle generate admin --password password --email test@email.net --name "John Doe" --user-filter name=foo --user-roles shell > users.yml
```
In this example, `admin` is the username. Email and name are optional but recommended to display accurate avatars. `docker run amir20/dozzle generate --help` displays all options.
| Flag | Description | Default |
| ------------ | ---------------- | ------- |
| `--password` | User's password | |
| `--email` | User's email | |
| `--name` | User's full name | |
| Flag | Description | Default |
|-----------------|------------------| ------- |
| `--password` | User's password | |
| `--email` | User's email | |
| `--name` | User's full name | |
| `--user-filter` | User's filters | |
| `--user-roles` | User's roles | |
See [authentication](/guide/authentication) for more information.

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)

50
main.go
View File

@@ -130,11 +130,11 @@ func main() {
}
func fileExists(filename string) bool {
_, err := os.Stat(filename)
if os.IsNotExist(err) {
return false
}
return err == nil
_, err := os.Stat(filename)
if os.IsNotExist(err) {
return false
}
return err == nil
}
func createServer(args cli.Args, hostService web.HostService) *http.Server {
@@ -143,12 +143,12 @@ func createServer(args cli.Args, hostService web.HostService) *http.Server {
var releaseCheckMode web.ReleaseCheckMode = web.Automatic
switch args.ReleaseCheckMode {
case "automatic":
releaseCheckMode = web.Automatic
case "manual":
releaseCheckMode = web.Manual
default:
log.Fatal().Str("releaseCheckMode", args.ReleaseCheckMode).Msg("Invalid release check mode")
case "automatic":
releaseCheckMode = web.Automatic
case "manual":
releaseCheckMode = web.Manual
default:
log.Fatal().Str("releaseCheckMode", args.ReleaseCheckMode).Msg("Invalid release check mode")
}
var provider web.AuthProvider = web.NONE
@@ -156,25 +156,25 @@ func createServer(args cli.Args, hostService web.HostService) *http.Server {
if args.AuthProvider == "forward-proxy" {
log.Debug().Msg("Using forward proxy authentication")
provider = web.FORWARD_PROXY
authorizer = auth.NewForwardProxyAuth(args.AuthHeaderUser, args.AuthHeaderEmail, args.AuthHeaderName, args.AuthHeaderFilter)
authorizer = auth.NewForwardProxyAuth(args.AuthHeaderUser, args.AuthHeaderEmail, args.AuthHeaderName, args.AuthHeaderFilter, args.AuthHeaderRoles)
} else if args.AuthProvider == "simple" {
log.Debug().Msg("Using simple authentication")
provider = web.SIMPLE
userFilePath := "./data/users.yml"
if !fileExists(userFilePath) {
userFilePath = "./data/users.yaml"
if !fileExists(userFilePath) {
log.Fatal().Msg("No users.yaml or users.yml file found.")
}
}
log.Debug().Msgf("Reading %s file", filepath.Base(userFilePath))
userFilePath := "./data/users.yml"
if !fileExists(userFilePath) {
userFilePath = "./data/users.yaml"
if !fileExists(userFilePath) {
log.Fatal().Msg("No users.yaml or users.yml file found.")
}
}
db, err := auth.ReadUsersFromFile(userFilePath)
if err != nil {
log.Fatal().Err(err).Msgf("Could not read users file: %s", userFilePath)
}
log.Debug().Msgf("Reading %s file", filepath.Base(userFilePath))
db, err := auth.ReadUsersFromFile(userFilePath)
if err != nil {
log.Fatal().Err(err).Msgf("Could not read users file: %s", userFilePath)
}
log.Debug().Int("users", len(db.Users)).Msg("Loaded users")
ttl := time.Duration(0)