Add proxy verb to kubernetes_resources (#67583)

* Add proxy verb to kubernetes_resources

* Preserve upstream kube Status code in forwarder

* Readable comment

* Strip [scheme:]name[:port] on kube proxy paths

* Use SplitSchemeNamePort from k8s apimachinery
This commit is contained in:
Jake Alti
2026-06-09 12:23:50 +00:00
committed by GitHub
parent 0404d46d73
commit d08509b84d
8 changed files with 220 additions and 5 deletions
+9
View File
@@ -6,6 +6,15 @@
### Breaking changes
#### Kubernetes proxy subresource access
Access to the Kubernetes API server proxy subresources
(`pods/{name}/proxy/{path}`, `services/{name}/proxy/{path}`, and
`nodes/{name}/proxy/{path}`) now requires the new `proxy` verb in
`kubernetes_resources`. Previously these endpoints were authorized as
the `get` verb. Roles that use the Kubernetes API server proxy must
add `"proxy"` to the relevant `verbs` list.
#### macOS 12
The minimum version of macOS required to run Teleport or associated client tools
+6
View File
@@ -1718,6 +1718,11 @@ const (
KubeVerbExec = "exec"
// KubeVerbPortForward is the Kubernetes verb for "pod/portforward".
KubeVerbPortForward = "portforward"
// KubeVerbProxy is the Kubernetes verb for the pods/proxy,
// services/proxy, and nodes/proxy subresources. These endpoints
// reach pod ports, service endpoints, or the kubelet API over HTTP
// (distinct from portforward, which tunnels raw TCP).
KubeVerbProxy = "proxy"
)
// The list below needs to be kept in sync with `kubernetesResourceVerbOptions`
@@ -1738,6 +1743,7 @@ var KubernetesVerbs = []string{
KubeVerbDeleteCollection,
KubeVerbExec,
KubeVerbPortForward,
KubeVerbProxy,
}
// KubernetesClusterWideResourceKinds is the list of supported Kubernetes cluster resource kinds
+15 -3
View File
@@ -809,7 +809,7 @@ func (f *Forwarder) formatStatusResponseError(rw http.ResponseWriter, respErr er
return
}
code := trace.ErrorToCode(respErr)
code, reason := kubeStatusCodeAndReason(respErr)
status := &metav1.Status{
Status: metav1.StatusFailure,
// Don't trace.Unwrap the error, in case it was wrapped with a
@@ -817,7 +817,7 @@ func (f *Forwarder) formatStatusResponseError(rw http.ResponseWriter, respErr er
// low-level to be useful.
Message: respErr.Error(),
Code: int32(code),
Reason: errorToKubeStatusReason(respErr, code),
Reason: reason,
}
data, err := runtime.Encode(globalKubeCodecs.LegacyCodec(), status)
if err != nil {
@@ -830,12 +830,24 @@ func (f *Forwarder) formatStatusResponseError(rw http.ResponseWriter, respErr er
// it correctly. If response code and status.Code drift, kubectl prints
// `Error from server (InternalError): an error on the server ("unknown")
// has prevented the request from succeeding`` instead of the correct reason.
rw.WriteHeader(trace.ErrorToCode(respErr))
rw.WriteHeader(code)
if _, err := rw.Write(data); err != nil && !utils.IsOKNetworkError(err) {
f.log.WarnContext(f.ctx, "Failed writing kube error response body", "error", err)
}
}
// kubeStatusCodeAndReason returns HTTP status code and Kubernetes status reason to use when surfacing error to user.
// Without this, trace.ErrorToCode falls back to 500 and rewrites the original 403 into an InternalError.
func kubeStatusCodeAndReason(respErr error) (int, metav1.StatusReason) {
var statusErr *kubeerrors.StatusError
if errors.As(respErr, &statusErr) && statusErr.ErrStatus.Code != 0 {
return int(statusErr.ErrStatus.Code), statusErr.ErrStatus.Reason
}
code := trace.ErrorToCode(respErr)
reason := errorToKubeStatusReason(respErr, code)
return code, reason
}
var errAmbiguousCluster = &trace.AccessDeniedError{Message: "could not disambiguate between two or more scoped kube clusters with the same name, please login with credentials for a narrower scope"}
func (f *Forwarder) setupContext(
+129
View File
@@ -48,6 +48,7 @@ import (
kubetypes "k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/rest"
restclientwatch "k8s.io/client-go/rest/watch"
controllerclient "sigs.k8s.io/controller-runtime/pkg/client"
@@ -3988,3 +3989,131 @@ func TestSpecificCustomResourcesRBAC(t *testing.T) {
})
}
}
// TestProxySubresourceRBAC verifies that:
// - pods/{name}/proxy/{path}
// - services/{name}/proxy/{path}
// - nodes/{name}/proxy/{path}
// subresources require the KubeVerbProxy verb in kubernetes_resources.
func TestProxySubresourceRBAC(t *testing.T) {
t.Parallel()
kubeMock, err := tkm.NewKubeAPIMock()
require.NoError(t, err)
t.Cleanup(func() { kubeMock.Close() })
testCtx := SetupTestContext(
context.Background(),
t,
TestConfig{
Clusters: []KubeClusterConfig{{Name: kubeCluster, APIEndpoint: kubeMock.URL}},
},
)
t.Cleanup(func() { require.NoError(t, testCtx.Close()) })
roleSpec := func(name string, resources []types.KubernetesResource) RoleSpec {
return RoleSpec{
Name: name,
KubeUsers: roleKubeUsers,
KubeGroups: roleKubeGroups,
SetupRoleFunc: func(r types.Role) {
r.SetKubeResources(types.Allow, resources)
},
}
}
podGetUser, _ := testCtx.CreateUserAndRole(testCtx.Context, t, "pod-get",
roleSpec("pod-get-role", []types.KubernetesResource{{
Kind: "pods", Namespace: types.Wildcard, Name: types.Wildcard,
Verbs: []string{"get", "list"}, APIGroup: types.Wildcard,
}}))
serviceGetUser, _ := testCtx.CreateUserAndRole(testCtx.Context, t, "service-get",
roleSpec("service-get-role", []types.KubernetesResource{{
Kind: "services", Namespace: types.Wildcard, Name: types.Wildcard,
Verbs: []string{"get", "list"}, APIGroup: types.Wildcard,
}}))
nodeGetUser, _ := testCtx.CreateUserAndRole(testCtx.Context, t, "node-get",
roleSpec("node-get-role", []types.KubernetesResource{{
Kind: "nodes", Name: types.Wildcard,
Verbs: []string{"get", "list"}, APIGroup: types.Wildcard,
}}))
// Negative control: configmaps allow rule, no pods/services/nodes match.
noMatchUser, _ := testCtx.CreateUserAndRole(testCtx.Context, t, "no-match",
roleSpec("no-match-role", []types.KubernetesResource{{
Kind: "configmaps", Namespace: types.Wildcard, Name: types.Wildcard,
Verbs: []string{"get"}, APIGroup: types.Wildcard,
}}))
sendGet := func(t *testing.T, user types.User, urlPath string) (int, string) {
t.Helper()
_, cfg := testCtx.GenTestKubeClientTLSCert(t, user.GetName(), kubeCluster)
transport, err := rest.TransportFor(cfg)
require.NoError(t, err)
client := &http.Client{Transport: transport}
req, err := http.NewRequestWithContext(testCtx.Context, http.MethodGet, cfg.Host+urlPath, nil)
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err)
t.Cleanup(func() { resp.Body.Close() })
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
return resp.StatusCode, string(body)
}
tests := []struct {
name string
user types.User
urlPath string
wantCode int
bodyContains string
}{
{
// Sanity check that portforward verb denial still works.
name: "portforward_denied_baseline",
user: podGetUser,
urlPath: "/api/v1/namespaces/default/pods/teleport/portforward",
wantCode: http.StatusForbidden,
bodyContains: "cannot portforward resource",
},
{
// podGetUser has get/list on pods but not proxy.
name: "pods_proxy_denied",
user: podGetUser,
urlPath: "/api/v1/namespaces/default/pods/teleport/proxy/8080",
wantCode: http.StatusForbidden,
bodyContains: "cannot proxy resource",
},
{
name: "services_proxy_denied",
user: serviceGetUser,
urlPath: "/api/v1/namespaces/default/services/svc/proxy/path",
wantCode: http.StatusForbidden,
bodyContains: "cannot proxy resource",
},
{
name: "nodes_proxy_denied",
user: nodeGetUser,
urlPath: "/api/v1/nodes/node-1/proxy/pods",
wantCode: http.StatusForbidden,
bodyContains: "cannot proxy resource",
},
{
// noMatchUser has no allow rule for pods at all - confirms the
// resource matcher is in the request path. The body includes
// the user name to make this distinct from the podGetUser case
// above.
name: "negative_control_denied",
user: noMatchUser,
urlPath: "/api/v1/namespaces/default/pods/teleport/proxy/8080",
wantCode: http.StatusForbidden,
bodyContains: `User \"no-match\" cannot proxy resource`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
code, body := sendGet(t, tt.user, tt.urlPath)
require.Equal(t, tt.wantCode, code, "body: %s", body)
require.Contains(t, body, tt.bodyContains)
})
}
}
+39
View File
@@ -29,6 +29,7 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/runtime/serializer"
utilnet "k8s.io/apimachinery/pkg/util/net"
"github.com/gravitational/teleport/api/types"
apievents "github.com/gravitational/teleport/api/types/events"
@@ -171,16 +172,32 @@ func parseResourcePath(p string) apiResource {
kind := append([]string{parts[2]}, parts[4:]...)
r.resourceKind = strings.Join(kind, "/")
r.resourceName = parts[3]
if len(parts) > 4 && parts[4] == "proxy" {
r.resourceName = stripProxyNamePortScheme(r.resourceName)
}
} else {
// e.g. /api/v1/nodes/{name}/proxy/{path}
kind := append([]string{parts[0]}, parts[2:]...)
r.resourceKind = strings.Join(kind, "/")
r.resourceName = parts[1]
if len(parts) > 2 && parts[2] == "proxy" {
r.resourceName = stripProxyNamePortScheme(r.resourceName)
}
}
}
return r
}
// stripProxyNamePortScheme extracts the bare resource name from the [scheme:]name[:port] segment that
// the Kubernetes API server accepts on pods/{name}/proxy, services/{name}/proxy, and nodes/{name}/proxy.
func stripProxyNamePortScheme(segment string) string {
_, name, _, valid := utilnet.SplitSchemeNamePort(segment)
if !valid {
return segment
}
return name
}
func (r apiResource) populateEvent(e *apievents.KubeRequest) {
e.ResourceAPIGroup = path.Join(r.apiGroup, r.apiGroupVersion)
e.ResourceNamespace = r.namespace
@@ -322,6 +339,22 @@ func getResourceFromAPIResource(resourceKind string) string {
return resourceKind
}
// splitResourceSubresource splits a resourceKind into its base resource and
// the first subresource segment. The trailing path (if any) is discarded.
// Examples:
//
// "pods" -> "pods", ""
// "pods/exec" -> "pods", "exec"
// "pods/proxy/8080" -> "pods", "proxy"
// "nodes/proxy/foo/bar" -> "nodes", "proxy"
func splitResourceSubresource(resourceKind string) (base, sub string) {
parts := strings.SplitN(resourceKind, "/", 3)
if len(parts) < 2 {
return resourceKind, ""
}
return parts[0], parts[1]
}
// isKubeWatchRequest returns true if the request is a watch request.
func isKubeWatchRequest(req *http.Request, r apiResource) bool {
if values := req.URL.Query()["watch"]; len(values) > 0 {
@@ -343,6 +376,12 @@ func (r apiResource) getVerb(req *http.Request) string {
case "pods/portforward":
verb = types.KubeVerbPortForward
default:
if base, sub := splitResourceSubresource(r.resourceKind); sub == "proxy" {
switch base {
case "pods", "services", "nodes":
return types.KubeVerbProxy
}
}
switch req.Method {
case http.MethodPost:
verb = types.KubeVerbCreate
+19 -1
View File
@@ -108,6 +108,17 @@ func TestParseResourcePath(t *testing.T) {
{path: "/api/v1/namespaces/kube-system/pods/foo/exec", want: apiResource{apiGroup: "", apiGroupVersion: "v1", namespace: "kube-system", resourceKind: "pods/exec", resourceName: "foo"}},
{path: "/apis/apiregistration.k8s.io/v1/apiservices/foo/status", want: apiResource{apiGroup: "apiregistration.k8s.io", apiGroupVersion: "v1", resourceKind: "apiservices/status", resourceName: "foo"}},
{path: "/api/v1/nodes/foo/proxy/bar", want: apiResource{apiGroup: "", apiGroupVersion: "v1", resourceKind: "nodes/proxy/bar", resourceName: "foo"}},
// Parser retains every trailing segment for proxy subresources.
{path: "/api/v1/namespaces/default/pods/foo/proxy/8080", want: apiResource{apiGroup: "", apiGroupVersion: "v1", namespace: "default", resourceKind: "pods/proxy/8080", resourceName: "foo"}},
{path: "/api/v1/namespaces/default/services/svc/proxy/path", want: apiResource{apiGroup: "", apiGroupVersion: "v1", namespace: "default", resourceKind: "services/proxy/path", resourceName: "svc"}},
{path: "/api/v1/nodes/node-1/proxy/pods", want: apiResource{apiGroup: "", apiGroupVersion: "v1", resourceKind: "nodes/proxy/pods", resourceName: "node-1"}},
// Proxy subresources accept the [scheme:]name[:port] format for the name segment.
// We strip it down to the bare name so RBAC rules scoped to a specific name still match.
{path: "/api/v1/namespaces/default/services/svc:443/proxy/path", want: apiResource{apiGroup: "", apiGroupVersion: "v1", namespace: "default", resourceKind: "services/proxy/path", resourceName: "svc"}},
{path: "/api/v1/namespaces/default/services/https:svc:443/proxy/path", want: apiResource{apiGroup: "", apiGroupVersion: "v1", namespace: "default", resourceKind: "services/proxy/path", resourceName: "svc"}},
{path: "/api/v1/namespaces/default/services/https:svc:/proxy/path", want: apiResource{apiGroup: "", apiGroupVersion: "v1", namespace: "default", resourceKind: "services/proxy/path", resourceName: "svc"}},
{path: "/api/v1/namespaces/default/pods/https:foo:8080/proxy/healthz", want: apiResource{apiGroup: "", apiGroupVersion: "v1", namespace: "default", resourceKind: "pods/proxy/healthz", resourceName: "foo"}},
{path: "/api/v1/nodes/https:node-1:10250/proxy/pods", want: apiResource{apiGroup: "", apiGroupVersion: "v1", resourceKind: "nodes/proxy/pods", resourceName: "node-1"}},
}
for _, tt := range tests {
@@ -192,6 +203,7 @@ func Test_getResourceFromRequest(t *testing.T) {
{path: "/api/v1/namespaces/kube-system/pods/foo/exec", want: &types.KubernetesResource{Kind: "pods", Namespace: "kube-system", Name: "foo", Verbs: []string{"exec"}, APIGroup: ""}},
{path: "/api/v1/namespaces/kube-system/pods/foo/attach", want: &types.KubernetesResource{Kind: "pods", Namespace: "kube-system", Name: "foo", Verbs: []string{"exec"}, APIGroup: ""}},
{path: "/api/v1/namespaces/kube-system/pods/foo/portforward", want: &types.KubernetesResource{Kind: "pods", Namespace: "kube-system", Name: "foo", Verbs: []string{"portforward"}, APIGroup: ""}},
{path: "/api/v1/namespaces/default/pods/foo/proxy/8080", want: &types.KubernetesResource{Kind: "pods", Namespace: "default", Name: "foo", Verbs: []string{"proxy"}, APIGroup: ""}},
{path: "/api/v1/namespaces/default/pods", body: bodyFunc("Pod", "v1"), want: &types.KubernetesResource{Kind: "pods", Namespace: "default", Name: "foo-create", Verbs: []string{"create"}, APIGroup: ""}},
{path: "/api/v1/namespaces/default/pods", body: bodyFuncWithoutGVK(), want: &types.KubernetesResource{Kind: "pods", Namespace: "default", Name: "foo-create", Verbs: []string{"create"}, APIGroup: ""}},
@@ -220,11 +232,17 @@ func Test_getResourceFromRequest(t *testing.T) {
// Nodes
{path: "/api/v1/nodes", want: &types.KubernetesResource{Kind: "nodes", Verbs: []string{"list"}, APIGroup: ""}},
{path: "/api/v1/nodes/foo/proxy/bar", want: &types.KubernetesResource{Kind: "nodes", Name: "foo", Verbs: []string{"get"}, APIGroup: ""}},
{path: "/api/v1/nodes/foo/proxy/bar", want: &types.KubernetesResource{Kind: "nodes", Name: "foo", Verbs: []string{"proxy"}, APIGroup: ""}},
{path: "/api/v1/nodes/node-1/proxy/pods", want: &types.KubernetesResource{Kind: "nodes", Name: "node-1", Verbs: []string{"proxy"}, APIGroup: ""}},
// Services
{path: "/api/v1/services", want: &types.KubernetesResource{Kind: "services", Verbs: []string{"list"}, APIGroup: ""}},
{path: "/api/v1/namespaces/default/services", want: &types.KubernetesResource{Kind: "services", Namespace: "default", Verbs: []string{"list"}, APIGroup: ""}},
{path: "/api/v1/namespaces/default/services/foo", want: &types.KubernetesResource{Kind: "services", Namespace: "default", Name: "foo", Verbs: []string{"get"}, APIGroup: ""}},
{path: "/api/v1/namespaces/default/services/svc/proxy/path", want: &types.KubernetesResource{Kind: "services", Namespace: "default", Name: "svc", Verbs: []string{"proxy"}, APIGroup: ""}},
// [scheme:]name[:port] format on proxy paths must reduce to the bare name so name-scoped RBAC rules still match.
{path: "/api/v1/namespaces/default/services/svc:443/proxy/path", want: &types.KubernetesResource{Kind: "services", Namespace: "default", Name: "svc", Verbs: []string{"proxy"}, APIGroup: ""}},
{path: "/api/v1/namespaces/default/services/https:svc:443/proxy/path", want: &types.KubernetesResource{Kind: "services", Namespace: "default", Name: "svc", Verbs: []string{"proxy"}, APIGroup: ""}},
{path: "/api/v1/namespaces/default/pods/https:foo:8080/proxy/healthz", want: &types.KubernetesResource{Kind: "pods", Namespace: "default", Name: "foo", Verbs: []string{"proxy"}, APIGroup: ""}},
{path: "/api/v1/watch/namespaces/default/services/foo", want: &types.KubernetesResource{Kind: "services", Namespace: "default", Name: "foo", Verbs: []string{"watch"}, APIGroup: ""}},
{path: "/api/v1/namespaces/default/services", body: bodyFunc("Service", "v1"), want: &types.KubernetesResource{Kind: "services", Namespace: "default", Name: "foo-create", Verbs: []string{"create"}, APIGroup: ""}},
@@ -368,6 +368,7 @@ export const kubernetesVerbOptions: KubernetesVerbOption[] = [
// in our config. We may want to explain them in the UI somehow.
'exec',
'portforward',
'proxy',
] as const
)
.toSorted((a, b) => a.localeCompare(b))
@@ -216,7 +216,8 @@ export type KubernetesVerb =
| 'watch'
| 'deletecollection'
| 'exec'
| 'portforward';
| 'portforward'
| 'proxy';
export type Rule = {
/**