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>
This commit is contained in:
Caleb Doxsey 2025-01-22 13:45:20 -07:00 committed by GitHub
parent 6e1fabec0b
commit e816cef2a1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 628 additions and 5 deletions

View file

@ -241,6 +241,7 @@ var internalPathsNeedingLogin = set.From([]string{
"/.pomerium/jwt", "/.pomerium/jwt",
"/.pomerium/user", "/.pomerium/user",
"/.pomerium/webauthn", "/.pomerium/webauthn",
"/.pomerium/api/v1/routes",
}) })
func (e *Evaluator) evaluateInternal(_ context.Context, req *Request) (*PolicyResponse, error) { func (e *Evaluator) evaluateInternal(_ context.Context, req *Request) (*PolicyResponse, error) {

View file

@ -40,11 +40,32 @@ func (p *Proxy) registerDashboardHandlers(r *mux.Router, opts *config.Options) *
c.Path("/").Handler(httputil.HandlerFunc(p.Callback)).Methods(http.MethodGet) c.Path("/").Handler(httputil.HandlerFunc(p.Callback)).Methods(http.MethodGet)
// Programmatic API handlers and middleware // Programmatic API handlers and middleware
a := r.PathPrefix(dashboardPath + "/api").Subrouter() // gorilla mux has a bug that prevents HTTP 405 errors from being returned properly so we do all this manually
// login api handler generates a user-navigable login url to authenticate // https://github.com/gorilla/mux/issues/739
a.Path("/v1/login").Handler(httputil.HandlerFunc(p.ProgrammaticLogin)). r.PathPrefix(dashboardPath + "/api").
Queries(urlutil.QueryRedirectURI, ""). Handler(httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
Methods(http.MethodGet) 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 return r
} }

56
proxy/handlers_portal.go Normal file
View file

@ -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
}

View file

@ -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())
}

105
proxy/portal/filter.go Normal file
View file

@ -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
}

View file

@ -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}
}

108
proxy/portal/matchers.go Normal file
View file

@ -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)
}

View file

@ -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
}

60
proxy/portal/portal.go Normal file
View file

@ -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
}

View file

@ -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,
},
}))
}