diff --git a/docs/user/README.md b/docs/user/README.md index 395253b344d23337acec02633974aa10f431c9e8..fb838b6130957a4eb8e4bcde8c58d173c2c60dca 100644 --- a/docs/user/README.md +++ b/docs/user/README.md @@ -10,5 +10,21 @@ * [Integrations](integrations.md) * [Labels](labels.md) +## User Impersonation + +Impersonation uses a reverse proxy to inject a user's identifying information (username, groups and extra scopes) as headers in each request to the API server. The Dashboard can pass these headers to the API server if your reverse proxy will inject them in the requests. + +![Impersonation Architecture](images/dashboard-impersonation.png "Impersonation Architecture") + +Impersonation is useful in situations where using a user's token isn't available, such as cloud-hosted Kubernetes services. To use impersonation a reverse proxy must: + +1. Have a Kubernetes service account that [has RBAC permissions to impersonate other users](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#user-impersonation) +2. Generate the `Impersonate-User` header with a unique name identifying the user +3. *Optional* Generate the `Impersonate-Group` header(s) with the impersonated user's group data +4. *Optional* Generate the `Impersonate-Extra` header(s) with additional authorization data + +Impersonation will only work when the reverse proxy provides the `Authorization` header with a valid service account. It will not work with any other method of authenticating to the dashboard. + + ---- _Copyright 2019 [The Kubernetes Dashboard Authors](https://github.com/kubernetes/dashboard/graphs/contributors)_ diff --git a/docs/user/images/dashboard-impersonation.png b/docs/user/images/dashboard-impersonation.png new file mode 100644 index 0000000000000000000000000000000000000000..4addd58fa729b81c7ddb1a2c378f8e80bde34d5d Binary files /dev/null and b/docs/user/images/dashboard-impersonation.png differ diff --git a/i18n/messages.fr.xlf b/i18n/messages.fr.xlf index 7d28b8a0bf1950368c5af4b36a581b6e397a6c03..f6706bfbab59c55f5f421799f9d5aa2cab1d3258 100644 --- a/i18n/messages.fr.xlf +++ b/i18n/messages.fr.xlf @@ -2597,7 +2597,7 @@ Compte de service par défaut ../src/app/frontend/chrome/userpanel/template.html - 26 + 27 @@ -2606,7 +2606,7 @@ Connexion ../src/app/frontend/chrome/userpanel/template.html - 34 + 36 @@ -2615,7 +2615,7 @@ Déconnexion ../src/app/frontend/chrome/userpanel/template.html - 39 + 41 diff --git a/i18n/messages.ja.xlf b/i18n/messages.ja.xlf index 2c1d71e650a9d12384c323ffe2b8c7bac14c836e..2db6b8f3867f7c9c8e24c214eecbad8e32a15ac4 100644 --- a/i18n/messages.ja.xlf +++ b/i18n/messages.ja.xlf @@ -2423,7 +2423,7 @@ デフォルトのサービスアカウント ../src/app/frontend/chrome/userpanel/template.html - 26 + 27 @@ -2432,7 +2432,7 @@ サインイン ../src/app/frontend/chrome/userpanel/template.html - 34 + 36 @@ -2441,7 +2441,7 @@ サインアウト ../src/app/frontend/chrome/userpanel/template.html - 39 + 41 diff --git a/i18n/messages.xlf b/i18n/messages.xlf index cf52c027fe5c69535207ce707c241aa35e511042..b3d2c41121446e4be3f2151acc909a5b4abbcfea 100644 --- a/i18n/messages.xlf +++ b/i18n/messages.xlf @@ -2224,7 +2224,7 @@ Default service account ../src/app/frontend/chrome/userpanel/template.html - 26 + 27 @@ -2232,7 +2232,7 @@ ../src/app/frontend/chrome/userpanel/template.html - 34 + 36 @@ -2240,7 +2240,7 @@ ../src/app/frontend/chrome/userpanel/template.html - 39 + 41 diff --git a/src/app/backend/client/manager.go b/src/app/backend/client/manager.go index 4bc5889596dc44e4c14db5835ddf43d25371dfb7..3be2266ed4ceddfc80262f466248dbc3cf2e6532 100644 --- a/src/app/backend/client/manager.go +++ b/src/app/backend/client/manager.go @@ -50,6 +50,8 @@ const ( JWETokenHeader = "jweToken" // Default http header for user-agent DefaultUserAgent = "dashboard" + //Impersonation Extra header + ImpersonateUserExtraHeader = "Impersonate-Extra-" ) // VERSION of this binary @@ -311,12 +313,37 @@ func (self *clientManager) buildCmdConfig(authInfo *api.AuthInfo, cfg *rest.Conf // Extracts authorization information from the request header func (self *clientManager) extractAuthInfo(req *restful.Request) (*api.AuthInfo, error) { authHeader := req.HeaderParameter("Authorization") + impersonationHeader := req.HeaderParameter("Impersonate-User") jweToken := req.HeaderParameter(JWETokenHeader) // Authorization header will be more important than our token token := self.extractTokenFromHeader(authHeader) if len(token) > 0 { - return &api.AuthInfo{Token: token}, nil + + authInfo := &api.AuthInfo{Token: token} + + if len(impersonationHeader) > 0 { + //there's an impersonation header, lets make sure to add it + authInfo.Impersonate = impersonationHeader + + //Check for impersonated groups + if groupsImpersonationHeader := req.Request.Header["Impersonate-Group"]; len(groupsImpersonationHeader) > 0 { + authInfo.ImpersonateGroups = groupsImpersonationHeader + } + + //check for extra fields + for headerName, headerValues := range req.Request.Header { + if strings.HasPrefix(headerName, ImpersonateUserExtraHeader) { + extraName := headerName[len(ImpersonateUserExtraHeader):] + if authInfo.ImpersonateUserExtra == nil { + authInfo.ImpersonateUserExtra = make(map[string][]string) + } + authInfo.ImpersonateUserExtra[extraName] = headerValues + } + } + } + + return authInfo, nil } if self.tokenManager != nil && len(jweToken) > 0 { diff --git a/src/app/backend/client/manager_test.go b/src/app/backend/client/manager_test.go index 03f00322d212b2628593858fb2c3a984079f6e19..1aa12b5da1f3a9e0cfb67744e274e956fc0f5a9b 100644 --- a/src/app/backend/client/manager_test.go +++ b/src/app/backend/client/manager_test.go @@ -305,3 +305,289 @@ func TestClientManager_InsecureAPIExtensionsClient(t *testing.T) { t.Fatalf("InsecureClient(): Expected insecure client not to be nil") } } + +func TestImpersonationUserClient(t *testing.T) { + args.GetHolderBuilder().SetEnableSkipLogin(true) + cases := []struct { + request *restful.Request + expected string + expectedImpersonationUser string + }{ + { + &restful.Request{ + Request: &http.Request{ + Header: http.Header(map[string][]string{ + "Authorization": {"Bearer test-token"}, + "Impersonate-User": {"impersonatedUser"}, + }), + TLS: &tls.ConnectionState{}, + }, + }, + "test-token", + "impersonatedUser", + }, + } + + for _, c := range cases { + manager := NewClientManager("", "https://localhost:8080") + cfg, err := manager.Config(c.request) + //authInfo := manager.extractAuthInfo(c.request) + if err != nil { + t.Fatalf("Config(%v): Expected config to be created but error was thrown:"+ + " %s", + c.request, err.Error()) + } + + if cfg.BearerToken != c.expected { + t.Fatalf("Config(%v): Expected token to be %s but got %s", + c.request, c.expected, cfg.BearerToken) + } + + if cfg.Impersonate.UserName != c.expectedImpersonationUser { + t.Fatalf("Config(%v): Expected impersonated user to be %s but got %s", + c.request, c.expectedImpersonationUser, cfg.Impersonate.UserName) + } + + } +} + +func TestNoImpersonationUserWithNoBearerClient(t *testing.T) { + args.GetHolderBuilder().SetEnableSkipLogin(true) + cases := []struct { + request *restful.Request + }{ + { + &restful.Request{ + Request: &http.Request{ + Header: http.Header(map[string][]string{}), + TLS: &tls.ConnectionState{}, + }, + }, + }, + } + + for _, c := range cases { + manager := NewClientManager("", "https://localhost:8080") + cfg, err := manager.Config(c.request) + //authInfo := manager.extractAuthInfo(c.request) + if err != nil { + t.Fatalf("Config(%v): Expected config to be created but error was thrown:"+ + " %s", + c.request, err.Error()) + } + + if len(cfg.BearerToken) > 0 { + t.Fatalf("Config(%v): Expected no token but got %s", + c.request, cfg.BearerToken) + } + + if len(cfg.Impersonate.UserName) > 0 { + t.Fatalf("Config(%v): Expected no impersonated user but got %s", + c.request, cfg.Impersonate.UserName) + } + + } +} + +func TestImpersonationOneGroupClient(t *testing.T) { + args.GetHolderBuilder().SetEnableSkipLogin(true) + cases := []struct { + request *restful.Request + expected string + expectedImpersonationUser string + expectedImpersonationGroups []string + }{ + { + &restful.Request{ + Request: &http.Request{ + Header: http.Header(map[string][]string{ + "Authorization": {"Bearer test-token"}, + "Impersonate-User": {"impersonatedUser"}, + "Impersonate-Group": {"group1"}, + }), + TLS: &tls.ConnectionState{}, + }, + }, + "test-token", + "impersonatedUser", + []string{"group1"}, + }, + } + + for _, c := range cases { + manager := NewClientManager("", "https://localhost:8080") + cfg, err := manager.Config(c.request) + //authInfo := manager.extractAuthInfo(c.request) + if err != nil { + t.Fatalf("Config(%v): Expected config to be created but error was thrown:"+ + " %s", + c.request, err.Error()) + } + + if cfg.BearerToken != c.expected { + t.Fatalf("Config(%v): Expected token to be %s but got %s", + c.request, c.expected, cfg.BearerToken) + } + + if cfg.Impersonate.UserName != c.expectedImpersonationUser { + t.Fatalf("Config(%v): Expected impersonated user to be %s but got %s", + c.request, c.expectedImpersonationUser, cfg.Impersonate.UserName) + } + + if len(cfg.Impersonate.Groups) != 1 { + t.Fatalf("Config(%v): Expected one impersonated group but got %d", + c.request, len(cfg.Impersonate.Groups)) + } + + if cfg.Impersonate.Groups[0] != c.expectedImpersonationGroups[0] { + t.Fatalf("Config(%v): Expected impersonated group to be %s but got %s", + c.request, cfg.Impersonate.Groups[0], c.expectedImpersonationGroups[0]) + } + } +} + +func TestImpersonationTwoGroupClient(t *testing.T) { + args.GetHolderBuilder().SetEnableSkipLogin(true) + cases := []struct { + request *restful.Request + expected string + expectedImpersonationUser string + expectedImpersonationGroups []string + }{ + { + &restful.Request{ + Request: &http.Request{ + Header: http.Header(map[string][]string{ + "Authorization": {"Bearer test-token"}, + "Impersonate-User": {"impersonatedUser"}, + "Impersonate-Group": {"group1", "groups2"}, + }), + TLS: &tls.ConnectionState{}, + }, + }, + "test-token", + "impersonatedUser", + []string{"group1", "groups2"}, + }, + } + + for _, c := range cases { + manager := NewClientManager("", "https://localhost:8080") + cfg, err := manager.Config(c.request) + //authInfo := manager.extractAuthInfo(c.request) + if err != nil { + t.Fatalf("Config(%v): Expected config to be created but error was thrown:"+ + " %s", + c.request, err.Error()) + } + + if cfg.BearerToken != c.expected { + t.Fatalf("Config(%v): Expected token to be %s but got %s", + c.request, c.expected, cfg.BearerToken) + } + + if cfg.Impersonate.UserName != c.expectedImpersonationUser { + t.Fatalf("Config(%v): Expected impersonated user to be %s but got %s", + c.request, c.expectedImpersonationUser, cfg.Impersonate.UserName) + } + + if len(cfg.Impersonate.Groups) != 2 { + t.Fatalf("Config(%v): Expected two impersonated group but got %d", + c.request, len(cfg.Impersonate.Groups)) + } + + if cfg.Impersonate.Groups[0] != c.expectedImpersonationGroups[0] { + t.Fatalf("Config(%v): Expected impersonated group to be %s but got %s", + c.request, cfg.Impersonate.Groups[0], c.expectedImpersonationGroups[0]) + } + + if cfg.Impersonate.Groups[1] != c.expectedImpersonationGroups[1] { + t.Fatalf("Config(%v): Expected impersonated group to be %s but got %s", + c.request, cfg.Impersonate.Groups[1], c.expectedImpersonationGroups[1]) + } + } +} + +func TestImpersonationExtrasClient(t *testing.T) { + args.GetHolderBuilder().SetEnableSkipLogin(true) + cases := []struct { + request *restful.Request + expected string + expectedImpersonationUser string + expectedImpersonationExtra map[string][]string + }{ + { + &restful.Request{ + Request: &http.Request{ + Header: http.Header(map[string][]string{ + "Authorization": {"Bearer test-token"}, + "Impersonate-User": {"impersonatedUser"}, + "Impersonate-Extra-scope": {"views", "writes"}, + "Impersonate-Extra-service": {"iguess"}, + }), + TLS: &tls.ConnectionState{}, + }, + }, + "test-token", + "impersonatedUser", + map[string][]string{"scope": {"views", "writes"}, + "service": {"iguess"}}, + }, + } + + for _, c := range cases { + manager := NewClientManager("", "https://localhost:8080") + cfg, err := manager.Config(c.request) + //authInfo := manager.extractAuthInfo(c.request) + if err != nil { + t.Fatalf("Config(%v): Expected config to be created but error was thrown:"+ + " %s", + c.request, err.Error()) + } + + if cfg.BearerToken != c.expected { + t.Fatalf("Config(%v): Expected token to be %s but got %s", + c.request, c.expected, cfg.BearerToken) + } + + if cfg.Impersonate.UserName != c.expectedImpersonationUser { + t.Fatalf("Config(%v): Expected impersonated user to be %s but got %s", + c.request, c.expectedImpersonationUser, cfg.Impersonate.UserName) + } + + if len(cfg.Impersonate.Extra) != 2 { + t.Fatalf("Config(%v): Expected two impersonated extra but got %d", + c.request, len(cfg.Impersonate.Extra)) + } + + if cfg.Impersonate.Extra["service"][0] != c.expectedImpersonationExtra["service"][0] { + t.Fatalf("Config(%v): Expected service extra to be %s but got %s", + c.request, cfg.Impersonate.Extra["service"][0], c.expectedImpersonationExtra["service"][0]) + + } + + //check multi value scope + + if len(cfg.Impersonate.Extra["scope"]) != 2 { + t.Fatalf("Config(%v): Expected two scope impersonated extra but got %d", + c.request, len(cfg.Impersonate.Extra["scope"])) + } + + if cfg.Impersonate.Extra["scope"][0] != c.expectedImpersonationExtra["scope"][0] { + t.Fatalf("Config(%v): Expected scope extra to be %s but got %s", + c.request, c.expectedImpersonationExtra["scope"][0], cfg.Impersonate.Extra["scope"][0]) + + } + + if cfg.Impersonate.Extra["scope"][1] != c.expectedImpersonationExtra["scope"][1] { + t.Fatalf("Config(%v): Expected scope extra to be %s but got %s", + c.request, c.expectedImpersonationExtra["scope"][1], cfg.Impersonate.Extra["scope"][1]) + + } + + if len(cfg.Impersonate.Extra["scope"]) != 2 { + t.Fatalf("Config(%v): Expected two scope impersonated extra but got %d", + c.request, len(cfg.Impersonate.Extra["scope"])) + } + } +} diff --git a/src/app/backend/validation/validateloginstatus.go b/src/app/backend/validation/validateloginstatus.go index 3919483377eeb52d3312d4d7a87974dad4c7280a..2aecded1910e6d977b0c453da4b2667a371031a9 100644 --- a/src/app/backend/validation/validateloginstatus.go +++ b/src/app/backend/validation/validateloginstatus.go @@ -30,21 +30,34 @@ type LoginStatus struct { // True if dashboard is configured to use HTTPS connection. It is required for secure // data exchange during login operation. HTTPSMode bool `json:"httpsMode"` + // True if impersonation is enabled + ImpersonationPresent bool `json:"impersonationPresent"` + + // The impersonated user + ImpersonatedUser string `json:"impersonatedUser"` } // ValidateLoginStatus returns information about user login status and if request was made over HTTPS. func ValidateLoginStatus(request *restful.Request) *LoginStatus { authHeader := request.HeaderParameter("Authorization") tokenHeader := request.HeaderParameter(client.JWETokenHeader) + impersonationHeader := request.HeaderParameter("Impersonate-User") httpsMode := request.Request.TLS != nil if args.Holder.GetEnableInsecureLogin() { httpsMode = true } - return &LoginStatus{ - TokenPresent: len(tokenHeader) > 0, - HeaderPresent: len(authHeader) > 0, - HTTPSMode: httpsMode, + loginStatus := &LoginStatus{ + TokenPresent: len(tokenHeader) > 0, + HeaderPresent: len(authHeader) > 0, + ImpersonationPresent: len(impersonationHeader) > 0, + HTTPSMode: httpsMode, + } + + if loginStatus.ImpersonationPresent { + loginStatus.ImpersonatedUser = impersonationHeader } + + return loginStatus } diff --git a/src/app/frontend/chrome/userpanel/template.html b/src/app/frontend/chrome/userpanel/template.html index fbdb25c60b1b9c3a5fd719b8faa115b9e2f805c8..de0f38942d24f58cd6e708b82f82d9f6a91d789d 100644 --- a/src/app/frontend/chrome/userpanel/template.html +++ b/src/app/frontend/chrome/userpanel/template.html @@ -18,13 +18,15 @@ limitations under the License.
- Logged in with auth header Logged in with token + {{loginStatus.impersonatedUser}} Default service account +