From e816cef2a1b23c093f8b67832cf8c0c69e710c6c Mon Sep 17 00:00:00 2001 From: Caleb Doxsey Date: Wed, 22 Jan 2025 13:45:20 -0700 Subject: [PATCH] proxy: add route portal json (#5428) * proxy: add route portal json * fix 405 issue * add link to issue * Update proxy/portal/filter_test.go Co-authored-by: Kenneth Jenkins <51246568+kenjenkins@users.noreply.github.com> --------- Co-authored-by: Kenneth Jenkins <51246568+kenjenkins@users.noreply.github.com> --- authorize/evaluator/evaluator.go | 1 + proxy/handlers.go | 31 +++++++-- proxy/handlers_portal.go | 56 ++++++++++++++++ proxy/handlers_portal_test.go | 51 +++++++++++++++ proxy/portal/filter.go | 105 ++++++++++++++++++++++++++++++ proxy/portal/filter_test.go | 67 +++++++++++++++++++ proxy/portal/matchers.go | 108 +++++++++++++++++++++++++++++++ proxy/portal/matchers_test.go | 83 ++++++++++++++++++++++++ proxy/portal/portal.go | 60 +++++++++++++++++ proxy/portal/portal_test.go | 71 ++++++++++++++++++++ 10 files changed, 628 insertions(+), 5 deletions(-) create mode 100644 proxy/handlers_portal.go create mode 100644 proxy/handlers_portal_test.go create mode 100644 proxy/portal/filter.go create mode 100644 proxy/portal/filter_test.go create mode 100644 proxy/portal/matchers.go create mode 100644 proxy/portal/matchers_test.go create mode 100644 proxy/portal/portal.go create mode 100644 proxy/portal/portal_test.go diff --git a/authorize/evaluator/evaluator.go b/authorize/evaluator/evaluator.go index 1523cf04c..e6316de6c 100644 --- a/authorize/evaluator/evaluator.go +++ b/authorize/evaluator/evaluator.go @@ -241,6 +241,7 @@ var internalPathsNeedingLogin = set.From([]string{ "/.pomerium/jwt", "/.pomerium/user", "/.pomerium/webauthn", + "/.pomerium/api/v1/routes", }) func (e *Evaluator) evaluateInternal(_ context.Context, req *Request) (*PolicyResponse, error) { diff --git a/proxy/handlers.go b/proxy/handlers.go index db5dd2179..6e9411ac8 100644 --- a/proxy/handlers.go +++ b/proxy/handlers.go @@ -40,11 +40,32 @@ func (p *Proxy) registerDashboardHandlers(r *mux.Router, opts *config.Options) * c.Path("/").Handler(httputil.HandlerFunc(p.Callback)).Methods(http.MethodGet) // Programmatic API handlers and middleware - a := r.PathPrefix(dashboardPath + "/api").Subrouter() - // login api handler generates a user-navigable login url to authenticate - a.Path("/v1/login").Handler(httputil.HandlerFunc(p.ProgrammaticLogin)). - Queries(urlutil.QueryRedirectURI, ""). - Methods(http.MethodGet) + // gorilla mux has a bug that prevents HTTP 405 errors from being returned properly so we do all this manually + // https://github.com/gorilla/mux/issues/739 + r.PathPrefix(dashboardPath + "/api"). + Handler(httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + switch r.URL.Path { + // login api handler generates a user-navigable login url to authenticate + case dashboardPath + "/api/v1/login": + if r.Method != http.MethodGet { + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + return nil + } + if !r.URL.Query().Has(urlutil.QueryRedirectURI) { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return nil + } + return p.ProgrammaticLogin(w, r) + case dashboardPath + "/api/v1/routes": + if r.Method != http.MethodGet { + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + return nil + } + return p.routesPortalJSON(w, r) + } + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return nil + })) return r } diff --git a/proxy/handlers_portal.go b/proxy/handlers_portal.go new file mode 100644 index 000000000..7d28aed06 --- /dev/null +++ b/proxy/handlers_portal.go @@ -0,0 +1,56 @@ +package proxy + +import ( + "encoding/json" + "net/http" + + "github.com/pomerium/pomerium/config" + "github.com/pomerium/pomerium/internal/handlers" + "github.com/pomerium/pomerium/internal/httputil" + "github.com/pomerium/pomerium/proxy/portal" +) + +func (p *Proxy) routesPortalJSON(w http.ResponseWriter, r *http.Request) error { + u := p.getUserInfoData(r) + rs := p.getPortalRoutes(u) + m := map[string]any{} + m["routes"] = rs + + b, err := json.Marshal(m) + if err != nil { + return httputil.NewError(http.StatusInternalServerError, err) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(b) + return nil +} + +func (p *Proxy) getPortalRoutes(u handlers.UserInfoData) []portal.Route { + options := p.currentOptions.Load() + pu := p.getPortalUser(u) + var routes []*config.Policy + for route := range options.GetAllPolicies() { + if portal.CheckRouteAccess(pu, route) { + routes = append(routes, route) + } + } + return portal.RoutesFromConfigRoutes(routes) +} + +func (p *Proxy) getPortalUser(u handlers.UserInfoData) portal.User { + pu := portal.User{} + pu.SessionID = u.Session.GetId() + pu.UserID = u.User.GetId() + pu.Email = u.User.GetEmail() + for _, dg := range u.DirectoryGroups { + if v := dg.ID; v != "" { + pu.Groups = append(pu.Groups, dg.ID) + } + if v := dg.Name; v != "" { + pu.Groups = append(pu.Groups, dg.Name) + } + } + return pu +} diff --git a/proxy/handlers_portal_test.go b/proxy/handlers_portal_test.go new file mode 100644 index 000000000..c0a885516 --- /dev/null +++ b/proxy/handlers_portal_test.go @@ -0,0 +1,51 @@ +package proxy + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/pomerium/pomerium/config" + "github.com/pomerium/pomerium/internal/httputil" +) + +func TestProxy_routesPortalJSON(t *testing.T) { + ctx := context.Background() + cfg := &config.Config{Options: config.NewDefaultOptions()} + to, err := config.ParseWeightedUrls("https://to.example.com") + require.NoError(t, err) + cfg.Options.Routes = append(cfg.Options.Routes, config.Policy{ + Name: "public", + Description: "PUBLIC ROUTE", + LogoURL: "https://logo.example.com", + From: "https://from.example.com", + To: to, + AllowPublicUnauthenticatedAccess: true, + }) + proxy, err := New(ctx, cfg) + require.NoError(t, err) + + r := httptest.NewRequest(http.MethodGet, "/.pomerium/api/v1/routes", nil) + w := httptest.NewRecorder() + + router := httputil.NewRouter() + router = proxy.registerDashboardHandlers(router, cfg.Options) + router.ServeHTTP(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) + assert.JSONEq(t, `{"routes":[ + { + "id": "4e71df99c0317efb", + "name": "public", + "from": "https://from.example.com", + "type": "http", + "description": "PUBLIC ROUTE", + "logo_url": "https://logo.example.com" + } + ]}`, w.Body.String()) +} diff --git a/proxy/portal/filter.go b/proxy/portal/filter.go new file mode 100644 index 000000000..f63966e50 --- /dev/null +++ b/proxy/portal/filter.go @@ -0,0 +1,105 @@ +package portal + +import ( + "strings" + + "github.com/pomerium/pomerium/config" + "github.com/pomerium/pomerium/pkg/policy/parser" +) + +// User is the computed user information needed for access decisions. +type User struct { + SessionID string + UserID string + Email string + Groups []string +} + +// CheckRouteAccess checks if the user has access to the route. +func CheckRouteAccess(user User, route *config.Policy) bool { + // check the main policy + ppl := route.ToPPL() + if checkPPLAccess(user, ppl) { + return true + } + + // check sub-policies + for _, sp := range route.SubPolicies { + if sp.SourcePPL == "" { + continue + } + + ppl, err := parser.New().ParseYAML(strings.NewReader(sp.SourcePPL)) + if err != nil { + // ignore invalid PPL + continue + } + + if checkPPLAccess(user, ppl) { + return true + } + } + + // nothing matched + return false +} + +func checkPPLAccess(user User, ppl *parser.Policy) bool { + for _, r := range ppl.Rules { + // ignore deny rules + if r.Action != parser.ActionAllow { + continue + } + + // ignore complex rules + if len(r.Nor) > 0 || len(r.Not) > 0 || len(r.And) > 1 { + continue + } + + cs := append(append([]parser.Criterion{}, r.Or...), r.And...) + for _, c := range cs { + ok := checkPPLCriterionAccess(user, c) + if ok { + return true + } + } + } + + return false +} + +func checkPPLCriterionAccess(user User, criterion parser.Criterion) bool { + switch criterion.Name { + case "accept": + return true + } + + // require a session + if user.SessionID == "" { + return false + } + + switch criterion.Name { + case "authenticated_user": + return true + } + + // require a user + if user.UserID == "" { + return false + } + + switch criterion.Name { + case "domain": + parts := strings.SplitN(user.Email, "@", 2) + return len(parts) == 2 && matchString(parts[1], criterion.Data) + case "email": + return matchString(user.Email, criterion.Data) + case "groups": + return matchStringList(user.Groups, criterion.Data) + case "user": + return matchString(user.UserID, criterion.Data) + } + + return false +} diff --git a/proxy/portal/filter_test.go b/proxy/portal/filter_test.go new file mode 100644 index 000000000..9a9d1519a --- /dev/null +++ b/proxy/portal/filter_test.go @@ -0,0 +1,67 @@ +package portal_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/pomerium/pomerium/config" + "github.com/pomerium/pomerium/pkg/policy/parser" + "github.com/pomerium/pomerium/proxy/portal" +) + +func TestCheckRouteAccess(t *testing.T) { + t.Parallel() + + u1 := portal.User{} + u2 := portal.User{SessionID: "s2", UserID: "u2", Email: "u2@example.com", Groups: []string{"g2"}} + + for _, tc := range []struct { + name string + user portal.User + route *config.Policy + }{ + {"no ppl", u1, &config.Policy{}}, + {"allow_any_authenticated_user", u1, &config.Policy{AllowAnyAuthenticatedUser: true}}, + {"allowed_domains", u2, &config.Policy{AllowedDomains: []string{"not.example.com"}}}, + {"allowed_users", u2, &config.Policy{AllowedUsers: []string{"u3"}}}, + {"not conditionals", u2, &config.Policy{Policy: mustParsePPL(t, `{"allow": {"not": [{"accept": 1}]}}`)}}, + {"nor conditionals", u2, &config.Policy{Policy: mustParsePPL(t, `{"allow": {"nor": [{"accept": 1}]}}`)}}, + {"and conditionals", u2, &config.Policy{Policy: mustParsePPL(t, `{"allow": {"and": [{"accept": 1}, {"accept": 1}]}}`)}}, + {"authenticated_user", u1, &config.Policy{Policy: mustParsePPL(t, `{"allow": {"or": [{"authenticated_user": 1}]}}`)}}, + {"domain", u2, &config.Policy{Policy: mustParsePPL(t, `{"allow": {"or": [{"domain": "not.example.com"}]}}`)}}, + {"email", u1, &config.Policy{Policy: mustParsePPL(t, `{"allow": {"or": [{"email": "u2@example.com"}]}}`)}}, + {"groups", u2, &config.Policy{Policy: mustParsePPL(t, `{"allow": {"or": [{"groups": {"has": "g3"}}]}}`)}}, + } { + assert.False(t, portal.CheckRouteAccess(tc.user, tc.route), "%s: should deny access for %v to %v", + tc.name, tc.user, tc.route) + } + + for _, tc := range []struct { + name string + user portal.User + route *config.Policy + }{ + {"allow_public_unauthenticated_access", u1, &config.Policy{AllowPublicUnauthenticatedAccess: true}}, + {"allow_any_authenticated_user", u2, &config.Policy{AllowAnyAuthenticatedUser: true}}, + {"allowed_domains", u2, &config.Policy{AllowedDomains: []string{"example.com"}}}, + {"allowed_users", u2, &config.Policy{AllowedUsers: []string{"u2"}}}, + {"and conditionals", u2, &config.Policy{Policy: mustParsePPL(t, `{"allow": {"and": [{"accept": 1}]}}`)}}, + {"or conditionals", u2, &config.Policy{Policy: mustParsePPL(t, `{"allow": {"or": [{"reject": 1}, {"accept": 1}]}}`)}}, + {"authenticated_user", u2, &config.Policy{Policy: mustParsePPL(t, `{"allow": {"or": [{"authenticated_user": 1}]}}`)}}, + {"domain", u2, &config.Policy{Policy: mustParsePPL(t, `{"allow": {"or": [{"domain": "example.com"}]}}`)}}, + {"email", u2, &config.Policy{Policy: mustParsePPL(t, `{"allow": {"or": [{"email": "u2@example.com"}]}}`)}}, + {"groups", u2, &config.Policy{Policy: mustParsePPL(t, `{"allow": {"or": [{"groups": {"has": "g2"}}]}}`)}}, + } { + assert.True(t, portal.CheckRouteAccess(tc.user, tc.route), "%s: should grant access for %v to %v", + tc.name, tc.user, tc.route) + } +} + +func mustParsePPL(t testing.TB, raw string) *config.PPLPolicy { + ppl, err := parser.New().ParseJSON(strings.NewReader(raw)) + require.NoError(t, err) + return &config.PPLPolicy{Policy: ppl} +} diff --git a/proxy/portal/matchers.go b/proxy/portal/matchers.go new file mode 100644 index 000000000..fad178e74 --- /dev/null +++ b/proxy/portal/matchers.go @@ -0,0 +1,108 @@ +package portal + +import ( + "strings" + + "github.com/pomerium/pomerium/pkg/policy/parser" +) + +type matcher[T any] func(left T, right parser.Value) bool + +var stringMatchers = map[string]matcher[string]{ + "contains": matchStringContains, + "ends_with": matchStringEndsWith, + "is": matchStringIs, + "starts_with": matchStringStartsWith, +} + +var stringListMatchers = map[string]matcher[[]string]{ + "has": matchStringListHas, + "is": matchStringListIs, +} + +func matchString(left string, right parser.Value) bool { + obj, ok := right.(parser.Object) + if !ok { + obj = parser.Object{ + "is": right, + } + } + + for k, v := range obj { + f, ok := stringMatchers[k] + if !ok { + return false + } + ok = f(left, v) + if ok { + return true + } + } + return false +} + +func matchStringContains(left string, right parser.Value) bool { + str, ok := right.(parser.String) + if !ok { + return false + } + return strings.Contains(left, string(str)) +} + +func matchStringEndsWith(left string, right parser.Value) bool { + str, ok := right.(parser.String) + if !ok { + return false + } + return strings.HasSuffix(left, string(str)) +} + +func matchStringIs(left string, right parser.Value) bool { + str, ok := right.(parser.String) + if !ok { + return false + } + return left == string(str) +} + +func matchStringStartsWith(left string, right parser.Value) bool { + str, ok := right.(parser.String) + if !ok { + return false + } + return strings.HasPrefix(left, string(str)) +} + +func matchStringList(left []string, right parser.Value) bool { + obj, ok := right.(parser.Object) + if !ok { + obj = parser.Object{ + "has": right, + } + } + + for k, v := range obj { + f, ok := stringListMatchers[k] + if !ok { + return false + } + ok = f(left, v) + if ok { + return true + } + } + return false +} + +func matchStringListHas(left []string, right parser.Value) bool { + for _, str := range left { + if matchStringIs(str, right) { + return true + } + } + return false +} + +func matchStringListIs(left []string, right parser.Value) bool { + return len(left) == 1 && matchStringListHas(left, right) +} diff --git a/proxy/portal/matchers_test.go b/proxy/portal/matchers_test.go new file mode 100644 index 000000000..bf1a9beab --- /dev/null +++ b/proxy/portal/matchers_test.go @@ -0,0 +1,83 @@ +package portal + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/pomerium/pomerium/pkg/policy/parser" +) + +func Test_matchString(t *testing.T) { + t.Parallel() + + t.Run("string", func(t *testing.T) { + assert.True(t, matchString("TEST", mustParseValue(t, `"TEST"`))) + }) + t.Run("bool", func(t *testing.T) { + assert.False(t, matchString("true", mustParseValue(t, `true`))) + }) + t.Run("number", func(t *testing.T) { + assert.False(t, matchString("1", mustParseValue(t, `1`))) + }) + t.Run("null", func(t *testing.T) { + assert.False(t, matchString("null", mustParseValue(t, `null`))) + }) + t.Run("array", func(t *testing.T) { + assert.False(t, matchString("[]", mustParseValue(t, `[]`))) + }) + t.Run("contains", func(t *testing.T) { + assert.True(t, matchString("XYZ", mustParseValue(t, `{"contains":"Y"}`))) + assert.False(t, matchString("XYZ", mustParseValue(t, `{"contains":"A"}`))) + }) + t.Run("ends_with", func(t *testing.T) { + assert.True(t, matchString("XYZ", mustParseValue(t, `{"ends_with":"Z"}`))) + assert.False(t, matchString("XYZ", mustParseValue(t, `{"ends_with":"X"}`))) + }) + t.Run("is", func(t *testing.T) { + assert.True(t, matchString("XYZ", mustParseValue(t, `{"is":"XYZ"}`))) + assert.False(t, matchString("XYZ", mustParseValue(t, `{"is":"X"}`))) + }) + t.Run("starts_with", func(t *testing.T) { + assert.True(t, matchString("XYZ", mustParseValue(t, `{"starts_with":"X"}`))) + assert.False(t, matchString("XYZ", mustParseValue(t, `{"starts_with":"Z"}`))) + }) +} + +func Test_matchStringList(t *testing.T) { + t.Parallel() + + t.Run("string", func(t *testing.T) { + assert.True(t, matchStringList([]string{"X", "Y", "Z"}, mustParseValue(t, `"Y"`))) + assert.False(t, matchStringList([]string{"X", "Y", "Z"}, mustParseValue(t, `"A"`))) + }) + t.Run("bool", func(t *testing.T) { + assert.False(t, matchStringList([]string{"true"}, mustParseValue(t, `true`))) + }) + t.Run("number", func(t *testing.T) { + assert.False(t, matchStringList([]string{"1"}, mustParseValue(t, `1`))) + }) + t.Run("null", func(t *testing.T) { + assert.False(t, matchStringList([]string{"null"}, mustParseValue(t, `null`))) + }) + t.Run("array", func(t *testing.T) { + assert.False(t, matchStringList([]string{"[]"}, mustParseValue(t, `[]`))) + }) + t.Run("has", func(t *testing.T) { + assert.True(t, matchStringList([]string{"X", "Y", "Z"}, mustParseValue(t, `{"has":"Y"}`))) + assert.False(t, matchStringList([]string{"X", "Y", "Z"}, mustParseValue(t, `{"has":"A"}`))) + }) + t.Run("is", func(t *testing.T) { + assert.True(t, matchStringList([]string{"X"}, mustParseValue(t, `{"is":"X"}`))) + assert.False(t, matchStringList([]string{"X", "Y", "Z"}, mustParseValue(t, `{"is":"Y"}`))) + assert.False(t, matchStringList([]string{"X", "Y", "Z"}, mustParseValue(t, `{"is":"A"}`))) + }) +} + +func mustParseValue(t testing.TB, raw string) parser.Value { + v, err := parser.ParseValue(strings.NewReader(raw)) + require.NoError(t, err) + return v +} diff --git a/proxy/portal/portal.go b/proxy/portal/portal.go new file mode 100644 index 000000000..3756d3638 --- /dev/null +++ b/proxy/portal/portal.go @@ -0,0 +1,60 @@ +// Package portal contains the code for the routes portal +package portal + +import ( + "fmt" + "strings" + + "github.com/pomerium/pomerium/config" + "github.com/pomerium/pomerium/internal/urlutil" + "github.com/pomerium/pomerium/pkg/zero/importutil" +) + +// A Route is a portal route. +type Route struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + From string `json:"from"` + Description string `json:"description"` + ConnectCommand string `json:"connect_command,omitempty"` + LogoURL string `json:"logo_url"` +} + +// RoutesFromConfigRoutes converts config routes into portal routes. +func RoutesFromConfigRoutes(routes []*config.Policy) []Route { + prs := make([]Route, len(routes)) + for i, route := range routes { + pr := Route{} + pr.ID = route.ID + if pr.ID == "" { + pr.ID = fmt.Sprintf("%x", route.MustRouteID()) + } + pr.Name = route.Name + pr.From = route.From + fromURL, err := urlutil.ParseAndValidateURL(route.From) + if err == nil { + if strings.HasPrefix(fromURL.Scheme, "tcp+") { + pr.Type = "tcp" + pr.ConnectCommand = "pomerium-cli tcp " + fromURL.Host + } else if strings.HasPrefix(fromURL.Scheme, "udp+") { + pr.Type = "udp" + pr.ConnectCommand = "pomerium-cli udp " + fromURL.Host + } else { + pr.Type = "http" + } + } else { + pr.Type = "http" + } + pr.Description = route.Description + pr.LogoURL = route.LogoURL + prs[i] = pr + } + // generate names if they're empty + for i, name := range importutil.GenerateRouteNames(routes) { + if prs[i].Name == "" { + prs[i].Name = name + } + } + return prs +} diff --git a/proxy/portal/portal_test.go b/proxy/portal/portal_test.go new file mode 100644 index 000000000..5b6887f29 --- /dev/null +++ b/proxy/portal/portal_test.go @@ -0,0 +1,71 @@ +package portal_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/pomerium/pomerium/config" + "github.com/pomerium/pomerium/proxy/portal" +) + +func TestRouteFromConfigRoute(t *testing.T) { + t.Parallel() + + to1, err := config.ParseWeightedUrls("https://to.example.com") + require.NoError(t, err) + to2, err := config.ParseWeightedUrls("tcp://postgres:5432") + require.NoError(t, err) + + assert.Equal(t, []portal.Route{ + { + ID: "4e71df99c0317efb", + Name: "from", + Type: "http", + From: "https://from.example.com", + Description: "ROUTE #1", + LogoURL: "https://logo.example.com", + }, + { + ID: "7c377f11cdb9700e", + Name: "from-path", + Type: "http", + From: "https://from.example.com", + }, + { + ID: "708e3cbd0bbe8547", + Name: "postgres", + Type: "tcp", + From: "tcp+https://postgres.example.com:5432", + ConnectCommand: "pomerium-cli tcp postgres.example.com:5432", + }, + { + ID: "2dd08d87486e051a", + Name: "dns", + Type: "udp", + From: "udp+https://dns.example.com:53", + ConnectCommand: "pomerium-cli udp dns.example.com:53", + }, + }, portal.RoutesFromConfigRoutes([]*config.Policy{ + { + From: "https://from.example.com", + To: to1, + Description: "ROUTE #1", + LogoURL: "https://logo.example.com", + }, + { + From: "https://from.example.com", + To: to1, + Path: "/path", + }, + { + From: "tcp+https://postgres.example.com:5432", + To: to2, + }, + { + From: "udp+https://dns.example.com:53", + To: to2, + }, + })) +}