diff --git a/internal/access/service.go b/internal/access/service.go index 7010ba2..7197386 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,23 @@ 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, "@") { + 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 {