Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion internal/access/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand All @@ -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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ValidAllowlistEntry is the shared validator for the new @domain form, but its accepted grammar is not documented. In YAML specifically, a leading @ is a reserved indicator, so - @flatrun.dev in service.yml fails to parse; operators must quote it as - "@flatrun.dev". Record the accepted forms and the quoting requirement next to the validator so producers use it correctly.

Suggested change
func ValidAllowlistEntry(value string) bool {
// ValidAllowlistEntry reports whether value is a usable allowlist entry: either
// a full email address, or an "@domain" entry that grants every verified address
// on that exact domain (subdomains excluded). Matches are case-insensitive, and
// because "@" is a reserved YAML indicator domain entries must be quoted in
// service.yml, e.g. - "@example.com".
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 {
Expand Down
28 changes: 28 additions & 0 deletions internal/access/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
12 changes: 12 additions & 0 deletions internal/api/access_handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()))
Expand Down
4 changes: 2 additions & 2 deletions internal/api/deployment_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading