Compare commits

..

14 Commits

Author SHA1 Message Date
coderabbitai[bot] bdc0a60116 CodeRabbit Generated Unit Tests: Add unit tests 2026-05-25 17:11:30 +00:00
Puneet Dixit c3461131f5 feat: support provider-specific OAuth whitelists (#882)
Co-authored-by: Puneet Dixit <236133619+puneetdixit200@users.noreply.github.com>
2026-05-24 20:18:33 +03:00
Stavros 3f584ca741 New Crowdin updates (#798) 2026-05-24 19:16:29 +03:00
dependabot[bot] 36d0ffc2b5 chore(deps): bump sqlc-dev/setup-sqlc from 4 to 5 (#880)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-24 19:14:15 +03:00
dependabot[bot] 37b79735f0 chore(deps): bump the minor-patch group across 1 directory with 6 updates (#881)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-24 19:13:53 +03:00
dependabot[bot] 09540fbe6e chore(deps): bump docker/build-push-action from 7.1.0 to 7.2.0 (#891)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-24 19:13:15 +03:00
dependabot[bot] 8e60a2e28e chore(deps): bump codecov/codecov-action from 6.0.0 to 6.0.1 (#879)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-24 19:12:51 +03:00
dependabot[bot] 9619024c37 chore(deps): bump github/codeql-action from 4.35.4 to 4.35.5 (#871)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-24 19:12:20 +03:00
dependabot[bot] 1c305bacca chore(deps): bump node from 26.1-alpine3.23 to 26.2-alpine3.23 (#883)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-24 19:11:55 +03:00
Scott McKendry e532cde2b6 fix: potential nil pointer dereferences (#893) 2026-05-24 17:23:48 +03:00
Stavros 2737a25227 fix: don't point to nil local users in bootstrap app 2026-05-23 20:24:54 +03:00
Scott McKendry 7aa25210f5 feat(config): allow global bypass by ip (#889) 2026-05-23 19:58:48 +03:00
Stavros 55bef72639 fix: ensure domain defined in acls is included in host rules (#884) 2026-05-23 17:13:41 +03:00
Stavros ae17bd3b66 fix: do not log user context not found errors in proxy controller 2026-05-23 16:43:03 +03:00
23 changed files with 716 additions and 58 deletions
+4
View File
@@ -101,6 +101,10 @@ TINYAUTH_OAUTH_PROVIDERS_name_CLIENTID=
TINYAUTH_OAUTH_PROVIDERS_name_CLIENTSECRET= TINYAUTH_OAUTH_PROVIDERS_name_CLIENTSECRET=
# Path to the file containing the OAuth client secret. # Path to the file containing the OAuth client secret.
TINYAUTH_OAUTH_PROVIDERS_name_CLIENTSECRETFILE= TINYAUTH_OAUTH_PROVIDERS_name_CLIENTSECRETFILE=
# Comma-separated list of allowed OAuth domains for this provider.
TINYAUTH_OAUTH_PROVIDERS_name_WHITELIST=
# Path to the OAuth whitelist file for this provider.
TINYAUTH_OAUTH_PROVIDERS_name_WHITELISTFILE=
# OAuth scopes. # OAuth scopes.
TINYAUTH_OAUTH_PROVIDERS_name_SCOPES= TINYAUTH_OAUTH_PROVIDERS_name_SCOPES=
# OAuth redirect URL. # OAuth redirect URL.
+2 -2
View File
@@ -29,7 +29,7 @@ jobs:
run: go mod download run: go mod download
- name: Setup sqlc - name: Setup sqlc
uses: sqlc-dev/setup-sqlc@v4 uses: sqlc-dev/setup-sqlc@v5
with: with:
sqlc-version: "1.31.1" sqlc-version: "1.31.1"
@@ -62,6 +62,6 @@ jobs:
run: go test -coverprofile=coverage.txt -v ./... run: go test -coverprofile=coverage.txt -v ./...
- name: Upload coverage reports to Codecov - name: Upload coverage reports to Codecov
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6 uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
+4 -4
View File
@@ -166,7 +166,7 @@ jobs:
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Build and push - name: Build and push
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7 uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
id: build id: build
with: with:
platforms: linux/amd64 platforms: linux/amd64
@@ -224,7 +224,7 @@ jobs:
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Build and push - name: Build and push
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7 uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
id: build id: build
with: with:
platforms: linux/amd64 platforms: linux/amd64
@@ -282,7 +282,7 @@ jobs:
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Build and push - name: Build and push
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7 uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
id: build id: build
with: with:
platforms: linux/arm64 platforms: linux/arm64
@@ -340,7 +340,7 @@ jobs:
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Build and push - name: Build and push
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7 uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
id: build id: build
with: with:
platforms: linux/arm64 platforms: linux/arm64
+4 -4
View File
@@ -136,7 +136,7 @@ jobs:
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Build and push - name: Build and push
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7 uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
id: build id: build
with: with:
platforms: linux/amd64 platforms: linux/amd64
@@ -191,7 +191,7 @@ jobs:
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Build and push - name: Build and push
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7 uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
id: build id: build
with: with:
platforms: linux/amd64 platforms: linux/amd64
@@ -246,7 +246,7 @@ jobs:
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Build and push - name: Build and push
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7 uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
id: build id: build
with: with:
platforms: linux/arm64 platforms: linux/arm64
@@ -301,7 +301,7 @@ jobs:
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Build and push - name: Build and push
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7 uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
id: build id: build
with: with:
platforms: linux/arm64 platforms: linux/arm64
+1 -1
View File
@@ -38,6 +38,6 @@ jobs:
retention-days: 5 retention-days: 5
- name: Upload to code-scanning - name: Upload to code-scanning
uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4 uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
with: with:
sarif_file: results.sarif sarif_file: results.sarif
+1 -1
View File
@@ -1,5 +1,5 @@
# Site builder # Site builder
FROM node:26.1-alpine3.23 AS frontend-builder FROM node:26.2-alpine3.23 AS frontend-builder
WORKDIR /frontend WORKDIR /frontend
+1 -1
View File
@@ -1,5 +1,5 @@
# Site builder # Site builder
FROM node:26.1-alpine3.23 AS frontend-builder FROM node:26.2-alpine3.23 AS frontend-builder
WORKDIR /frontend WORKDIR /frontend
+2 -2
View File
@@ -58,8 +58,8 @@
"invalidInput": "Input non valido", "invalidInput": "Input non valido",
"domainWarningTitle": "Dominio non valido", "domainWarningTitle": "Dominio non valido",
"domainWarningSubtitle": "Stai accedendo a questa istanza da un dominio errato. Scegliendo di procedere, potresti incontrare problemi con l'autenticazione.", "domainWarningSubtitle": "Stai accedendo a questa istanza da un dominio errato. Scegliendo di procedere, potresti incontrare problemi con l'autenticazione.",
"domainWarningCurrent": "Current:", "domainWarningCurrent": "Attuale:",
"domainWarningExpected": "Expected:", "domainWarningExpected": "Previsto:",
"ignoreTitle": "Ignora", "ignoreTitle": "Ignora",
"goToCorrectDomainTitle": "Vai al dominio corretto", "goToCorrectDomainTitle": "Vai al dominio corretto",
"authorizeTitle": "Autorizza", "authorizeTitle": "Autorizza",
+1 -1
View File
@@ -57,7 +57,7 @@
"fieldRequired": "Ово поље је неопходно", "fieldRequired": "Ово поље је неопходно",
"invalidInput": "Неисправан унос", "invalidInput": "Неисправан унос",
"domainWarningTitle": "Неисправан домен", "domainWarningTitle": "Неисправан домен",
"domainWarningSubtitle": "You are accessing this instance from an incorrect domain. If you proceed, you may encounter issues with authentication.", "domainWarningSubtitle": "Приступате овој инстанци са неисправног домена. Ако наставите, можете наићи на проблеме са аутентификацијом.",
"domainWarningCurrent": "Тренутни:", "domainWarningCurrent": "Тренутни:",
"domainWarningExpected": "Очекивани:", "domainWarningExpected": "Очекивани:",
"ignoreTitle": "Игнориши", "ignoreTitle": "Игнориши",
+13 -1
View File
@@ -97,7 +97,12 @@ func (app *BootstrapApp) Setup() error {
return fmt.Errorf("failed to load users: %w", err) return fmt.Errorf("failed to load users: %w", err)
} }
app.runtime.LocalUsers = *users if users != nil {
app.runtime.LocalUsers = *users
} else {
log.App.Debug().Msg("No local users found, local authentication will not be available")
app.runtime.LocalUsers = []model.LocalUser{}
}
// load oauth whitelist // load oauth whitelist
oauthWhitelist, err := utils.GetStringList(app.config.OAuth.Whitelist, app.config.OAuth.WhitelistFile) oauthWhitelist, err := utils.GetStringList(app.config.OAuth.Whitelist, app.config.OAuth.WhitelistFile)
@@ -112,6 +117,13 @@ func (app *BootstrapApp) Setup() error {
app.runtime.OAuthProviders = app.config.OAuth.Providers app.runtime.OAuthProviders = app.config.OAuth.Providers
for id, provider := range app.runtime.OAuthProviders { for id, provider := range app.runtime.OAuthProviders {
providerWhitelist, err := utils.GetStringList(provider.Whitelist, provider.WhitelistFile)
if err != nil {
return fmt.Errorf("failed to load oauth whitelist for provider %s: %w", id, err)
}
provider.Whitelist = providerWhitelist
secret := utils.GetSecret(provider.ClientSecret, provider.ClientSecretFile) secret := utils.GetSecret(provider.ClientSecret, provider.ClientSecretFile)
provider.ClientSecret = secret provider.ClientSecret = secret
provider.ClientSecretFile = "" provider.ClientSecretFile = ""
+117
View File
@@ -0,0 +1,117 @@
package bootstrap
import (
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tinyauthapp/tinyauth/internal/model"
)
func TestSetupStore_UnknownDriver(t *testing.T) {
tests := []struct {
driver string
wantErr string
}{
{
driver: "mysql",
wantErr: `unknown database driver "mysql": valid values are sqlite, postgres, memory`,
},
{
driver: "redis",
wantErr: `unknown database driver "redis": valid values are sqlite, postgres, memory`,
},
{
driver: "baddriver",
wantErr: `unknown database driver "baddriver": valid values are sqlite, postgres, memory`,
},
}
for _, tt := range tests {
t.Run("driver_"+tt.driver, func(t *testing.T) {
app := NewBootstrapApp(model.Config{
Database: model.DatabaseConfig{
Driver: tt.driver,
},
})
store, err := app.SetupStore()
assert.Nil(t, store)
require.Error(t, err)
assert.Equal(t, tt.wantErr, err.Error())
})
}
}
func TestSetupStore_Memory(t *testing.T) {
app := NewBootstrapApp(model.Config{
Database: model.DatabaseConfig{
Driver: "memory",
},
})
store, err := app.SetupStore()
require.NoError(t, err)
assert.NotNil(t, store)
}
func TestSetupStore_SQLite_ExplicitDriver(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
app := NewBootstrapApp(model.Config{
Database: model.DatabaseConfig{
Driver: "sqlite",
Path: dbPath,
},
})
store, err := app.SetupStore()
require.NoError(t, err)
assert.NotNil(t, store)
}
func TestSetupStore_SQLite_DefaultDriver(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "default.db")
app := NewBootstrapApp(model.Config{
Database: model.DatabaseConfig{
Driver: "",
Path: dbPath,
},
})
store, err := app.SetupStore()
require.NoError(t, err)
assert.NotNil(t, store)
}
func TestSetupStore_Postgres_InvalidURL(t *testing.T) {
app := NewBootstrapApp(model.Config{
Database: model.DatabaseConfig{
Driver: "postgres",
Path: "not-a-valid-postgres-url",
},
})
store, err := app.SetupStore()
// sql.Open does not fail on a bad URL for pgx — it only fails on first use.
// The error should come from pgxmigrate.WithInstance when the DB is actually
// pinged / connected, so we expect either success-with-error or an error here.
// What matters is that the postgres case is reached (i.e., no "unknown driver" error).
if err != nil {
assert.False(t, strings.Contains(err.Error(), "unknown database driver"))
assert.Nil(t, store)
}
}
func TestSetupStore_ErrorMessageIncludesPostgres(t *testing.T) {
app := NewBootstrapApp(model.Config{
Database: model.DatabaseConfig{
Driver: "oracle",
},
})
_, err := app.SetupStore()
require.Error(t, err)
assert.Contains(t, err.Error(), "postgres")
assert.Contains(t, err.Error(), "sqlite")
assert.Contains(t, err.Error(), "memory")
}
+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
+16 -16
View File
@@ -183,9 +183,23 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
return return
} }
if !controller.auth.IsEmailWhitelisted(user.Email) { svc, err := controller.auth.GetOAuthService(sessionIdCookie)
if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to get OAuth service for session")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL))
return
}
if svc.ID() != req.Provider {
controller.log.App.Warn().Msgf("OAuth provider mismatch: expected %s, got %s", req.Provider, svc.ID())
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL))
return
}
if !controller.auth.IsEmailWhitelisted(svc.ID(), user.Email) {
controller.log.App.Warn().Str("email", user.Email).Msg("Email not whitelisted, denying access") controller.log.App.Warn().Str("email", user.Email).Msg("Email not whitelisted, denying access")
controller.log.AuditLoginFailure(user.Email, req.Provider, c.ClientIP(), "email not whitelisted") controller.log.AuditLoginFailure(user.Email, svc.ID(), c.ClientIP(), "email not whitelisted")
queries, err := query.Values(UnauthorizedQuery{ queries, err := query.Values(UnauthorizedQuery{
Username: user.Email, Username: user.Email,
@@ -226,20 +240,6 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
username = strings.Replace(user.Email, "@", "_", 1) username = strings.Replace(user.Email, "@", "_", 1)
} }
svc, err := controller.auth.GetOAuthService(sessionIdCookie)
if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to get OAuth service for session")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL))
return
}
if svc.ID() != req.Provider {
controller.log.App.Warn().Msgf("OAuth provider mismatch: expected %s, got %s", req.Provider, svc.ID())
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL))
return
}
sessionCookie := repository.Session{ sessionCookie := repository.Session{
Username: username, Username: username,
Name: name, Name: name,
+4 -1
View File
@@ -160,7 +160,10 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
userContext, err := new(model.UserContext).NewFromGin(c) userContext, err := new(model.UserContext).NewFromGin(c)
if err != nil { if err != nil {
controller.log.App.Debug().Err(err).Msg("Failed to create user context from request, treating as unauthenticated") // No user context found is not an issue
if !errors.Is(err, model.ErrUserContextNotFound) {
controller.log.App.Error().Err(err).Msg("Failed to create user context from request, treating as unauthenticated")
}
userContext = &model.UserContext{ userContext = &model.UserContext{
Authenticated: false, Authenticated: false,
} }
+5 -1
View File
@@ -205,7 +205,7 @@ func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid string, ip stri
return nil, nil, fmt.Errorf("oauth provider from session cookie not found: %s", userContext.OAuth.ID) return nil, nil, fmt.Errorf("oauth provider from session cookie not found: %s", userContext.OAuth.ID)
} }
if !m.auth.IsEmailWhitelisted(userContext.OAuth.Email) { if !m.auth.IsEmailWhitelisted(userContext.OAuth.ID, userContext.OAuth.Email) {
m.auth.DeleteSession(ctx, uuid) m.auth.DeleteSession(ctx, uuid)
return nil, nil, fmt.Errorf("email from session cookie not whitelisted: %s", userContext.OAuth.Email) return nil, nil, fmt.Errorf("email from session cookie not whitelisted: %s", userContext.OAuth.Email)
} }
@@ -251,6 +251,10 @@ func (m *ContextMiddleware) basicAuth(username string, password string) (*model.
case model.UserLocal: case model.UserLocal:
user := m.auth.GetLocalUser(username) user := m.auth.GetLocalUser(username)
if user == nil {
return nil, nil, fmt.Errorf("user not found locally: %s", username)
}
if user.TOTPSecret != "" { if user.TOTPSecret != "" {
return nil, nil, fmt.Errorf("user with totp not allowed to login via basic auth: %s", username) return nil, nil, fmt.Errorf("user with totp not allowed to login via basic auth: %s", username)
} }
+5 -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 {
@@ -225,6 +226,8 @@ type OAuthServiceConfig struct {
ClientID string `description:"OAuth client ID." yaml:"clientId"` ClientID string `description:"OAuth client ID." yaml:"clientId"`
ClientSecret string `description:"OAuth client secret." yaml:"clientSecret"` ClientSecret string `description:"OAuth client secret." yaml:"clientSecret"`
ClientSecretFile string `description:"Path to the file containing the OAuth client secret." yaml:"clientSecretFile"` ClientSecretFile string `description:"Path to the file containing the OAuth client secret." yaml:"clientSecretFile"`
Whitelist []string `description:"Comma-separated list of allowed OAuth domains for this provider." yaml:"whitelist"`
WhitelistFile string `description:"Path to the OAuth whitelist file for this provider." yaml:"whitelistFile"`
Scopes []string `description:"OAuth scopes." yaml:"scopes"` Scopes []string `description:"OAuth scopes." yaml:"scopes"`
RedirectURL string `description:"OAuth redirect URL." yaml:"redirectUrl"` RedirectURL string `description:"OAuth redirect URL." yaml:"redirectUrl"`
AuthURL string `description:"OAuth authorization URL." yaml:"authUrl"` AuthURL string `description:"OAuth authorization URL." yaml:"authUrl"`
+90
View File
@@ -0,0 +1,90 @@
package model
import (
"reflect"
"testing"
"github.com/stretchr/testify/assert"
)
// TestDatabaseConfig_DescriptionMentionsPostgres verifies that the DatabaseConfig
// Driver field description explicitly lists "postgres" as a valid value, reflecting
// the newly added PostgreSQL support.
func TestDatabaseConfig_DescriptionMentionsPostgres(t *testing.T) {
rt := reflect.TypeOf(DatabaseConfig{})
driverField, ok := rt.FieldByName("Driver")
assert.True(t, ok, "DatabaseConfig should have a Driver field")
description := driverField.Tag.Get("description")
assert.Contains(t, description, "postgres", "DatabaseConfig.Driver description should mention postgres as a valid value")
assert.Contains(t, description, "sqlite", "DatabaseConfig.Driver description should mention sqlite as a valid value")
assert.Contains(t, description, "memory", "DatabaseConfig.Driver description should mention memory as a valid value")
}
// TestDatabaseConfig_PathDescriptionMentionsConnectionURL verifies that the Path
// field description covers both SQLite file path and PostgreSQL connection URL usage.
func TestDatabaseConfig_PathDescriptionMentionsConnectionURL(t *testing.T) {
rt := reflect.TypeOf(DatabaseConfig{})
pathField, ok := rt.FieldByName("Path")
assert.True(t, ok, "DatabaseConfig should have a Path field")
description := pathField.Tag.Get("description")
assert.Contains(t, description, "postgres",
"DatabaseConfig.Path description should mention postgres to clarify connection URL usage")
}
// TestIPConfig_NoBypassField verifies that the Bypass field has been removed
// from IPConfig as part of the PR changes. IP bypass lists are now only
// configured at the per-app ACL level.
func TestIPConfig_NoBypassField(t *testing.T) {
rt := reflect.TypeOf(IPConfig{})
_, hasBypass := rt.FieldByName("Bypass")
assert.False(t, hasBypass, "IPConfig should not have a Bypass field after PR changes")
}
// TestIPConfig_HasAllowAndBlock ensures the remaining Allow and Block fields
// are still present in IPConfig after the Bypass removal.
func TestIPConfig_HasAllowAndBlock(t *testing.T) {
rt := reflect.TypeOf(IPConfig{})
_, hasAllow := rt.FieldByName("Allow")
assert.True(t, hasAllow, "IPConfig should still have an Allow field")
_, hasBlock := rt.FieldByName("Block")
assert.True(t, hasBlock, "IPConfig should still have a Block field")
}
// TestOAuthServiceConfig_NoWhitelistField verifies that the per-provider Whitelist
// and WhitelistFile fields have been removed from OAuthServiceConfig. The global
// OAuthWhitelist on OAuthConfig/RuntimeConfig is now the only whitelist.
func TestOAuthServiceConfig_NoWhitelistField(t *testing.T) {
rt := reflect.TypeOf(OAuthServiceConfig{})
_, hasWhitelist := rt.FieldByName("Whitelist")
assert.False(t, hasWhitelist, "OAuthServiceConfig should not have a Whitelist field after PR changes")
_, hasWhitelistFile := rt.FieldByName("WhitelistFile")
assert.False(t, hasWhitelistFile, "OAuthServiceConfig should not have a WhitelistFile field after PR changes")
}
// TestOAuthServiceConfig_CoreFieldsPreserved ensures that removing the whitelist
// fields did not inadvertently drop unrelated fields.
func TestOAuthServiceConfig_CoreFieldsPreserved(t *testing.T) {
rt := reflect.TypeOf(OAuthServiceConfig{})
for _, fieldName := range []string{"ClientID", "ClientSecret", "ClientSecretFile", "Scopes", "RedirectURL", "AuthURL", "TokenURL", "UserinfoURL"} {
_, ok := rt.FieldByName(fieldName)
assert.True(t, ok, "OAuthServiceConfig should still have a %s field", fieldName)
}
}
// TestDatabaseConfig_ZeroValue ensures DatabaseConfig is usable as a zero value
// with the expected default (empty string) driver, which falls back to sqlite.
func TestDatabaseConfig_ZeroValue(t *testing.T) {
var cfg DatabaseConfig
assert.Equal(t, "", cfg.Driver, "zero-value Driver should be an empty string (defaults to sqlite)")
assert.Equal(t, "", cfg.Path, "zero-value Path should be an empty string")
}
+162
View File
@@ -0,0 +1,162 @@
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tinyauthapp/tinyauth/internal/repository"
)
// TestMapErr verifies that mapErr translates known sentinel errors and
// passes through all other errors unchanged.
func TestMapErr(t *testing.T) {
sentinel := errors.New("some other error")
tests := []struct {
name string
input error
want error
isWant bool // use errors.Is check
}{
{
name: "nil passes through unchanged",
input: nil,
want: nil,
isWant: false,
},
{
name: "sql.ErrNoRows maps to repository.ErrNotFound",
input: sql.ErrNoRows,
want: repository.ErrNotFound,
isWant: true,
},
{
name: "wrapped sql.ErrNoRows maps to repository.ErrNotFound",
input: fmt.Errorf("wrapped: %w", sql.ErrNoRows),
want: repository.ErrNotFound,
isWant: true,
},
{
name: "arbitrary error passes through unchanged",
input: sentinel,
want: sentinel,
isWant: true,
},
{
name: "wrapped arbitrary error passes through unchanged",
input: fmt.Errorf("outer: %w", sentinel),
want: fmt.Errorf("outer: %w", sentinel),
isWant: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := mapErr(tt.input)
if tt.input == nil {
assert.Nil(t, got)
return
}
if tt.isWant {
assert.True(t, errors.Is(got, tt.want), "expected errors.Is(%v, %v) to be true, got %v", got, tt.want, got)
} else {
// For wrapped-arbitrary-error passthrough: the original wrapped error is returned as-is
assert.Equal(t, tt.input, got)
}
})
}
}
// TestMapErr_ErrNoRows_IsRepositoryErrNotFound specifically asserts the contract
// that callers outside the package can detect repository.ErrNotFound using errors.Is.
func TestMapErr_ErrNoRows_IsRepositoryErrNotFound(t *testing.T) {
result := mapErr(sql.ErrNoRows)
require.NotNil(t, result)
assert.True(t, errors.Is(result, repository.ErrNotFound))
// Must NOT still be sql.ErrNoRows after mapping
assert.False(t, errors.Is(result, sql.ErrNoRows))
}
// TestMapErr_OtherError_IsNotRepositoryErrNotFound ensures unrecognised errors
// are NOT silently converted to ErrNotFound.
func TestMapErr_OtherError_IsNotRepositoryErrNotFound(t *testing.T) {
someErr := errors.New("connection refused")
result := mapErr(someErr)
require.NotNil(t, result)
assert.False(t, errors.Is(result, repository.ErrNotFound))
assert.True(t, errors.Is(result, someErr))
}
// TestNewStore ensures that NewStore returns a value satisfying the
// repository.Store interface (compile-time verified) and is not nil.
func TestNewStore(t *testing.T) {
q := New(nil) // Queries with a nil DBTX — adequate for construction checks
var store repository.Store = NewStore(q)
assert.NotNil(t, store)
}
// mockDBTX is a minimal DBTX implementation that returns a configurable error.
type mockDBTX struct {
err error
rowErr error
}
func (m *mockDBTX) ExecContext(_ context.Context, _ string, _ ...interface{}) (sql.Result, error) {
return nil, m.err
}
func (m *mockDBTX) PrepareContext(_ context.Context, _ string) (*sql.Stmt, error) {
return nil, m.err
}
func (m *mockDBTX) QueryContext(_ context.Context, _ string, _ ...interface{}) (*sql.Rows, error) {
return nil, m.err
}
func (m *mockDBTX) QueryRowContext(_ context.Context, _ string, _ ...interface{}) *sql.Row {
// *sql.Row cannot be constructed without internals; returning nil causes a
// nil-dereference in callers, so we can only test ExecContext-backed methods.
return nil
}
// TestStore_DeleteSession_PropagatesError verifies that an error returned by the
// underlying DBTX is forwarded (possibly mapped) by the Store wrapper.
func TestStore_DeleteSession_PropagatesError(t *testing.T) {
customErr := errors.New("exec error")
mock := &mockDBTX{err: customErr}
store := NewStore(New(mock))
err := store.DeleteSession(context.Background(), "some-uuid")
require.Error(t, err)
// The error is not ErrNoRows, so it must be passed through as-is.
assert.True(t, errors.Is(err, customErr))
}
// TestStore_DeleteOidcCode_PropagatesError verifies error propagation for a
// different delete method.
func TestStore_DeleteOidcCode_PropagatesError(t *testing.T) {
customErr := errors.New("exec error")
mock := &mockDBTX{err: customErr}
store := NewStore(New(mock))
err := store.DeleteOidcCode(context.Background(), "some-hash")
require.Error(t, err)
assert.True(t, errors.Is(err, customErr))
}
// TestStore_DeleteExpiredSessions_PropagatesErrNoRowsAsNotFound verifies that
// sql.ErrNoRows is mapped to repository.ErrNotFound through the Store wrapper.
func TestStore_DeleteExpiredSessions_PropagatesError(t *testing.T) {
customErr := errors.New("db unavailable")
mock := &mockDBTX{err: customErr}
store := NewStore(New(mock))
err := store.DeleteExpiredSessions(context.Background(), 0)
require.Error(t, err)
assert.True(t, errors.Is(err, customErr))
}
+15 -11
View File
@@ -114,7 +114,7 @@ type LDAPGroupRule struct {
} }
func (rule *LDAPGroupRule) Evaluate(ctx *ACLContext) Effect { func (rule *LDAPGroupRule) Evaluate(ctx *ACLContext) Effect {
if ctx == nil || ctx.UserContext == nil { if ctx == nil || ctx.UserContext == nil || ctx.ACLs == nil {
return EffectAbstain return EffectAbstain
} }
@@ -182,13 +182,14 @@ type IPAllowedRule struct {
} }
func (rule *IPAllowedRule) Evaluate(ctx *ACLContext) Effect { func (rule *IPAllowedRule) Evaluate(ctx *ACLContext) Effect {
if ctx.ACLs == nil { // merge global and per-app block/allow lists
return EffectAbstain blockedIps := append([]string{}, rule.Config.Auth.IP.Block...)
} allowedIPs := append([]string{}, rule.Config.Auth.IP.Allow...)
// Merge the global and app IP filter if ctx.ACLs != nil {
blockedIps := append(ctx.ACLs.IP.Block, rule.Config.Auth.IP.Block...) blockedIps = append(blockedIps, ctx.ACLs.IP.Block...)
allowedIPs := append(ctx.ACLs.IP.Allow, rule.Config.Auth.IP.Allow...) allowedIPs = append(allowedIPs, ctx.ACLs.IP.Allow...)
}
for _, blocked := range blockedIps { for _, blocked := range blockedIps {
match, err := utils.CheckIPFilter(blocked, ctx.IP.String()) match, err := utils.CheckIPFilter(blocked, ctx.IP.String())
@@ -224,15 +225,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")
+56 -5
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"
) )
@@ -558,12 +559,12 @@ func TestIPAllowedRule(t *testing.T) {
expected Effect expected Effect
}{ }{
{ {
name: "abstains when ACLs are nil", name: "allows when ACLs are nil and no global lists configured",
ctx: &ACLContext{ ctx: &ACLContext{
ACLs: nil, ACLs: nil,
IP: net.ParseIP("10.0.0.1"), IP: net.ParseIP("10.0.0.1"),
}, },
expected: EffectAbstain, expected: EffectAllow,
}, },
{ {
name: "denies when IP matches app block list", name: "denies when IP matches app block list",
@@ -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))
}) })
} }
} }
+8 -3
View File
@@ -285,10 +285,15 @@ func (auth *AuthService) RecordLoginAttempt(identifier string, success bool) {
} }
} }
func (auth *AuthService) IsEmailWhitelisted(email string) bool { func (auth *AuthService) IsEmailWhitelisted(provider string, email string) bool {
match, err := utils.CheckFilter(strings.Join(auth.runtime.OAuthWhitelist, ","), email) whitelist := auth.runtime.OAuthWhitelist
if providerConfig, ok := auth.runtime.OAuthProviders[provider]; ok && len(providerConfig.Whitelist) > 0 {
whitelist = providerConfig.Whitelist
}
match, err := utils.CheckFilter(strings.Join(whitelist, ","), email)
if err != nil { if err != nil {
auth.log.App.Warn().Err(err).Str("email", email).Msg("Invalid email filter pattern") auth.log.App.Warn().Err(err).Str("provider", provider).Str("email", email).Msg("Invalid email filter pattern")
return false return false
} }
return match return match
+131
View File
@@ -0,0 +1,131 @@
package service
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
)
func newTestAuthService(whitelist []string) *AuthService {
log := logger.NewLogger().WithTestConfig()
log.Init()
return &AuthService{
log: log,
runtime: model.RuntimeConfig{
OAuthWhitelist: whitelist,
},
}
}
func TestIsEmailWhitelisted(t *testing.T) {
tests := []struct {
name string
whitelist []string
email string
expected bool
}{
{
name: "empty whitelist denies all",
whitelist: []string{},
email: "user@example.com",
expected: false,
},
{
name: "nil whitelist denies all",
whitelist: nil,
email: "user@example.com",
expected: false,
},
{
name: "matching email is allowed",
whitelist: []string{"user@example.com"},
email: "user@example.com",
expected: true,
},
{
name: "non-matching email is denied",
whitelist: []string{"user@example.com"},
email: "other@example.com",
expected: false,
},
{
name: "multiple entries, matching email is allowed",
whitelist: []string{"alice@example.com", "bob@example.com"},
email: "bob@example.com",
expected: true,
},
{
name: "multiple entries, non-matching email is denied",
whitelist: []string{"alice@example.com", "bob@example.com"},
email: "charlie@example.com",
expected: false,
},
{
name: "regex pattern matches email",
whitelist: []string{"/@example\\.com$/"},
email: "anyone@example.com",
expected: true,
},
{
name: "regex pattern does not match different domain",
whitelist: []string{"/@example\\.com$/"},
email: "anyone@other.com",
expected: false,
},
{
name: "wildcard domain pattern with regex",
whitelist: []string{"/^.+@mycompany\\.org$/"},
email: "employee@mycompany.org",
expected: true,
},
{
name: "only global whitelist is used, not any per-provider list",
whitelist: []string{"global@example.com"},
email: "global@example.com",
expected: true,
},
{
name: "whitespace-only entries are handled gracefully",
whitelist: []string{" "},
email: "user@example.com",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
auth := newTestAuthService(tt.whitelist)
result := auth.IsEmailWhitelisted(tt.email)
assert.Equal(t, tt.expected, result)
})
}
}
// TestIsEmailWhitelistedNoPerProviderList verifies the new behaviour where
// per-provider whitelist overrides are no longer applied; only the global
// OAuthWhitelist is consulted regardless of which OAuth provider was used.
func TestIsEmailWhitelistedNoPerProviderList(t *testing.T) {
log := logger.NewLogger().WithTestConfig()
log.Init()
auth := &AuthService{
log: log,
runtime: model.RuntimeConfig{
OAuthWhitelist: []string{"global@example.com"},
// OAuthProviders still present but their Whitelist field has been removed
OAuthProviders: map[string]model.OAuthServiceConfig{
"github": {
ClientID: "github-client-id",
},
},
},
}
// Global whitelist allows this email regardless of provider
assert.True(t, auth.IsEmailWhitelisted("global@example.com"))
// Global whitelist denies this email even though it was previously
// allowed by a provider-specific list in the old implementation
assert.False(t, auth.IsEmailWhitelisted("provider-only@example.com"))
}
+72
View File
@@ -3,6 +3,7 @@ package service
import ( import (
"context" "context"
"fmt" "fmt"
"slices"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -167,6 +168,68 @@ func (k *KubernetesService) getByAppName(appName string) *model.App {
return nil return nil
} }
func (k *KubernetesService) extractPaths(rule map[string]any) ([]string, error) {
http, found, err := unstructured.NestedMap(rule, "http")
if err != nil {
return nil, fmt.Errorf("reading http from rule: %w", err)
}
if !found {
return nil, nil
}
paths, found, err := unstructured.NestedSlice(http, "paths")
if err != nil {
return nil, fmt.Errorf("reading http.paths: %w", err)
}
if !found {
return nil, nil
}
var result []string
for _, p := range paths {
path, ok := p.(map[string]any)
if !ok {
continue
}
if p, ok := path["path"].(string); ok && p != "" {
result = append(result, p)
}
}
return result, nil
}
func (k *KubernetesService) extractHosts(item *unstructured.Unstructured) ([]string, error) {
rules, found, err := unstructured.NestedSlice(item.Object, "spec", "rules")
if err != nil {
return nil, fmt.Errorf("reading spec.rules: %w", err)
}
if !found {
return nil, nil
}
var hosts []string
for _, r := range rules {
rule, ok := r.(map[string]any)
if !ok {
continue
}
if host, ok := rule["host"].(string); ok && host != "" {
hosts = append(hosts, host)
}
paths, err := k.extractPaths(rule)
if err != nil {
// This is purely to warn users, it doesn't affect our ability to extract hosts so we won't fail the whole operation
k.log.App.Warn().Err(err).Str("namespace", item.GetNamespace()).Str("name", item.GetName()).Msg("Failed to extract paths from ingress rule")
continue
}
if len(paths) == 0 {
continue
}
if !slices.Contains(paths, "/") {
k.log.App.Warn().Str("namespace", item.GetNamespace()).Str("name", item.GetName()).Strs("paths", paths).Msg("Ingress rule does not contain a catch-all path, another ingress may be able to bypass auth checks if it routes the same host with a different path. Consider adding a catch-all path to this rule to ensure auth checks are applied to all paths for this host.")
}
}
k.log.App.Trace().Strs("hosts", hosts).Msg("Extracted hosts from ingress rules")
return hosts, nil
}
func (k *KubernetesService) updateFromItem(item *unstructured.Unstructured) { func (k *KubernetesService) updateFromItem(item *unstructured.Unstructured) {
namespace := item.GetNamespace() namespace := item.GetNamespace()
name := item.GetName() name := item.GetName()
@@ -175,6 +238,11 @@ func (k *KubernetesService) updateFromItem(item *unstructured.Unstructured) {
k.removeIngress(namespace, name) k.removeIngress(namespace, name)
return return
} }
hosts, err := k.extractHosts(item)
if err != nil {
k.removeIngress(namespace, name)
return
}
labels, err := decoders.DecodeLabels[model.Apps](annotations, "apps") labels, err := decoders.DecodeLabels[model.Apps](annotations, "apps")
if err != nil { if err != nil {
k.log.App.Warn().Err(err).Str("namespace", namespace).Str("name", name).Msg("Failed to decode ingress labels, skipping") k.log.App.Warn().Err(err).Str("namespace", namespace).Str("name", name).Msg("Failed to decode ingress labels, skipping")
@@ -186,6 +254,10 @@ func (k *KubernetesService) updateFromItem(item *unstructured.Unstructured) {
if appLabels.Config.Domain == "" { if appLabels.Config.Domain == "" {
continue continue
} }
if len(hosts) > 0 && !slices.Contains(hosts, appLabels.Config.Domain) {
k.log.App.Warn().Str("namespace", namespace).Str("name", name).Str("appName", appName).Str("domain", appLabels.Config.Domain).Msg("App domain does not match any hosts defined in ingress rules, skipping")
continue
}
apps = append(apps, ingressApp{ apps = append(apps, ingressApp{
domain: appLabels.Config.Domain, domain: appLabels.Config.Domain,
appName: appName, appName: appName,