diff --git a/internal/access/service.go b/internal/access/service.go new file mode 100644 index 0000000..7010ba2 --- /dev/null +++ b/internal/access/service.go @@ -0,0 +1,270 @@ +package access + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net" + "net/mail" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "github.com/flatrun/agent/pkg/models" +) + +const CookieName = "flatrun_access" + +type Service struct { + secret []byte + usedLinksDir string + now func() time.Time + mu sync.Mutex + lastRequests map[string]time.Time + lastPrune time.Time +} + +type tokenPayload struct { + Kind string `json:"kind"` + Email string `json:"email"` + Host string `json:"host"` + Return string `json:"return,omitempty"` + Expiry int64 `json:"expiry"` +} + +func New(basePath string) (*Service, error) { + dir := filepath.Join(basePath, ".flatrun") + if err := os.MkdirAll(dir, 0700); err != nil { + return nil, fmt.Errorf("create access directory: %w", err) + } + path := filepath.Join(dir, "access-secret") + usedLinksDir := filepath.Join(dir, "used-access-links") + if err := os.MkdirAll(usedLinksDir, 0700); err != nil { + return nil, fmt.Errorf("create used access links directory: %w", err) + } + secret, err := os.ReadFile(path) + if os.IsNotExist(err) { + secret = make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + return nil, fmt.Errorf("generate access secret: %w", err) + } + if err := os.WriteFile(path, []byte(base64.RawURLEncoding.EncodeToString(secret)), 0600); err != nil { + return nil, fmt.Errorf("save access secret: %w", err) + } + return newService(secret, usedLinksDir), nil + } + if err != nil { + return nil, fmt.Errorf("read access secret: %w", err) + } + secret, err = base64.RawURLEncoding.DecodeString(strings.TrimSpace(string(secret))) + if err != nil || len(secret) != 32 { + return nil, fmt.Errorf("access secret is invalid") + } + return newService(secret, usedLinksDir), nil +} + +func Resolve(deployments []models.Deployment, host, requestPath string) (*models.DomainAccessConfig, bool) { + host = Hostname(host) + bestLength := -1 + var best *models.DomainAccessConfig + for i := range deployments { + if deployments[i].Metadata == nil { + continue + } + for _, domain := range deployments[i].Metadata.GetDomains() { + if !matchesHost(domain, host) || domain.Access == nil || !domain.Access.Enabled { + continue + } + prefix := domain.PathPrefix + if prefix == "" { + prefix = "/" + } + if !strings.HasPrefix(requestPath, prefix) || len(prefix) <= bestLength { + continue + } + copy := *domain.Access + best = © + bestLength = len(prefix) + } + } + return best, best != nil +} + +func (s *Service) MagicLink(email, host, returnPath string) (string, error) { + return s.sign(tokenPayload{Kind: "verify", Email: normalizeEmail(email), Host: Hostname(host), Return: SafeReturn(returnPath), Expiry: s.now().Add(15 * time.Minute).Unix()}) +} + +func (s *Service) VerifyMagicLink(value string) (string, string, string, error) { + payload, err := s.verify(value, "verify") + if err != nil { + return "", "", "", err + } + digest := sha256.Sum256([]byte(value)) + path := filepath.Join(s.usedLinksDir, fmt.Sprintf("%d-%x", payload.Expiry, digest)) + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if os.IsExist(err) { + return "", "", "", fmt.Errorf("token is invalid or expired") + } + if err != nil { + return "", "", "", fmt.Errorf("record used access link: %w", err) + } + if err := file.Close(); err != nil { + return "", "", "", fmt.Errorf("close used access link: %w", err) + } + s.pruneUsedLinks() + return payload.Email, payload.Host, payload.Return, nil +} + +func (s *Service) pruneUsedLinks() { + s.mu.Lock() + defer s.mu.Unlock() + if s.now().Sub(s.lastPrune) < time.Hour { + return + } + s.lastPrune = s.now() + entries, err := os.ReadDir(s.usedLinksDir) + if err != nil { + return + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + expiry, err := strconv.ParseInt(strings.SplitN(entry.Name(), "-", 2)[0], 10, 64) + if err == nil && expiry < s.now().Unix() { + _ = os.Remove(filepath.Join(s.usedLinksDir, entry.Name())) + } + } +} + +func (s *Service) AllowEmailRequest(host, email string) bool { + key := Hostname(host) + "\x00" + normalizeEmail(email) + now := s.now() + s.mu.Lock() + defer s.mu.Unlock() + for existing, last := range s.lastRequests { + if now.Sub(last) >= time.Minute { + delete(s.lastRequests, existing) + } + } + if last, ok := s.lastRequests[key]; ok && now.Sub(last) < time.Minute { + return false + } + s.lastRequests[key] = now + return true +} + +func (s *Service) Session(email, host string, hours int) (string, error) { + if hours <= 0 { + hours = 24 + } + return s.sign(tokenPayload{Kind: "session", Email: normalizeEmail(email), Host: Hostname(host), Expiry: s.now().Add(time.Duration(hours) * time.Hour).Unix()}) +} + +func (s *Service) ValidateSession(value, host string, policy *models.DomainAccessConfig) bool { + payload, err := s.verify(value, "session") + return err == nil && payload.Host == Hostname(host) && Allows(policy, payload.Email) +} + +func Allows(policy *models.DomainAccessConfig, email string) bool { + if policy == nil || !policy.Enabled || !ValidEmail(email) { + return false + } + if policy.Mode == "any_verified" { + return true + } + email = normalizeEmail(email) + for _, allowed := range policy.AllowedEmails { + if normalizeEmail(allowed) == email { + return true + } + } + return false +} + +func ValidEmail(value string) bool { + value = strings.TrimSpace(value) + address, err := mail.ParseAddress(value) + return err == nil && strings.EqualFold(address.Address, value) +} + +func (s *Service) sign(payload tokenPayload) (string, error) { + data, err := json.Marshal(payload) + if err != nil { + return "", err + } + encoded := base64.RawURLEncoding.EncodeToString(data) + mac := hmac.New(sha256.New, s.secret) + _, _ = mac.Write([]byte(encoded)) + return encoded + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil +} + +func (s *Service) verify(value, kind string) (tokenPayload, error) { + var payload tokenPayload + parts := strings.Split(value, ".") + if len(parts) != 2 { + return payload, fmt.Errorf("token is invalid") + } + signature, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return payload, fmt.Errorf("token is invalid") + } + mac := hmac.New(sha256.New, s.secret) + _, _ = mac.Write([]byte(parts[0])) + if !hmac.Equal(signature, mac.Sum(nil)) { + return payload, fmt.Errorf("token is invalid") + } + data, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil || json.Unmarshal(data, &payload) != nil || payload.Kind != kind || payload.Expiry < s.now().Unix() { + return tokenPayload{}, fmt.Errorf("token is invalid or expired") + } + return payload, nil +} + +func matchesHost(domain models.DomainConfig, host string) bool { + if Hostname(domain.Domain) == host { + return true + } + for _, alias := range domain.Aliases { + if Hostname(alias) == host { + return true + } + } + for _, alias := range domain.RouteOnlyAliases { + if Hostname(alias) == host { + return true + } + } + return false +} + +func Hostname(value string) string { + value = strings.TrimSpace(strings.ToLower(value)) + if host, _, err := net.SplitHostPort(value); err == nil { + return strings.TrimSuffix(host, ".") + } + return strings.TrimSuffix(value, ".") +} + +func normalizeEmail(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + +func SafeReturn(value string) string { + if !strings.HasPrefix(value, "/") || strings.HasPrefix(value, "//") || strings.ContainsAny(value, "\\\r\n") { + return "/" + } + return value +} + +func newService(secret []byte, usedLinksDir string) *Service { + return &Service{ + secret: secret, usedLinksDir: usedLinksDir, now: time.Now, lastRequests: make(map[string]time.Time), + } +} diff --git a/internal/access/service_test.go b/internal/access/service_test.go new file mode 100644 index 0000000..81bba35 --- /dev/null +++ b/internal/access/service_test.go @@ -0,0 +1,120 @@ +package access + +import ( + "testing" + "time" + + "github.com/flatrun/agent/pkg/models" +) + +func TestResolveUsesTheMostSpecificProtectedPath(t *testing.T) { + deployments := []models.Deployment{{Metadata: &models.ServiceMetadata{Domains: []models.DomainConfig{ + {Domain: "app.example.com", PathPrefix: "/", Access: &models.DomainAccessConfig{Enabled: true, Mode: "any_verified"}}, + {Domain: "app.example.com", PathPrefix: "/admin", Access: &models.DomainAccessConfig{Enabled: true, Mode: "allowlist", AllowedEmails: []string{"admin@example.com"}}}, + }}}} + + policy, ok := Resolve(deployments, "app.example.com", "/admin/users") + if !ok || policy.Mode != "allowlist" { + t.Fatalf("Resolve() = %#v, %v", policy, ok) + } + if Allows(policy, "visitor@example.com") { + t.Fatal("visitor unexpectedly passed the admin allowlist") + } +} + +func TestMagicLinkCreatesAHostBoundSession(t *testing.T) { + base := t.TempDir() + service, err := New(base) + if err != nil { + t.Fatal(err) + } + service.now = func() time.Time { return time.Unix(1_700_000_000, 0) } + link, err := service.MagicLink("Person@Example.com", "app.example.com", "/private") + if err != nil { + t.Fatal(err) + } + email, host, returnPath, err := service.VerifyMagicLink(link) + if err != nil || email != "person@example.com" || host != "app.example.com" || returnPath != "/private" { + t.Fatalf("VerifyMagicLink() = %q, %q, %q, %v", email, host, returnPath, err) + } + if _, _, _, err := service.VerifyMagicLink(link); err == nil { + t.Fatal("magic link was accepted twice") + } + restarted, err := New(base) + if err != nil { + t.Fatal(err) + } + restarted.now = service.now + if _, _, _, err := restarted.VerifyMagicLink(link); err == nil { + t.Fatal("magic link was accepted after restart") + } + session, err := service.Session(email, host, 24) + if err != nil { + t.Fatal(err) + } + policy := &models.DomainAccessConfig{Enabled: true, Mode: "allowlist", AllowedEmails: []string{"person@example.com"}} + if !service.ValidateSession(session, host, policy) { + t.Fatal("session was not accepted for its host and policy") + } + if service.ValidateSession(session, "other.example.com", policy) { + t.Fatal("session was accepted for another host") + } +} + +func TestAnyVerifiedPolicyRequiresOneValidEmailAddress(t *testing.T) { + policy := &models.DomainAccessConfig{Enabled: true, Mode: "any_verified"} + if !Allows(policy, "person@example.com") { + t.Fatal("valid email was rejected") + } + for _, invalid := range []string{"", "person@example.com,other@example.com", "Person "} { + if Allows(policy, invalid) { + t.Fatalf("invalid email %q was accepted", invalid) + } + } +} + +func TestMatchingRouteOnlyAliasDoesNotModifyAliases(t *testing.T) { + aliases := make([]string, 1, 2) + aliases[0] = "www.example.com" + backing := aliases[:2] + backing[1] = "keep.example.com" + domain := models.DomainConfig{ + Domain: "example.com", + Aliases: aliases, + RouteOnlyAliases: []string{"internal.example.com"}, + } + + if !matchesHost(domain, "internal.example.com") { + t.Fatal("route-only alias did not match") + } + if backing[1] != "keep.example.com" { + t.Fatalf("alias backing array was modified: %q", backing[1]) + } +} + +func TestAllowEmailRequestEvictsExpiredEntries(t *testing.T) { + service, err := New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + now := time.Unix(1_700_000_000, 0) + service.now = func() time.Time { return now } + service.AllowEmailRequest("app.example.com", "first@example.com") + now = now.Add(time.Minute) + service.AllowEmailRequest("app.example.com", "second@example.com") + if len(service.lastRequests) != 1 { + t.Fatalf("rate limit entries = %d", len(service.lastRequests)) + } +} + +func TestCanonicalAccessValues(t *testing.T) { + if Hostname("APP.EXAMPLE.COM.:443") != "app.example.com" { + t.Fatal("host with port was not normalized") + } + if Hostname("APP.EXAMPLE.COM.") != "app.example.com" { + t.Fatal("trailing dot was not removed") + } + if SafeReturn("//other.example.com") != "/" { + t.Fatal("unsafe return path was accepted") + } +} diff --git a/internal/api/access_handlers.go b/internal/api/access_handlers.go new file mode 100644 index 0000000..a9af4c8 --- /dev/null +++ b/internal/api/access_handlers.go @@ -0,0 +1,121 @@ +package api + +import ( + "fmt" + "html" + "log" + "net/http" + "net/url" + "strings" + + "github.com/flatrun/agent/internal/access" + "github.com/flatrun/agent/internal/notify" + "github.com/flatrun/agent/pkg/models" + "github.com/gin-gonic/gin" +) + +type accessEmailSender interface { + SendEmailTo(string, string, notify.Notification) error +} + +func (s *Server) checkApplicationAccess(c *gin.Context) { + policy, ok := s.applicationAccessPolicy(c.GetHeader("X-Original-Host"), c.GetHeader("X-Original-URI")) + if !ok { + c.Status(http.StatusUnauthorized) + return + } + cookie, err := c.Cookie(access.CookieName) + if err != nil || s.access == nil || !s.access.ValidateSession(cookie, c.GetHeader("X-Original-Host"), policy) { + c.Status(http.StatusUnauthorized) + return + } + c.Status(http.StatusNoContent) +} + +func (s *Server) applicationAccessLogin(c *gin.Context) { + returnPath := access.SafeReturn(c.Query("return")) + c.Header("Content-Type", "text/html; charset=utf-8") + c.String(http.StatusOK, ` + +Sign in +

Verify your email

We will email you a secure sign-in link.

`, html.EscapeString(returnPath)) +} + +func (s *Server) requestApplicationAccess(c *gin.Context) { + email := strings.TrimSpace(c.PostForm("email")) + returnPath := access.SafeReturn(c.PostForm("return")) + policy, ok := s.applicationAccessPolicy(c.Request.Host, returnPath) + if ok && s.access != nil && s.accessEmailSender != nil && access.Allows(policy, email) && s.access.AllowEmailRequest(c.Request.Host, email) { + token, err := s.access.MagicLink(email, c.Request.Host, returnPath) + if err == nil { + scheme := c.GetHeader("X-Forwarded-Proto") + if scheme != "https" { + scheme = "http" + } + link := fmt.Sprintf("%s://%s/_flatrun/access/verify?token=%s", scheme, c.Request.Host, url.QueryEscape(token)) + err = s.accessEmailSender.SendEmailTo(policy.EmailTargetID, email, notify.Notification{ + Title: "Your FlatRun access link", Message: "Open this link to continue: " + link, + }) + } + if err != nil { + log.Printf("application access email failed for host %q: %v", c.Request.Host, err) + } + } + c.Header("Content-Type", "text/html; charset=utf-8") + c.String(http.StatusAccepted, `Check your email

Check your email

If this address is allowed, a sign-in link is on its way.

`) +} + +func (s *Server) getAccessEmailTargets(c *gin.Context) { + 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 { + if target.Enabled && strings.HasPrefix(target.URL, "smtp://") { + options = append(options, gin.H{"id": target.ID, "name": target.Name}) + } + } + c.JSON(http.StatusOK, gin.H{"targets": options}) +} + +func (s *Server) verifyApplicationAccess(c *gin.Context) { + if s.access == nil { + c.String(http.StatusServiceUnavailable, "Access service is unavailable") + return + } + 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) || access.Hostname(c.Request.Host) != access.Hostname(host) { + c.String(http.StatusUnauthorized, "This sign-in link is invalid or expired") + return + } + session, err := s.access.Session(email, host, policy.SessionHours) + if err != nil { + c.String(http.StatusInternalServerError, "Could not create an access session") + return + } + https := c.GetHeader("X-Forwarded-Proto") == "https" + hours := policy.SessionHours + if hours <= 0 { + hours = 24 + } + c.SetSameSite(http.SameSiteLaxMode) + c.SetCookie(access.CookieName, session, hours*3600, "/", "", https, true) + c.Redirect(http.StatusFound, access.SafeReturn(returnPath)) +} + +func (s *Server) applicationAccessPolicy(host, requestPath string) (*models.DomainAccessConfig, bool) { + if s.manager == nil { + return nil, false + } + deployments, err := s.manager.FindDeployments() + if err != nil { + return nil, false + } + return access.Resolve(deployments, host, requestPath) +} diff --git a/internal/api/access_handlers_test.go b/internal/api/access_handlers_test.go new file mode 100644 index 0000000..1a3e72f --- /dev/null +++ b/internal/api/access_handlers_test.go @@ -0,0 +1,221 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/flatrun/agent/internal/access" + "github.com/flatrun/agent/internal/auth" + "github.com/flatrun/agent/internal/docker" + "github.com/flatrun/agent/internal/notify" + "github.com/flatrun/agent/pkg/config" + "github.com/gin-gonic/gin" +) + +type recordingAccessSender struct { + targetID string + recipient string + message string +} + +func (s *recordingAccessSender) SendEmailTo(targetID, recipient string, message notify.Notification) error { + s.targetID = targetID + s.recipient = recipient + s.message = message.Message + return nil +} + +func TestApplicationAccessCheckUsesTheVisitorHTTPBoundary(t *testing.T) { + base := t.TempDir() + deploymentPath := filepath.Join(base, "private-app") + if err := os.MkdirAll(deploymentPath, 0755); err != nil { + t.Fatal(err) + } + metadata := `name: private-app +type: web +domains: + - id: private + service: web + container_port: 80 + domain: private.example.com + access: + enabled: true + mode: allowlist + allowed_emails: + - person@example.com + email_target_id: smtp +` + if err := os.WriteFile(filepath.Join(deploymentPath, "service.yml"), []byte(metadata), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(deploymentPath, "docker-compose.yml"), []byte("services:\n web:\n image: nginx:alpine\n"), 0644); err != nil { + t.Fatal(err) + } + accessService, err := access.New(base) + if err != nil { + t.Fatal(err) + } + sender := &recordingAccessSender{} + server := &Server{manager: docker.NewManager(base), access: accessService, accessEmailSender: sender} + router := gin.New() + router.GET("/api/access/check", server.checkApplicationAccess) + router.POST("/api/access/request", server.requestApplicationAccess) + router.GET("/api/access/verify", server.verifyApplicationAccess) + + request := httptest.NewRequest(http.MethodGet, "/api/access/check", nil) + request.Header.Set("X-Original-Host", "private.example.com") + request.Header.Set("X-Original-URI", "/") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusUnauthorized { + t.Fatalf("anonymous response = %d", response.Code) + } + + request = httptest.NewRequest(http.MethodPost, "/api/access/request", strings.NewReader(url.Values{ + "email": {"person@example.com"}, "return": {"/"}, + }.Encode())) + request.Host = "private.example.com" + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.Header.Set("X-Forwarded-Proto", "https") + response = httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusAccepted || sender.targetID != "smtp" || sender.recipient != "person@example.com" { + t.Fatalf("access request = %d, target = %q, recipient = %q", response.Code, sender.targetID, sender.recipient) + } + link := strings.TrimPrefix(sender.message, "Open this link to continue: ") + parsed, err := url.Parse(link) + if err != nil || parsed.Scheme != "https" || parsed.Host != "private.example.com" { + t.Fatalf("access link = %q, error = %v", link, err) + } + sender.message = "" + request = httptest.NewRequest(http.MethodPost, "/api/access/request", strings.NewReader(url.Values{ + "email": {"other@example.com"}, "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.message != "" { + t.Fatalf("unlisted email response = %d, message = %q", response.Code, sender.message) + } + request = httptest.NewRequest(http.MethodGet, "/api/access/verify?"+parsed.RawQuery, nil) + request.Host = "private.example.com" + response = httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusFound || len(response.Result().Cookies()) != 1 { + t.Fatalf("verification response = %d, cookies = %v", response.Code, response.Result().Cookies()) + } + cookie := response.Result().Cookies()[0] + request = httptest.NewRequest(http.MethodGet, "/api/access/check", nil) + request.Header.Set("X-Original-Host", "private.example.com") + request.Header.Set("X-Original-URI", "/") + request.AddCookie(cookie) + response = httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusNoContent { + t.Fatalf("verified response = %d, body = %s", response.Code, response.Body.String()) + } + restarted, err := access.New(base) + if err != nil { + t.Fatal(err) + } + server.access = restarted + request = httptest.NewRequest(http.MethodGet, "/api/access/verify?"+parsed.RawQuery, nil) + request.Host = "private.example.com" + response = httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusUnauthorized { + t.Fatalf("replayed verification response = %d", response.Code) + } + if err := os.WriteFile(filepath.Join(deploymentPath, "service.yml"), []byte("domains: [invalid"), 0644); err != nil { + t.Fatal(err) + } + request = httptest.NewRequest(http.MethodGet, "/api/access/check", nil) + request.Header.Set("X-Original-Host", "private.example.com") + request.Header.Set("X-Original-URI", "/") + request.AddCookie(cookie) + response = httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusUnauthorized { + t.Fatalf("unreadable policy response = %d", response.Code) + } +} + +func TestAccessEmailTargetsRespectDeploymentGrants(t *testing.T) { + base := t.TempDir() + cfg := &config.Config{Auth: config.AuthConfig{Enabled: true, JWTSecret: "access-test-secret"}} + t.Setenv("FLATRUN_ADMIN_PASSWORD", "testadminpass") + authManager, err := auth.NewManager(base, &cfg.Auth, true) + if err != nil { + t.Fatal(err) + } + defer authManager.Close() + notifications := notify.NewService(base) + if err := notifications.Save(notify.Config{Targets: []notify.Target{ + {ID: "smtp", Name: "Mail", URL: "smtp://mail.example/?from=ops%40example.com", Enabled: true}, + {ID: "webhook", Name: "Webhook", URL: "generic+https://example.com", Enabled: true}, + {ID: "disabled", Name: "Disabled", URL: "smtp://mail.example/", Enabled: false}, + }}); err != nil { + t.Fatal(err) + } + server := &Server{notify: notifications} + middleware := auth.NewMiddlewareWithManager(&cfg.Auth, authManager) + router := gin.New() + protected := router.Group("/api", middleware.RequireAuth()) + protected.GET("/deployments/:name/access/email-targets", middleware.RequirePermission(auth.PermDeploymentsWrite), middleware.RequireDeploymentAccess(auth.AccessLevelWrite), server.getAccessEmailTargets) + shopKey := objectStoreKey(t, &Server{authManager: authManager}, "access-shop-key", []string{auth.PermDeploymentsWrite.String()}, auth.DeploymentAccess{"shop": auth.AccessLevelWrite}) + otherKey := objectStoreKey(t, &Server{authManager: authManager}, "access-other-key", []string{auth.PermDeploymentsWrite.String()}, auth.DeploymentAccess{"other": auth.AccessLevelWrite}) + readerKey := objectStoreKey(t, &Server{authManager: authManager}, "access-reader-key", []string{auth.PermDeploymentsRead.String()}, auth.DeploymentAccess{"shop": auth.AccessLevelWrite}) + + response := osReq(t, router, http.MethodGet, "/api/deployments/shop/access/email-targets", shopKey, nil) + if response.Code != http.StatusOK { + t.Fatalf("shop selector = %d: %s", response.Code, response.Body.String()) + } + var body struct { + Targets []map[string]string `json:"targets"` + } + if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if len(body.Targets) != 1 || body.Targets[0]["id"] != "smtp" || body.Targets[0]["name"] != "Mail" || len(body.Targets[0]) != 2 { + t.Fatalf("selector exposed unexpected targets: %s", response.Body.String()) + } + response = osReq(t, router, http.MethodGet, "/api/deployments/shop/access/email-targets", otherKey, nil) + if response.Code != http.StatusForbidden { + t.Fatalf("other deployment selector = %d", response.Code) + } + response = osReq(t, router, http.MethodGet, "/api/deployments/shop/access/email-targets", readerKey, nil) + if response.Code != http.StatusForbidden { + t.Fatalf("read-only selector = %d", response.Code) + } +} + +func TestAccessEmailTargetsAreEmptyWithoutNotificationService(t *testing.T) { + server := &Server{} + router := gin.New() + router.GET("/api/deployments/:name/access/email-targets", server.getAccessEmailTargets) + + response := httptest.NewRecorder() + router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/deployments/shop/access/email-targets", nil)) + if response.Code != http.StatusOK || response.Body.String() != "{\"targets\":[]}" { + t.Fatalf("selector response = %d: %s", response.Code, response.Body.String()) + } +} + +func TestApplicationAccessLoginRejectsUnsafeReturnPath(t *testing.T) { + server := &Server{} + router := gin.New() + router.GET("/api/access/login", server.applicationAccessLogin) + request := httptest.NewRequest(http.MethodGet, "/api/access/login?return=/%5Cother.example.com", nil) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `name="return" value="/"`) { + t.Fatalf("unsafe return path was accepted: %d %s", response.Code, response.Body.String()) + } +} diff --git a/internal/api/deployment_actions.go b/internal/api/deployment_actions.go index ac5caa1..af5307b 100644 --- a/internal/api/deployment_actions.go +++ b/internal/api/deployment_actions.go @@ -4,7 +4,9 @@ import ( "fmt" "log" "net/http" + "strings" + "github.com/flatrun/agent/internal/access" "github.com/flatrun/agent/pkg/models" "github.com/gin-gonic/gin" "gopkg.in/yaml.v3" @@ -108,6 +110,9 @@ func (s *Server) applyDeploymentDelete(name string, opts deploymentDeleteOptions // mutateDomainAdd validates the new domain and appends it to the // deployment metadata in memory only; persisting is the caller's job. func (s *Server) mutateDomainAdd(deployment *models.Deployment, domain *models.DomainConfig) error { + if err := s.validateDomainAccess(domain.Access); err != nil { + return err + } if domain.Domain == "" { return apiErrf(http.StatusBadRequest, "Domain is required") } @@ -158,6 +163,9 @@ func (s *Server) mutateDomainAdd(deployment *models.Deployment, domain *models.D // mutateDomainUpdate replaces the domain with the given ID in memory // only; persisting is the caller's job. func (s *Server) mutateDomainUpdate(deployment *models.Deployment, domainID string, updated *models.DomainConfig) error { + if err := s.validateDomainAccess(updated.Access); err != nil { + return err + } if deployment.Metadata == nil || len(deployment.Metadata.Domains) == 0 { return apiErrf(http.StatusNotFound, "Domain not found") } @@ -183,6 +191,35 @@ func (s *Server) mutateDomainUpdate(deployment *models.Deployment, domainID stri return apiErrf(http.StatusNotFound, "Domain not found") } +func (s *Server) validateDomainAccess(policy *models.DomainAccessConfig) error { + if policy == nil || !policy.Enabled { + return nil + } + if policy.Mode != "allowlist" && policy.Mode != "any_verified" { + return apiErrf(http.StatusBadRequest, "Access mode must be allowlist or any_verified") + } + if policy.Mode == "allowlist" && len(policy.AllowedEmails) == 0 { + 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 policy.SessionHours < 0 || policy.SessionHours > 720 { + return apiErrf(http.StatusBadRequest, "Session hours must be between 0 and 720") + } + if s.notify == nil { + return apiErrf(http.StatusBadRequest, "An enabled email notification target is required") + } + for _, target := range s.notify.Load().Targets { + if target.ID == policy.EmailTargetID && target.Enabled && strings.HasPrefix(target.URL, "smtp://") { + return nil + } + } + return apiErrf(http.StatusBadRequest, "An enabled email notification target is required") +} + // mutateDomainDelete removes the domain with the given ID in memory and // reports whether the proxy should be torn down (true) or re-rendered // (false). Persisting is the caller's job. diff --git a/internal/api/openapi.json b/internal/api/openapi.json index 818ae40..d210ad1 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -6,6 +6,76 @@ "version": "0.4.0-beta.7" }, "paths": { + "/api/access/check": { + "get": { + "operationId": "get-access-check", + "tags": [ + "access" + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/access/login": { + "get": { + "operationId": "get-access-login", + "tags": [ + "access" + ], + "parameters": [ + { + "name": "return", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/access/request": { + "post": { + "operationId": "post-access-request", + "tags": [ + "access" + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/access/verify": { + "get": { + "operationId": "get-access-verify", + "tags": [ + "access" + ], + "parameters": [ + { + "name": "token", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, "/api/agent/update": { "get": { "operationId": "get-agent-update", @@ -3932,6 +4002,30 @@ "x-plan-supported": true } }, + "/api/deployments/{name}/access/email-targets": { + "get": { + "operationId": "get-deployments-by-name-access-email-targets", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:write" + } + }, "/api/deployments/{name}/actions/{actionId}": { "post": { "operationId": "post-deployments-by-name-actions-by-actionId", @@ -13998,9 +14092,45 @@ "enabled" ] }, + "models.DomainAccessConfig": { + "type": "object", + "properties": { + "allowed_emails": { + "type": "array" + }, + "email_target_id": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "mode": { + "type": "string" + }, + "session_hours": { + "type": "integer" + } + }, + "x-property-order": [ + "enabled", + "mode", + "allowed_emails", + "email_target_id", + "session_hours" + ], + "x-columns": [ + "enabled", + "mode", + "email_target_id", + "session_hours" + ] + }, "models.DomainConfig": { "type": "object", "properties": { + "access": { + "$ref": "#/components/schemas/models.DomainAccessConfig" + }, "aliases": { "type": "array", "items": { @@ -14052,7 +14182,8 @@ "aliases", "route_only_aliases", "proxy_timeout", - "static_cache" + "static_cache", + "access" ], "x-columns": [ "id", diff --git a/internal/api/server.go b/internal/api/server.go index 3c14a50..3c43498 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -27,6 +27,7 @@ import ( "github.com/compose-spec/compose-go/v2/loader" composetypes "github.com/compose-spec/compose-go/v2/types" + "github.com/flatrun/agent/internal/access" "github.com/flatrun/agent/internal/ai" "github.com/flatrun/agent/internal/audit" "github.com/flatrun/agent/internal/auth" @@ -84,6 +85,8 @@ type Server struct { builtinDNS []plugins.Plugin pluginHost *pluginhost.Host notify *notify.Service + access *access.Service + accessEmailSender accessEmailSender pluginToken string authMiddleware *auth.Middleware authManager *auth.Manager @@ -232,6 +235,10 @@ func New(cfg *config.Config, configPath string) *Server { // raise a notification) without the full user auth flow. pluginToken := randomToken() notifyService := notify.NewService(cfg.DeploymentsPath) + accessService, accessErr := access.New(cfg.DeploymentsPath) + if accessErr != nil { + log.Printf("Warning: Failed to initialize application access: %v", accessErr) + } pluginHost := pluginhost.New( filepath.Join(cfg.DeploymentsPath, ".flatrun", "plugins"), filepath.Join(cfg.DeploymentsPath, ".flatrun", "run"), @@ -361,6 +368,8 @@ func New(cfg *config.Config, configPath string) *Server { builtinDNS: builtinDNS, pluginHost: pluginHost, notify: notifyService, + access: accessService, + accessEmailSender: notifyService, pluginToken: pluginToken, authMiddleware: authMiddleware, authManager: authManager, @@ -454,6 +463,10 @@ func (s *Server) setupRoutes() { api.GET("/auth/status", s.authMiddleware.GetAuthStatus) api.POST("/auth/login", s.authMiddleware.Login) api.GET("/auth/validate", s.authMiddleware.ValidateToken) + api.GET("/access/check", s.checkApplicationAccess) + api.GET("/access/login", s.applicationAccessLogin) + api.POST("/access/request", s.requestApplicationAccess) + api.GET("/access/verify", s.verifyApplicationAccess) // WebSocket endpoint handles its own auth via first-message api.GET("/containers/:id/exec", s.containerExec) @@ -544,6 +557,7 @@ func (s *Server) setupRoutes() { // Domain endpoints protected.GET("/deployments/:name/domains", s.authMiddleware.RequirePermission(auth.PermDeploymentsRead), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelRead), s.listDomains) + protected.GET("/deployments/:name/access/email-targets", s.authMiddleware.RequirePermission(auth.PermDeploymentsWrite), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelWrite), s.getAccessEmailTargets) protected.POST("/deployments/:name/domains", s.authMiddleware.RequirePermission(auth.PermDeploymentsWrite), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelWrite), s.addDomain) protected.PUT("/deployments/:name/domains/:domainId", s.authMiddleware.RequirePermission(auth.PermDeploymentsWrite), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelWrite), s.updateDomain) protected.DELETE("/deployments/:name/domains/:domainId", s.authMiddleware.RequirePermission(auth.PermDeploymentsWrite), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelWrite), s.deleteDomain) @@ -2159,6 +2173,14 @@ func (s *Server) updateDeploymentMetadata(c *gin.Context) { seenServices[healthCheck.Service] = struct{}{} } } + if _, sentDomains := sentFields["domains"]; sentDomains { + for i := range incoming.Domains { + if err := s.validateDomainAccess(incoming.Domains[i].Access); err != nil { + respondAPIError(c, err) + return + } + } + } metadata := mergeMetadata(deployment.Metadata, &incoming, sentFields) diff --git a/internal/nginx/manager.go b/internal/nginx/manager.go index d84a5b7..0dfcb6c 100644 --- a/internal/nginx/manager.go +++ b/internal/nginx/manager.go @@ -858,6 +858,7 @@ func (m *Manager) groupDomainsByHost(domains []models.DomainConfig, deploymentNa OriginalPath: d.PathPrefix, ProxyTimeout: timeout, StaticCache: d.StaticCache, + Access: d.Access, }) if d.SSL.Enabled { @@ -878,6 +879,14 @@ func (m *Manager) groupDomainsByHost(domains []models.DomainConfig, deploymentNa } } + hasAccess := false + for _, location := range locations { + if location.Access != nil && location.Access.Enabled { + hasAccess = true + break + } + } + servers = append(servers, serverData{ Domain: host, SSLEnabled: hasSSL, @@ -886,6 +895,7 @@ func (m *Manager) groupDomainsByHost(domains []models.DomainConfig, deploymentNa Locations: locations, ServerAliases: serverAliases, EnableStapling: hasSSL && m.shouldStaple(sslDomain), + HasAccess: hasAccess, }) } @@ -986,6 +996,7 @@ type serverData struct { SSLDomain string ServerAliases []string EnableStapling bool + HasAccess bool } type locationData struct { @@ -998,6 +1009,7 @@ type locationData struct { OriginalPath string ProxyTimeout int StaticCache bool + Access *models.DomainAccessConfig // Upstream is the value assigned to $upstream: an upstream block name when // keepalive is supported, otherwise the literal service:port. Upstream string @@ -1178,7 +1190,34 @@ upstream {{.Name}} { {{end -}} ` -const multiRouteHTTPTemplate = upstreamBlocks + `{{- range .Servers}} +const accessTemplates = `{{define "accessPortal"}}{{if .HasAccess}} + + location = /_flatrun/access/check { + internal; + proxy_pass http://host.docker.internal:8090/api/access/check; + proxy_set_header X-Original-Host $host; + proxy_set_header X-Original-URI $request_uri; + proxy_set_header Cookie $http_cookie; + } + + location ^~ /_flatrun/access/ { + proxy_pass http://host.docker.internal:8090/api/access/; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + } + + location @flatrun_access_login { + return 302 /_flatrun/access/login?return=$request_uri; + } +{{end}}{{end}} +{{define "accessCheck"}}{{if and .Access .Access.Enabled}} + auth_request /_flatrun/access/check; + error_page 401 = @flatrun_access_login; +{{end}}{{end}} +` + +const multiRouteHTTPTemplate = upstreamBlocks + accessTemplates + `{{- range .Servers}} server { listen 80; server_name {{.Domain}}{{range .ServerAliases}} {{.}}{{end}}; @@ -1187,9 +1226,11 @@ server { {{- range $.BlockedIPs}} deny {{.}}; {{- end}} +{{template "accessPortal" .}} {{- range .Locations}} location {{.Path}} { +{{template "accessCheck" .}} set $upstream {{.Upstream}}; {{- if .StripPrefix}} rewrite ^{{.OriginalPath}}(.*)$ /$1 break; @@ -1241,7 +1282,7 @@ server { } {{end}}` -const multiRouteSSLTemplate = upstreamBlocks + `{{- range .Servers}} +const multiRouteSSLTemplate = upstreamBlocks + accessTemplates + `{{- range .Servers}} server { listen 80; server_name {{.Domain}}{{range .ServerAliases}} {{.}}{{end}}; @@ -1281,9 +1322,11 @@ server { {{- range $.BlockedIPs}} deny {{.}}; {{- end}} +{{template "accessPortal" .}} {{- range .Locations}} location {{.Path}} { +{{template "accessCheck" .}} set $upstream {{.Upstream}}; {{- if .StripPrefix}} rewrite ^{{.OriginalPath}}(.*)$ /$1 break; @@ -1329,7 +1372,7 @@ server { } {{end}}` -const multiRouteMixedTemplate = upstreamBlocks + `{{- range .Servers}} +const multiRouteMixedTemplate = upstreamBlocks + accessTemplates + `{{- range .Servers}} {{- if .HasSSL}} server { listen 80; @@ -1370,9 +1413,11 @@ server { {{- range $.BlockedIPs}} deny {{.}}; {{- end}} +{{template "accessPortal" .}} {{- range .Locations}} location {{.Path}} { +{{template "accessCheck" .}} set $upstream {{.Upstream}}; {{- if .StripPrefix}} rewrite ^{{.OriginalPath}}(.*)$ /$1 break; @@ -1425,9 +1470,11 @@ server { {{- range $.BlockedIPs}} deny {{.}}; {{- end}} +{{template "accessPortal" .}} {{- range .Locations}} location {{.Path}} { +{{template "accessCheck" .}} set $upstream {{.Upstream}}; {{- if .StripPrefix}} rewrite ^{{.OriginalPath}}(.*)$ /$1 break; diff --git a/internal/nginx/manager_test.go b/internal/nginx/manager_test.go index 8f43ebe..cb36360 100644 --- a/internal/nginx/manager_test.go +++ b/internal/nginx/manager_test.go @@ -2288,6 +2288,31 @@ func TestRouteOnlyAliasEmittedIntoServerName(t *testing.T) { } } +func TestDomainAccessAddsEmailGateToProtectedLocation(t *testing.T) { + m := NewManager(&config.NginxConfig{}, t.TempDir(), "") + deployment := &models.Deployment{ + Name: "private-app", + Metadata: &models.ServiceMetadata{Domains: []models.DomainConfig{{ + ID: "private", Domain: "private.example.com", Service: "web", ContainerPort: 8080, + Access: &models.DomainAccessConfig{Enabled: true, Mode: "allowlist", AllowedEmails: []string{"person@example.com"}, EmailTargetID: "smtp"}, + }}}, + } + config, err := m.renderMultiDomainConfig(deployment, false) + if err != nil { + t.Fatal(err) + } + for _, expected := range []string{ + "location = /_flatrun/access/check", + "proxy_pass http://host.docker.internal:8090/api/access/check", + "auth_request /_flatrun/access/check", + "error_page 401 = @flatrun_access_login", + } { + if !strings.Contains(config, expected) { + t.Errorf("generated config is missing %q\n%s", expected, config) + } + } +} + // Static-asset caching is opt-in per domain: the expires directive appears only // when the domain enables it, so other domains keep their exact previous output. func TestStaticCacheEmitsExpiresOnlyWhenEnabled(t *testing.T) { diff --git a/internal/notify/notify.go b/internal/notify/notify.go index 6ae90f3..94db96b 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -323,6 +323,29 @@ func (s *Service) NotifyTargets(title, message string, ids []string) error { return s.NotifyNotificationTargets(Notification{Title: title, Message: message}, ids) } +func (s *Service) SendEmailTo(targetID, recipient string, notification Notification) error { + for _, target := range s.Load().Targets { + if target.ID != targetID { + continue + } + if !target.Enabled { + return fmt.Errorf("target is disabled") + } + parsed, err := url.Parse(target.URL) + if err != nil { + return fmt.Errorf("parse notification target: %w", err) + } + if parsed.Scheme != "smtp" { + return fmt.Errorf("notification target is not SMTP") + } + query := parsed.Query() + query.Set("to", recipient) + parsed.RawQuery = query.Encode() + return s.deliver(parsed.String(), notification) + } + return fmt.Errorf("target not found") +} + func (s *Service) TestTarget(id string) error { for _, target := range s.Load().Targets { if target.ID == id { diff --git a/internal/notify/notify_test.go b/internal/notify/notify_test.go index 2f1064b..c4da85a 100644 --- a/internal/notify/notify_test.go +++ b/internal/notify/notify_test.go @@ -25,6 +25,26 @@ func TestTargetJSONMasksURL(t *testing.T) { } } +func TestSendEmailToOverridesTheConfiguredRecipient(t *testing.T) { + service := NewService(t.TempDir()) + if err := service.Save(Config{Targets: []Target{{ + ID: "access", Name: "Access", URL: "smtp://mail.example/?from=ops%40example.com&to=ops%40example.com", Enabled: true, + }}}); err != nil { + t.Fatal(err) + } + var delivered string + service.send = func(rawURL, _ string) error { + delivered = rawURL + return nil + } + if err := service.SendEmailTo("access", "visitor@example.com", Notification{Title: "Sign in"}); err != nil { + t.Fatal(err) + } + if !strings.Contains(delivered, "to=visitor%40example.com") { + t.Fatalf("recipient was not replaced in %q", delivered) + } +} + func TestEmailMessageEmbedsImagesReferencedByContentID(t *testing.T) { png := "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" message, err := buildEmailMessage(Notification{ diff --git a/pkg/models/deployment.go b/pkg/models/deployment.go index 0f16a3e..1c9c665 100644 --- a/pkg/models/deployment.go +++ b/pkg/models/deployment.go @@ -82,7 +82,16 @@ type DomainConfig struct { // StaticCache opts this domain into a long browser cache for static assets // (css, js, images, fonts). It applies only to responses whose path has a // static extension; dynamic responses keep the app's own cache headers. - StaticCache bool `yaml:"static_cache,omitempty" json:"static_cache,omitempty"` + StaticCache bool `yaml:"static_cache,omitempty" json:"static_cache,omitempty"` + Access *DomainAccessConfig `yaml:"access,omitempty" json:"access,omitempty"` +} + +type DomainAccessConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + Mode string `yaml:"mode" json:"mode"` + AllowedEmails []string `yaml:"allowed_emails,omitempty" json:"allowed_emails,omitempty"` + EmailTargetID string `yaml:"email_target_id" json:"email_target_id"` + SessionHours int `yaml:"session_hours,omitempty" json:"session_hours,omitempty"` } type DatabaseConfig struct {