Skip to content

Commit cd7d7e4

Browse files
Tolstoadleong
andauthored
Include extra attributes in SubjectAccessReview (#14768)
* Include extra attributes in SubjectAccessReview Problem Kubernetes authorization plugins can rely on extra attributes on a user, provided via X-Remote-Extra- headers, e.g. AWS EKS with AccessEntry authentication. Currently, the Linkerd Viz tap API doesn't include these attributes when making SubjectAccessReview requests, preventing tap from working in clusters that use authorization plugins relying on these extra attributes. Solution Updated the tap API to extract X-Remote-Extra- headers from incoming requests and include them in SubjectAccessReview calls. The header prefix is read from the extension-apiserver-authentication ConfigMap to support custom configurations. This implementation is based on the original work by David Symons in PR #13170. Changes: - Modified ResourceAuthzForUser in pkg/k8s/authz.go to accept extra attributes as map[string]authV1.ExtraValue - Updated viz/tap/api/handlers.go to extract and URL-decode extra headers - Modified viz/tap/api/server.go to read the configurable header prefix from the Kubernetes ConfigMap - Added tests to verify extra attributes are correctly passed through Validation Ran go test ./viz/tap/api/... ./pkg/k8s/... and all tests pass. Added TestHandleTap_ExtraHeaders to verify extra attributes are correctly extracted and passed to the Kubernetes client. Tested with an actual EKS cluster with AccessEntry authentication. Fixes #13169 Signed-off-by: Nils Mueller <20240901+Tolsto@users.noreply.github.com> * Handle extra auth headers safely --------- Signed-off-by: Nils Mueller <20240901+Tolsto@users.noreply.github.com> Co-authored-by: Alex Leong <alex@buoyant.io>
1 parent 8abed43 commit cd7d7e4

File tree

5 files changed

+218
-31
lines changed

5 files changed

+218
-31
lines changed

pkg/k8s/authz.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,15 @@ func ResourceAuthz(
4848
func ResourceAuthzForUser(
4949
ctx context.Context,
5050
client kubernetes.Interface,
51-
namespace, verb, group, version, resource, subresource, name, user string, userGroups []string) error {
51+
namespace, verb, group, version, resource, subresource, name, user string,
52+
userGroups []string,
53+
extra map[string]authV1.ExtraValue,
54+
) error {
5255
sar := &authV1.SubjectAccessReview{
5356
Spec: authV1.SubjectAccessReviewSpec{
5457
User: user,
5558
Groups: userGroups,
59+
Extra: extra,
5660
ResourceAttributes: &authV1.ResourceAttributes{
5761
Namespace: namespace,
5862
Verb: verb,

viz/tap/api/handlers.go

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"fmt"
77
"net/http"
8+
"net/url"
89
"strings"
910

1011
"github.com/go-openapi/spec"
@@ -17,17 +18,19 @@ import (
1718
"github.com/prometheus/client_golang/prometheus/promhttp"
1819
"github.com/sirupsen/logrus"
1920
"google.golang.org/grpc/metadata"
21+
authV1 "k8s.io/api/authorization/v1"
2022
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2123
"k8s.io/apimachinery/pkg/runtime/schema"
2224
"k8s.io/apimachinery/pkg/version"
2325
)
2426

2527
type handler struct {
26-
k8sAPI *k8s.API
27-
usernameHeader string
28-
groupHeader string
29-
grpcTapServer pb.TapServer
30-
log *logrus.Entry
28+
k8sAPI *k8s.API
29+
usernameHeader string
30+
groupHeader string
31+
extraHeaderPrefix string
32+
grpcTapServer pb.TapServer
33+
log *logrus.Entry
3134
}
3235

3336
// TODO: share with api_handlers.go
@@ -123,6 +126,8 @@ func (h *handler) handleTap(w http.ResponseWriter, req *http.Request, p httprout
123126
namespace, resource, name, h.usernameHeader, h.groupHeader,
124127
)
125128

129+
extra := extractExtraHeaders(req.Header, h.extraHeaderPrefix)
130+
126131
// TODO: it's possible this SubjectAccessReview is redundant, consider
127132
// removing, more info at https://github.com/linkerd/linkerd2/issues/3182
128133
err := pkgK8s.ResourceAuthzForUser(
@@ -137,6 +142,7 @@ func (h *handler) handleTap(w http.ResponseWriter, req *http.Request, p httprout
137142
name,
138143
req.Header.Get(h.usernameHeader),
139144
req.Header.Values(h.groupHeader),
145+
extra,
140146
)
141147
if err != nil {
142148
err = fmt.Errorf("tap authorization failed (%w), visit %s for more information", err, pkg.TapRbacURL)
@@ -180,6 +186,29 @@ func (h *handler) handleTap(w http.ResponseWriter, req *http.Request, p httprout
180186
}
181187
}
182188

189+
func extractExtraHeaders(header http.Header, extraHeaderPrefix string) map[string]authV1.ExtraValue {
190+
if extraHeaderPrefix == "" {
191+
return nil
192+
}
193+
194+
extraHeaderPrefixLower := strings.ToLower(extraHeaderPrefix)
195+
extraHeaderPrefixLen := len(extraHeaderPrefix)
196+
extra := make(map[string]authV1.ExtraValue)
197+
198+
for key, values := range header {
199+
if !strings.HasPrefix(strings.ToLower(key), extraHeaderPrefixLower) {
200+
continue
201+
}
202+
203+
extraKey, err := url.QueryUnescape(key[extraHeaderPrefixLen:])
204+
if err == nil {
205+
extra[extraKey] = authV1.ExtraValue(values)
206+
}
207+
}
208+
209+
return extra
210+
}
211+
183212
// GET (not found)
184213
func handleNotFound() http.Handler {
185214
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {

viz/tap/api/handlers_test.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import (
1111
"github.com/julienschmidt/httprouter"
1212
"github.com/linkerd/linkerd2/controller/k8s"
1313
"github.com/sirupsen/logrus"
14+
authV1 "k8s.io/api/authorization/v1"
15+
k8sFake "k8s.io/client-go/kubernetes/fake"
16+
k8sTesting "k8s.io/client-go/testing"
1417
)
1518

1619
func TestHandleTap(t *testing.T) {
@@ -71,3 +74,115 @@ func TestHandleTap(t *testing.T) {
7174
})
7275
}
7376
}
77+
78+
func TestHandleTap_ExtraHeaders(t *testing.T) {
79+
k8sAPI, err := k8s.NewFakeAPI()
80+
if err != nil {
81+
t.Fatalf("NewFakeAPI returned an error: %s", err)
82+
}
83+
84+
h := &handler{
85+
k8sAPI: k8sAPI,
86+
log: logrus.WithField("test", t.Name()),
87+
extraHeaderPrefix: "X-Remote-Extra-",
88+
}
89+
90+
recorder := httptest.NewRecorder()
91+
req := httptest.NewRequest("POST", "/apis/tap.linkerd.io/v1alpha1/watch/namespaces/foo/tap", nil)
92+
req.Header.Set("X-Remote-Extra-Foo", "bar")
93+
req.Header.Set("X-Remote-Extra-Baz", "qux")
94+
95+
params := httprouter.Params{
96+
{Key: "namespace", Value: "foo"},
97+
}
98+
99+
h.handleTap(recorder, req, params)
100+
101+
sar := getSubjectAccessReview(t, k8sAPI)
102+
103+
if len(sar.Spec.Extra) != 2 {
104+
t.Errorf("Expected 2 extra headers, got %d", len(sar.Spec.Extra))
105+
}
106+
107+
if v, ok := sar.Spec.Extra["Foo"]; !ok || v[0] != "bar" {
108+
t.Errorf("Expected Extra['Foo'] to be ['bar'], got %v", v)
109+
}
110+
if v, ok := sar.Spec.Extra["Baz"]; !ok || v[0] != "qux" {
111+
t.Errorf("Expected Extra['Baz'] to be ['qux'], got %v", v)
112+
}
113+
}
114+
115+
func TestHandleTap_ExtraHeadersPrefixCaseInsensitive(t *testing.T) {
116+
k8sAPI, err := k8s.NewFakeAPI()
117+
if err != nil {
118+
t.Fatalf("NewFakeAPI returned an error: %s", err)
119+
}
120+
121+
h := &handler{
122+
k8sAPI: k8sAPI,
123+
log: logrus.WithField("test", t.Name()),
124+
extraHeaderPrefix: "x-remote-extra-",
125+
}
126+
127+
recorder := httptest.NewRecorder()
128+
req := httptest.NewRequest("POST", "/apis/tap.linkerd.io/v1alpha1/watch/namespaces/foo/tap", nil)
129+
req.Header.Set("X-Remote-Extra-Foo", "bar")
130+
131+
params := httprouter.Params{
132+
{Key: "namespace", Value: "foo"},
133+
}
134+
135+
h.handleTap(recorder, req, params)
136+
sar := getSubjectAccessReview(t, k8sAPI)
137+
138+
if v, ok := sar.Spec.Extra["Foo"]; !ok || v[0] != "bar" {
139+
t.Errorf("Expected Extra['Foo'] to be ['bar'], got %v", v)
140+
}
141+
}
142+
143+
func TestHandleTap_ExtraHeadersEmptyPrefix(t *testing.T) {
144+
k8sAPI, err := k8s.NewFakeAPI()
145+
if err != nil {
146+
t.Fatalf("NewFakeAPI returned an error: %s", err)
147+
}
148+
149+
h := &handler{
150+
k8sAPI: k8sAPI,
151+
log: logrus.WithField("test", t.Name()),
152+
}
153+
154+
recorder := httptest.NewRecorder()
155+
req := httptest.NewRequest("POST", "/apis/tap.linkerd.io/v1alpha1/watch/namespaces/foo/tap", nil)
156+
req.Header.Set("X-Remote-Extra-Foo", "bar")
157+
req.Header.Set("X-Remote-User", "alice")
158+
req.Header.Set("Content-Type", "application/json")
159+
160+
params := httprouter.Params{
161+
{Key: "namespace", Value: "foo"},
162+
}
163+
164+
h.handleTap(recorder, req, params)
165+
sar := getSubjectAccessReview(t, k8sAPI)
166+
167+
if len(sar.Spec.Extra) != 0 {
168+
t.Errorf("Expected 0 extra headers, got %d", len(sar.Spec.Extra))
169+
}
170+
}
171+
172+
func getSubjectAccessReview(t *testing.T, k8sAPI *k8s.API) *authV1.SubjectAccessReview {
173+
t.Helper()
174+
175+
client := k8sAPI.Client.(*k8sFake.Clientset)
176+
actions := client.Actions()
177+
178+
for _, action := range actions {
179+
if action.GetVerb() == "create" && action.GetResource().Resource == "subjectaccessreviews" {
180+
createAction := action.(k8sTesting.CreateAction)
181+
obj := createAction.GetObject()
182+
return obj.(*authV1.SubjectAccessReview)
183+
}
184+
}
185+
186+
t.Fatal("Expected SubjectAccessReview to be created")
187+
return nil
188+
}

viz/tap/api/server.go

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import (
2222
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2323
)
2424

25+
const defaultExtraHeaderPrefix = "X-Remote-Extra-"
26+
2527
// Server holds the underlying http server and its config
2628
type Server struct {
2729
*http.Server
@@ -50,7 +52,7 @@ func NewServer(
5052
}
5153
}()
5254

53-
clientCAPem, allowedNames, usernameHeader, groupHeader, err := serverAuth(ctx, k8sAPI)
55+
clientCAPem, allowedNames, usernameHeader, groupHeader, extraHeaderPrefix, err := serverAuth(ctx, k8sAPI)
5456
if err != nil {
5557
return nil, err
5658
}
@@ -80,11 +82,12 @@ func NewServer(
8082

8183
var emptyCert atomic.Value
8284
h := &handler{
83-
k8sAPI: k8sAPI,
84-
usernameHeader: usernameHeader,
85-
groupHeader: groupHeader,
86-
grpcTapServer: grpcTapServer,
87-
log: log,
85+
k8sAPI: k8sAPI,
86+
usernameHeader: usernameHeader,
87+
groupHeader: groupHeader,
88+
extraHeaderPrefix: extraHeaderPrefix,
89+
grpcTapServer: grpcTapServer,
90+
log: log,
8891
}
8992

9093
lis, err := net.Listen("tcp", addr)
@@ -164,28 +167,28 @@ func (a *Server) validate(req *http.Request) error {
164167
// authentication.
165168
// kubectl -n kube-system get cm/extension-apiserver-authentication
166169
// accessible via the extension-apiserver-authentication-reader role
167-
func serverAuth(ctx context.Context, k8sAPI *k8s.API) (string, []string, string, string, error) {
170+
func serverAuth(ctx context.Context, k8sAPI *k8s.API) (string, []string, string, string, string, error) {
168171

169172
cm, err := k8sAPI.Client.CoreV1().
170173
ConfigMaps(metav1.NamespaceSystem).
171174
Get(ctx, pkgk8s.ExtensionAPIServerAuthenticationConfigMapName, metav1.GetOptions{})
172175
if err != nil {
173-
return "", nil, "", "", fmt.Errorf("failed to load [%s] config: %w", pkgk8s.ExtensionAPIServerAuthenticationConfigMapName, err)
176+
return "", nil, "", "", "", fmt.Errorf("failed to load [%s] config: %w", pkgk8s.ExtensionAPIServerAuthenticationConfigMapName, err)
174177
}
175178

176179
clientCAPem, ok := cm.Data[pkgk8s.ExtensionAPIServerAuthenticationRequestHeaderClientCAFileKey]
177180
if !ok {
178-
return "", nil, "", "", fmt.Errorf("no client CA cert available for apiextension-server")
181+
return "", nil, "", "", "", fmt.Errorf("no client CA cert available for apiextension-server")
179182
}
180183

181184
allowedNames, err := deserializeStrings(cm.Data["requestheader-allowed-names"])
182185
if err != nil {
183-
return "", nil, "", "", err
186+
return "", nil, "", "", "", err
184187
}
185188

186189
usernameHeaders, err := deserializeStrings(cm.Data["requestheader-username-headers"])
187190
if err != nil {
188-
return "", nil, "", "", err
191+
return "", nil, "", "", "", err
189192
}
190193
usernameHeader := ""
191194
if len(usernameHeaders) > 0 {
@@ -194,14 +197,28 @@ func serverAuth(ctx context.Context, k8sAPI *k8s.API) (string, []string, string,
194197

195198
groupHeaders, err := deserializeStrings(cm.Data["requestheader-group-headers"])
196199
if err != nil {
197-
return "", nil, "", "", err
200+
return "", nil, "", "", "", err
198201
}
199202
groupHeader := ""
200203
if len(groupHeaders) > 0 {
201204
groupHeader = groupHeaders[0]
202205
}
203206

204-
return clientCAPem, allowedNames, usernameHeader, groupHeader, nil
207+
extraHeaderPrefixes, err := deserializeStrings(cm.Data["requestheader-extra-headers-prefix"])
208+
if err != nil {
209+
return "", nil, "", "", "", err
210+
}
211+
// The extra headers prefix is used to identify headers that contain additional
212+
// user attributes for authorization (e.g., "X-Remote-Extra-"). These headers are
213+
// forwarded by the Kubernetes API server when acting as an aggregating proxy.
214+
// The prefix is configurable via the --requestheader-extra-headers-prefix flag
215+
// on the API server (defaults to "X-Remote-Extra-").
216+
extraHeaderPrefix := defaultExtraHeaderPrefix
217+
if len(extraHeaderPrefixes) > 0 && extraHeaderPrefixes[0] != "" {
218+
extraHeaderPrefix = extraHeaderPrefixes[0]
219+
}
220+
221+
return clientCAPem, allowedNames, usernameHeader, groupHeader, extraHeaderPrefix, nil
205222
}
206223

207224
// copied from https://github.com/kubernetes/apiserver/blob/781c3cd1b3dc5b6f79c68ab0d16fe544600421ef/pkg/server/options/authentication.go#L360

viz/tap/api/server_test.go

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,13 @@ import (
1818

1919
func TestAPIServerAuth(t *testing.T) {
2020
expectations := []struct {
21-
k8sRes []string
22-
clientCAPem string
23-
allowedNames []string
24-
usernameHeader string
25-
groupHeader string
26-
err error
21+
k8sRes []string
22+
clientCAPem string
23+
allowedNames []string
24+
usernameHeader string
25+
groupHeader string
26+
extraHeaderPrefix string
27+
err error
2728
}{
2829
{
2930
err: fmt.Errorf("failed to load [%s] config: configmaps %q not found", k8sutils.ExtensionAPIServerAuthenticationConfigMapName, k8sutils.ExtensionAPIServerAuthenticationConfigMapName),
@@ -44,11 +45,29 @@ data:
4445
requestheader-username-headers: '["X-Remote-User"]'
4546
`,
4647
},
47-
clientCAPem: "requestheader-client-ca-file",
48-
allowedNames: []string{"name1", "name2"},
49-
usernameHeader: "X-Remote-User",
50-
groupHeader: "X-Remote-Group",
51-
err: nil,
48+
clientCAPem: "requestheader-client-ca-file",
49+
allowedNames: []string{"name1", "name2"},
50+
usernameHeader: "X-Remote-User",
51+
groupHeader: "X-Remote-Group",
52+
extraHeaderPrefix: "X-Remote-Extra-",
53+
err: nil,
54+
},
55+
{
56+
k8sRes: []string{`
57+
apiVersion: v1
58+
kind: ConfigMap
59+
metadata:
60+
name: extension-apiserver-authentication
61+
namespace: kube-system
62+
data:
63+
requestheader-client-ca-file: 'requestheader-client-ca-file'
64+
`},
65+
clientCAPem: "requestheader-client-ca-file",
66+
allowedNames: nil,
67+
usernameHeader: "",
68+
groupHeader: "",
69+
extraHeaderPrefix: defaultExtraHeaderPrefix,
70+
err: nil,
5271
},
5372
}
5473

@@ -62,7 +81,7 @@ data:
6281
t.Fatalf("NewFakeAPI returned an error: %s", err)
6382
}
6483

65-
clientCAPem, allowedNames, usernameHeader, groupHeader, err := serverAuth(ctx, k8sAPI)
84+
clientCAPem, allowedNames, usernameHeader, groupHeader, extraHeaderPrefix, err := serverAuth(ctx, k8sAPI)
6685

6786
if err != nil && exp.err != nil {
6887
if err.Error() != exp.err.Error() {
@@ -86,6 +105,9 @@ data:
86105
if groupHeader != exp.groupHeader {
87106
t.Errorf("apiServerAuth returned unexpected groupHeader: %q, expected: %q", groupHeader, exp.groupHeader)
88107
}
108+
if extraHeaderPrefix != exp.extraHeaderPrefix {
109+
t.Errorf("apiServerAuth returned unexpected extraHeaderPrefix: %q, expected: %q", extraHeaderPrefix, exp.extraHeaderPrefix)
110+
}
89111
})
90112
}
91113
}

0 commit comments

Comments
 (0)