Skip to content

feat(access): Gate domain routes with email verification - #225

Merged
nfebe merged 4 commits into
mainfrom
feat/email-access-gates
Sep 23, 2026
Merged

nfebe merged 4 commits into
mainfrom
feat/email-access-gates

Conversation

@nfebe

@nfebe nfebe commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

Add email verification gates for domains and paths. Operators can allow listed addresses or any verified address without changing the protected application.

@sourceant

sourceant Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Code Review Summary

✨ Adds an opt-in email-verification gate for individual domains and path prefixes. A new internal/access package issues HMAC-signed magic links and host-bound session cookies, enforces allowlist or any_verified policies, records used links on disk so they cannot be replayed, rate-limits link requests per host/address, and canonicalizes hosts, emails, and return paths. internal/api/access_handlers.go exposes the visitor-facing check, login, request, and verify endpoints plus an authenticated email-target selector, wired in internal/api/server.go. internal/nginx/manager.go gains auth_request and access-portal templates emitted only for locations whose access is enabled. Domain access config is modeled in pkg/models/deployment.go, validated on domain add/update and metadata updates (internal/api/deployment_actions.go), and documented in the OpenAPI schema. internal/notify/notify.go gains SendEmailTo so a notification target can deliver to a per-request recipient instead of its configured one. The change is covered by unit and handler-level tests across the access, API, nginx, and notify packages.

🚀 Key Improvements

  • internal/access/service.go binds sessions to both the host and the current domain policy, and validates them against allows on every check, so a cookie cannot be replayed against another host or after policy changes.
  • Magic links are single-use: the SHA-256 digest of each consumed token is persisted under .flatrun/used-access-links, so replay is rejected even after a process restart, with hourly pruning of expired records.
  • SafeReturn and ValidEmail reject open-redirect and multi-address/display-name inputs, and internal/api/access_handlers_test.go covers the unsafe-return and unlisted-email paths.
  • nginx access gating is emitted per location and per server, so domains without access enabled keep their previous generated configuration.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review complete. See the overview comment for a summary.

c.String(http.StatusServiceUnavailable, "Access service is unavailable")
return
}
email, host, returnPath, err := s.access.VerifyMagicLink(c.Query("token"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

s.applicationAccessPolicy calls s.manager.FindDeployments() (a full scan/parse of deployment metadata) on every auth_request. Here it is invoked even when the token fails to verify, wasting the scan and passing empty host/path. Check the error first and only then resolve the policy; this both short-circuits the expensive lookup and avoids resolving a policy from empty values.

Suggested change
email, host, returnPath, err := s.access.VerifyMagicLink(c.Query("token"))
email, host, returnPath, err := s.access.VerifyMagicLink(c.Query("token"))
if err != nil {
c.String(http.StatusUnauthorized, "This sign-in link is invalid or expired")
return
}
policy, ok := s.applicationAccessPolicy(host, returnPath)
if !ok || !access.Allows(policy, email) || !strings.EqualFold(hostnameOnly(c.Request.Host), hostnameOnly(host)) {
c.String(http.StatusUnauthorized, "This sign-in link is invalid or expired")
return
}

}

func (s *Server) getAccessEmailTargets(c *gin.Context) {
options := make([]gin.H, 0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

validateDomainAccess defensively checks s.notify == nil, but this handler dereferences s.notify unconditionally. A partially-initialized Server (as constructed in tests) would panic here. Return an empty list when the notification service is unavailable.

Suggested change
options := make([]gin.H, 0)
options := make([]gin.H, 0)
if s.notify == nil {
c.JSON(http.StatusOK, gin.H{"targets": options})
return
}
for _, target := range s.notify.Load().Targets {

Comment thread internal/access/service.go Outdated
if hostname(domain.Domain) == host {
return true
}
for _, alias := range append(domain.Aliases, domain.RouteOnlyAliases...) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

append(domain.Aliases, domain.RouteOnlyAliases...) may reuse the backing array of domain.Aliases when it has spare capacity, which can silently overwrite data shared by other slices. Iterate the two slices separately to avoid the aliasing hazard.

Suggested change
for _, alias := range append(domain.Aliases, domain.RouteOnlyAliases...) {
for _, alias := range domain.Aliases {
if hostname(alias) == host {
return true
}
}
for _, alias := range domain.RouteOnlyAliases {
if hostname(alias) == host {
return true
}
}

if policy.Mode == "allowlist" && len(policy.AllowedEmails) == 0 {
return apiErrf(http.StatusBadRequest, "At least one allowed email is required")
}
for _, email := range policy.AllowedEmails {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This loop duplicates the email syntax check already provided by access.ValidEmail, which is the function the runtime authorizer access.Allows actually uses. Because the config-time validator and the request-time authorizer are now separate implementations, an address can pass API validation yet be treated differently at request time (or vice-versa). Delegating to the shared helper keeps the two contracts identical. Note: add the github.com/flatrun/agent/internal/access import and drop the now-unused net/mail import.

Suggested change
for _, email := range policy.AllowedEmails {
for _, email := range policy.AllowedEmails {
if !access.ValidEmail(email) {
return apiErrf(http.StatusBadRequest, "Allowed email %q is invalid", email)
}
}

if policy.Mode == "allowlist" && len(policy.AllowedEmails) == 0 {
return apiErrf(http.StatusBadRequest, "At least one allowed email is required")
}
for _, email := range policy.AllowedEmails {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This inline loop duplicates access.ValidEmail (internal/access/service.go), which implements the exact same contract: trim, mail.ParseAddress, then strings.EqualFold(address.Address, trimmed). Because the api package already imports internal/access, the canonical validator should be reused so the two contracts cannot diverge. As written, a future tightening of access.ValidEmail (e.g. rejecting display-name forms or unicode) would not apply to domain saves, so an allowed_emails value accepted here could later be silently rejected by the login-time gate (access.Allows → ValidEmail), producing inconsistent behavior for the same field. Reusing the helper also removes the local re-derivation of the validation rule. Add the github.com/flatrun/agent/internal/access import and drop the now-unused net/mail import.

Suggested change
for _, email := range policy.AllowedEmails {
+` for _, email := range policy.AllowedEmails {
+ if !access.ValidEmail(email) {
+ return apiErrf(http.StatusBadRequest, "Allowed email %q is invalid", email)
+ }
+ }
+`

Operators can require an email allowlist or admit any verified address before
visitors reach an application. Sign-in links remain single use across agent
restarts, and sessions stay bound to the requested host.
Protected routes remain closed when policy data is unavailable. Deployment
editors can select email targets without notification administration access,
and sign-in requests are covered through HTTP.
Access requests avoid unnecessary policy scans and handle unavailable email targets safely.

Alias matching preserves shared storage, and saved emails use the runtime validation rules.
@nfebe
nfebe force-pushed the feat/email-access-gates branch from ac39917 to f79e1de Compare September 22, 2026 20:59

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review complete. See the overview comment for a summary.

}
}

func (s *Service) AllowEmailRequest(host, email 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.

lastRequests is keyed by host+email and is only ever written, never evicted. Because AllowEmailRequest is reached for every syntactically valid address in any_verified mode, an attacker (or ordinary traffic to distinct addresses) accumulates one map entry per key forever — an unbounded memory growth path. The rate-limit window is one minute, so entries older than that are dead weight and can be dropped opportunistically.

Suggested change
func (s *Service) AllowEmailRequest(host, email string) bool {
func (s *Service) AllowEmailRequest(host, email string) bool {
key := hostname(host) + "\x00" + normalizeEmail(email)
now := s.now()
s.mu.Lock()
defer s.mu.Unlock()
// lastRequests is only ever written, so stale entries (older than the
// one-minute window) would accumulate without bound; drop them once the
// map grows past a small threshold.
if len(s.lastRequests) > 1024 {
for entry, seen := range s.lastRequests {
if now.Sub(seen) >= time.Minute {
delete(s.lastRequests, entry)
}
}
}
if last, ok := s.lastRequests[key]; ok && now.Sub(last) < time.Minute {
return false
}
s.lastRequests[key] = now
return true
}

return apiErrf(http.StatusBadRequest, "Allowed email %q is invalid", email)
}
}
if policy.SessionHours < 0 || policy.SessionHours > 720 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The accepted range and the error text disagree: 0 is a valid value (it means "use the default" — access.Session falls back to 24 hours), yet the message claims the range is 1–720. A client that sends 0 gets no error, and a client that sends -1 is told the range starts at 1, which is misleading for a field whose zero value is legal.

Suggested change
if policy.SessionHours < 0 || policy.SessionHours > 720 {
if policy.SessionHours != 0 && (policy.SessionHours < 1 || policy.SessionHours > 720) {
return apiErrf(http.StatusBadRequest, "Session hours must be between 1 and 720, or 0 for the 24-hour default")
}

Comment thread internal/api/access_handlers.go Outdated
return value
}

func hostnameOnly(value string) string {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

hostnameOnly is a second host-normalizer that diverges from access.hostname, which is the normalizer both access.Resolve (policy lookup) and access.ValidateSession (the check endpoint) use. hostnameOnly only truncates at the first :, so a bracketed IPv6 authority ([::1]:8443 → [) and a trailing-dot host (app.example.com. → unchanged, because only access.hostname strips the trailing dot) are normalized differently. Concretely, verifyApplicationAccess compares hostnameOnly(c.Request.Host) against the magic link's host — which was produced by MagicLink via access.hostname — so a Host header carrying a trailing dot (or an IPv6 literal) is accepted by the check endpoint (ValidateSession → access.hostname) but rejected at verify, i.e. the same session is valid on one path and invalid on the other. Align this helper with access.hostname so both paths normalize identically.

Suggested change
func hostnameOnly(value string) string {
func hostnameOnly(value string) string {
value = strings.TrimSpace(strings.ToLower(value))
if strings.HasPrefix(value, "[") {
if end := strings.IndexByte(value, ']'); end > 0 {
return value[1:end]
}
}
if index := strings.IndexByte(value, ':'); index >= 0 {
return strings.TrimSuffix(value[:index], ".")
}
return strings.TrimSuffix(value, ".")
}

return apiErrf(http.StatusBadRequest, "Allowed email %q is invalid", email)
}
}
if policy.SessionHours < 0 || policy.SessionHours > 720 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The accepted range and the message disagree. The guard rejects only negative values and values above 720, so session_hours: 0 is accepted and is meaningful (access.Session and verifyApplicationAccess both substitute 24 for any value <= 0). The error text tells clients the floor is 1, so a client that honours the message and refuses to send 0 cannot express the document(ed) default. Make the message match the inputs the handler actually accepts.

Suggested change
if policy.SessionHours < 0 || policy.SessionHours > 720 {
if policy.SessionHours < 0 || policy.SessionHours > 720 {
return apiErrf(http.StatusBadRequest, "Session hours must be between 0 and 720, where 0 uses the default")
}

Comment thread internal/api/access_handlers.go Outdated
return access.Resolve(deployments, host, requestPath)
}

func safeAccessReturn(value string) string {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

safeAccessReturn duplicates safeReturn in internal/access/service.go character for character. It is the redirect/sanitisation guard for the protected-application flow: applicationAccessLogin and requestApplicationAccess use this copy, while access.MagicLink applies its own copy to the same value written into the token. Because the two guards are independent, a tightening of the canonical helper (for example also rejecting /%2e%2e or control characters) would leave the API copy permissive, so the login page and the return field could accept a value the token issuer would have normalised. Export the sanitizer from internal/access (rename safeReturn to SafeReturn) and delegate, so the open-redirect guard has one implementation.

Suggested change
func safeAccessReturn(value string) string {
+func safeAccessReturn(value string) string {
+ return access.SafeReturn(value)
+}

Comment thread internal/api/access_handlers.go Outdated
return value
}

func hostnameOnly(value string) string {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

hostnameOnly is a second implementation of the host normalisation that internal/access already provides as hostname (used by MagicLink, Session and ValidateSession). The two disagree: access.hostname lowercases, splits host:port with net.SplitHostPort, and strips a trailing dot, whereas this copy only truncates at the first : and keeps the trailing dot. In verifyApplicationAccess the token's host was produced by access.hostname, but the request host is normalised by this copy, so a request that arrives as private.example.com. (or with any input net.SplitHostPort would have handled differently) fails the strings.EqualFold binding check even though the link is valid. Delegate to the canonical normaliser (export hostname as Hostname) so the value that binds the token and the value checked at verification use the same rule.

Suggested change
func hostnameOnly(value string) string {
+func hostnameOnly(value string) string {
+ return access.Hostname(value)
+}

Access requests now share host and redirect normalization across every step.

Expired rate limit entries are removed, and session validation text matches accepted values.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review complete. No specific code suggestions were generated. See the overview comment for a summary.

@nfebe
nfebe merged commit 6ee65f7 into main Sep 23, 2026
6 checks passed
@nfebe
nfebe deleted the feat/email-access-gates branch September 23, 2026 13:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant