Skip to content

Commit 1ccf00f

Browse files
authored
feat: add support for user and groups impersonation
As Capsule Proxy proxies requests to the Kubernetes API server, it uses its own identity to impersonate the identity of the incoming request, towards the API server. As of now, if the incoming request is in turn trying to impersonate a user and possibly groups, the impersonation headers are ignored and overridden by Capsule Proxy with the identity of the incoming request. As such, Capsule Proxy proxies now requests to the API server impersonating on behalf of the identity of the incoming request, the user and the groups that that identity is trying to impersonate, if and only if, the token of that identity has required permissions bound.
1 parent ae7df54 commit 1ccf00f

File tree

6 files changed

+104
-14
lines changed

6 files changed

+104
-14
lines changed

Makefile

+3-1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ kind:
2828

2929
capsule:
3030
@echo "Installing capsule..."
31+
@sleep 5
3132
@helm repo add clastix https://clastix.github.io/charts
3233
@helm upgrade --install --create-namespace --namespace capsule-system capsule clastix/capsule \
3334
--set "manager.resources=null" \
@@ -55,7 +56,8 @@ ifeq ($(CAPSULE_PROXY_MODE),http)
5556
--set "service.nodePort=" \
5657
--set "kind=DaemonSet" \
5758
--set "daemonset.hostNetwork=true" \
58-
--set "serviceMonitor.enabled=false"
59+
--set "serviceMonitor.enabled=false" \
60+
--set "options.generateCertificates=false"
5961
else
6062
@echo "Running in HTTPS mode"
6163
@echo "capsule proxy certificates..."

e2e/kubectl-https-tests/namespaces/list.bats

+1-1
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ namespace/oil-staging"
7272
echo "User" >&3
7373
run kubectl --kubeconfig=${HACK_DIR}/alice.kubeconfig get namespace default
7474
[ $status -eq 1 ]
75-
[ "${lines[0]}" = 'Error from server (Forbidden): namespaces "default" is forbidden: User "alice" cannot get resource "namespaces" in API group "" in the namespace "default"' ]
75+
[ "${lines[0]}" = 'Error from server (NotFound): namespace "default" not found' ]
7676
run kubectl --kubeconfig=${HACK_DIR}/alice.kubeconfig --namespace default get pods
7777
[ $status -eq 1 ]
7878
[ "${lines[0]}" = 'Error from server (Forbidden): pods is forbidden: User "alice" cannot list resource "pods" in API group "" in the namespace "default"' ]

e2e/run.bash

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ HACK_DIR="$(git rev-parse --show-toplevel)/hack"
77
export HACK_DIR
88

99
echo ">>> Waiting for capsule-proxy pod to be ready for accepting requests"
10-
kubectl --namespace capsule-system wait --for=condition=ready --timeout=320s pod -l app.kubernetes.io/instance=capsule-proxy
10+
kubectl --namespace capsule-system wait --for=condition=ready --timeout=320s pod -l app.kubernetes.io/name=capsule-proxy
1111

1212
echo ">>> Waiting for capsule pod to be ready for accepting requests"
1313
kubectl --namespace capsule-system wait --for=condition=ready --timeout=320s pod -l app.kubernetes.io/instance=capsule

internal/request/error.go

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Copyright 2020-2021 Clastix Labs
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package request
5+
6+
type ErrUnauthorized struct {
7+
message string
8+
}
9+
10+
func NewErrUnauthorized(message string) *ErrUnauthorized {
11+
return &ErrUnauthorized{
12+
message: message,
13+
}
14+
}
15+
16+
func (e *ErrUnauthorized) Error() string {
17+
return e.message
18+
}

internal/request/http.go

+66-7
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import (
1111

1212
"github.com/golang-jwt/jwt"
1313
authenticationv1 "k8s.io/api/authentication/v1"
14+
authorizationv1 "k8s.io/api/authorization/v1"
15+
"k8s.io/apimachinery/pkg/util/sets"
1416
"sigs.k8s.io/controller-runtime/pkg/client"
1517
)
1618

@@ -36,28 +38,85 @@ func (h http) GetHTTPRequest() *h.Request {
3638
return h.Request
3739
}
3840

41+
//nolint:funlen
3942
func (h http) GetUserAndGroups() (username string, groups []string, err error) {
4043
switch h.getAuthType() {
4144
case certificateBased:
4245
pc := h.TLS.PeerCertificates
4346
if len(pc) == 0 {
44-
err = fmt.Errorf("no provided peer certificates")
45-
46-
return
47+
return "", nil, fmt.Errorf("no provided peer certificates")
4748
}
4849

4950
username, groups = pc[0].Subject.CommonName, pc[0].Subject.Organization
5051
case bearerBased:
5152
if h.isJwtToken() {
52-
return h.processJwtClaims()
53+
username, groups, err = h.processJwtClaims()
54+
55+
break
5356
}
5457

55-
return h.processBearerToken()
58+
username, groups, err = h.processBearerToken()
5659
case anonymousBased:
57-
err = fmt.Errorf("capsule does not support unauthenticated users")
60+
return "", nil, fmt.Errorf("capsule does not support unauthenticated users")
61+
}
62+
// In case of error, we're blocking the request flow here
63+
if err != nil {
64+
return "", nil, err
65+
}
66+
// In case the requester is asking for impersonation, we have to be sure that's allowed by creating a
67+
// SubjectAccessReview with the requested data, before proceeding.
68+
if impersonateUser := h.Request.Header.Get("Impersonate-User"); len(impersonateUser) > 0 {
69+
ac := &authorizationv1.SubjectAccessReview{
70+
Spec: authorizationv1.SubjectAccessReviewSpec{
71+
ResourceAttributes: &authorizationv1.ResourceAttributes{
72+
Verb: "impersonate",
73+
Resource: "users",
74+
Name: impersonateUser,
75+
},
76+
User: username,
77+
Groups: groups,
78+
},
79+
}
80+
if err = h.client.Create(h.Request.Context(), ac); err != nil {
81+
return "", nil, err
82+
}
83+
84+
if !ac.Status.Allowed {
85+
return "", nil, NewErrUnauthorized(fmt.Sprintf("the current user %s cannot impersonate the user %s", username, impersonateUser))
86+
}
87+
// The current user is allowed to perform authentication, allowing the override
88+
username = impersonateUser
89+
}
90+
91+
if impersonateGroups := h.Request.Header.Values("Impersonate-Group"); len(impersonateGroups) > 0 {
92+
for _, impersonateGroup := range impersonateGroups {
93+
ac := &authorizationv1.SubjectAccessReview{
94+
Spec: authorizationv1.SubjectAccessReviewSpec{
95+
ResourceAttributes: &authorizationv1.ResourceAttributes{
96+
Verb: "impersonate",
97+
Resource: "groups",
98+
Name: impersonateGroup,
99+
},
100+
User: username,
101+
Groups: groups,
102+
},
103+
}
104+
if err = h.client.Create(h.Request.Context(), ac); err != nil {
105+
return "", nil, err
106+
}
107+
108+
if !ac.Status.Allowed {
109+
return "", nil, NewErrUnauthorized(fmt.Sprintf("the current user %s cannot impersonate the group %s", username, impersonateGroup))
110+
}
111+
112+
if !sets.NewString(groups...).Has(impersonateGroup) {
113+
// The current user is allowed to perform authentication, allowing the override
114+
groups = append(groups, impersonateGroup)
115+
}
116+
}
58117
}
59118

60-
return
119+
return username, groups, nil
61120
}
62121

63122
func (h http) processJwtClaims() (username string, groups []string, err error) {

internal/webserver/webserver.go

+15-4
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ import (
4242
"github.com/clastix/capsule-proxy/internal/options"
4343
req "github.com/clastix/capsule-proxy/internal/request"
4444
"github.com/clastix/capsule-proxy/internal/tenant"
45-
serverr "github.com/clastix/capsule-proxy/internal/webserver/errors"
45+
server "github.com/clastix/capsule-proxy/internal/webserver/errors"
4646
"github.com/clastix/capsule-proxy/internal/webserver/middleware"
4747
)
4848

@@ -143,6 +143,10 @@ func (n kubeFilter) reverseProxyMiddleware(next http.Handler) http.Handler {
143143

144144
// nolint:interfacer
145145
func (n kubeFilter) handleRequest(request *http.Request, selector labels.Selector) {
146+
// Sanitizing the impersonation
147+
request.Header.Del("Impersonate-User")
148+
request.Header.Del("Impersonate-Group")
149+
146150
q := request.URL.Query()
147151
if e := q.Get("labelSelector"); len(e) > 0 {
148152
n.log.V(4).Info("handling current labelSelector", "selector", e)
@@ -174,7 +178,14 @@ func (n kubeFilter) impersonateHandler(writer http.ResponseWriter, request *http
174178
var err error
175179

176180
if username, groups, err = hr.GetUserAndGroups(); err != nil {
177-
serverr.HandleError(writer, err, "cannot retrieve user and group")
181+
msg := "cannot retrieve user and group"
182+
183+
var t *req.ErrUnauthorized
184+
if errors.As(err, &t) {
185+
server.HandleUnauthorized(writer, err, msg)
186+
} else {
187+
server.HandleError(writer, err, msg)
188+
}
178189
}
179190

180191
n.log.V(4).Info("impersonating for the current request", "username", username, "groups", groups)
@@ -231,7 +242,7 @@ func (n kubeFilter) registerModules(ctx context.Context, root *mux.Router) {
231242
username, groups, _ := proxyRequest.GetUserAndGroups()
232243
proxyTenants, err := n.getTenantsForOwner(ctx, username, groups)
233244
if err != nil {
234-
serverr.HandleError(writer, err, "cannot list Tenant resources")
245+
server.HandleError(writer, err, "cannot list Tenant resources")
235246
}
236247

237248
var selector labels.Selector
@@ -245,7 +256,7 @@ func (n kubeFilter) registerModules(ctx context.Context, root *mux.Router) {
245256
_, _ = writer.Write(b)
246257
panic(err.Error())
247258
}
248-
serverr.HandleError(writer, err, err.Error())
259+
server.HandleError(writer, err, err.Error())
249260
case selector == nil:
250261
// if there's no selector, let it pass to the
251262
n.impersonateHandler(writer, request)

0 commit comments

Comments
 (0)