Compare commits

...

2 Commits

Author SHA1 Message Date
Stavros b9abab2f17 fix: review comments 2026-05-12 18:17:01 +03:00
Stavros 3fd56272d2 feat: add support for deny by default access controls 2026-05-12 17:21:45 +03:00
7 changed files with 270 additions and 222 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ func (app *BootstrapApp) setupServices() error {
labelProvider = dockerService labelProvider = dockerService
} }
accessControlsService := service.NewAccessControlsService(app.log, &labelProvider, app.config.Apps) accessControlsService := service.NewAccessControlsService(app.log, app.config, &labelProvider)
app.services.accessControlService = accessControlsService app.services.accessControlService = accessControlsService
oauthBrokerService := service.NewOAuthBrokerService(app.log, app.runtime.OAuthProviders, app.ctx) oauthBrokerService := service.NewOAuthBrokerService(app.log, app.runtime.OAuthProviders, app.ctx)
+6 -12
View File
@@ -101,7 +101,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
clientIP := c.ClientIP() clientIP := c.ClientIP()
if controller.auth.IsBypassedIP(clientIP, acls) { if controller.acls.IsIPBypassed(clientIP, acls) {
controller.setHeaders(c, acls) controller.setHeaders(c, acls)
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"status": 200, "status": 200,
@@ -110,13 +110,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
return return
} }
authEnabled, err := controller.auth.IsAuthEnabled(proxyCtx.Path, acls) authEnabled := controller.acls.IsAuthEnabled(proxyCtx.Path, acls)
if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to determine if authentication is enabled for resource")
controller.handleError(c, proxyCtx)
return
}
if !authEnabled { if !authEnabled {
controller.log.App.Debug().Msg("Authentication is disabled for this resource, allowing access without authentication") controller.log.App.Debug().Msg("Authentication is disabled for this resource, allowing access without authentication")
@@ -128,7 +122,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
return return
} }
if !controller.auth.CheckIP(clientIP, acls) { if !controller.acls.IsIPAllowed(clientIP, acls) {
queries, err := query.Values(UnauthorizedQuery{ queries, err := query.Values(UnauthorizedQuery{
Resource: strings.Split(proxyCtx.Host, ".")[0], Resource: strings.Split(proxyCtx.Host, ".")[0],
IP: clientIP, IP: clientIP,
@@ -165,7 +159,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
} }
if userContext.Authenticated { if userContext.Authenticated {
userAllowed := controller.auth.IsUserAllowed(c, *userContext, acls) userAllowed := controller.acls.IsUserAllowed(*userContext, acls)
if !userAllowed { if !userAllowed {
controller.log.App.Warn().Str("user", userContext.GetUsername()).Str("resource", strings.Split(proxyCtx.Host, ".")[0]).Msg("User is not allowed to access resource") controller.log.App.Warn().Str("user", userContext.GetUsername()).Str("resource", strings.Split(proxyCtx.Host, ".")[0]).Msg("User is not allowed to access resource")
@@ -205,9 +199,9 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
var groupOK bool var groupOK bool
if userContext.IsOAuth() { if userContext.IsOAuth() {
groupOK = controller.auth.IsInOAuthGroup(c, *userContext, acls) groupOK = controller.acls.IsInOAuthGroup(*userContext, acls)
} else { } else {
groupOK = controller.auth.IsInLDAPGroup(c, *userContext, acls) groupOK = controller.acls.IsInLDAPGroup(*userContext, acls)
} }
if !groupOK { if !groupOK {
+1 -28
View File
@@ -24,33 +24,6 @@ func TestProxyController(t *testing.T) {
cfg, runtime := test.CreateTestConfigs(t) cfg, runtime := test.CreateTestConfigs(t)
acls := map[string]model.App{
"app_path_allow": {
Config: model.AppConfig{
Domain: "path-allow.example.com",
},
Path: model.AppPath{
Allow: "/allowed",
},
},
"app_user_allow": {
Config: model.AppConfig{
Domain: "user-allow.example.com",
},
Users: model.AppUsers{
Allow: "testuser",
},
},
"ip_bypass": {
Config: model.AppConfig{
Domain: "ip-bypass.example.com",
},
IP: model.AppIP{
Bypass: []string{"10.10.10.10"},
},
},
}
const browserUserAgent = ` const browserUserAgent = `
Mozilla/5.0 (Linux; Android 8.0.0; SM-G955U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36` Mozilla/5.0 (Linux; Android 8.0.0; SM-G955U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36`
@@ -391,7 +364,7 @@ func TestProxyController(t *testing.T) {
broker := service.NewOAuthBrokerService(log, map[string]model.OAuthServiceConfig{}, ctx) broker := service.NewOAuthBrokerService(log, map[string]model.OAuthServiceConfig{}, ctx)
authService := service.NewAuthService(log, cfg, runtime, ctx, wg, nil, queries, broker) authService := service.NewAuthService(log, cfg, runtime, ctx, wg, nil, queries, broker)
aclsService := service.NewAccessControlsService(log, nil, acls) aclsService := service.NewAccessControlsService(log, cfg, nil)
for _, test := range tests { for _, test := range tests {
t.Run(test.description, func(t *testing.T) { t.Run(test.description, func(t *testing.T) {
+8
View File
@@ -24,6 +24,9 @@ func NewDefaultConfiguration() *Config {
SessionMaxLifetime: 0, // disabled SessionMaxLifetime: 0, // disabled
LoginTimeout: 300, // 5 minutes LoginTimeout: 300, // 5 minutes
LoginMaxRetries: 3, LoginMaxRetries: 3,
ACLs: ACLsConfig{
Policy: "allow",
},
}, },
UI: UIConfig{ UI: UIConfig{
Title: "Tinyauth", Title: "Tinyauth",
@@ -114,6 +117,7 @@ type AuthConfig struct {
LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout"` LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout"`
LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries"` LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries"`
TrustedProxies []string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies"` TrustedProxies []string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies"`
ACLs ACLsConfig `description:"ACLs configuration." yaml:"acls"`
} }
type UserAttributes struct { type UserAttributes struct {
@@ -223,6 +227,10 @@ type OIDCClientConfig struct {
Name string `description:"Client name in UI." yaml:"name"` Name string `description:"Client name in UI." yaml:"name"`
} }
type ACLsConfig struct {
Policy string `description:"ACL policy for allow-by-default or deny-by-default, available options are allow and deny, default is allow." yaml:"policy"`
}
// ACLs // ACLs
type Apps struct { type Apps struct {
+225 -14
View File
@@ -1,44 +1,82 @@
package service package service
import ( import (
"regexp"
"strings" "strings"
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/logger"
) )
type AccessControlPolicy string
const (
PolicyAllow AccessControlPolicy = "allow"
PolicyDeny AccessControlPolicy = "deny"
)
func accessControlPolicyFromString(s string) (AccessControlPolicy, bool) {
switch strings.ToLower(s) {
case "allow":
return PolicyAllow, true
case "deny":
return PolicyDeny, true
default:
return PolicyAllow, false
}
}
type LabelProvider interface { type LabelProvider interface {
GetLabels(appDomain string) (*model.App, error) GetLabels(appDomain string) (*model.App, error)
} }
type AccessControlsService struct { type AccessControlsService struct {
log *logger.Logger log *logger.Logger
config model.Config
labelProvider *LabelProvider labelProvider *LabelProvider
static map[string]model.App policy AccessControlPolicy
} }
func NewAccessControlsService( func NewAccessControlsService(
log *logger.Logger, log *logger.Logger,
labelProvider *LabelProvider, config model.Config,
static map[string]model.App) *AccessControlsService { labelProvider *LabelProvider) *AccessControlsService {
return &AccessControlsService{
service := AccessControlsService{
log: log, log: log,
config: config,
labelProvider: labelProvider, labelProvider: labelProvider,
static: static,
} }
policy, ok := accessControlPolicyFromString(config.Auth.ACLs.Policy)
if !ok {
log.App.Warn().Str("policy", config.Auth.ACLs.Policy).Msg("Invalid ACL policy in config, defaulting to 'allow'")
}
if policy == PolicyAllow {
log.App.Debug().Msg("Using 'allow' ACL policy: access to apps will be allowed by default unless explicitly blocked")
} else {
log.App.Debug().Msg("Using 'deny' ACL policy: access to apps will be blocked by default unless explicitly allowed")
}
service.policy = policy
return &service
} }
func (acls *AccessControlsService) lookupStaticACLs(domain string) *model.App { func (service *AccessControlsService) lookupStaticACLs(domain string) *model.App {
var appAcls *model.App var appAcls *model.App
for app, config := range acls.static { for app, config := range service.config.Apps {
if config.Config.Domain == domain { if config.Config.Domain == domain {
acls.log.App.Debug().Str("name", app).Msg("Found matching container by domain") service.log.App.Debug().Str("name", app).Msg("Found matching container by domain")
appAcls = &config appAcls = &config
break // If we find a match by domain, we can stop searching break // If we find a match by domain, we can stop searching
} }
if strings.SplitN(domain, ".", 2)[0] == app { if strings.SplitN(domain, ".", 2)[0] == app {
acls.log.App.Debug().Str("name", app).Msg("Found matching container by app name") service.log.App.Debug().Str("name", app).Msg("Found matching container by app name")
appAcls = &config appAcls = &config
break // If we find a match by app name, we can stop searching break // If we find a match by app name, we can stop searching
} }
@@ -46,20 +84,193 @@ func (acls *AccessControlsService) lookupStaticACLs(domain string) *model.App {
return appAcls return appAcls
} }
func (acls *AccessControlsService) GetAccessControls(domain string) (*model.App, error) { func (service *AccessControlsService) GetAccessControls(domain string) (*model.App, error) {
// First check in the static config // First check in the static config
app := acls.lookupStaticACLs(domain) app := service.lookupStaticACLs(domain)
if app != nil { if app != nil {
acls.log.App.Debug().Msg("Using static ACLs for app") service.log.App.Debug().Msg("Using static ACLs for app")
return app, nil return app, nil
} }
// If we have a label provider configured, try to get ACLs from it // If we have a label provider configured, try to get ACLs from it
if acls.labelProvider != nil { if service.labelProvider != nil {
return (*acls.labelProvider).GetLabels(domain) return (*service.labelProvider).GetLabels(domain)
} }
// no labels // no labels
return nil, nil return nil, nil
} }
func (service *AccessControlsService) IsUserAllowed(context model.UserContext, acls *model.App) bool {
if acls == nil {
return service.policyResult(true)
}
if context.Provider == model.ProviderOAuth {
service.log.App.Debug().Msg("User is an OAuth user, checking OAuth whitelist")
return utils.CheckFilter(acls.OAuth.Whitelist, context.OAuth.Email)
}
if acls.Users.Block != "" {
service.log.App.Debug().Msg("Checking users block list")
if utils.CheckFilter(acls.Users.Block, context.GetUsername()) {
return false
}
}
service.log.App.Debug().Msg("Checking users allow list")
return service.policyResult(utils.CheckFilter(acls.Users.Allow, context.GetUsername()))
}
func (service *AccessControlsService) IsInOAuthGroup(context model.UserContext, acls *model.App) bool {
if acls == nil {
return true
}
if !context.IsOAuth() {
service.log.App.Debug().Msg("User is not an OAuth user, skipping OAuth group check")
return false
}
if _, ok := model.OverrideProviders[context.OAuth.ID]; ok {
service.log.App.Debug().Str("provider", context.OAuth.ID).Msg("Provider override detected, skipping group check")
return true
}
for _, userGroup := range context.OAuth.Groups {
if utils.CheckFilter(acls.OAuth.Groups, strings.TrimSpace(userGroup)) {
service.log.App.Trace().Str("group", userGroup).Str("required", acls.OAuth.Groups).Msg("User group matched")
return true
}
}
service.log.App.Debug().Msg("No groups matched")
return false
}
func (service *AccessControlsService) IsInLDAPGroup(context model.UserContext, acls *model.App) bool {
if acls == nil {
return true
}
if !context.IsLDAP() {
service.log.App.Debug().Msg("User is not an LDAP user, skipping LDAP group check")
return false
}
for _, userGroup := range context.LDAP.Groups {
if utils.CheckFilter(acls.LDAP.Groups, strings.TrimSpace(userGroup)) {
service.log.App.Trace().Str("group", userGroup).Str("required", acls.LDAP.Groups).Msg("User group matched")
return true
}
}
service.log.App.Debug().Msg("No groups matched")
return false
}
func (service *AccessControlsService) IsAuthEnabled(uri string, acls *model.App) bool {
if acls == nil {
return true
}
if acls.Path.Block != "" {
regex, err := regexp.Compile(acls.Path.Block)
if err != nil {
service.log.App.Error().Err(err).Msg("Failed to compile block regex")
return true
}
if !regex.MatchString(uri) {
return false
}
}
if acls.Path.Allow != "" {
regex, err := regexp.Compile(acls.Path.Allow)
if err != nil {
service.log.App.Error().Err(err).Msg("Failed to compile allow regex")
return true
}
if regex.MatchString(uri) {
return false
}
}
return true
}
func (service *AccessControlsService) IsIPAllowed(ip string, acls *model.App) bool {
if acls == nil {
return service.policyResult(true)
}
// Merge the global and app IP filter
blockedIps := append(acls.IP.Block, service.config.Auth.IP.Block...)
allowedIPs := append(acls.IP.Allow, service.config.Auth.IP.Allow...)
for _, blocked := range blockedIps {
res, err := utils.FilterIP(blocked, ip)
if err != nil {
service.log.App.Warn().Err(err).Str("item", blocked).Msg("Invalid IP/CIDR in block list")
continue
}
if res {
service.log.App.Debug().Str("ip", ip).Str("item", blocked).Msg("IP is in block list, denying access")
return false
}
}
for _, allowed := range allowedIPs {
res, err := utils.FilterIP(allowed, ip)
if err != nil {
service.log.App.Warn().Err(err).Str("item", allowed).Msg("Invalid IP/CIDR in allow list")
continue
}
if res {
service.log.App.Debug().Str("ip", ip).Str("item", allowed).Msg("IP is in allow list, allowing access")
return true
}
}
if len(allowedIPs) > 0 {
service.log.App.Debug().Str("ip", ip).Msg("IP not in allow list, denying access")
return false
}
service.log.App.Debug().Str("ip", ip).Msg("IP not in block or allow list, allowing access")
return service.policyResult(true)
}
func (service *AccessControlsService) IsIPBypassed(ip string, acls *model.App) bool {
if acls == nil {
return false
}
for _, bypassed := range acls.IP.Bypass {
res, err := utils.FilterIP(bypassed, ip)
if err != nil {
service.log.App.Warn().Err(err).Str("item", bypassed).Msg("Invalid IP/CIDR in bypass list")
continue
}
if res {
service.log.App.Debug().Str("ip", ip).Str("item", bypassed).Msg("IP is in bypass list, skipping authentication")
return true
}
}
service.log.App.Debug().Str("ip", ip).Msg("IP not in bypass list, proceeding with authentication")
return false
}
func (service *AccessControlsService) policyResult(result bool) bool {
if service.policy == PolicyAllow {
return result
} else {
return !result
}
}
-167
View File
@@ -6,7 +6,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"regexp"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -18,7 +17,6 @@ import (
"slices" "slices"
"github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"golang.org/x/oauth2" "golang.org/x/oauth2"
@@ -454,171 +452,6 @@ func (auth *AuthService) LDAPAuthConfigured() bool {
return auth.ldap != nil return auth.ldap != nil
} }
func (auth *AuthService) IsUserAllowed(c *gin.Context, context model.UserContext, acls *model.App) bool {
if acls == nil {
return true
}
if context.Provider == model.ProviderOAuth {
auth.log.App.Debug().Msg("User is an OAuth user, checking OAuth whitelist")
return utils.CheckFilter(acls.OAuth.Whitelist, context.OAuth.Email)
}
if acls.Users.Block != "" {
auth.log.App.Debug().Msg("Checking users block list")
if utils.CheckFilter(acls.Users.Block, context.GetUsername()) {
return false
}
}
auth.log.App.Debug().Msg("Checking users allow list")
return utils.CheckFilter(acls.Users.Allow, context.GetUsername())
}
func (auth *AuthService) IsInOAuthGroup(c *gin.Context, context model.UserContext, acls *model.App) bool {
if acls == nil {
return true
}
if !context.IsOAuth() {
auth.log.App.Debug().Msg("User is not an OAuth user, skipping OAuth group check")
return false
}
if _, ok := model.OverrideProviders[context.OAuth.ID]; ok {
auth.log.App.Debug().Str("provider", context.OAuth.ID).Msg("Provider override detected, skipping group check")
return true
}
for _, userGroup := range context.OAuth.Groups {
if utils.CheckFilter(acls.OAuth.Groups, strings.TrimSpace(userGroup)) {
auth.log.App.Trace().Str("group", userGroup).Str("required", acls.OAuth.Groups).Msg("User group matched")
return true
}
}
auth.log.App.Debug().Msg("No groups matched")
return false
}
func (auth *AuthService) IsInLDAPGroup(c *gin.Context, context model.UserContext, acls *model.App) bool {
if acls == nil {
return true
}
if !context.IsLDAP() {
auth.log.App.Debug().Msg("User is not an LDAP user, skipping LDAP group check")
return false
}
for _, userGroup := range context.LDAP.Groups {
if utils.CheckFilter(acls.LDAP.Groups, strings.TrimSpace(userGroup)) {
auth.log.App.Trace().Str("group", userGroup).Str("required", acls.LDAP.Groups).Msg("User group matched")
return true
}
}
auth.log.App.Debug().Msg("No groups matched")
return false
}
func (auth *AuthService) IsAuthEnabled(uri string, acls *model.App) (bool, error) {
if acls == nil {
return true, nil
}
// Check for block list
if acls.Path.Block != "" {
regex, err := regexp.Compile(acls.Path.Block)
if err != nil {
return true, err
}
if !regex.MatchString(uri) {
return false, nil
}
}
// Check for allow list
if acls.Path.Allow != "" {
regex, err := regexp.Compile(acls.Path.Allow)
if err != nil {
return true, err
}
if regex.MatchString(uri) {
return false, nil
}
}
return true, nil
}
func (auth *AuthService) CheckIP(ip string, acls *model.App) bool {
if acls == nil {
return true
}
// Merge the global and app IP filter
blockedIps := append(auth.config.Auth.IP.Block, acls.IP.Block...)
allowedIPs := append(auth.config.Auth.IP.Allow, acls.IP.Allow...)
for _, blocked := range blockedIps {
res, err := utils.FilterIP(blocked, ip)
if err != nil {
auth.log.App.Warn().Err(err).Str("item", blocked).Msg("Invalid IP/CIDR in block list")
continue
}
if res {
auth.log.App.Debug().Str("ip", ip).Str("item", blocked).Msg("IP is in block list, denying access")
return false
}
}
for _, allowed := range allowedIPs {
res, err := utils.FilterIP(allowed, ip)
if err != nil {
auth.log.App.Warn().Err(err).Str("item", allowed).Msg("Invalid IP/CIDR in allow list")
continue
}
if res {
auth.log.App.Debug().Str("ip", ip).Str("item", allowed).Msg("IP is in allow list, allowing access")
return true
}
}
if len(allowedIPs) > 0 {
auth.log.App.Debug().Str("ip", ip).Msg("IP not in allow list, denying access")
return false
}
auth.log.App.Debug().Str("ip", ip).Msg("IP not in any block or allow list, allowing access by default")
return true
}
func (auth *AuthService) IsBypassedIP(ip string, acls *model.App) bool {
if acls == nil {
return false
}
for _, bypassed := range acls.IP.Bypass {
res, err := utils.FilterIP(bypassed, ip)
if err != nil {
auth.log.App.Warn().Err(err).Str("item", bypassed).Msg("Invalid IP/CIDR in bypass list")
continue
}
if res {
auth.log.App.Debug().Str("ip", ip).Str("item", bypassed).Msg("IP is in bypass list, skipping authentication")
return true
}
}
auth.log.App.Debug().Str("ip", ip).Msg("IP not in bypass list, proceeding with authentication")
return false
}
func (auth *AuthService) NewOAuthSession(serviceName string, params OAuthURLParams) (string, OAuthPendingSession, error) { func (auth *AuthService) NewOAuthSession(serviceName string, params OAuthURLParams) (string, OAuthPendingSession, error) {
auth.ensureOAuthSessionLimit() auth.ensureOAuthSessionLimit()
+29
View File
@@ -40,6 +40,9 @@ func CreateTestConfigs(t *testing.T) (model.Config, model.RuntimeConfig) {
SessionExpiry: 10, SessionExpiry: 10,
LoginTimeout: 10, LoginTimeout: 10,
LoginMaxRetries: 3, LoginMaxRetries: 3,
ACLs: model.ACLsConfig{
Policy: "allow",
},
}, },
Database: model.DatabaseConfig{ Database: model.DatabaseConfig{
Path: filepath.Join(tempDir, "test.db"), Path: filepath.Join(tempDir, "test.db"),
@@ -48,6 +51,32 @@ func CreateTestConfigs(t *testing.T) (model.Config, model.RuntimeConfig) {
Enabled: true, Enabled: true,
Path: filepath.Join(tempDir, "resources"), Path: filepath.Join(tempDir, "resources"),
}, },
Apps: map[string]model.App{
"app_path_allow": {
Config: model.AppConfig{
Domain: "path-allow.example.com",
},
Path: model.AppPath{
Allow: "/allowed",
},
},
"app_user_allow": {
Config: model.AppConfig{
Domain: "user-allow.example.com",
},
Users: model.AppUsers{
Allow: "testuser",
},
},
"ip_bypass": {
Config: model.AppConfig{
Domain: "ip-bypass.example.com",
},
IP: model.AppIP{
Bypass: []string{"10.10.10.10"},
},
},
},
} }
passwd, err := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost) passwd, err := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost)