Compare commits

...

3 Commits

Author SHA1 Message Date
Stavros 5d2ca65ea4 fix: avoid o(2n) complexity in acl lookup 2026-05-18 11:50:38 +03:00
Stavros f841095b27 tests: add tests for access control service and policy engine 2026-05-18 11:43:32 +03:00
Stavros eb0a925ea3 chore: review comments 2026-05-18 11:27:50 +03:00
8 changed files with 389 additions and 40 deletions
+39 -27
View File
@@ -49,36 +49,48 @@ func (app *BootstrapApp) setupServices() error {
}
func (app *BootstrapApp) getLabelProvider() (service.LabelProvider, error) {
if app.config.LabelProvider == "none" {
return nil, nil
}
useKubernetes := app.config.LabelProvider == "kubernetes" ||
(app.config.LabelProvider == "auto" && os.Getenv("KUBERNETES_SERVICE_HOST") != "")
if useKubernetes {
app.log.App.Debug().Msg("Using Kubernetes label provider")
kubernetesService, err := service.NewKubernetesService(app.log, app.ctx, &app.wg)
if err != nil {
return nil, fmt.Errorf("failed to initialize kubernetes service: %w", err)
switch app.config.LabelProvider {
case "none", "docker", "kubernetes", "auto":
if app.config.LabelProvider == "none" {
return nil, nil
}
app.services.kubernetesService = kubernetesService
return kubernetesService, nil
useKubernetes := app.config.LabelProvider == "kubernetes" ||
(app.config.LabelProvider == "auto" && os.Getenv("KUBERNETES_SERVICE_HOST") != "")
if useKubernetes {
app.log.App.Debug().Msg("Using Kubernetes label provider")
kubernetesService, err := service.NewKubernetesService(app.log, app.ctx, &app.wg)
if err != nil {
return nil, fmt.Errorf("failed to initialize kubernetes service: %w", err)
}
app.services.kubernetesService = kubernetesService
return kubernetesService, nil
}
app.log.App.Debug().Msg("Using Docker label provider")
dockerService, err := service.NewDockerService(app.log, app.ctx, &app.wg)
if err != nil {
return nil, fmt.Errorf("failed to initialize docker service: %w", err)
}
if dockerService == nil {
if app.config.LabelProvider == "docker" {
app.log.App.Warn().Msg("Docker label provider selected but Docker is not available, will continue without it")
}
return nil, nil
}
app.services.dockerService = dockerService
return dockerService, nil
default:
return nil, fmt.Errorf("invalid label provider: %s", app.config.LabelProvider)
}
app.log.App.Debug().Msg("Using Docker label provider")
dockerService, err := service.NewDockerService(app.log, app.ctx, &app.wg)
if err != nil {
return nil, fmt.Errorf("failed to initialize docker service: %w", err)
}
app.services.dockerService = dockerService
return dockerService, nil
}
func (app *BootstrapApp) setupPolicyEngine() error {
+3 -3
View File
@@ -25,7 +25,7 @@ type UserAllowedRule struct {
}
func (rule *UserAllowedRule) Evaluate(ctx *ACLContext) Effect {
if ctx.ACLs == nil {
if ctx.ACLs == nil || ctx.UserContext == nil {
return EffectAbstain
}
@@ -80,7 +80,7 @@ type OAuthGroupRule struct {
}
func (rule *OAuthGroupRule) Evaluate(ctx *ACLContext) Effect {
if ctx.ACLs == nil {
if ctx.ACLs == nil || ctx.UserContext == nil {
return EffectAbstain
}
@@ -114,7 +114,7 @@ type LDAPGroupRule struct {
}
func (rule *LDAPGroupRule) Evaluate(ctx *ACLContext) Effect {
if ctx == nil {
if ctx == nil || ctx.UserContext == nil {
return EffectAbstain
}
@@ -33,6 +33,16 @@ func TestUserAllowedRule(t *testing.T) {
},
expected: EffectAbstain,
},
{
name: "abstains when user context is nil",
ctx: &ACLContext{
ACLs: &model.App{
OAuth: model.AppOAuth{Whitelist: "alice"},
},
UserContext: nil,
},
expected: EffectAbstain,
},
{
name: "allows OAuth user when email matches whitelist",
ctx: &ACLContext{
@@ -204,6 +214,16 @@ func TestOAuthGroupRule(t *testing.T) {
},
expected: EffectAbstain,
},
{
name: "abstains when user context is nil",
ctx: &ACLContext{
ACLs: &model.App{
OAuth: model.AppOAuth{Whitelist: "alice"},
},
UserContext: nil,
},
expected: EffectAbstain,
},
{
name: "abstains when user is not OAuth",
ctx: &ACLContext{
@@ -324,6 +344,16 @@ func TestLDAPGroupRule(t *testing.T) {
ctx: nil,
expected: EffectAbstain,
},
{
name: "abstains when user context is nil",
ctx: &ACLContext{
ACLs: &model.App{
OAuth: model.AppOAuth{Whitelist: "alice"},
},
UserContext: nil,
},
expected: EffectAbstain,
},
{
name: "abstains when user is not LDAP",
ctx: &ACLContext{
+8 -8
View File
@@ -30,21 +30,21 @@ func NewAccessControlsService(
}
func (service *AccessControlsService) lookupStaticACLs(domain string) *model.App {
var appAcls *model.App
var nameMatch *model.App
// First try to find a matching app by domain, then fallback to matching by app name (subdomain)
for app, config := range service.config.Apps {
if config.Config.Domain == domain {
service.log.App.Debug().Str("name", app).Msg("Found matching container by domain")
appAcls = &config
break // If we find a match by domain, we can stop searching
return &config
}
if strings.SplitN(domain, ".", 2)[0] == app {
service.log.App.Debug().Str("name", app).Msg("Found matching container by app name")
appAcls = &config
break // If we find a match by app name, we can stop searching
nameMatch = &config
}
}
return appAcls
return nameMatch
}
func (service *AccessControlsService) GetAccessControls(domain string) (*model.App, error) {
@@ -57,7 +57,7 @@ func (service *AccessControlsService) GetAccessControls(domain string) (*model.A
}
// If we have a label provider configured, try to get ACLs from it
if service.labelProvider != nil {
if service.labelProvider != nil && *service.labelProvider != nil {
return (*service.labelProvider).GetLabels(domain)
}
@@ -0,0 +1,199 @@
package service
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
)
type mockLabelProvider struct {
getLabelsFn func(appDomain string) (*model.App, error)
calledWith string
callCount int
}
func (m *mockLabelProvider) GetLabels(appDomain string) (*model.App, error) {
m.calledWith = appDomain
m.callCount++
if m.getLabelsFn != nil {
return m.getLabelsFn(appDomain)
}
return nil, nil
}
func TestLookupStaticACLs(t *testing.T) {
log := logger.NewLogger().WithTestConfig()
log.Init()
tests := []struct {
name string
apps map[string]model.App
domain string
expectNil bool
expectedDomain string
}{
{
name: "returns nil when no apps are configured",
apps: nil,
domain: "foo.example.com",
expectNil: true,
},
{
name: "returns nil when no app matches",
apps: map[string]model.App{
"foo": {Config: model.AppConfig{Domain: "foo.example.com"}},
},
domain: "bar.example.com",
expectNil: true,
},
{
name: "matches by exact domain",
apps: map[string]model.App{
"foo": {Config: model.AppConfig{Domain: "foo.example.com"}},
},
domain: "foo.example.com",
expectedDomain: "foo.example.com",
},
{
name: "matches by app name when domain does not match any app",
apps: map[string]model.App{
"foo": {Config: model.AppConfig{Domain: "configured.example.com"}},
},
domain: "foo.example.com",
expectedDomain: "configured.example.com",
},
{
name: "matches by app name for nested subdomains",
apps: map[string]model.App{
"foo": {Config: model.AppConfig{Domain: "configured.example.com"}},
},
domain: "foo.sub.example.com",
expectedDomain: "configured.example.com",
},
{
name: "selects the app matching by domain among multiple apps",
apps: map[string]model.App{
"unrelated": {Config: model.AppConfig{Domain: "other.example.com"}},
"target": {Config: model.AppConfig{Domain: "foo.example.com"}},
},
domain: "foo.example.com",
expectedDomain: "foo.example.com",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc := NewAccessControlsService(log, model.Config{Apps: tt.apps}, nil)
got := svc.lookupStaticACLs(tt.domain)
if tt.expectNil {
assert.Nil(t, got)
return
}
require.NotNil(t, got)
assert.Equal(t, tt.expectedDomain, got.Config.Domain)
})
}
}
func TestGetAccessControls(t *testing.T) {
log := logger.NewLogger().WithTestConfig()
log.Init()
t.Run("returns static ACLs when domain matches", func(t *testing.T) {
config := model.Config{
Apps: map[string]model.App{
"foo": {
Config: model.AppConfig{Domain: "foo.example.com"},
Users: model.AppUsers{Allow: "alice"},
},
},
}
svc := NewAccessControlsService(log, config, nil)
got, err := svc.GetAccessControls("foo.example.com")
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, "foo.example.com", got.Config.Domain)
assert.Equal(t, "alice", got.Users.Allow)
})
t.Run("returns nil when no static match and no label provider", func(t *testing.T) {
svc := NewAccessControlsService(log, model.Config{}, nil)
got, err := svc.GetAccessControls("unknown.example.com")
require.NoError(t, err)
assert.Nil(t, got)
})
t.Run("returns nil when label provider pointer wraps a nil interface", func(t *testing.T) {
var provider LabelProvider
svc := NewAccessControlsService(log, model.Config{}, &provider)
got, err := svc.GetAccessControls("unknown.example.com")
require.NoError(t, err)
assert.Nil(t, got)
})
t.Run("falls back to label provider when no static match", func(t *testing.T) {
expected := &model.App{
Config: model.AppConfig{Domain: "dynamic.example.com"},
Users: model.AppUsers{Allow: "bob"},
}
mock := &mockLabelProvider{
getLabelsFn: func(appDomain string) (*model.App, error) {
return expected, nil
},
}
var provider LabelProvider = mock
svc := NewAccessControlsService(log, model.Config{}, &provider)
got, err := svc.GetAccessControls("dynamic.example.com")
require.NoError(t, err)
assert.Same(t, expected, got)
assert.Equal(t, "dynamic.example.com", mock.calledWith)
assert.Equal(t, 1, mock.callCount)
})
t.Run("does not call label provider when static match found", func(t *testing.T) {
mock := &mockLabelProvider{}
var provider LabelProvider = mock
config := model.Config{
Apps: map[string]model.App{
"foo": {Config: model.AppConfig{Domain: "foo.example.com"}},
},
}
svc := NewAccessControlsService(log, config, &provider)
got, err := svc.GetAccessControls("foo.example.com")
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, "foo.example.com", got.Config.Domain)
assert.Equal(t, 0, mock.callCount)
})
t.Run("propagates label provider errors", func(t *testing.T) {
providerErr := errors.New("provider boom")
mock := &mockLabelProvider{
getLabelsFn: func(appDomain string) (*model.App, error) {
return nil, providerErr
},
}
var provider LabelProvider = mock
svc := NewAccessControlsService(log, model.Config{}, &provider)
got, err := svc.GetAccessControls("dynamic.example.com")
assert.Nil(t, got)
assert.ErrorIs(t, err, providerErr)
assert.Equal(t, 1, mock.callCount)
})
}
+8 -2
View File
@@ -85,17 +85,23 @@ func (docker *DockerService) GetLabels(appDomain string) (*model.App, error) {
return nil, err
}
var nameMatch *model.App
// First try to find a matching app by domain, then fallback to matching by app name (subdomain)
for appName, appLabels := range labels.Apps {
if appLabels.Config.Domain == appDomain {
docker.log.App.Debug().Str("id", inspect.ID).Str("name", inspect.Name).Msg("Found matching container by domain")
return &appLabels, nil
}
if strings.SplitN(appDomain, ".", 2)[0] == appName {
docker.log.App.Debug().Str("id", inspect.ID).Str("name", inspect.Name).Msg("Found matching container by app name")
return &appLabels, nil
nameMatch = &appLabels
}
}
if nameMatch != nil {
return nameMatch, nil
}
}
docker.log.App.Debug().Str("domain", appDomain).Msg("No matching container found for domain")
+9
View File
@@ -61,6 +61,7 @@ func NewPolicyEngine(config model.Config, log *logger.Logger) (*PolicyEngine, er
}
func (engine *PolicyEngine) RegisterRule(name RuleName, rule Rule) {
engine.log.App.Debug().Str("rule", string(name)).Msg("Registering ACL rule in policy engine")
engine.rules[name] = rule
}
@@ -99,3 +100,11 @@ func (engine *PolicyEngine) Evaluate(name RuleName, ctx *ACLContext) bool {
return access
}
func (engine *PolicyEngine) Policy() Policy {
return engine.policy
}
func (engine *PolicyEngine) Rules() map[RuleName]Rule {
return engine.rules
}
+93
View File
@@ -0,0 +1,93 @@
package service_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/tinyauthapp/tinyauth/internal/service"
"github.com/tinyauthapp/tinyauth/internal/test"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
)
// Create test rule
type TestRule struct{}
func (rule *TestRule) Evaluate(ctx *service.ACLContext) service.Effect {
switch ctx.Path {
case "/allowed":
return service.EffectAllow
case "/denied":
return service.EffectDeny
default:
return service.EffectAbstain
}
}
func TestPolicyEngine(t *testing.T) {
log := logger.NewLogger().WithTestConfig()
log.Init()
cfg, _ := test.CreateTestConfigs(t)
testRule := &TestRule{}
// Engine should fail with invalid policy
cfg.Auth.ACLs.Policy = "invalid_policy"
_, err := service.NewPolicyEngine(cfg, log)
assert.Error(t, err)
// Engine should initialize with 'allow' policy
cfg.Auth.ACLs.Policy = string(service.PolicyAllow)
engine, err := service.NewPolicyEngine(cfg, log)
assert.NoError(t, err)
assert.Equal(t, service.PolicyAllow, engine.Policy())
// Engine should initialize with 'deny' policy
cfg.Auth.ACLs.Policy = string(service.PolicyDeny)
engine, err = service.NewPolicyEngine(cfg, log)
assert.NoError(t, err)
assert.Equal(t, service.PolicyDeny, engine.Policy())
// Engine should allow adding rules
engine, err = service.NewPolicyEngine(cfg, log)
assert.NoError(t, err)
engine.RegisterRule("test-rule", testRule)
_, ok := engine.Rules()["test-rule"]
assert.True(t, ok)
// Begin allow policy tests
cfg.Auth.ACLs.Policy = string(service.PolicyAllow)
engine, err = service.NewPolicyEngine(cfg, log)
assert.NoError(t, err)
engine.RegisterRule("test-rule", testRule)
// With allow policy, if rule allows, access should be allowed
ctx := &service.ACLContext{Path: "/allowed"}
assert.Equal(t, true, engine.Evaluate("test-rule", ctx))
// With allow policy, if rule denies, access should be denied
ctx.Path = "/denied"
assert.Equal(t, false, engine.Evaluate("test-rule", ctx))
// With allow policy, if rule abstains, access should be allowed (default)
ctx.Path = "/abstain"
assert.Equal(t, true, engine.Evaluate("test-rule", ctx))
// Begin deny policy tests
cfg.Auth.ACLs.Policy = string(service.PolicyDeny)
engine, err = service.NewPolicyEngine(cfg, log)
assert.NoError(t, err)
engine.RegisterRule("test-rule", testRule)
// With deny policy, if rule allows, access should be allowed
ctx.Path = "/allowed"
assert.Equal(t, true, engine.Evaluate("test-rule", ctx))
// With deny policy, if rule denies, access should be denied
ctx.Path = "/denied"
assert.Equal(t, false, engine.Evaluate("test-rule", ctx))
// With deny policy, if rule abstains, access should be denied (default)
ctx.Path = "/abstain"
assert.Equal(t, false, engine.Evaluate("test-rule", ctx))
}