Skip to content

Commit 24509f8

Browse files
Maisem Alimaisem
authored andcommitted
cmd/k8s-operator: add support for control plane assigned groups
Previously we would use the Impersonate-Group header to pass through tags to the k8s api server. However, we would do nothing for non-tagged nodes. Now that we have a way to specify these via peerCaps respect those and send down groups for non-tagged nodes as well. For tagged nodes, it defaults to sending down the tags as groups to retain legacy behavior if there are no caps set. Otherwise, the tags are omitted. Updates tailscale#5055 Signed-off-by: Maisem Ali <maisem@tailscale.com>
1 parent 0913ec0 commit 24509f8

File tree

2 files changed

+183
-16
lines changed

2 files changed

+183
-16
lines changed

cmd/k8s-operator/proxy.go

Lines changed: 76 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,26 @@ import (
1616

1717
"tailscale.com/client/tailscale"
1818
"tailscale.com/client/tailscale/apitype"
19+
"tailscale.com/tailcfg"
1920
"tailscale.com/tsnet"
2021
"tailscale.com/types/logger"
22+
"tailscale.com/util/set"
2123
)
2224

2325
type whoIsKey struct{}
2426

27+
// whoIsFromRequest returns the WhoIsResponse previously stashed by a call to
28+
// addWhoIsToRequest.
29+
func whoIsFromRequest(r *http.Request) *apitype.WhoIsResponse {
30+
return r.Context().Value(whoIsKey{}).(*apitype.WhoIsResponse)
31+
}
32+
33+
// addWhoIsToRequest stashes who in r's context, retrievable by a call to
34+
// whoIsFromRequest.
35+
func addWhoIsToRequest(r *http.Request, who *apitype.WhoIsResponse) *http.Request {
36+
return r.WithContext(context.WithValue(r.Context(), whoIsKey{}, who))
37+
}
38+
2539
// authProxy is an http.Handler that authenticates requests using the Tailscale
2640
// LocalAPI and then proxies them to the Kubernetes API.
2741
type authProxy struct {
@@ -37,8 +51,7 @@ func (h *authProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
3751
http.Error(w, "failed to authenticate caller", http.StatusInternalServerError)
3852
return
3953
}
40-
r = r.WithContext(context.WithValue(r.Context(), whoIsKey{}, who))
41-
h.rp.ServeHTTP(w, r)
54+
h.rp.ServeHTTP(w, addWhoIsToRequest(r, who))
4255
}
4356

4457
// runAuthProxy runs an HTTP server that authenticates requests using the
@@ -67,6 +80,10 @@ func runAuthProxy(s *tsnet.Server, rt http.RoundTripper, logf logger.Logf) {
6780
lc: lc,
6881
rp: &httputil.ReverseProxy{
6982
Director: func(r *http.Request) {
83+
// Replace the URL with the Kubernetes APIServer.
84+
r.URL.Scheme = u.Scheme
85+
r.URL.Host = u.Host
86+
7087
// We want to proxy to the Kubernetes API, but we want to use
7188
// the caller's identity to do so. We do this by impersonating
7289
// the caller using the Kubernetes User Impersonation feature:
@@ -85,21 +102,9 @@ func runAuthProxy(s *tsnet.Server, rt http.RoundTripper, logf logger.Logf) {
85102
}
86103

87104
// Now add the impersonation headers that we want.
88-
who := r.Context().Value(whoIsKey{}).(*apitype.WhoIsResponse)
89-
if who.Node.IsTagged() {
90-
// Use the nodes FQDN as the username, and the nodes tags as the groups.
91-
// "Impersonate-Group" requires "Impersonate-User" to be set.
92-
r.Header.Set("Impersonate-User", strings.TrimSuffix(who.Node.Name, "."))
93-
for _, tag := range who.Node.Tags {
94-
r.Header.Add("Impersonate-Group", tag)
95-
}
96-
} else {
97-
r.Header.Set("Impersonate-User", who.UserProfile.LoginName)
105+
if err := addImpersonationHeaders(r); err != nil {
106+
panic("failed to add impersonation headers: " + err.Error())
98107
}
99-
100-
// Replace the URL with the Kubernetes APIServer.
101-
r.URL.Scheme = u.Scheme
102-
r.URL.Host = u.Host
103108
},
104109
Transport: rt,
105110
},
@@ -118,3 +123,58 @@ func runAuthProxy(s *tsnet.Server, rt http.RoundTripper, logf logger.Logf) {
118123
log.Fatalf("runAuthProxy: failed to serve %v", err)
119124
}
120125
}
126+
127+
const capabilityName = "https://tailscale.com/cap/kubernetes"
128+
129+
type capRule struct {
130+
// Impersonate is a list of rules that specify how to impersonate the caller
131+
// when proxying to the Kubernetes API.
132+
Impersonate *impersonateRule `json:"impersonate,omitempty"`
133+
}
134+
135+
// TODO(maisem): move this to some well-known location so that it can be shared
136+
// with control.
137+
type impersonateRule struct {
138+
Groups []string `json:"groups,omitempty"`
139+
}
140+
141+
// addImpersonationHeaders adds the appropriate headers to r to impersonate the
142+
// caller when proxying to the Kubernetes API. It uses the WhoIsResponse stashed
143+
// in the context by the authProxy.
144+
func addImpersonationHeaders(r *http.Request) error {
145+
who := whoIsFromRequest(r)
146+
rules, err := tailcfg.UnmarshalCapJSON[capRule](who.CapMap, capabilityName)
147+
if err != nil {
148+
return fmt.Errorf("failed to unmarshal capability: %v", err)
149+
}
150+
151+
var groupsAdded set.Slice[string]
152+
for _, rule := range rules {
153+
if rule.Impersonate == nil {
154+
continue
155+
}
156+
for _, group := range rule.Impersonate.Groups {
157+
if groupsAdded.Contains(group) {
158+
continue
159+
}
160+
r.Header.Add("Impersonate-Group", group)
161+
groupsAdded.Add(group)
162+
}
163+
}
164+
165+
if !who.Node.IsTagged() {
166+
r.Header.Set("Impersonate-User", who.UserProfile.LoginName)
167+
return nil
168+
}
169+
// "Impersonate-Group" requires "Impersonate-User" to be set, so we set it
170+
// to the node FQDN for tagged nodes.
171+
r.Header.Set("Impersonate-User", strings.TrimSuffix(who.Node.Name, "."))
172+
173+
// For legacy behavior (before caps), set the groups to the nodes tags.
174+
if groupsAdded.Slice().Len() == 0 {
175+
for _, tag := range who.Node.Tags {
176+
r.Header.Add("Impersonate-Group", tag)
177+
}
178+
}
179+
return nil
180+
}

cmd/k8s-operator/proxy_test.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// Copyright (c) Tailscale Inc & AUTHORS
2+
// SPDX-License-Identifier: BSD-3-Clause
3+
4+
package main
5+
6+
import (
7+
"net/http"
8+
"testing"
9+
10+
"github.com/google/go-cmp/cmp"
11+
"tailscale.com/client/tailscale/apitype"
12+
"tailscale.com/tailcfg"
13+
"tailscale.com/util/must"
14+
)
15+
16+
func TestImpersonationHeaders(t *testing.T) {
17+
tests := []struct {
18+
name string
19+
emailish string
20+
tags []string
21+
capMap tailcfg.PeerCapMap
22+
23+
wantHeaders http.Header
24+
}{
25+
{
26+
name: "user",
27+
emailish: "foo@example.com",
28+
wantHeaders: http.Header{
29+
"Impersonate-User": {"foo@example.com"},
30+
},
31+
},
32+
{
33+
name: "tagged",
34+
emailish: "tagged-device",
35+
tags: []string{"tag:foo", "tag:bar"},
36+
wantHeaders: http.Header{
37+
"Impersonate-User": {"node.ts.net"},
38+
"Impersonate-Group": {"tag:foo", "tag:bar"},
39+
},
40+
},
41+
{
42+
name: "user-with-cap",
43+
emailish: "foo@example.com",
44+
capMap: tailcfg.PeerCapMap{
45+
capabilityName: {
46+
[]byte(`{"impersonate":{"groups":["group1","group2"]}}`),
47+
[]byte(`{"impersonate":{"groups":["group1","group3"]}}`), // One group is duplicated.
48+
[]byte(`{"impersonate":{"groups":["group4"]}}`),
49+
[]byte(`{"impersonate":{"groups":["group2"]}}`), // duplicate
50+
51+
// These should be ignored, but should parse correctly.
52+
[]byte(`{}`),
53+
[]byte(`{"impersonate":{}}`),
54+
[]byte(`{"impersonate":{"groups":[]}}`),
55+
},
56+
},
57+
wantHeaders: http.Header{
58+
"Impersonate-Group": {"group1", "group2", "group3", "group4"},
59+
"Impersonate-User": {"foo@example.com"},
60+
},
61+
},
62+
{
63+
name: "tagged-with-cap",
64+
emailish: "tagged-device",
65+
tags: []string{"tag:foo", "tag:bar"},
66+
capMap: tailcfg.PeerCapMap{
67+
capabilityName: {
68+
[]byte(`{"impersonate":{"groups":["group1"]}}`),
69+
},
70+
},
71+
wantHeaders: http.Header{
72+
"Impersonate-Group": {"group1"},
73+
"Impersonate-User": {"node.ts.net"},
74+
},
75+
},
76+
{
77+
name: "bad-cap",
78+
emailish: "tagged-device",
79+
tags: []string{"tag:foo", "tag:bar"},
80+
capMap: tailcfg.PeerCapMap{
81+
capabilityName: {
82+
[]byte(`[]`),
83+
},
84+
},
85+
wantHeaders: http.Header{},
86+
},
87+
}
88+
89+
for _, tc := range tests {
90+
r := must.Get(http.NewRequest("GET", "https://op.ts.net/api/foo", nil))
91+
r = addWhoIsToRequest(r, &apitype.WhoIsResponse{
92+
Node: &tailcfg.Node{
93+
Name: "node.ts.net",
94+
Tags: tc.tags,
95+
},
96+
UserProfile: &tailcfg.UserProfile{
97+
LoginName: tc.emailish,
98+
},
99+
CapMap: tc.capMap,
100+
})
101+
addImpersonationHeaders(r)
102+
103+
if d := cmp.Diff(tc.wantHeaders, r.Header); d != "" {
104+
t.Errorf("unexpected header (-want +got):\n%s", d)
105+
}
106+
}
107+
}

0 commit comments

Comments
 (0)