Skip to content

Commit

Permalink
feat: add Public-Viewer support (#443)
Browse files Browse the repository at this point in the history
This commit adds the notion of Public-Viewer to the Proxy as described
in [JIRA ASC-532](https://issues.redhat.com/browse/ASC-532).

---------

Signed-off-by: Francesco Ilario <[email protected]>
Co-authored-by: Alexey Kazakov <[email protected]>
Co-authored-by: Francisc Munteanu <[email protected]>
  • Loading branch information
3 people authored Sep 26, 2024
1 parent c828088 commit 283ead3
Show file tree
Hide file tree
Showing 4 changed files with 816 additions and 75 deletions.
244 changes: 216 additions & 28 deletions pkg/proxy/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"github.com/codeready-toolchain/registration-service/pkg/proxy/access"
"github.com/codeready-toolchain/registration-service/pkg/proxy/handlers"
"github.com/codeready-toolchain/registration-service/pkg/proxy/metrics"
"github.com/codeready-toolchain/registration-service/pkg/signup"
commoncluster "github.com/codeready-toolchain/toolchain-common/pkg/cluster"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
Expand Down Expand Up @@ -119,6 +120,7 @@ func (p *Proxy) StartProxy(port string) *http.Server {
}
},
p.ensureUserIsNotBanned(),
p.addPublicViewerContext(),
)

// middleware after routing
Expand All @@ -142,6 +144,7 @@ func (p *Proxy) StartProxy(port string) *http.Server {
// Space lister routes
wg.GET("/:workspace", handlers.HandleSpaceGetRequest(p.spaceLister, p.getMembersFunc))
wg.GET("", handlers.HandleSpaceListRequest(p.spaceLister))

router.GET(proxyHealthEndpoint, p.health)
// SSO routes. Used by web login (oc login -w).
// Here is the expected flow for the "oc login -w" command:
Expand Down Expand Up @@ -269,47 +272,211 @@ func (p *Proxy) health(ctx echo.Context) error {
}

func (p *Proxy) processRequest(ctx echo.Context) (string, *access.ClusterAccess, error) {
// retrieve required information from the HTTP request
userID, _ := ctx.Get(context.SubKey).(string)
username, _ := ctx.Get(context.UsernameKey).(string)
proxyPluginName, workspaceName, err := getWorkspaceContext(ctx.Request())
if err != nil {
return "", nil, crterrors.NewBadRequest("unable to get workspace context", err.Error())
}

ctx.Set(context.WorkspaceKey, workspaceName) // set workspace context for logging
cluster, err := p.app.MemberClusterService().GetClusterAccess(userID, username, workspaceName, proxyPluginName, false)
if err != nil {
return "", nil, crterrors.NewInternalError(errs.New("unable to get target cluster"), err.Error())
}
// set workspace context for logging
ctx.Set(context.WorkspaceKey, workspaceName)

// before proxying the request, verify that the user has a spacebinding for the workspace and that the namespace (if any) belongs to the workspace
var workspaces []toolchainv1alpha1.Workspace
if workspaceName != "" {
// when a workspace name was provided
// validate that the user has access to the workspace by getting all spacebindings recursively, starting from this workspace and going up to the parent workspaces till the "root" of the workspace tree.
workspace, err := handlers.GetUserWorkspace(ctx, p.spaceLister, workspaceName)
// if the target workspace is NOT explicitly declared in the HTTP request,
// process the request against the user's home workspace
if workspaceName == "" {
cluster, err := p.processHomeWorkspaceRequest(ctx, userID, username, proxyPluginName)
if err != nil {
return "", nil, crterrors.NewInternalError(errs.New("unable to retrieve user workspaces"), err.Error())
}
if workspace == nil {
// not found
return "", nil, crterrors.NewForbiddenError("invalid workspace request", fmt.Sprintf("access to workspace '%s' is forbidden", workspaceName))
}
// workspace was found means we can forward the request
workspaces = []toolchainv1alpha1.Workspace{*workspace}
} else {
// list all workspaces
workspaces, err = handlers.ListUserWorkspaces(ctx, p.spaceLister)
if err != nil {
return "", nil, crterrors.NewInternalError(errs.New("unable to retrieve user workspaces"), err.Error())
return "", nil, err
}
return proxyPluginName, cluster, nil
}

// if the target workspace is explicitly declared in the HTTP request,
// process the request against the declared workspace
cluster, err := p.processWorkspaceRequest(ctx, userID, username, workspaceName, proxyPluginName)
if err != nil {
return "", nil, err
}
return proxyPluginName, cluster, nil
}

// processHomeWorkspaceRequest process an HTTP Request targeting the user's home workspace.
func (p *Proxy) processHomeWorkspaceRequest(ctx echo.Context, userID, username, proxyPluginName string) (*access.ClusterAccess, error) {
// retrieves the ClusterAccess for the user and their home workspace
cluster, err := p.app.MemberClusterService().GetClusterAccess(userID, username, "", proxyPluginName, false)
if err != nil {
return nil, crterrors.NewInternalError(errs.New("unable to get target cluster"), err.Error())
}

// list all workspaces the user has access to
workspaces, err := handlers.ListUserWorkspaces(ctx, p.spaceLister)
if err != nil {
return nil, crterrors.NewInternalError(errs.New("unable to retrieve user workspaces"), err.Error())
}

// check whether the user has access to the home workspace
// and whether the requestedNamespace -if any- exists in the workspace.
requestedNamespace := namespaceFromCtx(ctx)
if err := validateWorkspaceRequest(workspaceName, requestedNamespace, workspaces); err != nil {
return "", nil, crterrors.NewForbiddenError("invalid workspace request", err.Error())
if err := validateWorkspaceRequest("", requestedNamespace, workspaces...); err != nil {
return nil, crterrors.NewForbiddenError("invalid workspace request", err.Error())
}

return proxyPluginName, cluster, nil
// return the cluster access
return cluster, nil
}

// processWorkspaceRequest process an HTTP Request targeting a specific workspace.
func (p *Proxy) processWorkspaceRequest(ctx echo.Context, userID, username, workspaceName, proxyPluginName string) (*access.ClusterAccess, error) {
// check that the user is provisioned and the space exists.
// if the PublicViewer support is enabled, user check is skipped.
if err := p.checkUserIsProvisionedAndSpaceExists(ctx, userID, username, workspaceName); err != nil {
return nil, err
}

// retrieve the requested Workspace with SpaceBindings
workspace, err := p.getUserWorkspaceWithBindings(ctx, workspaceName)
if err != nil {
return nil, err
}

// check whether the user has access to the workspace
// and whether the requestedNamespace -if any- exists in the workspace.
requestedNamespace := namespaceFromCtx(ctx)
if err := validateWorkspaceRequest(workspaceName, requestedNamespace, *workspace); err != nil {
return nil, crterrors.NewForbiddenError("invalid workspace request", err.Error())
}

// retrieve the ClusterAccess for the user and the target workspace
return p.getClusterAccess(ctx, userID, username, proxyPluginName, workspace)
}

// checkUserIsProvisionedAndSpaceExists checks that the user is provisioned and the Space exists.
// If the PublicViewer support is enabled, User check is skipped.
func (p *Proxy) checkUserIsProvisionedAndSpaceExists(ctx echo.Context, userID, username, workspaceName string) error {
if err := p.checkUserIsProvisioned(ctx, userID, username); err != nil {
return crterrors.NewInternalError(errs.New("unable to get target cluster"), err.Error())
}
if err := p.checkSpaceExists(workspaceName); err != nil {
return crterrors.NewInternalError(errs.New("unable to get target cluster"), err.Error())
}
return nil
}

// checkSpaceExists checks whether the Space exists.
func (p *Proxy) checkSpaceExists(workspaceName string) error {
if _, err := p.app.InformerService().GetSpace(workspaceName); err != nil {
// log the actual error but do not return it so that it doesn't reveal information about a space that may not belong to the requestor
log.Errorf(nil, err, "requested space '%s' does not exist", workspaceName)
return fmt.Errorf("access to workspace '%s' is forbidden", workspaceName)
}
return nil
}

// checkUserIsProvisioned checks whether the user is Approved, if they are not an error is returned.
// If public-viewer is enabled, user validation is skipped.
func (p *Proxy) checkUserIsProvisioned(ctx echo.Context, userID, username string) error {
// skip if public-viewer is enabled: read-only operations on community workspaces are always permitted.
if context.IsPublicViewerEnabled(ctx) {
return nil
}

// retrieve the UserSignup for the requesting user.
//
// UserSignup complete status is not checked, since it might cause the proxy blocking the request
// and returning an error when quick transitions from ready to provisioning are happening.
userSignup, err := p.app.SignupService().GetSignupFromInformer(nil, userID, username, false)
if err != nil {
return err
}

// if the UserSignup is nil or has NOT the CompliantUsername set,
// it means that MUR was NOT created and useraccount is NOT provisioned yet
if userSignup == nil || userSignup.CompliantUsername == "" {
cause := errs.New("user is not provisioned (yet)")
log.Error(nil, cause, fmt.Sprintf("signup object: %+v", userSignup))
return cause
}
return nil
}

// getClusterAccess retrieves the access to the cluster hosting the requested workspace,
// if the user has access to it.
// Access can be either direct (a SpaceBinding linking the user to the workspace exists)
// or community (a SpaceBinding linking the PublicViewer user to the workspace exists).
func (p *Proxy) getClusterAccess(ctx echo.Context, userID, username, proxyPluginName string, workspace *toolchainv1alpha1.Workspace) (*access.ClusterAccess, error) {
// retrieve cluster access as requesting user or PublicViewer
cluster, err := p.getClusterAccessAsUserOrPublicViewer(ctx, userID, username, proxyPluginName, workspace)
if err != nil {
return nil, crterrors.NewInternalError(errs.New("unable to get target cluster"), err.Error())
}
return cluster, nil
}

// getClusterAccessAsUserOrPublicViewer if the requesting user exists and has direct access to the workspace,
// this function returns the ClusterAccess impersonating the requesting user.
// If PublicViewer support is enabled and PublicViewer user has access to the workspace,
// this function returns the ClusterAccess impersonating the PublicViewer user.
// If requesting user does not exists and PublicViewer is disabled or does not have access to the workspace,
// this function returns an error.
func (p *Proxy) getClusterAccessAsUserOrPublicViewer(ctx echo.Context, userID, username, proxyPluginName string, workspace *toolchainv1alpha1.Workspace) (*access.ClusterAccess, error) {
// retrieve the requesting user's UserSignup
userSignup, err := p.app.SignupService().GetSignupFromInformer(nil, userID, username, false)
if err != nil {
log.Error(nil, err, fmt.Sprintf("error retrieving user signup for userID '%s' and username '%s'", userID, username))
return nil, crterrors.NewInternalError(errs.New("unable to get user info"), "error retrieving user")
}

// proceed as PublicViewer if the feature is enabled and userSignup is nil
publicViewerEnabled := context.IsPublicViewerEnabled(ctx)
if publicViewerEnabled && !userHasDirectAccess(userSignup, workspace) {
return p.app.MemberClusterService().GetClusterAccess(
toolchainv1alpha1.KubesawAuthenticatedUsername,
toolchainv1alpha1.KubesawAuthenticatedUsername,
workspace.Name,
proxyPluginName,
publicViewerEnabled)
}

// otherwise retrieve the ClusterAccess for the cluster hosting the workspace and the given user.
return p.app.MemberClusterService().GetClusterAccess(userID, username, workspace.Name, proxyPluginName, publicViewerEnabled)
}

// userHasDirectAccess checks if an UserSignup has access to a workspace.
// Workspace's bindings are obtained from its `status.bindings` property.
func userHasDirectAccess(signup *signup.Signup, workspace *toolchainv1alpha1.Workspace) bool {
if signup == nil {
return false
}

return userHasBinding(signup.CompliantUsername, workspace)
}

func userHasBinding(username string, workspace *toolchainv1alpha1.Workspace) bool {
for _, b := range workspace.Status.Bindings {
if b.MasterUserRecord == username {
return true
}
}
return false

}

// getUserWorkspaceWithBindings retrieves the workspace with the SpaceBindings if the requesting user has access to it.
// User access to the Workspace is checked by getting all spacebindings recursively,
// starting from this workspace and going up to the parent workspaces till the "root" of the workspace tree.
func (p *Proxy) getUserWorkspaceWithBindings(ctx echo.Context, workspaceName string) (*toolchainv1alpha1.Workspace, error) {
workspace, err := handlers.GetUserWorkspaceWithBindings(ctx, p.spaceLister, workspaceName, p.getMembersFunc)
if err != nil {
return nil, crterrors.NewInternalError(errs.New("unable to retrieve user workspaces"), err.Error())
}
if workspace == nil {
// not found
return nil, crterrors.NewForbiddenError("invalid workspace request", fmt.Sprintf("access to workspace '%s' is forbidden", workspaceName))
}
// workspace was found means we can forward the request
return workspace, nil
}

func (p *Proxy) handleRequestAndRedirect(ctx echo.Context) error {
Expand Down Expand Up @@ -409,6 +576,18 @@ func (p *Proxy) addUserContext() echo.MiddlewareFunc {
}
}

// addPublicViewerContext updates echo.Context with the configuration's PublicViewerEnabled value.
func (p *Proxy) addPublicViewerContext() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error {
publicViewerEnabled := configuration.GetRegistrationServiceConfig().PublicViewerEnabled()
ctx.Set(context.PublicViewerEnabled, publicViewerEnabled)

return next(ctx)
}
}
}

// ensureUserIsNotBanned rejects the request if the user is banned.
// This Middleware requires the context to contain the email of the user,
// so it needs to be executed after the `addUserContext` Middleware.
Expand Down Expand Up @@ -503,11 +682,17 @@ func extractUserToken(req *http.Request) (string, error) {
func (p *Proxy) newReverseProxy(ctx echo.Context, target *access.ClusterAccess, isPlugin bool) *httputil.ReverseProxy {
req := ctx.Request()
targetQuery := target.APIURL().RawQuery
username, _ := ctx.Get(context.UsernameKey).(string)
// set username in context for logging purposes
ctx.Set(context.ImpersonateUser, target.Username())

director := func(req *http.Request) {
origin := req.URL.String()
req.URL.Scheme = target.APIURL().Scheme
req.URL.Host = target.APIURL().Host
req.URL.Path = singleJoiningSlash(target.APIURL().Path, req.URL.Path)
req.Header.Set("X-SSO-User", username)

if isPlugin {
// for non k8s clients testing, like vanilla http clients accessing plugin proxy flows, testing has proven that the request
// host needs to be updated in addition to the URL in order to have the reverse proxy contact the openshift
Expand Down Expand Up @@ -671,7 +856,10 @@ func replaceTokenInWebsocketRequest(req *http.Request, newToken string) {
req.Header.Set(ph, strings.Join(protocols, ","))
}

func validateWorkspaceRequest(requestedWorkspace, requestedNamespace string, workspaces []toolchainv1alpha1.Workspace) error {
// validateWorkspaceRequest checks whether the requested workspace is in the list of workspaces the user has visibility on (retrieved via the spaceLister).
// If `requestedWorkspace` is zero, this function looks for the home workspace (the one with `status.Type` set to `home`).
// If `requestedNamespace` is NOT zero, this function checks if the namespace exists in the workspace.
func validateWorkspaceRequest(requestedWorkspace, requestedNamespace string, workspaces ...toolchainv1alpha1.Workspace) error {
// check workspace access
isHomeWSRequested := requestedWorkspace == ""

Expand Down
Loading

0 comments on commit 283ead3

Please sign in to comment.