mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-12 02:15:01 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user