[WIP] API refactor (#1737)

* refactor openpitrix API

Signed-off-by: hongming <talonwan@yunify.com>

* add openpitrix mock client

Signed-off-by: hongming <talonwan@yunify.com>

* refactor tenant API

Signed-off-by: hongming <talonwan@yunify.com>

* refactor IAM API

Signed-off-by: hongming <talonwan@yunify.com>

* refactor IAM API

Signed-off-by: hongming <talonwan@yunify.com>
This commit is contained in:
hongming
2020-01-13 13:36:21 +08:00
committed by zryfish
parent c40d1542a2
commit 71849f028f
66 changed files with 5415 additions and 4366 deletions

View File

@@ -1,18 +0,0 @@
/*
Copyright 2019 The KubeSphere authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package iam contains iam API versions
package iam

View File

@@ -1,33 +0,0 @@
/*
Copyright 2019 The KubeSphere Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package install
import (
"github.com/emicklei/go-restful"
urlruntime "k8s.io/apimachinery/pkg/util/runtime"
"kubesphere.io/kubesphere/pkg/apiserver/runtime"
"kubesphere.io/kubesphere/pkg/kapis/iam/v1alpha2"
)
func init() {
Install(runtime.Container)
}
func Install(container *restful.Container) {
urlruntime.Must(v1alpha2.AddToContainer(container))
}

View File

@@ -1 +1,327 @@
package v1alpha2
import (
"errors"
"github.com/dgrijalva/jwt-go"
"github.com/emicklei/go-restful"
"github.com/go-ldap/ldap"
rbacv1 "k8s.io/api/rbac/v1"
k8serr "k8s.io/apimachinery/pkg/api/errors"
"kubesphere.io/kubesphere/pkg/api"
iamv1alpha2 "kubesphere.io/kubesphere/pkg/api/iam/v1alpha2"
"kubesphere.io/kubesphere/pkg/models/iam"
"kubesphere.io/kubesphere/pkg/models/iam/policy"
kserr "kubesphere.io/kubesphere/pkg/server/errors"
"kubesphere.io/kubesphere/pkg/server/params"
"kubesphere.io/kubesphere/pkg/utils/iputil"
"kubesphere.io/kubesphere/pkg/utils/jwtutil"
"net/http"
"sort"
)
type iamHandler struct {
amOperator iam.AccessManagementInterface
imOperator iam.IdentityManagementInterface
}
func newIAMHandler() *iamHandler {
return &iamHandler{}
}
// k8s token review
func (h *iamHandler) TokenReviewHandler(req *restful.Request, resp *restful.Response) {
var tokenReview iamv1alpha2.TokenReview
err := req.ReadEntity(&tokenReview)
if err != nil {
api.HandleBadRequest(resp, err)
return
}
if tokenReview.Spec == nil {
api.HandleBadRequest(resp, errors.New("token must not be null"))
return
}
uToken := tokenReview.Spec.Token
token, err := jwtutil.ValidateToken(uToken)
if err != nil {
failed := iamv1alpha2.TokenReview{APIVersion: tokenReview.APIVersion,
Kind: iam.KindTokenReview,
Status: &iamv1alpha2.Status{
Authenticated: false,
},
}
resp.WriteEntity(failed)
return
}
claims := token.Claims.(jwt.MapClaims)
username, ok := claims["username"].(string)
if !ok {
api.HandleBadRequest(resp, errors.New("username not found"))
return
}
user, err := h.imOperator.DescribeUser(username)
if err != nil {
api.HandleInternalError(resp, err)
return
}
success := iamv1alpha2.TokenReview{APIVersion: tokenReview.APIVersion,
Kind: iam.KindTokenReview,
Status: &iamv1alpha2.Status{
Authenticated: true,
User: map[string]interface{}{"username": user.Username, "uid": user.Username, "groups": user.Groups},
},
}
resp.WriteEntity(success)
}
func (h *iamHandler) Login(req *restful.Request, resp *restful.Response) {
var loginRequest iamv1alpha2.LoginRequest
err := req.ReadEntity(&loginRequest)
if err != nil || loginRequest.Username == "" || loginRequest.Password == "" {
resp.WriteHeaderAndEntity(http.StatusUnauthorized, errors.New("incorrect username or password"))
return
}
ip := iputil.RemoteIp(req.Request)
token, err := h.imOperator.Login(loginRequest.Username, loginRequest.Password, ip)
if err != nil {
if serviceError, ok := err.(restful.ServiceError); ok {
resp.WriteHeaderAndEntity(serviceError.Code, errors.New(serviceError.Message))
return
}
resp.WriteHeaderAndEntity(http.StatusUnauthorized, err)
return
}
resp.WriteEntity(token)
}
func (h *iamHandler) CreateUser(req *restful.Request, resp *restful.Response) {
var createRequest iamv1alpha2.UserCreateRequest
err := req.ReadEntity(&createRequest)
if err != nil {
api.HandleBadRequest(resp, err)
return
}
if err := createRequest.Validate(); err != nil {
api.HandleBadRequest(resp, err)
return
}
created, err := h.imOperator.CreateUser(createRequest.User)
if err != nil {
if ldap.IsErrorWithCode(err, ldap.LDAPResultEntryAlreadyExists) {
resp.WriteHeaderAndEntity(http.StatusConflict, kserr.Wrap(err))
return
}
api.HandleInternalError(resp, err)
return
}
err := h.amOperator.CreateClusterRoleBinding(created.Username, createRequest.ClusterRole)
if err != nil {
api.HandleInternalError(resp, err)
return
}
resp.WriteEntity(created)
}
func (h *iamHandler) ListRoleUsers(req *restful.Request, resp *restful.Response) {
role := req.PathParameter("role")
namespace := req.PathParameter("namespace")
roleBindings, err := h.amOperator.ListRoleBindings(namespace, role)
if err != nil {
api.HandleInternalError(resp, err)
return
}
result := make([]*iam.User, 0)
for _, roleBinding := range roleBindings {
for _, subject := range roleBinding.Subjects {
if subject.Kind == rbacv1.UserKind {
user, err := h.imOperator.GetUserInfo(subject.Name)
// skip if user not exist
if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) {
continue
}
if err != nil {
api.HandleInternalError(resp, err)
return
}
result = append(result, user)
}
}
}
resp.WriteEntity(result)
}
func (h *iamHandler) ListClusterRoles(req *restful.Request, resp *restful.Response) {
conditions, err := params.ParseConditions(req.QueryParameter(params.ConditionsParam))
orderBy := req.QueryParameter(params.OrderByParam)
limit, offset := params.ParsePaging(req.QueryParameter(params.PagingParam))
reverse := params.ParseReverse(req)
if err != nil {
resp.WriteHeaderAndEntity(http.StatusBadRequest, errors.Wrap(err))
return
}
result, err := iam.ListClusterRoles(conditions, orderBy, reverse, limit, offset)
if err != nil {
resp.WriteHeaderAndEntity(http.StatusInternalServerError, errors.Wrap(err))
return
}
resp.WriteAsJson(result)
}
func (h *iamHandler) ListRoles(req *restful.Request, resp *restful.Response) {
namespace := req.PathParameter("namespace")
conditions, err := params.ParseConditions(req.QueryParameter(params.ConditionsParam))
orderBy := req.QueryParameter(params.OrderByParam)
limit, offset := params.ParsePaging(req.QueryParameter(params.PagingParam))
reverse := params.ParseReverse(req)
if err != nil {
resp.WriteHeaderAndEntity(http.StatusBadRequest, errors.Wrap(err))
return
}
result, err := iam.ListRoles(namespace, conditions, orderBy, reverse, limit, offset)
if err != nil {
resp.WriteHeaderAndEntity(http.StatusInternalServerError, errors.Wrap(err))
return
}
resp.WriteAsJson(result)
}
// List users by namespace
func (h *iamHandler) ListNamespaceUsers(req *restful.Request, resp *restful.Response) {
namespace := req.PathParameter("namespace")
users, err := iam.NamespaceUsers(namespace)
if err != nil {
resp.WriteHeaderAndEntity(http.StatusInternalServerError, errors.Wrap(err))
return
}
// sort by time by default
sort.Slice(users, func(i, j int) bool {
return users[i].RoleBindTime.After(*users[j].RoleBindTime)
})
resp.WriteAsJson(users)
}
func (h *iamHandler) ListUserRoles(req *restful.Request, resp *restful.Response) {
username := req.PathParameter("user")
roles, err := iam.GetUserRoles("", username)
if err != nil {
resp.WriteHeaderAndEntity(http.StatusInternalServerError, errors.Wrap(err))
return
}
_, clusterRoles, err := iam.GetUserClusterRoles(username)
if err != nil {
resp.WriteHeaderAndEntity(http.StatusInternalServerError, errors.Wrap(err))
return
}
roleList := RoleList{}
roleList.Roles = roles
roleList.ClusterRoles = clusterRoles
resp.WriteAsJson(roleList)
}
func (h *iamHandler) RulesMapping(req *restful.Request, resp *restful.Response) {
rules := policy.RoleRuleMapping
resp.WriteAsJson(rules)
}
func (h *iamHandler) ClusterRulesMapping(req *restful.Request, resp *restful.Response) {
rules := policy.ClusterRoleRuleMapping
resp.WriteAsJson(rules)
}
func (h *iamHandler) ListClusterRoleRules(req *restful.Request, resp *restful.Response) {
clusterRoleName := req.PathParameter("clusterrole")
rules, err := iam.GetClusterRoleSimpleRules(clusterRoleName)
if err != nil {
resp.WriteError(http.StatusInternalServerError, err)
return
}
resp.WriteAsJson(rules)
}
func (h *iamHandler) ListClusterRoleUsers(req *restful.Request, resp *restful.Response) {
clusterRoleName := req.PathParameter("clusterrole")
conditions, err := params.ParseConditions(req.QueryParameter(params.ConditionsParam))
orderBy := req.QueryParameter(params.OrderByParam)
limit, offset := params.ParsePaging(req.QueryParameter(params.PagingParam))
reverse := params.ParseReverse(req)
if err != nil {
resp.WriteHeaderAndEntity(http.StatusBadRequest, errors.Wrap(err))
return
}
result, err := iam.ListClusterRoleUsers(clusterRoleName, conditions, orderBy, reverse, limit, offset)
if err != nil {
if k8serr.IsNotFound(err) {
resp.WriteError(http.StatusNotFound, err)
} else {
resp.WriteError(http.StatusInternalServerError, err)
}
return
}
resp.WriteAsJson(result)
}
func (h *iamHandler) ListRoleRules(req *restful.Request, resp *restful.Response) {
namespaceName := req.PathParameter("namespace")
roleName := req.PathParameter("role")
rules, err := iam.GetRoleSimpleRules(namespaceName, roleName)
if err != nil {
resp.WriteHeaderAndEntity(http.StatusInternalServerError, errors.Wrap(err))
return
}
resp.WriteAsJson(rules)
}

View File

@@ -22,10 +22,13 @@ import (
"github.com/emicklei/go-restful-openapi"
rbacv1 "k8s.io/api/rbac/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"kubesphere.io/kubesphere/pkg/api"
iamv1alpha2 "kubesphere.io/kubesphere/pkg/api/iam/v1alpha2"
"kubesphere.io/kubesphere/pkg/apiserver/iam"
"kubesphere.io/kubesphere/pkg/apiserver/runtime"
"kubesphere.io/kubesphere/pkg/constants"
"kubesphere.io/kubesphere/pkg/models"
iam2 "kubesphere.io/kubesphere/pkg/models/iam"
"kubesphere.io/kubesphere/pkg/models/iam/policy"
"kubesphere.io/kubesphere/pkg/server/errors"
"net/http"
@@ -118,174 +121,169 @@ type DescribeWorkspaceUserResponse struct {
func addWebService(c *restful.Container) error {
ws := runtime.NewWebService(GroupVersion)
ok := "ok"
handler := newIAMHandler()
ws.Route(ws.POST("/authenticate").
To(iam.TokenReviewHandler).
To(handler.TokenReviewHandler).
Doc("TokenReview attempts to authenticate a token to a known user. Note: TokenReview requests may be cached by the webhook token authenticator plugin in the kube-apiserver.").
Reads(iam.TokenReview{}).
Returns(http.StatusOK, ok, iam.TokenReview{}).
Reads(iamv1alpha2.TokenReview{}).
Returns(http.StatusOK, api.StatusOK, iamv1alpha2.TokenReview{}).
Metadata(restfulspec.KeyOpenAPITags, []string{constants.IdentityManagementTag}))
ws.Route(ws.POST("/login").
To(iam.Login).
To(handler.Login).
Doc("KubeSphere APIs support token-based authentication via the Authtoken request header. The POST Login API is used to retrieve the authentication token. After the authentication token is obtained, it must be inserted into the Authtoken header for all requests.").
Reads(iam.LoginRequest{}).
Returns(http.StatusOK, ok, models.AuthGrantResponse{}).
Reads(iamv1alpha2.LoginRequest{}).
Returns(http.StatusOK, api.StatusOK, models.AuthGrantResponse{}).
Metadata(restfulspec.KeyOpenAPITags, []string{constants.IdentityManagementTag}))
ws.Route(ws.POST("/token").
To(iam.OAuth).
Doc("OAuth API,only support resource owner password credentials grant").
Reads(iam.LoginRequest{}).
Returns(http.StatusOK, ok, models.AuthGrantResponse{}).
ws.Route(ws.POST("/users").
To(handler.CreateUser).
Doc("Create a user account.").
Reads(CreateUserRequest{}).
Returns(http.StatusOK, api.StatusOK, errors.Error{}).
Metadata(restfulspec.KeyOpenAPITags, []string{constants.IdentityManagementTag}))
ws.Route(ws.GET("/users/{user}").
To(iam.DescribeUser).
Doc("Describe the specified user.").
Param(ws.PathParameter("user", "username")).
Returns(http.StatusOK, ok, models.User{}).
Metadata(restfulspec.KeyOpenAPITags, []string{constants.IdentityManagementTag}))
ws.Route(ws.POST("/users").
To(iam.CreateUser).
Doc("Create a user account.").
Reads(CreateUserRequest{}).
Returns(http.StatusOK, ok, errors.Error{}).
Returns(http.StatusOK, api.StatusOK, iam2.User{}).
Metadata(restfulspec.KeyOpenAPITags, []string{constants.IdentityManagementTag}))
ws.Route(ws.DELETE("/users/{user}").
To(iam.DeleteUser).
Doc("Delete the specified user.").
Param(ws.PathParameter("user", "username")).
Returns(http.StatusOK, ok, errors.Error{}).
Returns(http.StatusOK, api.StatusOK, errors.Error{}).
Metadata(restfulspec.KeyOpenAPITags, []string{constants.IdentityManagementTag}))
ws.Route(ws.PUT("/users/{user}").
To(iam.UpdateUser).
Doc("Update information about the specified user.").
Param(ws.PathParameter("user", "username")).
Reads(UserUpdateRequest{}).
Returns(http.StatusOK, ok, errors.Error{}).
Returns(http.StatusOK, api.StatusOK, errors.Error{}).
Metadata(restfulspec.KeyOpenAPITags, []string{constants.IdentityManagementTag}))
ws.Route(ws.GET("/users/{user}/logs").
To(iam.UserLoginLogs).
Doc("Retrieve the \"login logs\" for the specified user.").
Param(ws.PathParameter("user", "username")).
Returns(http.StatusOK, ok, LoginLog{}).
Returns(http.StatusOK, api.StatusOK, LoginLog{}).
Metadata(restfulspec.KeyOpenAPITags, []string{constants.IdentityManagementTag}))
ws.Route(ws.GET("/users").
To(iam.ListUsers).
Doc("List all users.").
Returns(http.StatusOK, ok, UserList{}).
Returns(http.StatusOK, api.StatusOK, UserList{}).
Metadata(restfulspec.KeyOpenAPITags, []string{constants.IdentityManagementTag}))
ws.Route(ws.GET("/users/{user}/roles").
To(iam.ListUserRoles).
Doc("Retrieve all the roles that are assigned to the specified user.").
Param(ws.PathParameter("user", "username")).
Returns(http.StatusOK, ok, iam.RoleList{}).
Returns(http.StatusOK, api.StatusOK, iam.RoleList{}).
Metadata(restfulspec.KeyOpenAPITags, []string{constants.AccessManagementTag}))
ws.Route(ws.GET("/namespaces/{namespace}/roles").
To(iam.ListRoles).
Doc("Retrieve the roles that are assigned to the user in the specified namespace.").
Param(ws.PathParameter("namespace", "kubernetes namespace")).
Returns(http.StatusOK, ok, RoleList{}).
Returns(http.StatusOK, api.StatusOK, RoleList{}).
Metadata(restfulspec.KeyOpenAPITags, []string{constants.AccessManagementTag}))
ws.Route(ws.GET("/clusterroles").
To(iam.ListClusterRoles).
Doc("List all cluster roles.").
Returns(http.StatusOK, ok, ClusterRoleList{}).
Returns(http.StatusOK, api.StatusOK, ClusterRoleList{}).
Metadata(restfulspec.KeyOpenAPITags, []string{constants.AccessManagementTag}))
ws.Route(ws.GET("/namespaces/{namespace}/roles/{role}/users").
To(iam.ListRoleUsers).
To(handler.ListRoleUsers).
Doc("Retrieve the users that are bound to the role in the specified namespace.").
Param(ws.PathParameter("namespace", "kubernetes namespace")).
Param(ws.PathParameter("role", "role name")).
Returns(http.StatusOK, ok, []NamespacedUser{}).
Returns(http.StatusOK, api.StatusOK, []NamespacedUser{}).
Metadata(restfulspec.KeyOpenAPITags, []string{constants.AccessManagementTag}))
ws.Route(ws.GET("/namespaces/{namespace}/users").
To(iam.ListNamespaceUsers).
Doc("List all users in the specified namespace.").
Param(ws.PathParameter("namespace", "kubernetes namespace")).
Returns(http.StatusOK, ok, []NamespacedUser{}).
Returns(http.StatusOK, api.StatusOK, []NamespacedUser{}).
Metadata(restfulspec.KeyOpenAPITags, []string{constants.AccessManagementTag}))
ws.Route(ws.GET("/clusterroles/{clusterrole}/users").
To(iam.ListClusterRoleUsers).
Doc("List all users that are bound to the specified cluster role.").
Param(ws.PathParameter("clusterrole", "cluster role name")).
Returns(http.StatusOK, ok, UserList{}).
Returns(http.StatusOK, api.StatusOK, UserList{}).
Metadata(restfulspec.KeyOpenAPITags, []string{constants.AccessManagementTag}))
ws.Route(ws.GET("/clusterroles/{clusterrole}/rules").
To(iam.ListClusterRoleRules).
Doc("List all policy rules of the specified cluster role.").
Param(ws.PathParameter("clusterrole", "cluster role name")).
Returns(http.StatusOK, ok, []models.SimpleRule{}).
Returns(http.StatusOK, api.StatusOK, []iam2.SimpleRule{}).
Metadata(restfulspec.KeyOpenAPITags, []string{constants.AccessManagementTag}))
ws.Route(ws.GET("/namespaces/{namespace}/roles/{role}/rules").
To(iam.ListRoleRules).
Doc("List all policy rules of the specified role in the given namespace.").
Param(ws.PathParameter("namespace", "kubernetes namespace")).
Param(ws.PathParameter("role", "role name")).
Returns(http.StatusOK, ok, []models.SimpleRule{}).
Returns(http.StatusOK, api.StatusOK, []iam2.SimpleRule{}).
Metadata(restfulspec.KeyOpenAPITags, []string{constants.AccessManagementTag}))
ws.Route(ws.GET("/devops/{devops}/roles/{role}/rules").
To(iam.ListDevopsRoleRules).
Doc("List all policy rules of the specified role in the given devops project.").
Param(ws.PathParameter("devops", "devops project ID")).
Param(ws.PathParameter("role", "devops role name")).
Returns(http.StatusOK, ok, []models.SimpleRule{}).
Returns(http.StatusOK, api.StatusOK, []iam2.SimpleRule{}).
Metadata(restfulspec.KeyOpenAPITags, []string{constants.AccessManagementTag}))
ws.Route(ws.GET("/rulesmapping/clusterroles").
To(iam.ClusterRulesMapping).
Doc("Get the mapping relationships between cluster roles and policy rules.").
Returns(http.StatusOK, ok, policy.ClusterRoleRuleMapping).
Returns(http.StatusOK, api.StatusOK, policy.ClusterRoleRuleMapping).
Metadata(restfulspec.KeyOpenAPITags, []string{constants.AccessManagementTag}))
ws.Route(ws.GET("/rulesmapping/roles").
To(iam.RulesMapping).
Doc("Get the mapping relationships between namespaced roles and policy rules.").
Returns(http.StatusOK, ok, policy.RoleRuleMapping).
Returns(http.StatusOK, api.StatusOK, policy.RoleRuleMapping).
Metadata(restfulspec.KeyOpenAPITags, []string{constants.AccessManagementTag}))
ws.Route(ws.GET("/workspaces/{workspace}/roles").
To(iam.ListWorkspaceRoles).
Doc("List all workspace roles.").
Param(ws.PathParameter("workspace", "workspace name")).
Returns(http.StatusOK, ok, ClusterRoleList{}).
Returns(http.StatusOK, api.StatusOK, ClusterRoleList{}).
Metadata(restfulspec.KeyOpenAPITags, []string{constants.AccessManagementTag}))
ws.Route(ws.GET("/workspaces/{workspace}/roles/{role}").
To(iam.DescribeWorkspaceRole).
Doc("Describe the workspace role.").
Param(ws.PathParameter("workspace", "workspace name")).
Param(ws.PathParameter("role", "workspace role name")).
Returns(http.StatusOK, ok, rbacv1.ClusterRole{}).
Returns(http.StatusOK, api.StatusOK, rbacv1.ClusterRole{}).
Metadata(restfulspec.KeyOpenAPITags, []string{constants.AccessManagementTag}))
ws.Route(ws.GET("/workspaces/{workspace}/roles/{role}/rules").
To(iam.ListWorkspaceRoleRules).
Doc("List all policy rules of the specified workspace role.").
Param(ws.PathParameter("workspace", "workspace name")).
Param(ws.PathParameter("role", "workspace role name")).
Returns(http.StatusOK, ok, []models.SimpleRule{}).
Returns(http.StatusOK, api.StatusOK, []iam2.SimpleRule{}).
Metadata(restfulspec.KeyOpenAPITags, []string{constants.AccessManagementTag}))
ws.Route(ws.GET("/workspaces/{workspace}/members").
To(iam.ListWorkspaceUsers).
Doc("List all members in the specified workspace.").
Param(ws.PathParameter("workspace", "workspace name")).
Returns(http.StatusOK, ok, UserList{}).
Returns(http.StatusOK, api.StatusOK, UserList{}).
Metadata(restfulspec.KeyOpenAPITags, []string{constants.AccessManagementTag}))
ws.Route(ws.POST("/workspaces/{workspace}/members").
To(iam.InviteUser).
Doc("Invite a member to the specified workspace.").
Param(ws.PathParameter("workspace", "workspace name")).
Reads(InviteUserRequest{}).
Returns(http.StatusOK, ok, errors.Error{}).
Returns(http.StatusOK, api.StatusOK, errors.Error{}).
Metadata(restfulspec.KeyOpenAPITags, []string{constants.AccessManagementTag}))
ws.Route(ws.DELETE("/workspaces/{workspace}/members/{member}").
To(iam.RemoveUser).
Doc("Remove the specified member from the workspace.").
Param(ws.PathParameter("workspace", "workspace name")).
Param(ws.PathParameter("member", "username")).
Returns(http.StatusOK, ok, errors.Error{}).
Returns(http.StatusOK, api.StatusOK, errors.Error{}).
Metadata(restfulspec.KeyOpenAPITags, []string{constants.AccessManagementTag}))
ws.Route(ws.GET("/workspaces/{workspace}/members/{member}").
To(iam.DescribeWorkspaceUser).
Doc("Describe the specified user in the given workspace.").
Param(ws.PathParameter("workspace", "workspace name")).
Param(ws.PathParameter("member", "username")).
Returns(http.StatusOK, ok, DescribeWorkspaceUserResponse{}).
Returns(http.StatusOK, api.StatusOK, DescribeWorkspaceUserResponse{}).
Metadata(restfulspec.KeyOpenAPITags, []string{constants.AccessManagementTag}))
c.Add(ws)
return nil