From c5bb389258ef459315b5f0c2163988eebea2fa83 Mon Sep 17 00:00:00 2001 From: Chris Ellrich <36817049+chrellrich@users.noreply.github.com> Date: Tue, 21 Oct 2025 15:02:31 +0200 Subject: [PATCH] feat: ACL labels from environment variables (#422) * feat: add LabelService to retrieve application labels from environment variables * feat: allow usage of labels from docker and env variables simultaneously Prioritize labels from environment variables over labels from docker labels * fix: handle error returned by label_serive.go/LoadLabels see https://github.com/steveiliop56/tinyauth/pull/422#discussion_r2443443032 * refactor(label_service): use simple loop instead of slices.ContainsFunc to avoid experimental slices package see https://github.com/steveiliop56/tinyauth/pull/422#pullrequestreview-3354632045 * refactor: merge acl logic into one service --------- Co-authored-by: Stavros --- internal/bootstrap/app_bootstrap.go | 6 +- internal/controller/proxy_controller.go | 41 ++++---- internal/controller/proxy_controller_test.go | 7 +- internal/service/access_controls_service.go | 103 +++++++++++++++++++ internal/service/auth_service.go | 22 ++-- 5 files changed, 145 insertions(+), 34 deletions(-) create mode 100644 internal/service/access_controls_service.go diff --git a/internal/bootstrap/app_bootstrap.go b/internal/bootstrap/app_bootstrap.go index 498b41c..fdbd382 100644 --- a/internal/bootstrap/app_bootstrap.go +++ b/internal/bootstrap/app_bootstrap.go @@ -139,12 +139,14 @@ func (app *BootstrapApp) Setup() error { // Create services dockerService := service.NewDockerService() + aclsService := service.NewAccessControlsService(dockerService) authService := service.NewAuthService(authConfig, dockerService, ldapService, database) oauthBrokerService := service.NewOAuthBrokerService(oauthProviders) - // Initialize services + // Initialize services (order matters) services := []Service{ dockerService, + aclsService, authService, oauthBrokerService, } @@ -246,7 +248,7 @@ func (app *BootstrapApp) Setup() error { proxyController := controller.NewProxyController(controller.ProxyControllerConfig{ AppURL: app.config.AppURL, - }, apiRouter, dockerService, authService) + }, apiRouter, aclsService, authService) userController := controller.NewUserController(controller.UserControllerConfig{ CookieDomain: cookieDomain, diff --git a/internal/controller/proxy_controller.go b/internal/controller/proxy_controller.go index 8ded9dc..2b6738a 100644 --- a/internal/controller/proxy_controller.go +++ b/internal/controller/proxy_controller.go @@ -24,15 +24,15 @@ type ProxyControllerConfig struct { type ProxyController struct { config ProxyControllerConfig router *gin.RouterGroup - docker *service.DockerService + acls *service.AccessControlsService auth *service.AuthService } -func NewProxyController(config ProxyControllerConfig, router *gin.RouterGroup, docker *service.DockerService, auth *service.AuthService) *ProxyController { +func NewProxyController(config ProxyControllerConfig, router *gin.RouterGroup, acls *service.AccessControlsService, auth *service.AuthService) *ProxyController { return &ProxyController{ config: config, router: router, - docker: docker, + acls: acls, auth: auth, } } @@ -76,20 +76,21 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { proto := c.Request.Header.Get("X-Forwarded-Proto") host := c.Request.Header.Get("X-Forwarded-Host") - labels, err := controller.docker.GetLabels(host) + // Get acls + acls, err := controller.acls.GetAccessControls(host) if err != nil { - log.Error().Err(err).Msg("Failed to get labels from Docker") + log.Error().Err(err).Msg("Failed to get access controls for resource") controller.handleError(c, req, isBrowser) return } - log.Trace().Interface("labels", labels).Msg("Labels for resource") + log.Trace().Interface("acls", acls).Msg("ACLs for resource") clientIP := c.ClientIP() - if controller.auth.IsBypassedIP(labels.IP, clientIP) { - controller.setHeaders(c, labels) + if controller.auth.IsBypassedIP(acls.IP, clientIP) { + controller.setHeaders(c, acls) c.JSON(200, gin.H{ "status": 200, "message": "Authenticated", @@ -97,7 +98,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { return } - authEnabled, err := controller.auth.IsAuthEnabled(uri, labels.Path) + authEnabled, err := controller.auth.IsAuthEnabled(uri, acls.Path) if err != nil { log.Error().Err(err).Msg("Failed to check if auth is enabled for resource") @@ -107,7 +108,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { if !authEnabled { log.Debug().Msg("Authentication disabled for resource, allowing access") - controller.setHeaders(c, labels) + controller.setHeaders(c, acls) c.JSON(200, gin.H{ "status": 200, "message": "Authenticated", @@ -115,7 +116,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { return } - if !controller.auth.CheckIP(labels.IP, clientIP) { + if !controller.auth.CheckIP(acls.IP, clientIP) { if req.Proxy == "nginx" || !isBrowser { c.JSON(401, gin.H{ "status": 401, @@ -160,7 +161,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { } if userContext.IsLoggedIn { - appAllowed := controller.auth.IsResourceAllowed(c, userContext, labels) + appAllowed := controller.auth.IsResourceAllowed(c, userContext, acls) if !appAllowed { log.Warn().Str("user", userContext.Username).Str("resource", strings.Split(host, ".")[0]).Msg("User not allowed to access resource") @@ -194,7 +195,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { } if userContext.OAuth { - groupOK := controller.auth.IsInOAuthGroup(c, userContext, labels.OAuth.Groups) + groupOK := controller.auth.IsInOAuthGroup(c, userContext, acls.OAuth.Groups) if !groupOK { log.Warn().Str("user", userContext.Username).Str("resource", strings.Split(host, ".")[0]).Msg("User OAuth groups do not match resource requirements") @@ -234,7 +235,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { c.Header("Remote-Email", utils.SanitizeHeader(userContext.Email)) c.Header("Remote-Groups", utils.SanitizeHeader(userContext.OAuthGroups)) - controller.setHeaders(c, labels) + controller.setHeaders(c, acls) c.JSON(200, gin.H{ "status": 200, @@ -264,21 +265,21 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/login?%s", controller.config.AppURL, queries.Encode())) } -func (controller *ProxyController) setHeaders(c *gin.Context, labels config.App) { +func (controller *ProxyController) setHeaders(c *gin.Context, acls config.App) { c.Header("Authorization", c.Request.Header.Get("Authorization")) - headers := utils.ParseHeaders(labels.Response.Headers) + headers := utils.ParseHeaders(acls.Response.Headers) for key, value := range headers { log.Debug().Str("header", key).Msg("Setting header") c.Header(key, value) } - basicPassword := utils.GetSecret(labels.Response.BasicAuth.Password, labels.Response.BasicAuth.PasswordFile) + basicPassword := utils.GetSecret(acls.Response.BasicAuth.Password, acls.Response.BasicAuth.PasswordFile) - if labels.Response.BasicAuth.Username != "" && basicPassword != "" { - log.Debug().Str("username", labels.Response.BasicAuth.Username).Msg("Setting basic auth header") - c.Header("Authorization", fmt.Sprintf("Basic %s", utils.GetBasicAuth(labels.Response.BasicAuth.Username, basicPassword))) + if acls.Response.BasicAuth.Username != "" && basicPassword != "" { + log.Debug().Str("username", acls.Response.BasicAuth.Username).Msg("Setting basic auth header") + c.Header("Authorization", fmt.Sprintf("Basic %s", utils.GetBasicAuth(acls.Response.BasicAuth.Username, basicPassword))) } } diff --git a/internal/controller/proxy_controller_test.go b/internal/controller/proxy_controller_test.go index fce2ec3..e7e27cf 100644 --- a/internal/controller/proxy_controller_test.go +++ b/internal/controller/proxy_controller_test.go @@ -39,6 +39,11 @@ func setupProxyController(t *testing.T, middlewares *[]gin.HandlerFunc) (*gin.En assert.NilError(t, dockerService.Init()) + // Access controls + accessControlsService := service.NewAccessControlsService(dockerService) + + assert.NilError(t, accessControlsService.Init()) + // Auth service authService := service.NewAuthService(service.AuthServiceConfig{ Users: []config.User{ @@ -59,7 +64,7 @@ func setupProxyController(t *testing.T, middlewares *[]gin.HandlerFunc) (*gin.En // Controller ctrl := controller.NewProxyController(controller.ProxyControllerConfig{ AppURL: "http://localhost:8080", - }, group, dockerService, authService) + }, group, accessControlsService, authService) ctrl.SetupRoutes() return router, recorder, authService diff --git a/internal/service/access_controls_service.go b/internal/service/access_controls_service.go new file mode 100644 index 0000000..cde27e5 --- /dev/null +++ b/internal/service/access_controls_service.go @@ -0,0 +1,103 @@ +package service + +import ( + "os" + "strings" + "tinyauth/internal/config" + "tinyauth/internal/utils/decoders" + + "github.com/rs/zerolog/log" +) + +type AccessControlsService struct { + docker *DockerService + envACLs config.Apps +} + +func NewAccessControlsService(docker *DockerService) *AccessControlsService { + return &AccessControlsService{ + docker: docker, + } +} + +func (acls *AccessControlsService) Init() error { + acls.envACLs = config.Apps{} + env := os.Environ() + appEnvVars := []string{} + + for _, e := range env { + if strings.HasPrefix(e, "TINYAUTH_APPS_") { + appEnvVars = append(appEnvVars, e) + } + } + + err := acls.loadEnvACLs(appEnvVars) + + if err != nil { + return err + } + + return nil +} + +func (acls *AccessControlsService) loadEnvACLs(appEnvVars []string) error { + if len(appEnvVars) == 0 { + return nil + } + + envAcls := map[string]string{} + + for _, e := range appEnvVars { + parts := strings.SplitN(e, "=", 2) + if len(parts) != 2 { + continue + } + + // Normalize key, this should use the same normalization logic as in utils/decoders/decoders.go + key := parts[0] + key = strings.ToLower(key) + key = strings.ReplaceAll(key, "_", ".") + value := parts[1] + envAcls[key] = value + } + + apps, err := decoders.DecodeLabels(envAcls) + + if err != nil { + return err + } + + acls.envACLs = apps + return nil +} + +func (acls *AccessControlsService) lookupEnvACLs(appDomain string) *config.App { + if len(acls.envACLs.Apps) == 0 { + return nil + } + + for appName, appACLs := range acls.envACLs.Apps { + if appACLs.Config.Domain == appDomain { + return &appACLs + } + + if strings.SplitN(appDomain, ".", 2)[0] == appName { + return &appACLs + } + } + + return nil +} + +func (acls *AccessControlsService) GetAccessControls(appDomain string) (config.App, error) { + // First check environment variables + envACLs := acls.lookupEnvACLs(appDomain) + + if envACLs != nil { + log.Debug().Str("domain", appDomain).Msg("Found matching access controls in environment variables") + return *envACLs, nil + } + + // Fallback to Docker labels + return acls.docker.GetLabels(appDomain) +} diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index afcd185..a75bb04 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -289,21 +289,21 @@ func (auth *AuthService) UserAuthConfigured() bool { return len(auth.config.Users) > 0 || auth.ldap != nil } -func (auth *AuthService) IsResourceAllowed(c *gin.Context, context config.UserContext, labels config.App) bool { +func (auth *AuthService) IsResourceAllowed(c *gin.Context, context config.UserContext, acls config.App) bool { if context.OAuth { log.Debug().Msg("Checking OAuth whitelist") - return utils.CheckFilter(labels.OAuth.Whitelist, context.Email) + return utils.CheckFilter(acls.OAuth.Whitelist, context.Email) } - if labels.Users.Block != "" { + if acls.Users.Block != "" { log.Debug().Msg("Checking blocked users") - if utils.CheckFilter(labels.Users.Block, context.Username) { + if utils.CheckFilter(acls.Users.Block, context.Username) { return false } } log.Debug().Msg("Checking users") - return utils.CheckFilter(labels.Users.Allow, context.Username) + return utils.CheckFilter(acls.Users.Allow, context.Username) } func (auth *AuthService) IsInOAuthGroup(c *gin.Context, context config.UserContext, requiredGroups string) bool { @@ -371,8 +371,8 @@ func (auth *AuthService) GetBasicAuth(c *gin.Context) *config.User { } } -func (auth *AuthService) CheckIP(labels config.AppIP, ip string) bool { - for _, blocked := range labels.Block { +func (auth *AuthService) CheckIP(acls config.AppIP, ip string) bool { + for _, blocked := range acls.Block { res, err := utils.FilterIP(blocked, ip) if err != nil { log.Warn().Err(err).Str("item", blocked).Msg("Invalid IP/CIDR in block list") @@ -384,7 +384,7 @@ func (auth *AuthService) CheckIP(labels config.AppIP, ip string) bool { } } - for _, allowed := range labels.Allow { + for _, allowed := range acls.Allow { res, err := utils.FilterIP(allowed, ip) if err != nil { log.Warn().Err(err).Str("item", allowed).Msg("Invalid IP/CIDR in allow list") @@ -396,7 +396,7 @@ func (auth *AuthService) CheckIP(labels config.AppIP, ip string) bool { } } - if len(labels.Allow) > 0 { + if len(acls.Allow) > 0 { log.Debug().Str("ip", ip).Msg("IP not in allow list, denying access") return false } @@ -405,8 +405,8 @@ func (auth *AuthService) CheckIP(labels config.AppIP, ip string) bool { return true } -func (auth *AuthService) IsBypassedIP(labels config.AppIP, ip string) bool { - for _, bypassed := range labels.Bypass { +func (auth *AuthService) IsBypassedIP(acls config.AppIP, ip string) bool { + for _, bypassed := range acls.Bypass { res, err := utils.FilterIP(bypassed, ip) if err != nil { log.Warn().Err(err).Str("item", bypassed).Msg("Invalid IP/CIDR in bypass list")