From 12f0792c2e407f5cf9cab76c61b9af0a1f27042c Mon Sep 17 00:00:00 2001 From: Xavier Coulon Date: Fri, 9 Mar 2018 17:31:01 +0100 Subject: [PATCH] Detect if the user to init on Tenant already has a project on OSO (OSIO#1446) Add function to list projects on OSO for the user, given his/her OSO token. Check that there's no project, or a single project named after the user. Include option in User service to support custom HTTP transport. Rename vars in controller.Tenant to avoid collision with packages (`user` and `cluster`). Add test on the controller.TenantController.Setup() method using `go-vcr`. Add a function in `openshift` pkg to retrieve the name of the user's projects (and factorize the code to perform the GET request on the Openshift API endpoint). Also: use encoded token in the go-vcr recording for TestResolveServiceAccountToken Fixes openshiftio/openshift.io#1446 Signed-off-by: Xavier Coulon --- auth/auth_client.go | 3 +- cluster/resolve.go | 3 + cluster/service_test.go | 7 +- controller/tenant.go | 106 ++++--- controller/tenant_test.go | 166 +++++++++++ controller/tenants_test.go | 10 +- design/tenant.go | 2 +- jsonapi/jsonapi_utility.go | 12 +- openshift/api.go | 40 +++ openshift/list_projects.go | 39 +++ openshift/list_projects_test.go | 74 +++++ openshift/whoami.go | 26 +- openshift/whoami_test.go | 7 +- test/data/controller/setup_tenant.yaml | 282 ++++++++++++++++++ test/data/openshift/list_projects.yaml | 117 ++++++++ .../data/token/auth_resolve_target_token.yaml | 6 +- test/jwt_token.go | 8 +- test/recorder/recorder.go | 36 ++- token/decode.go | 11 +- token/service_test.go | 32 +- user/user_service.go | 16 +- 21 files changed, 893 insertions(+), 110 deletions(-) create mode 100644 controller/tenant_test.go create mode 100644 openshift/api.go create mode 100644 openshift/list_projects.go create mode 100644 openshift/list_projects_test.go create mode 100644 test/data/controller/setup_tenant.yaml create mode 100644 test/data/openshift/list_projects.yaml diff --git a/auth/auth_client.go b/auth/auth_client.go index 38e9d9f..b03ae94 100644 --- a/auth/auth_client.go +++ b/auth/auth_client.go @@ -8,7 +8,6 @@ import ( authclient "github.com/fabric8-services/fabric8-tenant/auth/client" "github.com/fabric8-services/fabric8-tenant/configuration" - "github.com/fabric8-services/fabric8-wit/log" goaclient "github.com/goadesign/goa/client" ) @@ -33,7 +32,7 @@ func NewClient(authURL, token string, options ...configuration.HTTPClientOption) }) client.Host = u.Host client.Scheme = u.Scheme - log.Debug(nil, map[string]interface{}{"host": client.Host, "scheme": client.Scheme}, "initializing auth client") + // log.Debug(nil, map[string]interface{}{"host": client.Host, "scheme": client.Scheme}, "initializing auth client") return client, nil } diff --git a/cluster/resolve.go b/cluster/resolve.go index 2058d12..3572bcb 100644 --- a/cluster/resolve.go +++ b/cluster/resolve.go @@ -3,6 +3,8 @@ package cluster import ( "context" "fmt" + + "github.com/fabric8-services/fabric8-wit/log" ) // Resolve a func to resolve a cluster @@ -12,6 +14,7 @@ type Resolve func(ctx context.Context, target string) (*Cluster, error) func NewResolve(clusters []*Cluster) Resolve { return func(ctx context.Context, target string) (*Cluster, error) { for _, cluster := range clusters { + log.Debug(nil, map[string]interface{}{"target_url": cleanURL(target), "cluster_url": cleanURL(cluster.APIURL)}, "comparing URLs...") if cleanURL(target) == cleanURL(cluster.APIURL) { return cluster, nil } diff --git a/cluster/service_test.go b/cluster/service_test.go index 2d9d765..21c51fa 100644 --- a/cluster/service_test.go +++ b/cluster/service_test.go @@ -79,7 +79,12 @@ func TestResolveCluster(t *testing.T) { defer r.Stop() authURL := "http://authservice" resolveToken := token.NewResolve(authURL, configuration.WithRoundTripper(r.Transport)) - saToken, err := testsupport.NewToken("tenant_service", "../test/private_key.pem") + saToken, err := testsupport.NewToken( + map[string]interface{}{ + "sub": "tenant_service", + }, + "../test/private_key.pem", + ) require.NoError(t, err) t.Run("ok", func(t *testing.T) { diff --git a/controller/tenant.go b/controller/tenant.go index bb2f4e8..3ba9065 100644 --- a/controller/tenant.go +++ b/controller/tenant.go @@ -57,50 +57,76 @@ func NewTenantController( // Setup runs the setup action. func (c *TenantController) Setup(ctx *app.SetupTenantContext) error { - userToken := goajwt.ContextJWT(ctx) - if userToken == nil { + usrToken := goajwt.ContextJWT(ctx) + if usrToken == nil { return jsonapi.JSONErrorResponse(ctx, errors.NewUnauthorizedError("Missing JWT token")) } - ttoken := &TenantToken{token: userToken} - exists := c.tenantService.Exists(ttoken.Subject()) - if exists { - return ctx.Conflict() + tenantToken := &TenantToken{token: usrToken} + if c.tenantService.Exists(tenantToken.Subject()) { + log.Error(ctx, map[string]interface{}{"tenant_id": tenantToken.Subject()}, "a tenant with the same ID already exists") + return jsonapi.JSONErrorResponse(ctx, errors.NewDataConflictError(fmt.Sprintf("a tenant with the same ID already exists: %s", tenantToken.Subject()))) } - // fetch the cluster the user belongs to - user, err := c.userService.GetUser(ctx, ttoken.Subject()) + usr, err := c.userService.GetUser(ctx, tenantToken.Subject()) if err != nil { return jsonapi.JSONErrorResponse(ctx, err) } - if user.Cluster == nil { + if usr.Cluster == nil { log.Error(ctx, nil, "no cluster defined for tenant") return jsonapi.JSONErrorResponse(ctx, errors.NewInternalError(ctx, fmt.Errorf("unable to provision to undefined cluster"))) } // fetch the users cluster token - openshiftUsername, openshiftUserToken, err := c.resolveTenant(ctx, *user.Cluster, userToken.Raw) + openshiftUsername, openshiftUserToken, err := c.resolveTenant(ctx, *usr.Cluster, usrToken.Raw) if err != nil { log.Error(ctx, map[string]interface{}{ "err": err, - "cluster_url": *user.Cluster, + "cluster_url": *usr.Cluster, }, "unable to fetch tenant token from auth") return jsonapi.JSONErrorResponse(ctx, errors.NewUnauthorizedError("Could not resolve user token")) } + log.Debug(ctx, map[string]interface{}{ + "openshift_username": openshiftUsername, + "openshift_token": openshiftUserToken}, + "resolved user on cluster") // fetch the cluster info - cluster, err := c.resolveCluster(ctx, *user.Cluster) + clustr, err := c.resolveCluster(ctx, *usr.Cluster) if err != nil { log.Error(ctx, map[string]interface{}{ "err": err, - "cluster_url": *user.Cluster, + "cluster_url": *usr.Cluster, }, "unable to fetch cluster") return jsonapi.JSONErrorResponse(ctx, errors.NewInternalError(ctx, err)) } + log.Debug(ctx, map[string]interface{}{ + "cluster_api_url": clustr.APIURL, + "user_id": tenantToken.Subject().String()}, + "resolved cluster for user") + + // check if the user already has a project on the cluster + userProjects, err := openshift.ListProjects(ctx, clustr.APIURL, openshiftUserToken) + if err != nil { + log.Error(ctx, map[string]interface{}{ + "err": err, + "cluster_url": *usr.Cluster, + }, "unable to fetch user's projects on the cluster") + return jsonapi.JSONErrorResponse(ctx, errors.NewInternalError(ctx, err)) + } + log.Debug(ctx, map[string]interface{}{"cluster_api_url": clustr.APIURL, "user_id": tenantToken.Subject().String()}, "resolved cluster for user") + if len(userProjects) == 1 && userProjects[0] != openshiftUsername { + log.Error(ctx, map[string]interface{}{ + "cluster_url": *usr.Cluster, + "openshift_user_project": userProjects[0], + "openshift_user_name": openshiftUsername, + }, "user already has a project on the cluster, with a different name") + return jsonapi.JSONErrorResponse(ctx, errors.NewDataConflictError(fmt.Sprintf("user already has a project on the cluster, with a different name: %s", userProjects[0]))) + } // create openshift config - openshiftConfig := openshift.NewConfig(c.defaultOpenshiftConfig, user, cluster.User, cluster.Token, cluster.APIURL) - tenant := &tenant.Tenant{ID: ttoken.Subject(), Email: ttoken.Email()} + openshiftConfig := openshift.NewConfig(c.defaultOpenshiftConfig, usr, clustr.User, clustr.Token, clustr.APIURL) + tenant := &tenant.Tenant{ID: tenantToken.Subject(), Email: tenantToken.Email()} err = c.tenantService.SaveTenant(tenant) if err != nil { log.Error(ctx, map[string]interface{}{ @@ -134,48 +160,48 @@ func (c *TenantController) Setup(ctx *app.SetupTenantContext) error { // Update runs the setup action. func (c *TenantController) Update(ctx *app.UpdateTenantContext) error { - userToken := goajwt.ContextJWT(ctx) - if userToken == nil { + usrToken := goajwt.ContextJWT(ctx) + if usrToken == nil { return jsonapi.JSONErrorResponse(ctx, errors.NewUnauthorizedError("Missing JWT token")) } - ttoken := &TenantToken{token: userToken} - tenant, err := c.tenantService.GetTenant(ttoken.Subject()) + tenantToken := &TenantToken{token: usrToken} + tenant, err := c.tenantService.GetTenant(tenantToken.Subject()) if err != nil { - return jsonapi.JSONErrorResponse(ctx, errors.NewNotFoundError("tenants", ttoken.Subject().String())) + return jsonapi.JSONErrorResponse(ctx, errors.NewNotFoundError("tenants", tenantToken.Subject().String())) } // fetch the cluster the user belongs to - user, err := c.userService.GetUser(ctx, ttoken.Subject()) + usr, err := c.userService.GetUser(ctx, tenantToken.Subject()) if err != nil { return jsonapi.JSONErrorResponse(ctx, err) } - if user.Cluster == nil { + if usr.Cluster == nil { log.Error(ctx, nil, "no cluster defined for tenant") return jsonapi.JSONErrorResponse(ctx, errors.NewInternalError(ctx, fmt.Errorf("unable to provision to undefined cluster"))) } // fetch the users cluster token - openshiftUsername, _, err := c.resolveTenant(ctx, *user.Cluster, userToken.Raw) + openshiftUsername, _, err := c.resolveTenant(ctx, *usr.Cluster, usrToken.Raw) if err != nil { log.Error(ctx, map[string]interface{}{ "err": err, - "cluster_url": *user.Cluster, + "cluster_url": *usr.Cluster, }, "unable to fetch tenant token from auth") return jsonapi.JSONErrorResponse(ctx, errors.NewUnauthorizedError("Could not resolve user token")) } - cluster, err := c.resolveCluster(ctx, *user.Cluster) + clustr, err := c.resolveCluster(ctx, *usr.Cluster) if err != nil { log.Error(ctx, map[string]interface{}{ "err": err, - "cluster_url": *user.Cluster, + "cluster_url": *usr.Cluster, }, "unable to fetch cluster") return jsonapi.JSONErrorResponse(ctx, errors.NewInternalError(ctx, err)) } - + log.Info(ctx, map[string]interface{}{"cluster_api_url": clustr.APIURL, "user_id": tenantToken.Subject().String()}, "resolved cluster for user") // create openshift config - openshiftConfig := openshift.NewConfig(c.defaultOpenshiftConfig, user, cluster.User, cluster.Token, cluster.APIURL) + openshiftConfig := openshift.NewConfig(c.defaultOpenshiftConfig, usr, clustr.User, clustr.Token, clustr.APIURL) go func() { ctx := ctx @@ -201,46 +227,46 @@ func (c *TenantController) Update(ctx *app.UpdateTenantContext) error { // Clean runs the setup action for the tenant namespaces. func (c *TenantController) Clean(ctx *app.CleanTenantContext) error { - userToken := goajwt.ContextJWT(ctx) - if userToken == nil { + usrToken := goajwt.ContextJWT(ctx) + if usrToken == nil { return jsonapi.JSONErrorResponse(ctx, errors.NewUnauthorizedError("Missing JWT token")) } - ttoken := &TenantToken{token: userToken} + tenantToken := &TenantToken{token: usrToken} // fetch the cluster the user belongs to - user, err := c.userService.GetUser(ctx, ttoken.Subject()) + usr, err := c.userService.GetUser(ctx, tenantToken.Subject()) if err != nil { return jsonapi.JSONErrorResponse(ctx, err) } // fetch the users cluster token - openshiftUsername, _, err := c.resolveTenant(ctx, *user.Cluster, userToken.Raw) + openshiftUsername, _, err := c.resolveTenant(ctx, *usr.Cluster, usrToken.Raw) if err != nil { log.Error(ctx, map[string]interface{}{ "err": err, - "cluster_url": *user.Cluster, + "cluster_url": *usr.Cluster, }, "unable to fetch tenant token from auth") return jsonapi.JSONErrorResponse(ctx, errors.NewUnauthorizedError("Could not resolve user token")) } - cluster, err := c.resolveCluster(ctx, *user.Cluster) + clustr, err := c.resolveCluster(ctx, *usr.Cluster) if err != nil { log.Error(ctx, map[string]interface{}{ "err": err, - "cluster_url": *user.Cluster, + "cluster_url": *usr.Cluster, }, "unable to fetch cluster") return jsonapi.JSONErrorResponse(ctx, errors.NewInternalError(ctx, err)) } // create openshift config - openshiftConfig := openshift.NewConfig(c.defaultOpenshiftConfig, user, cluster.User, cluster.Token, cluster.APIURL) + openshiftConfig := openshift.NewConfig(c.defaultOpenshiftConfig, usr, clustr.User, clustr.Token, clustr.APIURL) err = openshift.CleanTenant(ctx, openshiftConfig, openshiftUsername, c.templateVars, ctx.Remove) if err != nil { return jsonapi.JSONErrorResponse(ctx, err) } if ctx.Remove { - err = c.tenantService.DeleteAll(ttoken.Subject()) + err = c.tenantService.DeleteAll(tenantToken.Subject()) if err != nil { return jsonapi.JSONErrorResponse(ctx, err) } @@ -255,8 +281,8 @@ func (c *TenantController) Show(ctx *app.ShowTenantContext) error { return jsonapi.JSONErrorResponse(ctx, errors.NewUnauthorizedError("Missing JWT token")) } - ttoken := &TenantToken{token: token} - tenantID := ttoken.Subject() + tenantToken := &TenantToken{token: token} + tenantID := tenantToken.Subject() tenant, err := c.tenantService.GetTenant(tenantID) if err != nil { return jsonapi.JSONErrorResponse(ctx, errors.NewNotFoundError("tenants", tenantID.String())) diff --git a/controller/tenant_test.go b/controller/tenant_test.go new file mode 100644 index 0000000..83bd71f --- /dev/null +++ b/controller/tenant_test.go @@ -0,0 +1,166 @@ +package controller_test + +import ( + "context" + "net/http" + "testing" + + jwt "github.com/dgrijalva/jwt-go" + "github.com/fabric8-services/fabric8-tenant/app/test" + "github.com/fabric8-services/fabric8-tenant/cluster" + "github.com/fabric8-services/fabric8-tenant/configuration" + "github.com/fabric8-services/fabric8-tenant/controller" + "github.com/fabric8-services/fabric8-tenant/openshift" + "github.com/fabric8-services/fabric8-tenant/tenant" + testsupport "github.com/fabric8-services/fabric8-tenant/test" + "github.com/fabric8-services/fabric8-tenant/test/gormsupport" + "github.com/fabric8-services/fabric8-tenant/test/recorder" + "github.com/fabric8-services/fabric8-tenant/token" + "github.com/fabric8-services/fabric8-tenant/user" + "github.com/fabric8-services/fabric8-wit/resource" + "github.com/goadesign/goa" + goajwt "github.com/goadesign/goa/middleware/security/jwt" + "github.com/jinzhu/gorm" + errs "github.com/pkg/errors" + uuid "github.com/satori/go.uuid" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type TenantControllerTestSuite struct { + gormsupport.DBTestSuite +} + +func TestTenantController(t *testing.T) { + resource.Require(t, resource.Database) + suite.Run(t, &TenantControllerTestSuite{DBTestSuite: gormsupport.NewDBTestSuite("../config.yaml")}) +} + +func (s *TenantControllerTestSuite) TestInitTenant() { + // given + r, err := recorder.New("../test/data/controller/setup_tenant", recorder.WithJWTMatcher()) + require.NoError(s.T(), err) + defer r.Stop() + saToken, err := testsupport.NewToken( + map[string]interface{}{ + "sub": "tenant_service", + }, + "../test/private_key.pem", + ) + require.NoError(s.T(), err) + svc, ctrl, err := newTestTenantController(saToken, s.DB, r.Transport) + require.NoError(s.T(), err) + + s.T().Run("accepted", func(t *testing.T) { + + t.Run("no namespace already exists on OpenShift", func(t *testing.T) { + // given + tenantID := "83fdcae2-634f-4a52-958a-f723cb621700" // ok, well... we could probably use a random UUID and use it in a template based on "../test/data/controller/setup_tenant" to generate the actual cassette file to use with go-vcr... + ctx, err := createValidUserContext(map[string]interface{}{ + "sub": tenantID, + "email": "user_foo@bar.com", + }) + require.NoError(t, err) + // when/then + test.SetupTenantAccepted(t, ctx, svc, ctrl) + }) + + t.Run("namespace already exists on OpenShift", func(t *testing.T) { + // given + tenantID := "38b33b8b-996d-4ba4-b565-f32a526de85c" // ok, well... we could probably use a random UUID and use it in a template based on "../test/data/controller/setup_tenant" to generate the actual cassette file to use with go-vcr... + ctx, err := createValidUserContext(map[string]interface{}{ + "sub": tenantID, + "email": "user_foo2@bar.com", + }) + require.NoError(t, err) + // when/then + test.SetupTenantAccepted(t, ctx, svc, ctrl) + }) + }) + + s.T().Run("fail", func(t *testing.T) { + + t.Run("tenant already exists in DB", func(t *testing.T) { + // given a user that already exists in the tenant DB + tenantID := uuid.NewV4() + tenant.NewDBService(s.DB).SaveTenant(&tenant.Tenant{ID: tenantID}) + ctx, err := createValidUserContext(map[string]interface{}{ + "sub": tenantID.String(), + "email": "user_known@bar.com", + }) + require.NoError(t, err) + // when/then + test.SetupTenantConflict(t, ctx, svc, ctrl) + }) + + t.Run("missing token", func(t *testing.T) { + // when using default context with no JWT + test.SetupTenantUnauthorized(t, context.Background(), svc, ctrl) + }) + + t.Run("cluster not found", func(t *testing.T) { + // given + tenantID := "526ea9ac-0cf7-4e12-a835-0b76eab45517" + ctx, err := createValidUserContext(map[string]interface{}{ + "sub": tenantID, + "email": "user_unknown_cluster@bar.com", + }) + require.NoError(t, err) + // when/then + test.SetupTenantInternalServerError(t, ctx, svc, ctrl) + }) + + t.Run("namespace already exists on OpenShift", func(t *testing.T) { + // given an account that already has a namespace with a different name on OpenShift + tenantID := "02a6474c-3b04-4dc4-bfd2-4867102581e0" + ctx, err := createValidUserContext(map[string]interface{}{ + "sub": tenantID, + "email": "user_ns_exists@bar.com", + }) + require.NoError(t, err) + // when/then + test.SetupTenantConflict(t, ctx, svc, ctrl) + }) + }) + +} + +func newTestTenantController(saToken *jwt.Token, db *gorm.DB, rt http.RoundTripper) (*goa.Service, *controller.TenantController, error) { + authURL := "http://authservice" + resolveToken := token.NewResolve(authURL, configuration.WithRoundTripper(rt)) + clusterService := cluster.NewService( + authURL, + saToken.Raw, + resolveToken, + token.NewGPGDecypter("foo"), + configuration.WithRoundTripper(rt), + ) + clusters, err := clusterService.GetClusters(context.Background()) + if err != nil { + return nil, nil, errs.Wrapf(err, "unable to initialize tenant controller") + } + resolveCluster := cluster.NewResolve(clusters) + resolveTenant := func(ctx context.Context, target, userToken string) (user, accessToken string, err error) { + // log.Debug(ctx, map[string]interface{}{"user_token": userToken}, "attempting to resolve tenant for user...") + return resolveToken(ctx, target, userToken, false, token.PlainText) // no need to use "forcePull=true" to validate the user's token on the target. + } + tenantService := tenant.NewDBService(db) + userService := user.NewService( + authURL, + saToken.Raw, + configuration.WithRoundTripper(rt), + ) + defaultOpenshiftConfig := openshift.Config{} + templateVars := make(map[string]string) + svc := goa.New("Tenants-service") + ctrl := controller.NewTenantController(svc, tenantService, userService, resolveTenant, resolveCluster, defaultOpenshiftConfig, templateVars) + return svc, ctrl, nil +} + +func createValidUserContext(claims map[string]interface{}) (context.Context, error) { + tok, err := testsupport.NewToken(jwt.MapClaims(claims), "../test/private_key.pem") + if err != nil { + return nil, errs.Wrapf(err, "failed to create token") + } + return goajwt.WithJWT(context.Background(), tok), nil +} diff --git a/controller/tenants_test.go b/controller/tenants_test.go index 7a43d3a..47b851c 100644 --- a/controller/tenants_test.go +++ b/controller/tenants_test.go @@ -23,13 +23,13 @@ import ( "github.com/stretchr/testify/suite" ) -type TenantControllerTestSuite struct { +type TenantsControllerTestSuite struct { gormsupport.DBTestSuite } -func TestTenantController(t *testing.T) { +func TestTenantsController(t *testing.T) { resource.Require(t, resource.Database) - suite.Run(t, &TenantControllerTestSuite{DBTestSuite: gormsupport.NewDBTestSuite("../config.yaml")}) + suite.Run(t, &TenantsControllerTestSuite{DBTestSuite: gormsupport.NewDBTestSuite("../config.yaml")}) } var resolveCluster = func(ctx context.Context, target string) (*cluster.Cluster, error) { @@ -44,7 +44,7 @@ var resolveCluster = func(ctx context.Context, target string) (*cluster.Cluster, }, nil } -func (s *TenantControllerTestSuite) TestShowTenants() { +func (s *TenantsControllerTestSuite) TestShowTenants() { s.T().Run("OK", func(t *testing.T) { // given @@ -82,7 +82,7 @@ func (s *TenantControllerTestSuite) TestShowTenants() { }) } -func (s *TenantControllerTestSuite) TestSearchTenants() { +func (s *TenantsControllerTestSuite) TestSearchTenants() { // given svc := goa.New("Tenants-service") diff --git a/design/tenant.go b/design/tenant.go index 4ab1ed5..886b862 100644 --- a/design/tenant.go +++ b/design/tenant.go @@ -98,7 +98,7 @@ var _ = a.Resource("tenant", func() { a.Description("Initialize new tenant environment.") a.Response(d.Accepted) - a.Response(d.Conflict) + a.Response(d.Conflict, JSONAPIErrors) a.Response(d.BadRequest, JSONAPIErrors) a.Response(d.NotFound, JSONAPIErrors) a.Response(d.InternalServerError, JSONAPIErrors) diff --git a/jsonapi/jsonapi_utility.go b/jsonapi/jsonapi_utility.go index 1c40366..d599e77 100644 --- a/jsonapi/jsonapi_utility.go +++ b/jsonapi/jsonapi_utility.go @@ -3,6 +3,7 @@ package jsonapi import ( "context" "net/http" + "reflect" "strconv" "github.com/fabric8-services/fabric8-tenant/app" @@ -17,13 +18,14 @@ const ( ErrorCodeNotFound = "not_found" ErrorCodeBadParameter = "bad_parameter" ErrorCodeVersionConflict = "version_conflict" + ErrorCodeDataConflict = "data_conflict_error" + ErrorCodeProjectConflict = "project_conflict" ErrorCodeUnknownError = "unknown_error" ErrorCodeConversionError = "conversion_error" ErrorCodeInternalError = "internal_error" ErrorCodeUnauthorizedError = "unauthorized_error" ErrorCodeForbiddenError = "forbidden_error" ErrorCodeJWTSecurityError = "jwt_security_error" - ErrorCodeDataConflict = "data_conflict_error" ) // ErrorToJSONAPIError returns the JSONAPI representation @@ -95,6 +97,12 @@ func ErrorToJSONAPIError(ctx context.Context, err error) (app.JSONAPIError, int) Title: &title, Detail: detail, } + log.Debug(ctx, map[string]interface{}{ + "code": code, + "status": statusCodeStr, + "title": title, + "detail": detail, + }, "converted error to JSON Error") return jerr, statusCode } @@ -145,6 +153,7 @@ func JSONErrorResponse(obj interface{}, err error) error { c := obj.(context.Context) jsonErr, status := ErrorToJSONAPIErrors(c, err) + log.Debug(c, map[string]interface{}{"status": status, "jsonErr type": reflect.TypeOf(jsonErr)}, "processing JSON error") switch status { case http.StatusBadRequest: if ctx, ok := x.(BadRequest); ok { @@ -166,6 +175,7 @@ func JSONErrorResponse(obj interface{}, err error) error { if ctx, ok := x.(Conflict); ok { return errs.WithStack(ctx.Conflict(jsonErr)) } + log.Debug(c, nil, "CANNOT convert context to Conflict") default: return errs.WithStack(x.InternalServerError(jsonErr)) } diff --git a/openshift/api.go b/openshift/api.go new file mode 100644 index 0000000..1151379 --- /dev/null +++ b/openshift/api.go @@ -0,0 +1,40 @@ +package openshift + +import ( + "bytes" + "net/http" + + "github.com/fabric8-services/fabric8-tenant/configuration" + "github.com/pkg/errors" +) + +// executeRequest executes/submits a request to the given URL using the given HTTP method and authorization token. +// returns the response body or an error if the response status is not "200 OK" +func executeRequest(url, token string, clientOptions ...configuration.HTTPClientOption) ([]byte, error) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, errors.Wrapf(err, "unable to initialize request") + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + client := http.DefaultClient + for _, applyOption := range clientOptions { + applyOption(client) + } + resp, err := client.Do(req) + if err != nil { + return nil, errors.Wrapf(err, "unable to execute request") + } + defer resp.Body.Close() + + buf := new(bytes.Buffer) + _, err = buf.ReadFrom(resp.Body) + if err != nil { + return nil, errors.Wrapf(err, "unable to read response body") + } + body := buf.Bytes() + if resp.StatusCode != http.StatusOK { + return nil, errors.Errorf("server responded with a non-OK status: %s", string(body)) + } + return body, nil +} diff --git a/openshift/list_projects.go b/openshift/list_projects.go new file mode 100644 index 0000000..6391a58 --- /dev/null +++ b/openshift/list_projects.go @@ -0,0 +1,39 @@ +package openshift + +import ( + "context" + "fmt" + "strings" + + "github.com/fabric8-services/fabric8-tenant/configuration" + "github.com/pkg/errors" + yaml "gopkg.in/yaml.v2" +) + +// ListProjects returns the name of the projects of the user identified by the given token +func ListProjects(ctx context.Context, clusterURL string, token string, clientOptions ...configuration.HTTPClientOption) ([]string, error) { + respBody, err := executeRequest(fmt.Sprintf("%s/oapi/v1/projects", strings.TrimSuffix(clusterURL, "/")), token, clientOptions...) + if err != nil { + return nil, errors.Wrapf(err, "unable to retrieve the user's projects from the API endpoint") + } + var prjcts projects + err = yaml.Unmarshal(respBody, &prjcts) + if err != nil { + return nil, errors.Wrapf(err, "unable to retrieve the user's projects from the API endpoint") + } + prjNames := make([]string, len(prjcts.Items)) + for i, p := range prjcts.Items { + prjNames[i] = p.Metadata.Name + } + return prjNames, nil +} + +type projects struct { + Items []project +} + +type project struct { + Metadata struct { + Name string + } +} diff --git a/openshift/list_projects_test.go b/openshift/list_projects_test.go new file mode 100644 index 0000000..7336a46 --- /dev/null +++ b/openshift/list_projects_test.go @@ -0,0 +1,74 @@ +package openshift_test + +import ( + "context" + "testing" + + "github.com/fabric8-services/fabric8-tenant/configuration" + "github.com/fabric8-services/fabric8-tenant/openshift" + testsupport "github.com/fabric8-services/fabric8-tenant/test" + "github.com/fabric8-services/fabric8-tenant/test/recorder" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListProjects(t *testing.T) { + // given + clusterURL := "https://openshift.test" + r, err := recorder.New("../test/data/openshift/list_projects", recorder.WithJWTMatcher()) + require.NoError(t, err) + defer r.Stop() + + t.Run("no project", func(t *testing.T) { + // given + token, err := testsupport.NewToken( + map[string]interface{}{ + "sub": "user_no_project", + }, + "../test/private_key.pem", + ) + require.NoError(t, err) + // when + projectNames, err := openshift.ListProjects(context.Background(), clusterURL, token.Raw, configuration.WithRoundTripper(r.Transport)) + // then + require.NoError(t, err) + assert.Empty(t, projectNames) + + }) + + t.Run("single project", func(t *testing.T) { + // given + token, err := testsupport.NewToken( + map[string]interface{}{ + "sub": "user_single_project", + }, + "../test/private_key.pem", + ) + require.NoError(t, err) + // when + projectNames, err := openshift.ListProjects(context.Background(), clusterURL, token.Raw, configuration.WithRoundTripper(r.Transport)) + // then + require.NoError(t, err) + require.Len(t, projectNames, 1) + assert.Equal(t, "foo", projectNames[0]) + }) + + t.Run("multiple projects", func(t *testing.T) { + // given + token, err := testsupport.NewToken( + map[string]interface{}{ + "sub": "user_multi_projects", + }, + "../test/private_key.pem", + ) + require.NoError(t, err) + // when + projectNames, err := openshift.ListProjects(context.Background(), clusterURL, token.Raw, configuration.WithRoundTripper(r.Transport)) + // then + require.NoError(t, err) + require.Len(t, projectNames, 2) + assert.Equal(t, "foo1", projectNames[0]) + assert.Equal(t, "foo2", projectNames[1]) + + }) +} diff --git a/openshift/whoami.go b/openshift/whoami.go index 8943215..7c06908 100644 --- a/openshift/whoami.go +++ b/openshift/whoami.go @@ -1,10 +1,8 @@ package openshift import ( - "bytes" "context" "fmt" - "net/http" "strings" "github.com/fabric8-services/fabric8-tenant/configuration" @@ -15,32 +13,10 @@ import ( // WhoAmI checks with OSO who owns the current token. // returns the username func WhoAmI(ctx context.Context, clusterURL string, token string, clientOptions ...configuration.HTTPClientOption) (string, error) { - - req, err := http.NewRequest("GET", fmt.Sprintf("%s/apis/user.openshift.io/v1/users/~", strings.TrimSuffix(clusterURL, "/")), nil) - if err != nil { - return "", errors.Wrapf(err, "unable to retrieve the username from the `whoami` API endpoint") - } - req.Header.Set("Accept", "application/json") - req.Header.Set("Authorization", "Bearer "+token) - client := http.DefaultClient - for _, applyOption := range clientOptions { - applyOption(client) - } - resp, err := client.Do(req) + body, err := executeRequest(fmt.Sprintf("%s/apis/user.openshift.io/v1/users/~", strings.TrimSuffix(clusterURL, "/")), token, clientOptions...) if err != nil { return "", errors.Wrapf(err, "unable to retrieve the username from the `whoami` API endpoint") } - defer resp.Body.Close() - - buf := new(bytes.Buffer) - _, err = buf.ReadFrom(resp.Body) - if err != nil { - return "", errors.Wrapf(err, "unable to retrieve the username from the `whoami` API endpoint") - } - body := buf.Bytes() - if resp.StatusCode != http.StatusOK { - return "", errors.Errorf("unexpected response code: \n%v\n%v", resp.StatusCode, string(body)) - } var u user err = yaml.Unmarshal(body, &u) if err != nil { diff --git a/openshift/whoami_test.go b/openshift/whoami_test.go index 8aeadeb..68339ee 100644 --- a/openshift/whoami_test.go +++ b/openshift/whoami_test.go @@ -17,7 +17,12 @@ func TestWhoAmI(t *testing.T) { r, err := recorder.New("../test/data/openshift/whoami", recorder.WithJWTMatcher()) require.NoError(t, err) defer r.Stop() - tok, err := testsupport.NewToken("user_foo", "../test/private_key.pem") + tok, err := testsupport.NewToken( + map[string]interface{}{ + "sub": "user_foo", + }, + "../test/private_key.pem", + ) require.NoError(t, err) t.Run("ok", func(t *testing.T) { diff --git a/test/data/controller/setup_tenant.yaml b/test/data/controller/setup_tenant.yaml new file mode 100644 index 0000000..846957b --- /dev/null +++ b/test/data/controller/setup_tenant.yaml @@ -0,0 +1,282 @@ +--- +version: 1 +interactions: +# requests to retrieve the user from auth service, given his/her ID +- request: + method: GET + url: http://authservice/api/users/83fdcae2-634f-4a52-958a-f723cb621700 + headers: + sub: ["tenant_service"] # will be compared against the `sub` claim in the incoming request's token + response: + status: 200 OK + code: 200 + body: '{ + "data": { + "attributes": { + "username": "user_foo", + "email": "user_foo@bar.com", + "cluster": "http://api.cluster1/" + } + } + }' +- request: + method: GET + url: http://authservice/api/users/38b33b8b-996d-4ba4-b565-f32a526de85c + headers: + sub: ["tenant_service"] # will be compared against the `sub` claim in the incoming request's token + response: + status: 200 OK + code: 200 + body: '{ + "data": { + "attributes": { + "username": "user_foo2", + "email": "user_foo2@bar.com", + "cluster": "http://api.cluster1/" + } + } + }' +- request: + method: GET + url: http://authservice/api/users/526ea9ac-0cf7-4e12-a835-0b76eab45517 + headers: + sub: ["tenant_service"] # will be compared against the `sub` claim in the incoming request's token + response: + status: 200 OK + code: 200 + body: '{ + "data": { + "attributes": { + "username": "user_unknown_cluster", + "email": "user_unknown_cluster@bar.com", + "cluster": "http://api.cluster.unknown/" + } + } + }' +- request: + method: GET + url: http://authservice/api/users/02a6474c-3b04-4dc4-bfd2-4867102581e0 + headers: + sub: ["tenant_service"] # will be compared against the `sub` claim in the incoming request's token + response: + status: 200 OK + code: 200 + body: '{ + "data": { + "attributes": { + "username": "user_with_namespace", + "email": "user_with_namespace@bar.com", + "cluster": "http://api.cluster1/" + } + } + }' + +# requests to retrieve the list of clusters configured in `auth` service +- request: + method: GET + url: http://authservice/api/clusters/ + headers: + sub: ["tenant_service"] # will be compared against the `sub` claim in the incoming request's token + response: + status: 200 OK + code: 200 + body: '{ + "data":[ + { + "name": "cluster_name", + "api-url": "http://api.cluster1/", + "console-url": "http://console.cluster1/", + "metrics-url": "http://metrics.cluster1/", + "logging-url": "http://logs.cluster1/", + "app-dns": "foo" + } + ] + }' + +# requests to resolve the user's token on his/her target cluster +- request: + method: GET + url: http://authservice/api/token?for=http%3A%2F%2Fapi.cluster1%2F&force_pull=false + headers: + sub: ["tenant_service"] # will be compared against the `sub` claim in the incoming request's token + response: + status: 200 OK + code: 200 + # response with encrypted token for the "tenant service" account + body: '{ + "token_type": "bearer", + "username": "tenant_service", + "access_token": "jA0ECQMCbp6BH9LCNqNg0sDNAcVRutDsLnJvXYKwHnaQ+7K5kJEw4HOIFRLp77nfh/3CSnNBgY2gZc8d/o0gYww01C4LpUdvyPB/m/Mohp1/yQ2ErIsJDSiYWBgVA/sUPhZf3vY/JYDfg4XOMVA28C7L3ritvlzHT2IwQkZw65NPnJRNvaFA+5fSNqR5XUIKZ84kWotIpyvTwzS/4yAjVAlnu6W/hU6SRDfABPH3Tw/QJPigoRpwyLzN7hVprfXW7Kq54Qx/tmYmceeals0DYfuThKDfx7r2LHW0Yq6A5yzzkKueUaEm4sqJhGkdwf1LsOa+F+/T5MMT7DCiaX88FOkVuQ6cgmCGWbVz5x4fo9fEIy0f56WQAOZwR8clHXS6/pSuj4R5axeX67e3ijAZokiTi3eGCvVmgo2Zb/I49oXCreZQ6kUDQUvu/lwcBDxnrZeOcpftb0OiGNWvCdjwinxgRf0n3FgK+ZYcTxpdYewgOKTauCDhC64rf6hYeV72r6/416zRiaQrvatfJEXLC7mtw1qmALP64WBJOxqpkw==" + }' +- request: + method: GET + url: http://authservice/api/token?for=http%3A%2F%2Fapi.cluster1%2F&force_pull=false + headers: + sub: ["83fdcae2-634f-4a52-958a-f723cb621700"] # will be compared against the `sub` claim in the incoming request's token + response: + status: 200 OK + code: 200 + # response with plaintext token for the "user" account + body: '{ + "token_type": "bearer", + "username": "user_foo", + "access_token": "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InVzZXJfZm9vQGJhci5jb20iLCJzdWIiOiI4M2ZkY2FlMi02MzRmLTRhNTItOTU4YS1mNzIzY2I2MjE3MDAifQ.B7NvUSO3I30Ois-YUHm7a5iZfcamgwzMXUehtA5R1XTPLVn4F_QK7e0jrN0ZG4GrOrX6TFgmewL8yeSQMBTsf5iCgRSrr11F4wTzrC-MH7v1FizPrhYQ-4wmXcHKB8VtAFqPQnWA1tfHcr32MVVh9lXLD3KJJbB6TYFq8eVsMGyMYcEYWzXgJQOj1UmsTZpH6DBb5YxfM3PzNk_eYH9fKZAH_WntuYnXNGA-iix0RIC43IBPiQ4DTKyIhKq-wDzz7gDCtqpaAwhQyxdcmlty7TudXvJ_c8jahaXQ6fb_eZF-Mhd-gGXtkwjRy41BPEUnPLNXQh7vEjl1ARWami0" + }' +- request: + method: GET + url: http://authservice/api/token?for=http%3A%2F%2Fapi.cluster1%2F&force_pull=false + headers: + sub: ["38b33b8b-996d-4ba4-b565-f32a526de85c"] # will be compared against the `sub` claim in the incoming request's token + response: + status: 200 OK + code: 200 + # response with plaintext token for the "user" account + body: '{ + "token_type": "bearer", + "username": "user_foo2", + "access_token": "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InVzZXJfZm9vMkBiYXIuY29tIiwic3ViIjoiMzhiMzNiOGItOTk2ZC00YmE0LWI1NjUtZjMyYTUyNmRlODVjIn0.C2vhwU8u-4dflxCvANRYM4YhbxGkQ_VSHgRCoTqNAws-tqs9lD-IHRFNVE5EAz_4ZbPlV_rm3PyuWhUlJRFsItGBAM7Uz8nmRDQlC23QZtJ5FpS1NI_45pYpqFe1VG5DgmyaXGOjrCtdjSgejT7WRGIZM11gFiO-tizyfr6uZKJ-OfMgynQHXrMEhBFnEW6uFRKnzqcQBmIkvn2on1rk7EbCvmzhVM6LlBbPElKTgt94asVAateVPFAVSMXqDiDQCsxYYmsFhxDXPYUjMJabkXQitInyHQeRjhFF9giB0fg57aZ-tOJRBzhm2W7fGXoDC3f_u2VV4kVWF8EhL5I" + }' +- request: + method: GET + url: http://authservice/api/token?for=http%3A%2F%2Fapi.cluster.unknown%2F&force_pull=false + headers: + sub: ["526ea9ac-0cf7-4e12-a835-0b76eab45517"] # will be compared against the `sub` claim in the incoming request's token + response: + status: 200 OK + code: 200 + # response with plaintext token for the "user" account + body: '{ + "token_type": "bearer", + "username": "user_unknown_cluster", + "access_token": "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InVzZXJfdW5rbm93bl9jbHVzdGVyQGJhci5jb20iLCJzdWIiOiI1MjZlYTlhYy0wY2Y3LTRlMTItYTgzNS0wYjc2ZWFiNDU1MTcifQ.B5IuR1J2_C6x1ZQ6u4CQd7Sl-DQo_I-xm53HkQO0Hsh22nwf_7xeBp6bExRxkrn6I8uGNXOjmYbc5YDL6d_hxpmmw_UYCYyb7Trk4UOJLEz5fUn22ej9xIya4GmiPepGhEPU6Xi6Qv352JhJxssZy41Ba1ecRnEvUolDFRh1i-RfbBMfMBIMy7aYhAYxOMl2satd64TJ465P0HWc0SLUPQeyqPoKCl9ZN_etvSH_c5W9KPxykOhJyWlJVCBZ00bX18sF_WXKzdrZgIz-UpxXtzC7bkyj54VEDL6sVuAUELpTfp0Wq0OcuSBvkt8LGdb9P3VmDFfSzpueGblxa8k" + }' +- request: + method: GET + url: http://authservice/api/token?for=http%3A%2F%2Fapi.cluster1%2F&force_pull=false + headers: + sub: ["02a6474c-3b04-4dc4-bfd2-4867102581e0"] # will be compared against the `sub` claim in the incoming request's token + response: + status: 200 OK + code: 200 + # response with plaintext token for the "user" account + body: '{ + "token_type": "bearer", + "username": "user_with_namespace", + "access_token": "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InVzZXJfbnNfZXhpc3RzQGJhci5jb20iLCJzdWIiOiIwMmE2NDc0Yy0zYjA0LTRkYzQtYmZkMi00ODY3MTAyNTgxZTAifQ.CLyi2xYuF2El8W4js8dJQ_rVy38c7YIBNF5_rlkhhQ_-gllnw1PYNNcaYRbqYZ8dXJ0ZcVDBp1-zMucbP-AV2kn2n8BkzxiK2ohxkpLYtwY9sVTmFgxbOB3REx7WTKCiMuogdAJ1ssC_ze5dsoVmFHACyGeGpaTvMeZh76TnYAcgjgs3iIUEtoAvTRlcZ7HDZPJcOmNmUjnNa6v3JiQeOcBwgq6dW9ye18o2tctcTZx7-aKZNWXUdn1SQipvnZu1ksDNpU-7r_cRhizdrHfgylbNwWV8ZxofOBo_U6Rrb7bSpt2FQ8-9ngyQxPKY6jD_eBxWUbcW3YdyNIryoqA" + }' + +# requests to verify that the token is still valid, using the `whoami` API on the user's target cluster +- request: + method: GET + url: http://api.cluster1/apis/user.openshift.io/v1/users/~ + headers: + sub: ["tenant_service"] # will be compared against the `sub` claim in the incoming request's token + response: + status: 200 OK + code: 200 + body: '{ + "kind":"User", + "apiVersion":"user.openshift.io/v1", + "metadata":{ + "name":"tenant_service", + }, + "identities":[], + "groups":[] + }' + + +# requests to retrieve the user's list of projects on OSO +- request: + method: GET + url: http://api.cluster1/oapi/v1/projects + headers: + sub: ["83fdcae2-634f-4a52-958a-f723cb621700"] # will be compared against the `sub` claim in the incoming request's token + response: + status: 200 OK + code: 200 + body: '{ + "kind": "ProjectList", + "apiVersion": "v1", + "metadata": { + "selfLink": "/oapi/v1/projects" + }, + "items": [] + }' +- request: + method: GET + url: http://api.cluster1/oapi/v1/projects + headers: + sub: ["38b33b8b-996d-4ba4-b565-f32a526de85c"] # will be compared against the `sub` claim in the incoming request's token + response: + status: 200 OK + code: 200 + body: '{ + "kind": "ProjectList", + "apiVersion": "v1", + "metadata": { + "selfLink": "/oapi/v1/projects" + }, + "items": [ + { + "metadata": { + "name": "user_foo2", + "selfLink": "/oapi/v1/projects/user_foo2", + "uid": "51e07e7a-1597-11e7-ad87-0000000000001", + "resourceVersion": "0", + "creationTimestamp": "2017-03-30T22:22:11Z", + "annotations": { + "openshift.io/description": "", + "openshift.io/display-name": "", + } + }, + "spec": { + "finalizers": [ + "openshift.io/origin", + "kubernetes" + ] + }, + "status": { + "phase": "Active" + } + } + ] + }' +- request: + method: GET + url: http://api.cluster1/oapi/v1/projects + headers: + sub: ["02a6474c-3b04-4dc4-bfd2-4867102581e0"] # will be compared against the `sub` claim in the incoming request's token + response: + status: 200 OK + code: 200 + body: '{ + "kind": "ProjectList", + "apiVersion": "v1", + "metadata": { + "selfLink": "/oapi/v1/projects" + }, + "items": [ + { + "metadata": { + "name": "foo1", + "selfLink": "/oapi/v1/projects/foo1", + "uid": "51e07e7a-1597-11e7-ad87-0000000000001", + "resourceVersion": "0", + "creationTimestamp": "2017-03-30T22:22:11Z", + "annotations": { + "openshift.io/description": "", + "openshift.io/display-name": "", + } + }, + "spec": { + "finalizers": [ + "openshift.io/origin", + "kubernetes" + ] + }, + "status": { + "phase": "Active" + } + } + ] + }' \ No newline at end of file diff --git a/test/data/openshift/list_projects.yaml b/test/data/openshift/list_projects.yaml new file mode 100644 index 0000000..8dbf973 --- /dev/null +++ b/test/data/openshift/list_projects.yaml @@ -0,0 +1,117 @@ +--- +version: 1 +interactions: +- request: + method: GET + url: https://openshift.test/oapi/v1/projects + headers: + sub: ["user_multi_projects"] # will be compared against the `sub` claim in the incoming request's token + response: + status: 200 OK + code: 200 + body: '{ + "kind": "ProjectList", + "apiVersion": "v1", + "metadata": { + "selfLink": "/oapi/v1/projects" + }, + "items": [ + { + "metadata": { + "name": "foo1", + "selfLink": "/oapi/v1/projects/foo1", + "uid": "51e07e7a-1597-11e7-ad87-0000000000001", + "resourceVersion": "0", + "creationTimestamp": "2017-03-30T22:22:11Z", + "annotations": { + "openshift.io/description": "", + "openshift.io/display-name": "", + } + }, + "spec": { + "finalizers": [ + "openshift.io/origin", + "kubernetes" + ] + }, + "status": { + "phase": "Active" + } + }, + { + "metadata": { + "name": "foo2", + "selfLink": "/oapi/v1/projects/foo2", + "uid": "51e07e7a-1597-11e7-ad87-0000000000002", + "resourceVersion": "0", + "creationTimestamp": "2017-03-30T22:22:11Z", + "annotations": { + "openshift.io/description": "", + "openshift.io/display-name": "", + } + }, + "spec": { + "finalizers": [ + "openshift.io/origin", + "kubernetes" + ] + }, + "status": { + "phase": "Active" + } + } + ]}' +- request: + method: GET + url: https://openshift.test/oapi/v1/projects + headers: + sub: ["user_single_project"] # will be compared against the `sub` claim in the incoming request's token + response: + status: 200 OK + code: 200 + body: '{ + "kind": "ProjectList", + "apiVersion": "v1", + "metadata": { + "selfLink": "/oapi/v1/projects" + }, + "items": [ + { + "metadata": { + "name": "foo", + "selfLink": "/oapi/v1/projects/foo", + "uid": "51e07e7a-1597-11e7-ad87-0e6aaf341bbf", + "resourceVersion": "0", + "creationTimestamp": "2017-03-30T22:22:11Z", + "annotations": { + "openshift.io/description": "", + "openshift.io/display-name": "", + } + }, + "spec": { + "finalizers": [ + "openshift.io/origin", + "kubernetes" + ] + }, + "status": { + "phase": "Active" + } + } + ]}' +- request: + method: GET + url: https://openshift.test/oapi/v1/projects + headers: + sub: ["user_no_project"] # will be compared against the `sub` claim in the incoming request's token + response: + status: 200 OK + code: 200 + body: '{ + "kind": "ProjectList", + "apiVersion": "v1", + "metadata": { + "selfLink": "/oapi/v1/projects" + }, + "items": [] + }' \ No newline at end of file diff --git a/test/data/token/auth_resolve_target_token.yaml b/test/data/token/auth_resolve_target_token.yaml index 81b8e9e..c149b53 100644 --- a/test/data/token/auth_resolve_target_token.yaml +++ b/test/data/token/auth_resolve_target_token.yaml @@ -10,9 +10,9 @@ interactions: status: 200 OK code: 200 body: '{ - "access_token": "an_openshift_token", "token_type": "bearer", - "username": "user_foo" + "username": "user_foo", + "access_token": "an_openshift_token" }' - request: method: GET @@ -56,7 +56,7 @@ interactions: status: 200 OK code: 200 body: '{ - "access_token": "an_openshift_token", + "access_token": "jA0ECQMCV1nKvgRtKl1g0sDSAfxyo94B+Mi1erfAyYHddxrqYXHQ7LS+ZhObCbTbYdIxDc5EnGxm68yUbuSf/PARs8ZhHV/8lSPHhLjA1aWjp9SiFtaC+hrx+UEcc55PQ3scUCuDqIaUdJeAt9Nib5D0dZL/Pd4j5kAVQ6Yhu39C4xZlCpGI72UWO6/+BeLLMg5NKmVp+9ouCcVwUgvt5cZLkljC8kSPjX9T6sdb1zU8UFE/zJkr3DNsIPvoxXkbffBjMQ8KlKNbjDF2a/EawKiQU5j47j7issGBwodW1SCyp9Ull2DZ2YkHO1w3BDU/AUQncyRBr+d+SyIh5vId87oP4s2dJCluN8ZaP3uRCCuNUf1nKL1WiYCdxCEezAKOwEYIxFVkIGr4rOvNppkyxdxpW7h+OOOsXUCE5u2RqfmP0KcifT1IlBE2xypmoIHgQ5bBtiVvpH8FPf0uZBX7G7VsS44bedWxNVUp3/vLX4dhzHBQmAQkimNxlT6lhKTZc4j8FY5Nbea0C8wH9o+9qQgpJWoYFfqItF6F+U0KEk1v90Ho", "token_type": "bearer", "username": "tenant_service" }' diff --git a/test/jwt_token.go b/test/jwt_token.go index fd4c6fb..78315d4 100644 --- a/test/jwt_token.go +++ b/test/jwt_token.go @@ -6,10 +6,8 @@ import ( ) // NewToken creates a new JWT using the given sub claim and signed with the private key in the given filename -func NewToken(sub string, privatekeyFilename string) (*jwt.Token, error) { - claims := jwt.MapClaims{} - claims["sub"] = sub - token := jwt.NewWithClaims(jwt.SigningMethodRS512, claims) +func NewToken(claims map[string]interface{}, privatekeyFilename string) (*jwt.Token, error) { + token := jwt.NewWithClaims(jwt.SigningMethodRS512, jwt.MapClaims(claims)) // use the test private key to sign the token key, err := PrivateKey(privatekeyFilename) if err != nil { @@ -20,6 +18,6 @@ func NewToken(sub string, privatekeyFilename string) (*jwt.Token, error) { return nil, err } token.Raw = signed - log.Debug(nil, map[string]interface{}{"signed_token": signed, "sub": sub}, "generated test token with custom sub") + log.Debug(nil, map[string]interface{}{"signed_token": signed, "claims": claims}, "generated test token with custom sub") return token, nil } diff --git a/test/recorder/recorder.go b/test/recorder/recorder.go index 3e1fe27..1356c3b 100644 --- a/test/recorder/recorder.go +++ b/test/recorder/recorder.go @@ -54,27 +54,43 @@ func JWTMatcher() cassette.Matcher { // check the request URI and method if httpRequest.Method != cassetteRequest.Method || (httpRequest.URL != nil && httpRequest.URL.String() != cassetteRequest.URL) { - log.Debug(nil, map[string]interface{}{ - "httpRequest_method": httpRequest.Method, - "cassetteRequest_method": cassetteRequest.Method, - "httpRequest_url": httpRequest.URL, - "cassetteRequest_url": cassetteRequest.URL, - }, "Cassette method/url doesn't match with the current request") + // log.Debug(nil, map[string]interface{}{ + // "http_request_method": httpRequest.Method, + // "cassette_method": cassetteRequest.Method, + // "http_request_url": httpRequest.URL, + // "cassette_url": cassetteRequest.URL, + // }, "Cassette method/url doesn't match with the current request") return false } - // look-up the JWT's "sub" claim and compare with the request token, err := jwtrequest.ParseFromRequest(httpRequest, jwtrequest.AuthorizationHeaderExtractor, func(*jwt.Token) (interface{}, error) { return testsupport.PublicKey("../test/public_key.pem") }) if err != nil { - log.Error(nil, map[string]interface{}{"error": err.Error(), "request_method": cassetteRequest.Method, "request_url": cassetteRequest.URL, "authorization_header": httpRequest.Header["Authorization"]}, "failed to parse token from request") + log.Error(nil, map[string]interface{}{ + "error": err.Error(), + "request_method": cassetteRequest.Method, + "request_url": cassetteRequest.URL, + "authorization_header": httpRequest.Header["Authorization"]}, + "failed to parse token from request") return false } claims := token.Claims.(jwt.MapClaims) - if sub, found := cassetteRequest.Headers["sub"]; found { - return sub[0] == claims["sub"] + sub, found := cassetteRequest.Headers["sub"] + if found && len(sub) > 0 && sub[0] == claims["sub"] { + // log.Debug(nil, map[string]interface{}{ + // "method": cassetteRequest.Method, + // "url": cassetteRequest.URL, + // "sub": sub[0]}, "found interaction") + return true } + log.Debug(nil, map[string]interface{}{ + "method": cassetteRequest.Method, + "url": cassetteRequest.URL, + "cassetteRequest_sub": sub, + "http_request_sub": claims["sub"], + }, "Authorization header's 'sub' claim doesn't match with the current request") + return false } } diff --git a/token/decode.go b/token/decode.go index 84c94bb..dbb346c 100644 --- a/token/decode.go +++ b/token/decode.go @@ -6,6 +6,7 @@ import ( "errors" "io/ioutil" + "github.com/fabric8-services/fabric8-wit/log" "golang.org/x/crypto/openpgp" ) @@ -15,12 +16,14 @@ type Decode func(data string) (string, error) // PlainText is a Decode function that can be used to fetch tokens that are not encrypted. // Simply return the same token back func PlainText(token string) (string, error) { + log.Debug(nil, nil, "decoding a plain text value...") return token, nil } // NewGPGDecypter takes a passphrase and returns a GPG based Decypter decode function func NewGPGDecypter(passphrase string) Decode { return func(body string) (string, error) { + log.Debug(nil, nil, "decoding a gpg-encrypted value...") return gpgDecyptToken(body, passphrase) } } @@ -28,11 +31,13 @@ func NewGPGDecypter(passphrase string) Decode { // GPGDecyptToken decrypts a Base64 encoded GPG un armored encrypted string // using provided passphrase. // on Linux: -// echo -n "SuperSecret" | gpg --symmetric --cipher-algo AES256 | base64 -w0 +// echo -n "SuperSecret" | gpg --symmetric --cipher-algo AES256 | base64 -w0 +// // on macOS: -// echo -n "SuperSecret" | gpg --symmetric --cipher-algo AES256 | base64 +// echo -n "SuperSecret" | gpg --symmetric --cipher-algo AES256 | base64 // and keep the result then use a Docker container to run: -// echo -n $TOKEN | base64 -d | base64 -w0 +// echo -n $TOKEN | base64 -d | base64 -w0 +// // in any case, don't forget the `-n` arg in the `echo` command! func gpgDecyptToken(base64Body, passphrase string) (string, error) { diff --git a/token/service_test.go b/token/service_test.go index e931e58..e32af0f 100644 --- a/token/service_test.go +++ b/token/service_test.go @@ -19,7 +19,12 @@ func TestResolveUserToken(t *testing.T) { require.NoError(t, err) defer r.Stop() resolveToken := token.NewResolve("http://authservice", configuration.WithRoundTripper(r.Transport)) - tok, err := testsupport.NewToken("user_foo", "../test/private_key.pem") + tok, err := testsupport.NewToken( + map[string]interface{}{ + "sub": "user_foo", + }, + "../test/private_key.pem", + ) require.NoError(t, err) t.Run("ok", func(t *testing.T) { @@ -52,38 +57,49 @@ func TestResolveServiceAccountToken(t *testing.T) { require.NoError(t, err) defer r.Stop() resolveToken := token.NewResolve("http://authservice", configuration.WithRoundTripper(r.Transport)) - tok, err := testsupport.NewToken("tenant_service", "../test/private_key.pem") + tok, err := testsupport.NewToken( + map[string]interface{}{ + "sub": "tenant_service", + }, + "../test/private_key.pem", + ) require.NoError(t, err) t.Run("ok", func(t *testing.T) { // when - username, accessToken, err := resolveToken(context.Background(), "some_valid_openshift_resource", tok.Raw, true, token.PlainText) + username, accessToken, err := resolveToken(context.Background(), "some_valid_openshift_resource", tok.Raw, true, token.NewGPGDecypter("foo")) // then require.NoError(t, err) assert.Equal(t, "tenant_service", username) - assert.Equal(t, "an_openshift_token", accessToken) + assert.Equal(t, "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZW5hbnRfc2VydmljZSJ9.A0RwMutAqJkSgyZS5LIDD8z8uRXvW-PdeRou3bUmgn0d8DU2u1fymARWvfJZ2s5L0yym4x9QUeaQuFGue4XvATF_NAakETHJFPWMoAKj22jJQ4r6fkDy8tseyH5WQ7NkTrngVjlQCVBewb7kFWHD_r74vbV8YxRsFKcvbwyemEJ-s3KRtAT2Tgj6CXnNhytp1r7vxUfa0C9amCpLkbBeUs11C8UhDKIp8HvF1Mh4j5bTxdmnOFCUmYbMlvpNSFrIPfSmhW2vxh6kEXkBVkoR2CUp5ikRNZSUXK6yIguXY1UUWY-oGk64KIjBJGTOzOAO2v_M0yfe_FcVDPiaMEA", accessToken) }) t.Run("expired token", func(t *testing.T) { // given - tok, err := testsupport.NewToken("expired_tenant_service", "../test/private_key.pem") + tok, err := testsupport.NewToken( + map[string]interface{}{ + "sub": "expired_tenant_service", + }, + "../test/private_key.pem", + ) + require.NoError(t, err) // when - _, _, err = resolveToken(context.Background(), "some_valid_openshift_resource", tok.Raw, true, token.PlainText) + _, _, err = resolveToken(context.Background(), "some_valid_openshift_resource", tok.Raw, true, token.NewGPGDecypter("foo")) // then require.Error(t, err) }) t.Run("invalid resource", func(t *testing.T) { // when - _, _, err := resolveToken(context.Background(), "some_invalid_resource", tok.Raw, true, token.PlainText) + _, _, err := resolveToken(context.Background(), "some_invalid_resource", tok.Raw, true, token.NewGPGDecypter("foo")) // then require.Error(t, err) }) t.Run("empty access token", func(t *testing.T) { // when - _, _, err := resolveToken(context.Background(), "some_valid_openshift_resource", "", true, token.PlainText) + _, _, err := resolveToken(context.Background(), "some_valid_openshift_resource", "", true, token.NewGPGDecypter("foo")) // then require.Error(t, err) }) diff --git a/user/user_service.go b/user/user_service.go index 20a1676..0df8043 100644 --- a/user/user_service.go +++ b/user/user_service.go @@ -5,6 +5,7 @@ import ( "github.com/fabric8-services/fabric8-tenant/auth" authclient "github.com/fabric8-services/fabric8-tenant/auth/client" + "github.com/fabric8-services/fabric8-tenant/configuration" goaclient "github.com/goadesign/goa/client" "github.com/pkg/errors" uuid "github.com/satori/go.uuid" @@ -16,17 +17,22 @@ type Service interface { } // NewService creates a new User service -func NewService(authURL string, serviceToken string) Service { - return &userService{authURL: authURL, serviceToken: serviceToken} +func NewService(authURL string, serviceToken string, options ...configuration.HTTPClientOption) Service { + return &userService{ + authURL: authURL, + serviceToken: serviceToken, + clientOptions: options, + } } type userService struct { - authURL string - serviceToken string + authURL string + serviceToken string + clientOptions []configuration.HTTPClientOption } func (s *userService) GetUser(ctx context.Context, id uuid.UUID) (*authclient.UserDataAttributes, error) { - c, err := auth.NewClient(s.authURL, s.serviceToken) + c, err := auth.NewClient(s.authURL, s.serviceToken, s.clientOptions...) if err != nil { return nil, err }