diff --git a/internal/bootstrap/service_bootstrap.go b/internal/bootstrap/service_bootstrap.go index 68f5f380..7474ec27 100644 --- a/internal/bootstrap/service_bootstrap.go +++ b/internal/bootstrap/service_bootstrap.go @@ -2,7 +2,6 @@ package bootstrap import ( "fmt" - "os" "github.com/tinyauthapp/tinyauth/internal/service" @@ -126,7 +125,8 @@ func (app *BootstrapApp) setupPolicyEngine() error { Config: app.config, }) policyEngine.RegisterRule(service.RuleIPBypassed, &service.IPBypassedRule{ - Log: app.log, + Log: app.log, + Config: app.config, }) app.services.policyEngine = policyEngine diff --git a/internal/model/config.go b/internal/model/config.go index b5a9842d..5963e431 100644 --- a/internal/model/config.go +++ b/internal/model/config.go @@ -154,8 +154,9 @@ type AddressClaim struct { } type IPConfig struct { - Allow []string `description:"List of allowed IPs or CIDR ranges." yaml:"allow"` - Block []string `description:"List of blocked IPs or CIDR ranges." yaml:"block"` + Allow []string `description:"List of allowed IPs or CIDR ranges." yaml:"allow"` + Block []string `description:"List of blocked IPs or CIDR ranges." yaml:"block"` + Bypass []string `description:"List of IPs or CIDR ranges that bypass authentication entirely." yaml:"bypass"` } type OAuthConfig struct { diff --git a/internal/service/access_controls_rules.go b/internal/service/access_controls_rules.go index 93245c15..e18917b0 100644 --- a/internal/service/access_controls_rules.go +++ b/internal/service/access_controls_rules.go @@ -224,15 +224,18 @@ func (rule *IPAllowedRule) Evaluate(ctx *ACLContext) Effect { } type IPBypassedRule struct { - Log *logger.Logger + Log *logger.Logger + Config model.Config } func (rule *IPBypassedRule) Evaluate(ctx *ACLContext) Effect { - if ctx.ACLs == nil { - return EffectDeny + // merge global and per-app bypass lists + bypassList := append([]string{}, rule.Config.Auth.IP.Bypass...) + if ctx.ACLs != nil { + bypassList = append(bypassList, ctx.ACLs.IP.Bypass...) } - for _, bypassed := range ctx.ACLs.IP.Bypass { + for _, bypassed := range bypassList { match, err := utils.CheckIPFilter(bypassed, ctx.IP.String()) if err != nil { rule.Log.App.Warn().Err(err).Str("item", bypassed).Msg("Invalid IP/CIDR in bypass list") diff --git a/internal/service/access_controls_rules_test.go b/internal/service/access_controls_rules_test.go index 16dde083..d64224d9 100644 --- a/internal/service/access_controls_rules_test.go +++ b/internal/service/access_controls_rules_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/utils/logger" ) @@ -669,23 +670,70 @@ func TestIPBypassedRule(t *testing.T) { log := logger.NewLogger().WithTestConfig() log.Init() - rule := &IPBypassedRule{Log: log} + defaultIPBR := &IPBypassedRule{Log: log} + globBypassIPBR := &IPBypassedRule{ + Log: log, + Config: model.Config{Auth: model.AuthConfig{IP: model.IPConfig{Bypass: []string{"10.0.0.0/24"}}}}, + } tests := []struct { name string + rule *IPBypassedRule ctx *ACLContext expected Effect }{ { - name: "deny when ACLs are nil", + name: "deny when ACLs are nil and no global bypass", + rule: defaultIPBR, ctx: &ACLContext{ ACLs: nil, IP: net.ParseIP("10.0.0.1"), }, expected: EffectDeny, }, + { + name: "allows when ACLs are nil but IP matches global bypass", + rule: globBypassIPBR, + ctx: &ACLContext{ + ACLs: nil, + IP: net.ParseIP("10.0.0.5"), + }, + expected: EffectAllow, + }, + { + name: "denies when ACLs are nil and IP does not match global bypass", + rule: globBypassIPBR, + ctx: &ACLContext{ + ACLs: nil, + IP: net.ParseIP("192.168.1.1"), + }, + expected: EffectDeny, + }, + { + name: "allows when IP matches per-app bypass but not global bypass", + rule: defaultIPBR, + ctx: &ACLContext{ + ACLs: &model.App{ + IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}}, + }, + IP: net.ParseIP("10.0.0.5"), + }, + expected: EffectAllow, + }, + { + name: "allows when IP matches global bypass but not per-app bypass", + rule: globBypassIPBR, + ctx: &ACLContext{ + ACLs: &model.App{ + IP: model.AppIP{Bypass: []string{"172.16.0.0/24"}}, + }, + IP: net.ParseIP("10.0.0.5"), + }, + expected: EffectAllow, + }, { name: "allows when IP matches bypass list", + rule: defaultIPBR, ctx: &ACLContext{ ACLs: &model.App{ IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}}, @@ -696,6 +744,7 @@ func TestIPBypassedRule(t *testing.T) { }, { name: "denies when IP does not match bypass list", + rule: defaultIPBR, ctx: &ACLContext{ ACLs: &model.App{ IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}}, @@ -706,6 +755,7 @@ func TestIPBypassedRule(t *testing.T) { }, { name: "denies when bypass list is empty", + rule: defaultIPBR, ctx: &ACLContext{ ACLs: &model.App{}, IP: net.ParseIP("10.0.0.1"), @@ -714,6 +764,7 @@ func TestIPBypassedRule(t *testing.T) { }, { name: "skips invalid bypass entries and allows on later match", + rule: defaultIPBR, ctx: &ACLContext{ ACLs: &model.App{ IP: model.AppIP{Bypass: []string{"not-an-ip", "10.0.0.1"}}, @@ -726,7 +777,7 @@ func TestIPBypassedRule(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.expected, rule.Evaluate(tt.ctx)) + assert.Equal(t, tt.expected, tt.rule.Evaluate(tt.ctx)) }) } }