From fdc9b642122d51287eb768ca1b25cc92f2902c3e Mon Sep 17 00:00:00 2001 From: nfebe Date: Wed, 23 Sep 2026 14:47:27 +0100 Subject: [PATCH 1/2] feat(access): Allow verified email domains Operators can grant visitor access to every verified address on an exact email domain. Subdomains and suffix lookalikes remain outside the allowlist. --- internal/access/service.go | 18 +++++++++++++++++- internal/access/service_test.go | 28 ++++++++++++++++++++++++++++ internal/api/access_handlers_test.go | 12 ++++++++++++ internal/api/deployment_actions.go | 4 ++-- 4 files changed, 59 insertions(+), 3 deletions(-) diff --git a/internal/access/service.go b/internal/access/service.go index 7010ba2..7bdc4b2 100644 --- a/internal/access/service.go +++ b/internal/access/service.go @@ -181,7 +181,8 @@ func Allows(policy *models.DomainAccessConfig, email string) bool { } email = normalizeEmail(email) for _, allowed := range policy.AllowedEmails { - if normalizeEmail(allowed) == email { + allowed = normalizeEmail(allowed) + if allowed == email || strings.HasPrefix(allowed, "@") && allowed == emailDomain(email) { return true } } @@ -194,6 +195,21 @@ func ValidEmail(value string) bool { return err == nil && strings.EqualFold(address.Address, value) } +func ValidAllowlistEntry(value string) bool { + value = strings.TrimSpace(value) + if strings.HasPrefix(value, "@") { + return len(value) > 1 && ValidEmail("access"+value) + } + return ValidEmail(value) +} + +func emailDomain(value string) string { + if index := strings.LastIndexByte(value, '@'); index >= 0 { + return value[index:] + } + return "" +} + func (s *Service) sign(payload tokenPayload) (string, error) { data, err := json.Marshal(payload) if err != nil { diff --git a/internal/access/service_test.go b/internal/access/service_test.go index 81bba35..1fa65e2 100644 --- a/internal/access/service_test.go +++ b/internal/access/service_test.go @@ -73,6 +73,34 @@ func TestAnyVerifiedPolicyRequiresOneValidEmailAddress(t *testing.T) { } } +func TestAllowlistAcceptsExactEmailDomains(t *testing.T) { + policy := &models.DomainAccessConfig{ + Enabled: true, + Mode: "allowlist", + AllowedEmails: []string{"@flatrun.dev", "@WhileSmart.dev"}, + } + for _, email := range []string{"person@flatrun.dev", "admin@whilesmart.dev"} { + if !Allows(policy, email) { + t.Fatalf("domain member %q was rejected", email) + } + } + for _, email := range []string{"person@sub.flatrun.dev", "person@notflatrun.dev", "person@example.com"} { + if Allows(policy, email) { + t.Fatalf("non-member %q was accepted", email) + } + } + for _, entry := range []string{"@flatrun.dev", "@WhileSmart.dev", "person@example.com"} { + if !ValidAllowlistEntry(entry) { + t.Fatalf("allowlist entry %q was rejected", entry) + } + } + for _, entry := range []string{"@", "@flatrun.dev@example.com", "flatrun.dev"} { + if ValidAllowlistEntry(entry) { + t.Fatalf("invalid allowlist entry %q was accepted", entry) + } + } +} + func TestMatchingRouteOnlyAliasDoesNotModifyAliases(t *testing.T) { aliases := make([]string, 1, 2) aliases[0] = "www.example.com" diff --git a/internal/api/access_handlers_test.go b/internal/api/access_handlers_test.go index 1a3e72f..9b5274e 100644 --- a/internal/api/access_handlers_test.go +++ b/internal/api/access_handlers_test.go @@ -49,6 +49,7 @@ domains: mode: allowlist allowed_emails: - person@example.com + - "@flatrun.dev" email_target_id: smtp ` if err := os.WriteFile(filepath.Join(deploymentPath, "service.yml"), []byte(metadata), 0644); err != nil { @@ -94,6 +95,17 @@ domains: t.Fatalf("access link = %q, error = %v", link, err) } sender.message = "" + request = httptest.NewRequest(http.MethodPost, "/api/access/request", strings.NewReader(url.Values{ + "email": {"visitor@flatrun.dev"}, "return": {"/"}, + }.Encode())) + request.Host = "private.example.com" + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + response = httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusAccepted || sender.recipient != "visitor@flatrun.dev" || sender.message == "" { + t.Fatalf("domain access request = %d, recipient = %q, message = %q", response.Code, sender.recipient, sender.message) + } + sender.message = "" request = httptest.NewRequest(http.MethodPost, "/api/access/request", strings.NewReader(url.Values{ "email": {"other@example.com"}, "return": {"/"}, }.Encode())) diff --git a/internal/api/deployment_actions.go b/internal/api/deployment_actions.go index af5307b..e27a171 100644 --- a/internal/api/deployment_actions.go +++ b/internal/api/deployment_actions.go @@ -202,8 +202,8 @@ func (s *Server) validateDomainAccess(policy *models.DomainAccessConfig) error { return apiErrf(http.StatusBadRequest, "At least one allowed email is required") } for _, email := range policy.AllowedEmails { - if !access.ValidEmail(email) { - return apiErrf(http.StatusBadRequest, "Allowed email %q is invalid", email) + if !access.ValidAllowlistEntry(email) { + return apiErrf(http.StatusBadRequest, "Allowed email or domain %q is invalid", email) } } if policy.SessionHours < 0 || policy.SessionHours > 720 { From 8d57589af60ace8e34abc2109fdea98924bc2ef2 Mon Sep 17 00:00:00 2001 From: nfebe Date: Wed, 23 Sep 2026 14:57:58 +0100 Subject: [PATCH 2/2] docs(access): Explain email domain allowlist syntax Manual YAML configurations now state that domain entries need quotes around the leading at sign. --- internal/access/service.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/access/service.go b/internal/access/service.go index 7bdc4b2..7197386 100644 --- a/internal/access/service.go +++ b/internal/access/service.go @@ -195,6 +195,8 @@ func ValidEmail(value string) bool { return err == nil && strings.EqualFold(address.Address, value) } +// ValidAllowlistEntry accepts a complete email address or an exact domain prefixed with @. +// YAML producers must quote domain entries because @ is a reserved leading indicator. func ValidAllowlistEntry(value string) bool { value = strings.TrimSpace(value) if strings.HasPrefix(value, "@") {