Skip to content

Commit

Permalink
Support routing to Kubernetes clusters by request path
Browse files Browse the repository at this point in the history
This implements path-based routing for Kubernetes clusters as
described by [RFD0185]. A new prefixed path handler is added that
accepts base64-encoded Teleport and Kubernetes cluster names. The
request is routed to the destination Teleport cluster using these
parameters instead of those embedded in the session TLS identity, and
then the preexisting handlers check authorization and complete the
request as usual.

This removes the need for certificates to be issued per Kubernetes
cluster: so long as the incoming identity is granted access to the
cluster via its roles, access can succeed, and no `KubernetesCluster`
attribute or cert usage restrictions are needed.

[RFD0185]: #47436
  • Loading branch information
timothyb89 committed Dec 24, 2024
1 parent bc68383 commit f7883aa
Show file tree
Hide file tree
Showing 3 changed files with 227 additions and 12 deletions.
129 changes: 118 additions & 11 deletions lib/kube/proxy/forwarder.go
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,11 @@ func NewForwarder(cfg ForwarderConfig) (*Forwarder, error) {

router.GET("/api/:ver/teleport/join/:session", fwd.withAuthPassthrough(fwd.join))

path := fmt.Sprintf("/v1/teleport/:%s/:%s/*path", paramTeleportCluster, paramKubernetesCluster)
for _, method := range allHTTPMethods() {
router.Handle(method, path, fwd.withAuthPassthrough(fwd.singleCertHandler, withRouteFromPath()))
}

router.NotFound = fwd.withAuthStd(fwd.catchAll)

fwd.router = instrumentHTTPHandler(fwd.cfg.KubeServiceType, router)
Expand Down Expand Up @@ -520,7 +525,7 @@ type handlerWithAuthFuncStd func(ctx *authContext, w http.ResponseWriter, r *htt
const accessDeniedMsg = "[00] access denied"

// authenticate function authenticates request
func (f *Forwarder) authenticate(req *http.Request) (*authContext, error) {
func (f *Forwarder) authenticate(req *http.Request, params httprouter.Params, routeSourcer routeSourcer) (*authContext, error) {
// If the cluster is not licensed for Kubernetes, return an error to the client.
if !f.cfg.ClusterFeatures.GetEntitlement(entitlements.K8s).Enabled {
// If the cluster is not licensed for Kubernetes, return an error to the client.
Expand Down Expand Up @@ -561,7 +566,12 @@ func (f *Forwarder) authenticate(req *http.Request) (*authContext, error) {
return nil, trace.Wrap(err)
}

authContext, err := f.setupContext(ctx, *userContext, req, isRemoteUser)
routeSource, err := routeSourcer(userContext.Identity, params)
if err != nil {
return nil, trace.Wrap(err)
}

authContext, err := f.setupContext(ctx, *userContext, req, isRemoteUser, routeSource)
if err != nil {
f.log.WithError(err).Warn("Unable to setup context.")
if trace.IsAccessDenied(err) {
Expand All @@ -586,7 +596,8 @@ func (f *Forwarder) withAuthStd(handler handlerWithAuthFuncStd) http.HandlerFunc
req = req.WithContext(ctx)
defer span.End()

authContext, err := f.authenticate(req)
// note: empty params as this has no route
authContext, err := f.authenticate(req, httprouter.Params{}, identityRouteSourcer())
if err != nil {
return nil, trace.Wrap(err)
}
Expand Down Expand Up @@ -630,6 +641,11 @@ type authOption func(*authOptions)
type authOptions struct {
// errFormater is a function that formats the error response.
errFormater func(http.ResponseWriter, error)

// routeSourcer is an optional function that determines the route to the
// Kubernetes cluster. If unset, `withAuth()` and `withAuthPassthrough()`
// will use an identity route sourcer, for legacy compatibility.
routeSourcer routeSourcer
}

// withCustomErrFormatter allows to override the default error formatter.
Expand All @@ -639,13 +655,72 @@ func withCustomErrFormatter(f func(http.ResponseWriter, error)) authOption {
}
}

// routeSource indicates a specific Kubernetes cluster by its name and parent
// Teleport cluster name.
type routeSource struct {
teleportClusterName string
kubeClusterName string
}

// routeSourcer is a function that produces routing information from an identity
// and request parameters. Implementors may use either or both of these inputs
// to produce a route.
type routeSourcer func(authz.IdentityGetter, httprouter.Params) (*routeSource, error)

// identityRouteSourcer extracts routing information from a TLS identity. It is
// separate from `withRouteFromIdentity` so it can be constructed easily for
// legacy cases.
func identityRouteSourcer() routeSourcer {
return func(idGetter authz.IdentityGetter, params httprouter.Params) (*routeSource, error) {
ident := idGetter.GetIdentity()

return &routeSource{
teleportClusterName: ident.RouteToCluster,
kubeClusterName: ident.KubernetesCluster,
}, nil
}
}

// withRouteFromPath applies a routeSourcer that extracts routing information
// from the request path
func withRouteFromPath() authOption {
return func(o *authOptions) {
o.routeSourcer = func(idGetter authz.IdentityGetter, params httprouter.Params) (*routeSource, error) {
teleportCluster, kubeCluster, err := parseRouteFromPath(params)
if err != nil {
return nil, trace.Wrap(err)
}

return &routeSource{
teleportClusterName: teleportCluster,
kubeClusterName: kubeCluster,
}, nil
}
}
}

// withRouteFromIdentity applies a routeSourcer that extracts routing
// information from the session's TLS identity
func withRouteFromIdentity() authOption {
return func(o *authOptions) {
o.routeSourcer = identityRouteSourcer()
}
}

func (f *Forwarder) withAuth(handler handlerWithAuthFunc, opts ...authOption) httprouter.Handle {
authOpts := authOptions{
errFormater: f.formatStatusResponseError,
}
for _, opt := range opts {
opt(&authOpts)
}

// If no route sourcer was explicitly provided, fall back to the legacy
// identity route source.
if authOpts.routeSourcer == nil {
authOpts.routeSourcer = identityRouteSourcer()
}

return httplib.MakeHandlerWithErrorWriter(func(w http.ResponseWriter, req *http.Request, p httprouter.Params) (any, error) {
ctx, span := f.cfg.tracer.Start(
req.Context(),
Expand All @@ -658,7 +733,7 @@ func (f *Forwarder) withAuth(handler handlerWithAuthFunc, opts ...authOption) ht
)
req = req.WithContext(ctx)
defer span.End()
authContext, err := f.authenticate(req)
authContext, err := f.authenticate(req, p, authOpts.routeSourcer)
if err != nil {
return nil, trace.Wrap(err)
}
Expand All @@ -675,7 +750,20 @@ func (f *Forwarder) withAuth(handler handlerWithAuthFunc, opts ...authOption) ht

// withAuthPassthrough authenticates the request and fetches information but doesn't deny if the user
// doesn't have RBAC access to the Kubernetes cluster.
func (f *Forwarder) withAuthPassthrough(handler handlerWithAuthFunc) httprouter.Handle {
func (f *Forwarder) withAuthPassthrough(handler handlerWithAuthFunc, opts ...authOption) httprouter.Handle {
authOpts := authOptions{
errFormater: f.formatStatusResponseError,
}
for _, opt := range opts {
opt(&authOpts)
}

// If no route sourcer was explicitly provided, fall back to the legacy
// identity route source.
if authOpts.routeSourcer == nil {
authOpts.routeSourcer = identityRouteSourcer()
}

return httplib.MakeHandlerWithErrorWriter(func(w http.ResponseWriter, req *http.Request, p httprouter.Params) (any, error) {
ctx, span := f.cfg.tracer.Start(
req.Context(),
Expand All @@ -689,7 +777,7 @@ func (f *Forwarder) withAuthPassthrough(handler handlerWithAuthFunc) httprouter.
req = req.WithContext(ctx)
defer span.End()

authContext, err := f.authenticate(req)
authContext, err := f.authenticate(req, p, authOpts.routeSourcer)
if err != nil {
return nil, trace.Wrap(err)
}
Expand Down Expand Up @@ -746,6 +834,7 @@ func (f *Forwarder) setupContext(
authCtx authz.Context,
req *http.Request,
isRemoteUser bool,
route *routeSource,
) (*authContext, error) {
ctx, span := f.cfg.tracer.Start(
ctx,
Expand All @@ -765,7 +854,7 @@ func (f *Forwarder) setupContext(
sessionTTL := roles.AdjustSessionTTL(time.Hour)

identity := authCtx.Identity.GetIdentity()
teleportClusterName := identity.RouteToCluster
teleportClusterName := route.teleportClusterName
if teleportClusterName == "" {
teleportClusterName = f.cfg.ClusterName
}
Expand All @@ -783,7 +872,8 @@ func (f *Forwarder) setupContext(
err error
)

kubeCluster := identity.KubernetesCluster
kubeCluster := route.kubeClusterName

// Only check k8s principals for local clusters.
//
// For remote clusters, everything will be remapped to new roles on the
Expand Down Expand Up @@ -2542,18 +2632,19 @@ func (f *Forwarder) newClusterSessionDirect(ctx context.Context, authCtx authCon
// - for HTTP2 in all other cases.
// The reason being is that streaming requests are going to be upgraded to SPDY, which is only
// supported coming from an HTTP1 request.
func (f *Forwarder) makeSessionForwarder(sess *clusterSession) (*reverseproxy.Forwarder, error) {
func (f *Forwarder) makeSessionForwarder(sess *clusterSession, extraOptions ...reverseproxy.Option) (*reverseproxy.Forwarder, error) {
transport, err := f.transportForRequest(sess)
if err != nil {
return nil, trace.Wrap(err)
}
opts := []reverseproxy.Option{

opts := append([]reverseproxy.Option{
reverseproxy.WithFlushInterval(100 * time.Millisecond),
reverseproxy.WithRoundTripper(transport),
// TODO(tross): convert this to use f.log once it has been converted to use slog
reverseproxy.WithLogger(slog.Default()),
reverseproxy.WithErrorHandler(f.formatForwardResponseError),
}
}, extraOptions...)
if sess.isLocalKubernetesCluster {
// If the target cluster is local, i.e. the cluster that is served by this
// teleport service, then we set up the forwarder to allow re-writing
Expand Down Expand Up @@ -2748,3 +2839,19 @@ func errorToKubeStatusReason(err error, code int) metav1.StatusReason {
return metav1.StatusReasonUnknown
}
}

// allHTTPMethods returns a list of all HTTP methods, useful for creating
// non-root catch-all handlers.
func allHTTPMethods() []string {
return []string{
http.MethodConnect,
http.MethodDelete,
http.MethodGet,
http.MethodHead,
http.MethodOptions,
http.MethodPatch,
http.MethodPost,
http.MethodPut,
http.MethodTrace,
}
}
2 changes: 1 addition & 1 deletion lib/kube/proxy/forwarder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -749,7 +749,7 @@ func TestAuthenticate(t *testing.T) {
f.clusterDetails = nil
}

gotCtx, err := f.authenticate(req)
gotCtx, err := f.authenticate(req, httprouter.Params{}, identityRouteSourcer())
if tt.wantErr {
require.Error(t, err)
require.Equal(t, tt.wantAuthErr, trace.IsAccessDenied(err))
Expand Down
108 changes: 108 additions & 0 deletions lib/kube/proxy/single_cert_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
* Teleport
* Copyright (C) 2023 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package proxy

import (
"encoding/base64"
"net/http"
"strings"

"github.com/gravitational/teleport/lib/authz"
"github.com/gravitational/trace"
"github.com/julienschmidt/httprouter"
)

const (
// paramTeleportCluster is the path parameter key containing a base64
// encoded Teleport cluster name for path-routed forwarding.
paramTeleportCluster = "encodedTeleportCluster"

// paramKubernetesCluster is the path parameter key containing a base64
// encoded Teleport cluster name for path-routed forwarding.
paramKubernetesCluster = "encodedKubernetesCluster"
)

// parseRouteFromPath extracts route information from the given path parameters
// using constant-defined parameter keys.
func parseRouteFromPath(p httprouter.Params) (string, string, error) {
encodedTeleportCluster := p.ByName(paramTeleportCluster)
if encodedTeleportCluster == "" {
return "", "", trace.BadParameter("no Teleport cluster name found in path")
}

decodedTeleportCluster, err := base64.RawURLEncoding.DecodeString(encodedTeleportCluster)
if err != nil {
return "", "", trace.Wrap(err)
}

encodedKubernetesCluster := p.ByName(paramKubernetesCluster)
if encodedKubernetesCluster == "" {
return "", "", trace.BadParameter("no Kubernetes cluster name found in path")
}

decodedKubernetesCluster, err := base64.RawStdEncoding.DecodeString(encodedKubernetesCluster)
if err != nil {
return "", "", trace.Wrap(err)
}

// TODO: do we care to otherwise validate these results before casting
// to a string?
return string(decodedTeleportCluster), string(decodedKubernetesCluster), nil
}

func (f *Forwarder) singleCertHandler(_ *authContext, w http.ResponseWriter, req *http.Request, p httprouter.Params) (resp any, err error) {
teleportCluster, kubeCluster, err := parseRouteFromPath(p)
if err != nil {
return nil, trace.Wrap(err)
}

userTypeI, err := authz.UserFromContext(req.Context())
if err != nil {
f.log.WithError(err).Warn("error getting user from context")
return nil, trace.AccessDenied(accessDeniedMsg)
}

var userType authz.IdentityGetter
switch o := userTypeI.(type) {
case authz.LocalUser:
o.Identity.RouteToCluster = teleportCluster
o.Identity.KubernetesCluster = kubeCluster
userType = o
case authz.RemoteUser:
o.Identity.RouteToCluster = teleportCluster
o.Identity.KubernetesCluster = kubeCluster
userType = o
default:
f.log.Warningf("Denying proxy access to unsupported user type: %T.", userTypeI)
return nil, trace.AccessDenied(accessDeniedMsg)
}

ctx := authz.ContextWithUser(req.Context(), userType)
req = req.WithContext(ctx)

path := p.ByName("path")
if !strings.HasPrefix(path, "/") {
path = "/" + path
}

req.URL.Path = path
req.RequestURI = req.URL.RequestURI()

f.router.ServeHTTP(w, req)
return nil, nil
}

0 comments on commit f7883aa

Please sign in to comment.