Compare commits

..

1 Commits

Author SHA1 Message Date
Scott McKendry 1f446d5897 feat(config): allow global bypass by ip 2026-05-22 07:19:38 +12:00
4 changed files with 66 additions and 11 deletions
+2 -2
View File
@@ -2,7 +2,6 @@ package bootstrap
import ( import (
"fmt" "fmt"
"os" "os"
"github.com/tinyauthapp/tinyauth/internal/service" "github.com/tinyauthapp/tinyauth/internal/service"
@@ -126,7 +125,8 @@ func (app *BootstrapApp) setupPolicyEngine() error {
Config: app.config, Config: app.config,
}) })
policyEngine.RegisterRule(service.RuleIPBypassed, &service.IPBypassedRule{ policyEngine.RegisterRule(service.RuleIPBypassed, &service.IPBypassedRule{
Log: app.log, Log: app.log,
Config: app.config,
}) })
app.services.policyEngine = policyEngine app.services.policyEngine = policyEngine
+3 -2
View File
@@ -154,8 +154,9 @@ type AddressClaim struct {
} }
type IPConfig struct { type IPConfig struct {
Allow []string `description:"List of allowed IPs or CIDR ranges." yaml:"allow"` Allow []string `description:"List of allowed IPs or CIDR ranges." yaml:"allow"`
Block []string `description:"List of blocked IPs or CIDR ranges." yaml:"block"` 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 { type OAuthConfig struct {
+7 -4
View File
@@ -224,15 +224,18 @@ func (rule *IPAllowedRule) Evaluate(ctx *ACLContext) Effect {
} }
type IPBypassedRule struct { type IPBypassedRule struct {
Log *logger.Logger Log *logger.Logger
Config model.Config
} }
func (rule *IPBypassedRule) Evaluate(ctx *ACLContext) Effect { func (rule *IPBypassedRule) Evaluate(ctx *ACLContext) Effect {
if ctx.ACLs == nil { // merge global and per-app bypass lists
return EffectDeny 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()) match, err := utils.CheckIPFilter(bypassed, ctx.IP.String())
if err != nil { if err != nil {
rule.Log.App.Warn().Err(err).Str("item", bypassed).Msg("Invalid IP/CIDR in bypass list") rule.Log.App.Warn().Err(err).Str("item", bypassed).Msg("Invalid IP/CIDR in bypass list")
+54 -3
View File
@@ -5,6 +5,7 @@ import (
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/logger"
) )
@@ -669,23 +670,70 @@ func TestIPBypassedRule(t *testing.T) {
log := logger.NewLogger().WithTestConfig() log := logger.NewLogger().WithTestConfig()
log.Init() 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 { tests := []struct {
name string name string
rule *IPBypassedRule
ctx *ACLContext ctx *ACLContext
expected Effect expected Effect
}{ }{
{ {
name: "deny when ACLs are nil", name: "deny when ACLs are nil and no global bypass",
rule: defaultIPBR,
ctx: &ACLContext{ ctx: &ACLContext{
ACLs: nil, ACLs: nil,
IP: net.ParseIP("10.0.0.1"), IP: net.ParseIP("10.0.0.1"),
}, },
expected: EffectDeny, 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", name: "allows when IP matches bypass list",
rule: defaultIPBR,
ctx: &ACLContext{ ctx: &ACLContext{
ACLs: &model.App{ ACLs: &model.App{
IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}}, 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", name: "denies when IP does not match bypass list",
rule: defaultIPBR,
ctx: &ACLContext{ ctx: &ACLContext{
ACLs: &model.App{ ACLs: &model.App{
IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}}, 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", name: "denies when bypass list is empty",
rule: defaultIPBR,
ctx: &ACLContext{ ctx: &ACLContext{
ACLs: &model.App{}, ACLs: &model.App{},
IP: net.ParseIP("10.0.0.1"), 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", name: "skips invalid bypass entries and allows on later match",
rule: defaultIPBR,
ctx: &ACLContext{ ctx: &ACLContext{
ACLs: &model.App{ ACLs: &model.App{
IP: model.AppIP{Bypass: []string{"not-an-ip", "10.0.0.1"}}, 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 { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { 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))
}) })
} }
} }