Repository: ovh/cds
Pull request #4978 (2020-02-13 16:03:16)

fix(hooks,ui): display payload from gerrit + fix hook edit
1. Description
1. Related issues
1. About tests
1. Mentions

@ovh/cds


ovh-cds: 
CDS Report build-all-cds#12410.0 ✘  
* Build
  * Build and Package All ✔ 
  * Build Debpacker ✔ 
  * Build OS-Ansible-Inventory ✔ 
  * Build SMTPMock ✔ 
  * Lint UI Translate ✔ 
  * Test CLI ✔ 
  * Test Engine ✘  
  * Test SDK ✔ 
  * Test SDK Rust ✔ 
  * Test UI ✔ 

sguiheux: @yesnault There is no documentation on gerrit integration. It will be done in another PR

Patch:
diff --git a/cli/cdsctl/consumer.go b/cli/cdsctl/consumer.go
index ac1b97ade..d3cca0126 100644
--- a/cli/cdsctl/consumer.go
+++ b/cli/cdsctl/consumer.go
@@ -152,10 +152,10 @@ func authConsumerNewRun(v cli.Values) error {
 	}
 
 	res, err := client.AuthConsumerCreateForUser(username, sdk.AuthConsumer{
-		Name:        name,
-		Description: description,
-		GroupIDs:    groupIDs,
-		Scopes:      scopes,
+		Name:         name,
+		Description:  description,
+		GroupIDs:     groupIDs,
+		ScopeDetails: sdk.NewAuthConsumerScopeDetails(scopes...),
 	})
 	if err != nil {
 		return err
diff --git a/cli/cdsctl/login.go b/cli/cdsctl/login.go
index 687122942..e4b976efd 100644
--- a/cli/cdsctl/login.go
+++ b/cli/cdsctl/login.go
@@ -371,9 +371,9 @@ func createOrRegenConsumer(apiURL, username, sessionToken string) (string, strin
 	// consumer not found, create it
 	if signinToken == "" {
 		resCreate, err := client.AuthConsumerCreateForUser(username, sdk.AuthConsumer{
-			Name:        consumerName,
-			Description: "Consumer created with cdsctl login",
-			Scopes:      sdk.AuthConsumerScopes,
+			Name:         consumerName,
+			Description:  "Consumer created with cdsctl login",
+			ScopeDetails: sdk.NewAuthConsumerScopeDetails(sdk.AuthConsumerScopes...),
 		})
 		if err != nil {
 			return "", "", fmt.Errorf("cdsctl: failed to create consumer: %v", err)
diff --git a/engine/api/api_routes.go b/engine/api/api_routes.go
index 32edb9e4f..bd639b1c7 100644
--- a/engine/api/api_routes.go
+++ b/engine/api/api_routes.go
@@ -10,6 +10,7 @@ import (
 
 type HandlerScope []sdk.AuthConsumerScope
 
+// Scope set for handler. If multiple scopes are given, one should match consumer scopes.
 func Scope(s ...sdk.AuthConsumerScope) HandlerScope {
 	return HandlerScope(s)
 }
@@ -429,4 +430,6 @@ func (api *API) InitRouter() {
 
 	//Not Found handler
 	r.Mux.NotFoundHandler = http.HandlerFunc(NotFoundHandler)
+
+	r.computeScopeDetails()
 }
diff --git a/engine/api/auth.go b/engine/api/auth.go
index ee471511e..bf4a60b3e 100644
--- a/engine/api/auth.go
+++ b/engine/api/auth.go
@@ -37,7 +37,7 @@ func (api *API) getAuthDriversHandler() service.Handler {
 
 func (api *API) getAuthScopesHandler() service.Handler {
 	return func(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
-		return service.WriteJSON(w, sdk.AuthConsumerScopes, http.StatusOK)
+		return service.WriteJSON(w, api.Router.scopeDetails, http.StatusOK)
 	}
 }
 
diff --git a/engine/api/auth_consumer.go b/engine/api/auth_consumer.go
index 3d6df29f2..95ad2bad2 100644
--- a/engine/api/auth_consumer.go
+++ b/engine/api/auth_consumer.go
@@ -68,13 +68,13 @@ func (api *API) postConsumerByUserHandler() service.Handler {
 		if err := service.UnmarshalBody(r, &reqData); err != nil {
 			return err
 		}
-		if err := reqData.IsValid(); err != nil {
+		if err := reqData.IsValid(api.Router.scopeDetails); err != nil {
 			return err
 		}
 
 		// Create the new built in consumer from request data
 		newConsumer, token, err := builtin.NewConsumer(ctx, api.mustDB(), reqData.Name, reqData.Description,
-			consumer, reqData.GroupIDs, reqData.Scopes)
+			consumer, reqData.GroupIDs, reqData.ScopeDetails)
 		if err != nil {
 			return err
 		}
diff --git a/engine/api/auth_local.go b/engine/api/auth_local.go
index 695bb5661..06bf8b9a3 100644
--- a/engine/api/auth_local.go
+++ b/engine/api/auth_local.go
@@ -122,15 +122,15 @@ func initBuiltinConsumersFromStartupConfig(ctx context.Context, tx gorp.SqlExecu
 
 	// Create the consumers provided by the startup configuration
 	for _, cfg := range startupConfig.Consumers {
-		var scopes sdk.AuthConsumerScopeSlice
+		var scopes sdk.AuthConsumerScopeDetails
 
 		switch cfg.ServiceType {
 		case services.TypeHatchery:
-			scopes = []sdk.AuthConsumerScope{sdk.AuthConsumerScopeService, sdk.AuthConsumerScopeHatchery, sdk.AuthConsumerScopeRunExecution, sdk.AuthConsumerScopeWorkerModel}
+			scopes = sdk.NewAuthConsumerScopeDetails(sdk.AuthConsumerScopeService, sdk.AuthConsumerScopeHatchery, sdk.AuthConsumerScopeRunExecution, sdk.AuthConsumerScopeWorkerModel)
 		case services.TypeHooks:
-			scopes = []sdk.AuthConsumerScope{sdk.AuthConsumerScopeService, sdk.AuthConsumerScopeHooks, sdk.AuthConsumerScopeProject, sdk.AuthConsumerScopeRun}
+			scopes = sdk.NewAuthConsumerScopeDetails(sdk.AuthConsumerScopeService, sdk.AuthConsumerScopeHooks, sdk.AuthConsumerScopeProject, sdk.AuthConsumerScopeRun)
 		default:
-			scopes = []sdk.AuthConsumerScope{sdk.AuthConsumerScopeService}
+			scopes = sdk.NewAuthConsumerScopeDetails(sdk.AuthConsumerScopeService)
 		}
 
 		var c = sdk.AuthConsumer{
@@ -142,7 +142,7 @@ func initBuiltinConsumersFromStartupConfig(ctx context.Context, tx gorp.SqlExecu
 			Type:               sdk.ConsumerBuiltin,
 			Data:               map[string]string{},
 			GroupIDs:           []int64{group.SharedInfraGroup.ID},
-			Scopes:             scopes,
+			ScopeDetails:       scopes,
 			IssuedAt:           time.Unix(startupConfig.IAT, 0),
 		}
 
diff --git a/engine/api/authentication/builtin/builtin.go b/engine/api/authentication/builtin/builtin.go
index 2b2dcc83b..39812d830 100644
--- a/engine/api/authentication/builtin/builtin.go
+++ b/engine/api/authentication/builtin/builtin.go
@@ -66,7 +66,7 @@ func (d AuthDriver) CheckSigninRequest(req sdk.AuthConsumerSigninRequest) error
 // NewConsumer returns a new builtin consumer for given data.
 // The parent consumer should be given with all data loaded including the authentified user.
 func NewConsumer(ctx context.Context, db gorp.SqlExecutor, name, description string, parentConsumer *sdk.AuthConsumer,
-	groupIDs []int64, scopes []sdk.AuthConsumerScope) (*sdk.AuthConsumer, string, error) {
+	groupIDs []int64, scopes sdk.AuthConsumerScopeDetails) (*sdk.AuthConsumer, string, error) {
 	if name == "" {
 		return nil, "", sdk.NewErrorFrom(sdk.ErrWrongRequest, "name should be given to create a built in consumer")
 	}
@@ -86,24 +86,9 @@ func NewConsumer(ctx context.Context, db gorp.SqlExecutor, name, description str
 		}
 	}
 
-	// At least one scope should be given, for each given scope checks if its authorized and if it's in parent scopes
-	if len(scopes) == 0 {
-		return nil, "", sdk.NewErrorFrom(sdk.ErrWrongRequest, "built in consumer creation requires at least one scope to be set")
-	}
-	for i := range scopes {
-		// If parent scopes length equals 0 this means all scopes else checks that given scope is in parent scopes.
-		if len(parentConsumer.Scopes) > 0 {
-			var found bool
-			for j := range parentConsumer.Scopes {
-				if scopes[i] == parentConsumer.Scopes[j] {
-					found = true
-					break
-				}
-			}
-			if !found {
-				return nil, "", sdk.NewErrorFrom(sdk.ErrWrongRequest, "invalid given scope %s when creating built in consumer", scopes[i])
-			}
-		}
+	// Check that given scopes are valid and if they match parent scopes
+	if err := checkNewConsumerScopes(parentConsumer.ScopeDetails, scopes); err != nil {
+		return nil, "", err
 	}
 
 	c := sdk.AuthConsumer{
@@ -114,7 +99,7 @@ func NewConsumer(ctx context.Context, db gorp.SqlExecutor, name, description str
 		Type:               sdk.ConsumerBuiltin,
 		Data:               map[string]string{},
 		GroupIDs:           groupIDs,
-		Scopes:             scopes,
+		ScopeDetails:       scopes,
 		IssuedAt:           time.Now(),
 	}
 
@@ -129,3 +114,61 @@ func NewConsumer(ctx context.Context, db gorp.SqlExecutor, name, description str
 
 	return &c, jws, nil
 }
+
+func checkNewConsumerScopes(parentScopes, scopes sdk.AuthConsumerScopeDetails) error {
+	// At least one scope should be given, for each given scope checks if its authorized and if it's in parent scopes
+	if len(scopes) == 0 {
+		return sdk.NewErrorFrom(sdk.ErrWrongRequest, "built in consumer creation requires at least one scope to be set")
+	}
+	if err := scopes.IsValid(); err != nil {
+		return err
+	}
+	// If parent scopes length equals 0 this means all scopes else checks that given scope is in parent scopes
+	if len(parentScopes) == 0 {
+		return nil
+	}
+
+	for i := range scopes {
+		var validScope bool
+		for j := range parentScopes {
+			if scopes[i].Scope == parentScopes[j].Scope {
+				// if no endpoint restrictions on parent scope is valid
+				if len(parentScopes[j].Endpoints) == 0 {
+					validScope = true
+					break
+				}
+
+				// invalid if no endpoint given and parents endpoint list is not empty
+				if len(scopes[i].Endpoints) == 0 {
+					return sdk.NewErrorFrom(sdk.ErrWrongRequest, "invalid endpoints for scope %s when creating built in consumer", scopes[i].Scope)
+				}
+
+				// if parent as scope restrictions, child should contains only all or a subset of those restrictions
+				for _, e := range scopes[i].Endpoints {
+					existsParentEndpoint, parentEndpoint := parentScopes[j].Endpoints.FindEndpoint(e.Route)
+					if !existsParentEndpoint {
+						return sdk.NewErrorFrom(sdk.ErrWrongRequest, "invalid given route %s for scope %s when creating built in consumer", e.Route, scopes[i].Scope)
+					}
+					if len(parentEndpoint.Methods) > 0 {
+						if len(e.Methods) == 0 {
+							return sdk.NewErrorFrom(sdk.ErrWrongRequest, "invalid given methods for route %s and scope %s when creating built in consumer", e.Route, scopes[i].Scope)
+						}
+						for _, m := range e.Methods {
+							if !parentEndpoint.Methods.Contains(m) {
+								return sdk.NewErrorFrom(sdk.ErrWrongRequest, "invalid given method %s for route %s and scope %s when creating built in consumer", m, e.Route, scopes[i].Scope)
+							}
+						}
+					}
+				}
+
+				validScope = true
+				break
+			}
+		}
+		if !validScope {
+			return sdk.NewErrorFrom(sdk.ErrWrongRequest, "invalid given scope %s when creating built in consumer", scopes[i])
+		}
+	}
+
+	return nil
+}
diff --git a/engine/api/authentication/consumer.go b/engine/api/authentication/consumer.go
index 93ee22b32..bdff33fc4 100644
--- a/engine/api/authentication/consumer.go
+++ b/engine/api/authentication/consumer.go
@@ -17,13 +17,13 @@ func NewConsumerWorker(ctx context.Context, db gorp.SqlExecutor, name string, ha
 		Type:               sdk.ConsumerBuiltin,
 		Data:               map[string]string{},
 		GroupIDs:           groupIDs,
-		Scopes: []sdk.AuthConsumerScope{
+		ScopeDetails: sdk.NewAuthConsumerScopeDetails(
 			sdk.AuthConsumerScopeWorker,
 			sdk.AuthConsumerScopeWorkerModel,
 			sdk.AuthConsumerScopeProject,
 			sdk.AuthConsumerScopeRun,
 			sdk.AuthConsumerScopeRunExecution,
-		},
+		),
 		IssuedAt: time.Now(),
 	}
 
diff --git a/engine/api/authentication/gorp_model.go b/engine/api/authentication/gorp_model.go
index d74a1984a..b531cd5b8 100644
--- a/engine/api/authentication/gorp_model.go
+++ b/engine/api/authentication/gorp_model.go
@@ -8,11 +8,14 @@ import (
 type authConsumer struct {
 	sdk.AuthConsumer
 	gorpmapping.SignedEntity
+	// deprecated
+	Scopes sdk.AuthConsumerScopeSlice `json:"scopes,omitempty" cli:"scopes" db:"scopes"`
 }
 
 func (c authConsumer) Canonical() gorpmapping.CanonicalForms {
-	_ = []interface{}{c.ID, c.AuthentifiedUserID, c.Type, c.Data, c.Created, c.GroupIDs, c.Scopes, c.Disabled} // Checks that fields exists at compilation
+	_ = []interface{}{c.ID, c.AuthentifiedUserID, c.Type, c.Data, c.Created, c.GroupIDs, c.Scopes, c.ScopeDetails, c.Disabled} // Checks that fields exists at compilation
 	return []gorpmapping.CanonicalForm{
+		"{{.ID}}{{.AuthentifiedUserID}}{{print .Type}}{{print .Data}}{{printDate .Created}}{{print .GroupIDs}}{{print .ScopeDetails}}{{print .Disabled}}",
 		"{{.ID}}{{.AuthentifiedUserID}}{{print .Type}}{{print .Data}}{{printDate .Created}}{{print .GroupIDs}}{{print .Scopes}}{{print .Disabled}}",
 	}
 }
diff --git a/engine/api/router.go b/engine/api/router.go
index 9fb429ab8..2c562cfc5 100644
--- a/engine/api/router.go
+++ b/engine/api/router.go
@@ -4,7 +4,6 @@ import (
 	"bytes"
 	"compress/gzip"
 	"context"
-	"errors"
 	"fmt"
 	"io"
 	"io/ioutil"
@@ -18,6 +17,7 @@ import (
 
 	"github.com/gorilla/handlers"
 	"github.com/gorilla/mux"
+	"github.com/pkg/errors"
 	"go.opencensus.io/stats"
 	"go.opencensus.io/tag"
 
@@ -25,6 +25,8 @@ import (
 	"github.com/ovh/cds/engine/service"
 	"github.com/ovh/cds/sdk"
 	"github.com/ovh/cds/sdk/cdsclient"
+	"github.com/ovh/cds/sdk/doc"
+	docSDK "github.com/ovh/cds/sdk/doc"
 	"github.com/ovh/cds/sdk/log"
 	"github.com/ovh/cds/sdk/tracingutils"
 )
@@ -45,8 +47,7 @@ var (
 
 // Router is a wrapper around mux.Router
 type Router struct {
-	Background context.Context
-	//	AuthDriver             auth.Driver
+	Background             context.Context
 	Mux                    *mux.Router
 	SetHeaderFunc          func() map[string]string
 	Prefix                 string
@@ -58,6 +59,7 @@ type Router struct {
 	panicked               bool
 	nbPanic                int
 	lastPanic              *time.Time
+	scopeDetails           []sdk.AuthConsumerScopeDetail
 }
 
 // HandlerConfigParam is a type used in handler configuration, to set specific config on a route given a method
@@ -182,6 +184,67 @@ func DefaultHeaders() map[string]string {
 	}
 }
 
+// computeScopeDetails iterate over declared handlers for routers and populate router scope details.
+func (r *Router) computeScopeDetails() {
+	// create temporary map of scopes, for each scope we will create a map of routes with methods.
+	m := make(map[sdk.AuthConsumerScope]map[string]map[string]struct{})
+
+	for uri, cfg := range r.mapRouterConfigs {
+		var err error
+		uri, err = docSDK.CleanAndCheckURL(uri)
+		if err != nil {
+			panic(errors.Wrap(err, "error computing scope detail"))
+		}
+
+		if len(cfg.Config) == 0 {
+			continue
+		}
+
+		methods := make([]string, 0, len(cfg.Config))
+		var scopes []sdk.AuthConsumerScope
+		for method, handler := range cfg.Config {
+			// Take scopes from the first handler as every handlers should have the same scopes
+			if scopes == nil {
+				scopes = handler.AllowedScopes
+			}
+			methods = append(methods, method)
+		}
+
+		for i := range scopes {
+			if _, ok := m[scopes[i]]; !ok {
+				m[scopes[i]] = make(map[string]map[string]struct{})
+			}
+			if _, ok := m[scopes[i]][uri]; !ok {
+				m[scopes[i]][uri] = make(map[string]struct{})
+			}
+			for j := range methods {
+				m[scopes[i]][uri][methods[j]] = struct{}{}
+			}
+		}
+	}
+
+	// return scope details
+	details := make([]sdk.AuthConsumerScopeDetail, len(sdk.AuthConsumerScopes))
+	for i, scope := range sdk.AuthConsumerScopes {
+		endpoints := make([]sdk.AuthConsumerScopeEndpoint, 0, len(m[scope]))
+		for uri, mMethods := range m[scope] {
+			methods := make([]string, 0, len(mMethods))
+			for k := range mMethods {
+				methods = append(methods, k)
+			}
+			endpoints = append(endpoints, sdk.AuthConsumerScopeEndpoint{
+				Route:   uri,
+				Methods: methods,
+			})
+		}
+
+		details[i].Scope = scope
+		details[i].Endpoints = endpoints
+	}
+
+	r.scopeDetails = details
+}
+
 // Handle adds all handler for their specific verb in gorilla router for given uri
 func (r *Router) Handle(uri string, scope HandlerScope, handlers ...*service.HandlerConfig) {
 	uri = r.Prefix + uri
@@ -193,15 +256,17 @@ func (r *Router) Handle(uri string, scope HandlerScope, handlers ...*service.Han
 	}
 	r.mapRouterConfigs[uri] = cfg
 
+	cleanURL := doc.CleanURL(uri)
 	for i := range handlers {
+		handlers[i].CleanURL = cleanURL
 		handlers[i].AllowedScopes = scope
-		cfg.Config[handlers[i].Method] = handlers[i]
 		name := runtime.FuncForPC(reflect.ValueOf(handlers[i].Handler).Pointer()).Name()
 		name = strings.Replace(name, ".func1", "", 1)
 		name = strings.Replace(name, ".1", "", 1)
 		name = strings.Replace(name, "github.com/ovh/cds/engine/", "", 1)
 		log.Debug("Registering handler %s on %s %s", name, handlers[i].Method, uri)
 		handlers[i].Name = name
+		cfg.Config[handlers[i].Method] = handlers[i]
 	}
 
 	f := func(w http.ResponseWriter, req *http.Request) {
diff --git a/engine/api/router_middleware_auth.go b/engine/api/router_middleware_auth.go
index 5def76b6f..c6551885f 100644
--- a/engine/api/router_middleware_auth.go
+++ b/engine/api/router_middleware_auth.go
@@ -106,22 +106,32 @@ func (api *API) authMiddleware(ctx context.Context, w http.ResponseWriter, req *
 
 		ctx = context.WithValue(ctx, contextAPIConsumer, consumer)
 
-		// Checks scopes, all expected scopes should be in actual scopes
+		// Checks scopes, one of expected scopes should be in actual scopes
 		// Actual scope empty list means wildcard scope, we don't need to check scopes
-		expectedScopes, actualScopes := rc.AllowedScopes, consumer.Scopes
+		expectedScopes, actualScopes := rc.AllowedScopes, consumer.ScopeDetails
 		if len(expectedScopes) > 0 && len(actualScopes) > 0 {
 			var found bool
 		findScope:
 			for i := range expectedScopes {
 				for j := range actualScopes {
-					if actualScopes[j] == expectedScopes[i] {
-						found = true
-						break findScope
+					if actualScopes[j].Scope == expectedScopes[i] {
+						// Check if there are scope details, if yes we should check if current route/method is allowed in restrictions
+						if len(actualScopes[j].Endpoints) == 0 {
+							found = true
+							break findScope
+						}
+
+						// if the route is not in current consumer allowed endpoints we should not validate the scope
+						if exists, endpoint := actualScopes[j].Endpoints.FindEndpoint(rc.CleanURL); exists &&
+							len(endpoint.Methods) == 0 || endpoint.Methods.Contains(rc.Method) {
+							found = true
+							break findScope
+						}
 					}
 				}
 			}
 			if !found {
-				return ctx, sdk.WrapError(sdk.ErrUnauthorized, "token scope (%v) doesn't match (%v)", actualScopes, expectedScopes)
+				return ctx, sdk.WrapError(sdk.ErrUnauthorized, "token scopes doesn't match expected: %v", expectedScopes)
 			}
 		}
 
diff --git a/engine/hooks/gerrit.go b/engine/hooks/gerrit.go
index 5c0d9e31c..f9bee9ad6 100644
--- a/engine/hooks/gerrit.go
+++ b/engine/hooks/gerrit.go
@@ -115,6 +115,7 @@ func (s *Service) doGerritExecution(e *sdk.TaskExecution) (*sdk.WorkflowNodeRunH
 		payload["gerrit.change.status"] = gerritEvent.Change.Status
 		payload["gerrit.change.branch"] = gerritEvent.Change.Branch
 	}
+
 	// ref-updated
 	if gerritEvent.RefUpdate != nil {
 		payload["git.hash.before"] = gerritEvent.RefUpdate.OldRev
@@ -150,6 +151,17 @@ func (s *Service) doGerritExecution(e *sdk.TaskExecution) (*sdk.WorkflowNodeRunH
 	if gerritEvent.NewRev != "" {
 		payload["git.hash"] = gerritEvent.NewRev
 	}
+
+	// Comment
+	if gerritEvent.Type == GerritEventTypeCommentAdded {
+		payload["gerrit.comment"] = gerritEvent.Comment
+		if gerritEvent.Author != nil {
+			payload["gerrit.comment.author.username"] = gerritEvent.Author.Username
+			payload["gerrit.comment.author.name"] = gerritEvent.Author.Name
+			payload["gerrit.comment.author.email"] = gerritEvent.Author.Email
+		}
+	}
+
 	payload["gerrit.type"] = gerritEvent.Type
 
 	payload["payload"] = string(e.GerritEvent.Message)
@@ -214,6 +226,7 @@ func (s *Service) ComputeGerritStreamEvent(ctx context.Context, vcsServer string
 					GerritEvent: &sdk.GerritEventExecution{
 						Message: msg,
 					},
+					Type: gerritHook.Type,
 				}
 
 				//Save the web hook execution
diff --git a/engine/service/http.go b/engine/service/http.go
index ea473835e..1df99663d 100644
--- a/engine/service/http.go
+++ b/engine/service/http.go
@@ -57,6 +57,7 @@ type HandlerConfig struct {
 	AllowedTokens    []string
 	AllowedScopes    []sdk.AuthConsumerScope
 	PermissionLevel  int
+	CleanURL         string
 }
 
 // Accepted is a helper function used by asynchronous handlers
diff --git a/engine/sql/185_consumer_scopes.sql b/engine/sql/185_consumer_scopes.sql
new file mode 100644
index 000000000..78891c593
--- /dev/null
+++ b/engine/sql/185_consumer_scopes.sql
@@ -0,0 +1,20 @@
+-- +migrate Up
+ALTER TABLE "auth_consumer" ADD COLUMN scope_details JSONB;
+
+UPDATE auth_consumer SET scope_details = scopes WHERE scopes::TEXT = 'null';
+
+UPDATE auth_consumer
+SET scope_details = tmp1.scope_details
+FROM (
+	SELECT
+    tmp2.id AS id,
+    json_agg(jsonb_build_object('scope', tmp2."scope")) AS scope_details
+  FROM (
+		SELECT id, jsonb_array_elements(scopes::JSONB) AS "scope" FROM auth_consumer WHERE auth_consumer.scopes::TEXT <> 'null'
+	) AS tmp2 GROUP BY tmp2.id
+) AS tmp1
+WHERE
+  auth_consumer.id = tmp1.id AND auth_consumer.scopes::TEXT <> 'null';
+
+-- +migrate Down
+ALTER TABLE "auth_consumer" DROP COLUMN scope_details;
diff --git a/sdk/common.go b/sdk/common.go
index e1b72f678..266d636d2 100644
--- a/sdk/common.go
+++ b/sdk/common.go
@@ -214,6 +214,15 @@ func (s StringSlice) Value() (driver.Value, error) {
 	return j, WrapError(err, "cannot marshal StringSlice")
 }
 
+func (s StringSlice) Contains(value string) bool {
+	for i := range s {
+		if s[i] == value {
+			return true
+		}
+	}
+	return false
+}
+
 // Int64Slice type used for database json storage.
 type Int64Slice []int64
 
diff --git a/sdk/doc/generate.go b/sdk/doc/generate.go
index 065656f63..3a7f7f6a1 100644
--- a/sdk/doc/generate.go
+++ b/sdk/doc/generate.go
@@ -313,52 +313,7 @@ func extractFromRouteInfo(doc *Doc, routeInfo RouteInfo) {
 	doc.HTTPOperation = routeInfo.HTTPOperation
 	doc.Middlewares = routeInfo.Middlewares
 	doc.Scopes = routeInfo.Scopes
-	doc.URL = buildURL(routeInfo.URL)
-}
-
-func buildURL(url string) string {
-	url = strings.Replace(url, "\"", "", -1)
-	urlSplitted := strings.Split(url, "/")
-	for i, u := range urlSplitted {
-		u = strings.Replace(strings.Replace(u, "{", "<", 1), "}", ">", 1)
-		switch u {
-		case "<consumerType>":
-			u = "<consumer-type>"
-		case "<key>", "<permProjectKey>":
-			u = "<project-key>"
-		case "<permWorkflowName>":
-			u = "<workflow-name>"
-		case "<applicationName>":
-			u = "<application-name>"
-		case "<permGroupName>", "<groupName>":
-			u = "<group-name>"
-		case "permUsernamePublic", "<permUsername>":
-			u = "<username>"
-		case "<permActionName>", "permActionBuiltinName":
-			u = "<action-name>"
-		case "<permJobID>":
-			u = "<job-id>"
-		case "<pipelineKey>":
-			u = "<pipeline-key>"
-		case "<environmentName>":
-			u = "<environment-name>"
-		case "<nodeRunID>":
-			u = "node-run-id"
-		case "nodeID":
-			u = "node-id"
-		case "permModelName":
-			u = "model-name"
-		case "permTemplateSlug":
-			u = "template-slug"
-		case "permConsumerID":
-			u = "consumer-id"
-		case "permSessionID":
-			u = "session-id"
-		}
-
-		urlSplitted[i] = u
-	}
-	return strings.Join(urlSplitted, "/")
+	doc.URL = CleanURL(routeInfo.URL)
 }
 
 func filterFile(f os.FileInfo) bool {
diff --git a/sdk/doc/route.go b/sdk/doc/route.go
new file mode 100644
index 000000000..6d594f517
--- /dev/null
+++ b/sdk/doc/route.go
@@ -0,0 +1,109 @@
+package doc
+
+import (
+	"strings"
+
+	"github.com/pkg/errors"
+
+	"github.com/ovh/cds/sdk/slug"
+)
+
+// CleanAndCheckURL returns cleaned URL but also an error if given url contains variables that can't be cleaned.
+// Ex: /myresource/{permMyresourceID} should be transformed to /myresource/<myresource-id>, an error will be returned if permMyResourceID not converted.
+func CleanAndCheckURL(url string) (string, error) {
+	url = CleanURL(url)
+	urlSplitted := strings.Split(url, "/")
+	for i := range urlSplitted {
+		u := strings.TrimSuffix(strings.TrimPrefix(urlSplitted[i], "<"), ">")
+		if u == urlSplitted[i] {
+			continue
+		}
+		if !slug.Valid(u) {
+			return url, errors.Errorf("given url %s contains variable %s that was not cleaned", url, u)
+		}
+	}
+	return url, nil
+}
+
+// CleanURL given a URL with declared variable inside, returns the same URL with harmonized variable names.
+// Ex: permProjectKey -> projectKey
+func CleanURL(url string) string {
+	url = strings.Replace(url, "\"", "", -1)
+	urlSplitted := strings.Split(url, "/")
+	for i := range urlSplitted {
+		u := strings.TrimSuffix(strings.TrimPrefix(urlSplitted[i], "{"), "}")
+		if u == urlSplitted[i] {
+			continue
+		}
+
+		switch u {
+		case "consumerType":
+			u = "consumer-type"
+		case "key", "permProjectKey":
+			u = "project-key"
+		case "permWorkflowName":
+			u = "workflow-name"
+		case "workflowID":
+			u = "workflow-id"
+		case "applicationName":
+			u = "application-name"
+		case "permGroupName", "groupName":
+			u = "group-name"
+		case "permUsernamePublic", "permUsername":
+			u = "username"
+		case "permActionName", "permActionBuiltinName":
+			u = "action-name"
+		case "permJobID", "jobID":
+			u = "job-id"
+		case "pipelineKey":
+			u = "pipeline-key"
+		case "environmentName":
+			u = "environment-name"
+		case "nodeRunID":
+			u = "node-run-id"
+		case "runJobId":
+			u = "run-job-id"
+		case "stepOrder":
+			u = "step-order"
+		case "nodeID":
+			u = "node-id"
+		case "permModelName":
+			u = "model-name"
+		case "permTemplateSlug", "templateSlug":
+			u = "template-slug"
+		case "instanceID":
+			u = "instance-id"
+		case "bulkID":
+			u = "bulk-id"
+		case "permConsumerID":
+			u = "consumer-id"
+		case "permSessionID":
+			u = "session-id"
+		case "integrationID":
+			u = "integration-id"
+		case "integrationName":
+			u = "integration-name"
+		case "auditID":
+			u = "audit-id"
+		case "stageID":
+			u = "stage-id"
+		case "labelID":
+			u = "label-id"
+		case "nodeName":
+			u = "node-name"
+		case "artifactId":
+			u = "artifact-id"
+		case "hookRunID":
+			u = "hook-run-id"
+		case "vcsServer":
+			u = "vcs-server"
+		case "metricName":
+      u = "metric-name"
+    case "cloneName":
+      u = "clone-name"
+		}
+
+		urlSplitted[i] = "<" + u + ">"
+	}
+	return strings.Join(urlSplitted, "/")
+}
diff --git a/sdk/token.go b/sdk/token.go
index 8c85ab1a4..7ed834091 100644
--- a/sdk/token.go
+++ b/sdk/token.go
@@ -4,6 +4,7 @@ import (
 	"context"
 	"database/sql/driver"
 	json "encoding/json"
+	"net/http"
 	"time"
 
 	jwt "github.com/dgrijalva/jwt-go"
@@ -69,6 +70,28 @@ type AuthDriverManifest struct {
 // AuthConsumerScope alias type for string.
 type AuthConsumerScope string
 
+type AuthConsumerScopeEndpoints []AuthConsumerScopeEndpoint
+
+func (e AuthConsumerScopeEndpoints) FindEndpoint(route string) (bool, AuthConsumerScopeEndpoint) {
+	for i := range e {
+		if e[i].Route == route {
+			return true, e[i]
+		}
+	}
+	return false, AuthConsumerScopeEndpoint{}
+}
+
+type AuthConsumerScopeEndpoint struct {
+	Route   string      `json:"route"`
+	Methods StringSlice `json:"methods,omitempty"`
+}
+
+// AuthConsumerScopeDetail contains all endpoints for a scope.
+type AuthConsumerScopeDetail struct {
+	Scope     AuthConsumerScope          `json:"scope"`
+	Endpoints AuthConsumerScopeEndpoints `json:"endpoints,omitempty"`
+}
+
 // IsValid returns validity for scope.
 func (s AuthConsumerScope) IsValid() bool {
 	for i := range AuthConsumerScopes {
@@ -115,6 +138,77 @@ var AuthConsumerScopes = []AuthConsumerScope{
 	AuthConsumerScopeService,
 }
 
+func NewAuthConsumerScopeDetails(scopes ...AuthConsumerScope) AuthConsumerScopeDetails {
+	ds := make(AuthConsumerScopeDetails, len(scopes))
+	for i := range scopes {
+		ds[i] = AuthConsumerScopeDetail{
+			Scope: scopes[i],
+		}
+	}
+	return ds
+}
+
+// AuthConsumerScopeDetails type used for database json storage.
+type AuthConsumerScopeDetails []AuthConsumerScopeDetail
+
+// IsValid returns an error if current details are invalids.
+func (s AuthConsumerScopeDetails) IsValid() error {
+	// Check that each scope is unique
+	mScope := map[AuthConsumerScope]struct{}{}
+	for _, detail := range s {
+		if _, ok := mScope[detail.Scope]; ok {
+			return NewErrorFrom(ErrWrongRequest, "duplicated scope %s in given details", detail.Scope)
+		}
+		mScope[detail.Scope] = struct{}{}
+
+		// Check that each route is unique for scope
+		mRoute := map[string]struct{}{}
+		for _, endpoint := range detail.Endpoints {
+			if _, ok := mRoute[endpoint.Route]; ok {
+				return NewErrorFrom(ErrWrongRequest, "duplicated route %s for scope %s in given details", endpoint.Route, detail.Scope)
+			}
+			mRoute[endpoint.Route] = struct{}{}
+
+			// Check that each method is unique for scope and match GET, POST, PUT or DELETE
+			mMethod := map[string]struct{}{}
+			for _, method := range endpoint.Methods {
+				if _, ok := mMethod[method]; ok {
+					return NewErrorFrom(ErrWrongRequest, "duplicated method %s for route %s and scope %s in given details", method, endpoint.Route, detail.Scope)
+				}
+				mMethod[method] = struct{}{}
+				if !(method == http.MethodGet || method == http.MethodPost || method == http.MethodPut || method == http.MethodDelete) {
+					return NewErrorFrom(ErrWrongRequest, "invalid method %s for route %s and scope %s in given details", method, endpoint.Route, detail.Scope)
+				}
+			}
+		}
+	}
+
+	return nil
+}
+
+func (s AuthConsumerScopeDetails) ToEndpointsMap() map[AuthConsumerScope]AuthConsumerScopeEndpoints {
+	m := make(map[AuthConsumerScope]AuthConsumerScopeEndpoints, len(s))
+	for i := range s {
+		m[s[i].Scope] = s[i].Endpoints
+	}
+	return m
+}
+
+// Scan scope detail slice.
+func (s *AuthConsumerScopeDetails) Scan(src interface{}) error {
+	source, ok := src.([]byte)
+	if !ok {
+		return WithStack(errors.New("type assertion .([]byte) failed"))
+	}
+	return WrapError(json.Unmarshal(source, s), "cannot unmarshal AuthConsumerScopeDetails")
+}
+
+// Value returns driver.Value from scope detail slice.
+func (s AuthConsumerScopeDetails) Value() (driver.Value, error) {
+	j, err := json.Marshal(s)
+	return j, WrapError(err, "cannot marshal AuthConsumerScopeDetails")
+}
+
 // AuthConsumerScopeSlice type used for database json storage.
 type AuthConsumerScopeSlice []AuthConsumerScope
 
@@ -283,20 +377,20 @@ type AuthConsumers []AuthConsumer
 
 // AuthConsumer issues session linked to an authentified user.
 type AuthConsumer struct {
-	ID                 string                 `json:"id" cli:"id,key" db:"id"`
-	Name               string                 `json:"name" cli:"name" db:"name"`
-	Description        string                 `json:"description" cli:"description" db:"description"`
-	ParentID           *string                `json:"parent_id,omitempty" db:"parent_id"`
-	AuthentifiedUserID string                 `json:"user_id,omitempty" db:"user_id"`
-	Type               AuthConsumerType       `json:"type" cli:"type" db:"type"`
-	Data               AuthConsumerData       `json:"-" db:"data"` // NEVER returns auth consumer data in json, TODO this fields should be visible only in auth package
-	Created            time.Time              `json:"created" cli:"created" db:"created"`
-	GroupIDs           Int64Slice             `json:"group_ids,omitempty" cli:"group_ids" db:"group_ids"`
-	InvalidGroupIDs    Int64Slice             `json:"invalid_group_ids,omitempty" db:"invalid_group_ids"`
-	Scopes             AuthConsumerScopeSlice `json:"scopes,omitempty" cli:"scopes" db:"scopes"`
-	IssuedAt           time.Time              `json:"issued_at" cli:"issued_at" db:"issued_at"`
-	Disabled           bool                   `json:"disabled" cli:"disabled" db:"disabled"`
-	Warnings           AuthConsumerWarnings   `json:"warnings,omitempty" db:"warnings"`
+	ID                 string                   `json:"id" cli:"id,key" db:"id"`
+	Name               string                   `json:"name" cli:"name" db:"name"`
+	Description        string                   `json:"description" cli:"description" db:"description"`
+	ParentID           *string                  `json:"parent_id,omitempty" db:"parent_id"`
+	AuthentifiedUserID string                   `json:"user_id,omitempty" db:"user_id"`
+	Type               AuthConsumerType         `json:"type" cli:"type" db:"type"`
+	Data               AuthConsumerData         `json:"-" db:"data"` // NEVER returns auth consumer data in json, TODO this fields should be visible only in auth package
+	Created            time.Time                `json:"created" cli:"created" db:"created"`
+	GroupIDs           Int64Slice               `json:"group_ids,omitempty" cli:"group_ids" db:"group_ids"`
+	InvalidGroupIDs    Int64Slice               `json:"invalid_group_ids,omitempty" db:"invalid_group_ids"`
+	ScopeDetails       AuthConsumerScopeDetails `json:"scope_details,omitempty" cli:"scope_details" db:"scope_details"`
+	IssuedAt           time.Time                `json:"issued_at" cli:"issued_at" db:"issued_at"`
+	Disabled           bool                     `json:"disabled" cli:"disabled" db:"disabled"`
+	Warnings           AuthConsumerWarnings     `json:"warnings,omitempty" db:"warnings"`
 	// aggregates
 	AuthentifiedUser *AuthentifiedUser `json:"user,omitempty" db:"-"`
 	Groups           Groups            `json:"groups,omitempty" db:"-"`
@@ -305,12 +399,38 @@ type AuthConsumer struct {
 }
 
 // IsValid returns validity for auth consumer.
-func (c AuthConsumer) IsValid() error {
-	for _, s := range c.Scopes {
-		if !s.IsValid() {
+func (c AuthConsumer) IsValid(scopeDetails AuthConsumerScopeDetails) error {
+	if c.Name == "" {
+		return NewErrorFrom(ErrWrongRequest, "invalid given name")
+	}
+
+	if err := c.ScopeDetails.IsValid(); err != nil {
+		return err
+	}
+
+	mEndpoints := scopeDetails.ToEndpointsMap()
+
+	for _, s := range c.ScopeDetails {
+		if !s.Scope.IsValid() {
 			return NewErrorFrom(ErrWrongRequest, "invalid given scope value %s", s)
 		}
+
+		// Checks that given route/method restriction match existing routes
+		existingEndpoints := mEndpoints[s.Scope]
+		for _, e := range s.Endpoints {
+			exists, existingEndpoint := existingEndpoints.FindEndpoint(e.Route)
+			if !exists {
+				return NewErrorFrom(ErrWrongRequest, "invalid given route %s for scope %s", e.Route, s)
+			}
+
+			for _, m := range e.Methods {
+				if !existingEndpoint.Methods.Contains(m) {
+					return NewErrorFrom(ErrWrongRequest, "invalid given method %s for route %s with scope %s", m, e.Route, s)
+				}
+			}
+		}
 	}
+
 	return nil
 }
 
diff --git a/ui/src/app/model/authentication.model.ts b/ui/src/app/model/authentication.model.ts
index 499207836..160d959fa 100644
--- a/ui/src/app/model/authentication.model.ts
+++ b/ui/src/app/model/authentication.model.ts
@@ -2,6 +2,16 @@ import { WithKey } from 'app/shared/table/data-table.component';
 import { Group } from './group.model';
 import { AuthentifiedUser } from './user.model';
 
+export class AuthConsumerScopeDetail {
+    scope: string;
+    endpoints: Array<AuthConsumerScopeEndpoint>;
+}
+
+export class AuthConsumerScopeEndpoint {
+    route: string;
+    methods: Array<string>;
+}
+
 export class AuthScope implements WithKey {
     value: string;
 
@@ -64,7 +74,7 @@ export class AuthConsumer {
     type: string;
     created: string;
     group_ids: Array<number>;
-    scopes: Array<string>;
+    scope_details: Array<AuthConsumerScopeDetail>;
     groups: Array<Group>;
     disabled: boolean;
     warnings: Array<AuthConsumerWarning>;
@@ -80,8 +90,6 @@ export class AuthSession {
     consumer_id: string;
     expire_at: string;
     created: string;
-    group_ids: Array<number>;
-    scopes: Array<string>;
     current: boolean;
 
     // UI fields
diff --git a/ui/src/app/model/workflow.hook.model.ts b/ui/src/app/model/workflow.hook.model.ts
index c2eb9a76c..5a013ec3d 100644
--- a/ui/src/app/model/workflow.hook.model.ts
+++ b/ui/src/app/model/workflow.hook.model.ts
@@ -42,11 +42,16 @@ export class TaskExecution {
     config: Map<string, WorkflowNodeHookConfigValue>;
     webhook: Webhook;
     rabbitmq: RabbitMQ;
+    gerrit: GerritExecution;
     kafka: Kafka;
     scheduled_task?: any;
     status: HookStatus;
 }
 
+export class GerritExecution {
+    message: string;
+}
+
 export class Webhook {
     reques_url: string;
     request_body: string;
diff --git a/ui/src/app/service/authentication/authentication.service.ts b/ui/src/app/service/authentication/authentication.service.ts
index b7792714c..ca2f8ddf0 100644
--- a/ui/src/app/service/authentication/authentication.service.ts
+++ b/ui/src/app/service/authentication/authentication.service.ts
@@ -1,11 +1,11 @@
 import { HttpClient, HttpParams } from '@angular/common/http';
 import { Injectable } from '@angular/core';
 import {
+    AuthConsumerScopeDetail,
     AuthConsumerSigninResponse,
     AuthCurrentConsumerResponse,
     AuthDriverManifests,
-    AuthDriverSigningRedirect,
-    AuthScope
+    AuthDriverSigningRedirect
 } from 'app/model/authentication.model';
 import { Observable } from 'rxjs';
 
@@ -37,10 +37,8 @@ export class AuthenticationService {
         return this._http.get<AuthDriverManifests>('/auth/driver');
     }
 
-    getScopes(): Observable<Array<AuthScope>> {
-        return this._http.get<Array<string>>('/auth/scope').map(ss => {
-            return ss.map(s => new AuthScope(s));
-        });
+    getScopes(): Observable<Array<AuthConsumerScopeDetail>> {
+        return this._http.get<Array<AuthConsumerScopeDetail>>('/auth/scope');
     }
 
     signin(consumerType: string, code: string, state: string, init_token: string): Observable<AuthConsumerSigninResponse> {
diff --git a/ui/src/app/shared/workflow/node/hook/details/hook.details.component.html b/ui/src/app/shared/workflow/node/hook/details/hook.details.component.html
index 75788ba6f..0af0fda4f 100644
--- a/ui/src/app/shared/workflow/node/hook/details/hook.details.component.html
+++ b/ui/src/app/shared/workflow/node/hook/details/hook.details.component.html
@@ -20,37 +20,37 @@
                           </ng-container>
                         </div>
                     </div>
-                    <h3>{{'workflow_hook_log_title' | translate}}</h3>
-                    <div class="inline fields" *ngIf="task.workflow_run">
-                      <div class="four wide field"><label>{{'workflow_hook_log_workflow_run' | translate}}</label></div>
-                      <input type="text" [value]="task.workflow_run" [readonly]="true">
-                    </div>
-                    <div class="inline fields">
-                        <div class="four wide field"><label>{{'worker_model_status' | translate}}</label></div>
-                        <input type="text" [value]="task.status" [readonly]="true">
-                    </div>
-                    <div class="inline fields">
-                      <div class="four wide field"><label>{{'common_begin' | translate}}</label></div>
-                      <input type="text" [value]="(task.timestamp / 1000000) | date:'short'" [readonly]="true">
-                    </div>
-                    <div class="inline fields">
-                      <div class="four wide field"><label>{{'common_end' | translate}}</label></div>
-                      <input type="text" [value]="(task.processing_timestamp / 1000000) | date:'short'" [readonly]="true">
-                    </div>
-                    <div class="inline fields" *ngIf="task.last_error">
-                      <div class="four wide field"><label>{{'common_error' | translate}}</label></div>
-                      <input type="text" [value]="task.last_error" [readonly]="true">
-                    </div>
-                    <div class="inline fields" *ngIf="task.webhook && task.webhook.request_body">
-                      <div class="four wide field"><label>Body</label></div>
-                      <codemirror
-                        [ngModel]="task.webhook.request_body"
-                        [config]="codeMirrorConfig"
-                        #code>
-                      </codemirror>
-                    </div>
                 </ng-container>
                 <div class="ui info message" *ngIf="!task.config">{{ 'workflow_node_hook_no_configuration' | translate }}</div>
+                <h3>{{'workflow_hook_log_title' | translate}}</h3>
+                <div class="inline fields" *ngIf="task.workflow_run">
+                    <div class="four wide field"><label>{{'workflow_hook_log_workflow_run' | translate}}</label></div>
+                    <input type="text" [value]="task.workflow_run" [readonly]="true">
+                </div>
+                <div class="inline fields">
+                    <div class="four wide field"><label>{{'worker_model_status' | translate}}</label></div>
+                    <input type="text" [value]="task.status" [readonly]="true">
+                </div>
+                <div class="inline fields">
+                    <div class="four wide field"><label>{{'common_begin' | translate}}</label></div>
+                    <input type="text" [value]="(task.timestamp / 1000000) | date:'short'" [readonly]="true">
+                </div>
+                <div class="inline fields">
+                    <div class="four wide field"><label>{{'common_end' | translate}}</label></div>
+                    <input type="text" [value]="(task.processing_timestamp / 1000000) | date:'short'" [readonly]="true">
+                </div>
+                <div class="inline fields" *ngIf="task.last_error">
+                    <div class="four wide field"><label>{{'common_error' | translate}}</label></div>
+                    <input type="text" [value]="task.last_error" [readonly]="true">
+                </div>
+                <div class="inline fields" *ngIf="body">
+                    <div class="four wide field"><label>Body</label></div>
+                    <codemirror
+                            [ngModel]="body"
+                            [config]="codeMirrorConfig"
+                            #code>
+                    </codemirror>
+                </div>
             </ng-container>
         </div>
     </div>
diff --git a/ui/src/app/shared/workflow/node/hook/details/hook.details.component.scss b/ui/src/app/shared/workflow/node/hook/details/hook.details.component.scss
index 2b8dc725c..f9ad4da55 100644
--- a/ui/src/app/shared/workflow/node/hook/details/hook.details.component.scss
+++ b/ui/src/app/shared/workflow/node/hook/details/hook.details.component.scss
@@ -1,3 +1,8 @@
 codemirror {
   width: 100%;
 }
+
+.content {
+  height: 600px;
+  overflow: auto;
+}
diff --git a/ui/src/app/shared/workflow/node/hook/details/hook.details.component.ts b/ui/src/app/shared/workflow/node/hook/details/hook.details.component.ts
index 06d2f8b24..19e69038a 100644
--- a/ui/src/app/shared/workflow/node/hook/details/hook.details.component.ts
+++ b/ui/src/app/shared/workflow/node/hook/details/hook.details.component.ts
@@ -23,6 +23,7 @@ export class WorkflowNodeHookDetailsComponent implements OnInit {
   task: TaskExecution;
   codeMirrorConfig: any;
   themeSubscription: Subscription;
+  body: string;
 
   constructor(
     private _modalService: SuiModalService,
@@ -51,16 +52,21 @@ export class WorkflowNodeHookDetailsComponent implements OnInit {
   }
 
   show(taskExec: TaskExecution): void {
-    this.task = cloneDeep(taskExec);
-    if (this.task.webhook && this.task.webhook.request_body) {
-      let body = atob(this.task.webhook.request_body);
+      this.task = cloneDeep(taskExec);
+      let jsonBody;
+      if (this.task.webhook) {
+            jsonBody = atob(this.task.webhook.request_body);
+      } else if (this.task.gerrit) {
+          jsonBody = atob(this.task.gerrit.message);
+      }
       try {
-        this.task.webhook.request_body = JSON.stringify(JSON.parse(body), null, 4);
+          this.body = JSON.stringify(JSON.parse(jsonBody), null, 4);
       } catch (e) {
-        this.task.webhook.request_body = body;
+          this.body = jsonBody;
       }
-    }
+
     this.modalConfig = new TemplateModalConfig<boolean, boolean, void>(this.nodeHookDetailsModal);
+      this.modalConfig.size = 'large';
     this.modal = this._modalService.open(this.modalConfig);
   }
 }
diff --git a/ui/src/app/store/workflow.state.ts b/ui/src/app/store/workflow.state.ts
index e7b299b5f..a9416f732 100644
--- a/ui/src/app/store/workflow.state.ts
+++ b/ui/src/app/store/workflow.state.ts
@@ -202,9 +202,18 @@ export class WorkflowState {
                 wf.template_up_to_date = cloneDeep(oldWorkflow.template_up_to_date);
                 wf.as_code_events = cloneDeep(oldWorkflow.as_code_events);
 
+                // Generate hook ref for UI edition
+                if (wf && wf.workflow_data && wf.workflow_data.node && wf.workflow_data.node.hooks) {
+                    wf.workflow_data.node.hooks.forEach(h => {
+                        if (!h.ref) {
+                            h.ref = h.uuid;
+                        }
+                    });
+                }
+
                 ctx.setState({
                     ...state,
-                    workflow: wf,
+                    workflow: wf
                 });
                 ctx.dispatch(new ActionProject.UpdateWorkflowInProject({
                     previousWorkflowName: action.payload.workflowName,
@@ -225,11 +234,20 @@ export class WorkflowState {
                 wfUpdated.template_up_to_date = state.workflow.template_up_to_date;
                 wfUpdated.as_code_events = state.workflow.as_code_events;
 
+                // Generate hook ref for UI edition
+                if (wfUpdated.workflow_data && wfUpdated.workflow_data.node && wfUpdated.workflow_data.node.hooks) {
+                    wfUpdated.workflow_data.node.hooks.forEach(h => {
+                        if (!h.ref) {
+                            h.ref = h.uuid;
+                        }
+                    });
+                }
+
                 ctx.setState({
                     ...state,
-                    workflow: wf,
+                    workflow: wfUpdated
                 });
-                ctx.dispatch(new UpdateModal({ workflow: wf }));
+                ctx.dispatch(new UpdateModal({ workflow: wfUpdated }));
             }
         }));
     }
@@ -849,6 +867,16 @@ export class WorkflowState {
                 let canEdit = wf.permissions.writable;
                 let editWorkflow: Workflow;
                 let editMode: boolean;
+
+                // Generate hook ref for UI edition
+                if (wf && wf.workflow_data && wf.workflow_data.node && wf.workflow_data.node.hooks) {
+                    wf.workflow_data.node.hooks.forEach(h => {
+                        if (!h.ref) {
+                            h.ref = h.uuid;
+                        }
+                    });
+                }
+
                 if (wf.from_repository) {
                     editWorkflow = cloneDeep(wf);
                     editMode = true;
diff --git a/ui/src/app/views/settings/settings.module.ts b/ui/src/app/views/settings/settings.module.ts
index a6fc1ae0d..f49cb666c 100644
--- a/ui/src/app/views/settings/settings.module.ts
+++ b/ui/src/app/views/settings/settings.module.ts
@@ -16,6 +16,7 @@ import { QueueComponent } from './queue/queue.component';
 import { SettingsComponent } from './settings.component';
 import { SettingsRouting } from './settings.routing';
 import { ConsumerCreateModalComponent } from './user/consumer-create-modal/consumer-create-modal.component';
+import { ScopeDetailComponent } from './user/consumer-create-modal/scope-detail.component';
 import { ConsumerDetailsModalComponent } from './user/consumer-details-modal/consumer-details-modal.component';
 import { ConsumerDisplaySigninTokenComponent } from './user/consumer-display-signin-token/consumer-display-signin-token-component';
 import { UserEditComponent } from './user/edit/user.edit.component';
@@ -62,7 +63,8 @@ import { WorkflowTemplateListComponent } from './workflow-template/list/workflow
         WorkflowTemplateEditComponent,
         WorkflowTemplateListComponent,
         WorkflowTemplateHelpComponent,
-        QueueComponent
+        QueueComponent,
+        ScopeDetailComponent
     ],
     imports: [
         SharedModule,
diff --git a/ui/src/app/views/settings/user/consumer-create-modal/consumer-create-modal.component.ts b/ui/src/app/views/settings/user/consumer-create-modal/consumer-create-modal.component.ts
index d08616d0f..c8f50e991 100644
--- a/ui/src/app/views/settings/user/consumer-create-modal/consumer-create-modal.component.ts
+++ b/ui/src/app/views/settings/user/consumer-create-modal/consumer-create-modal.component.ts
@@ -9,7 +9,7 @@ import {
 } from '@angular/core';
 import { TranslateService } from '@ngx-translate/core';
 import { ModalTemplate, SuiActiveModal, SuiModalService, TemplateModalConfig } from '@richardlt/ng2-semantic-ui';
-import { AuthConsumer, AuthScope } from 'app/model/authentication.model';
+import { AuthConsumer, AuthConsumerScopeDetail } from 'app/model/authentication.model';
 import { Group } from 'app/model/group.model';
 import { AuthentifiedUser } from 'app/model/user.model';
 import { AuthenticationService } from 'app/service/authentication/authentication.service';
@@ -24,6 +24,13 @@ export enum CloseEventType {
     CLOSED = 'CLOSED'
 }
 
+export enum FormStepName {
+    INFORMATIONS = 0,
+    GROUPS = 1,
+    SCOPES = 2,
+    TOKEN = 3
+}
+
 @Component({
     selector: 'app-consumer-create-modal',
     templateUrl: './consumer-create-modal.html',
@@ -46,9 +53,12 @@ export class ConsumerCreateModalComponent {
     columnsGroups: Array<Column<Group>>;
     selectedGroupKeys: Array<string>;
     loadingScopes: boolean;
-    scopes: Array<AuthScope>;
-    columnsScopes: Array<Column<AuthScope>>;
-    selectedScopeKeys: Array<string>;
+    scopes: Array<AuthConsumerScopeDetail>;
+    selectedScopeDetails: Array<AuthConsumerScopeDetail>;
+
+    formStepName = FormStepName;
+    activeStep: FormStepName;
+    maxActivedStep: FormStepName;
 
     constructor(
         private _modalService: SuiModalService,
@@ -66,14 +76,6 @@ export class ConsumerCreateModalComponent {
                 selector: (g: Group) => g.name
             }
         ];
-
-        this.columnsScopes = [
-            <Column<AuthScope>>{
-                name: 'common_name',
-                class: 'fourteen',
-                selector: (s: AuthScope) => s.value
-            }
-        ];
     }
 
     show() {
@@ -105,7 +107,10 @@ export class ConsumerCreateModalComponent {
         this.newConsumer = new AuthConsumer();
         this.signinToken = null;
         this.selectedGroupKeys = null;
-        this.selectedScopeKeys = null;
+        this.selectedScopeDetails = [];
+
+        this.activeStep = FormStepName.INFORMATIONS;
+        this.maxActivedStep = this.activeStep;
 
         this.loadingGroups = true;
         this.loadingScopes = true;
@@ -126,7 +131,7 @@ export class ConsumerCreateModalComponent {
                 this._cd.markForCheck();
             }))
             .subscribe((ss) => {
-                this.scopes = ss.sort((a, b) => a.value < b.value ? -1 : 1);
+                this.scopes = ss.sort((a, b) => a.scope < b.scope ? -1 : 1);
             });
     }
 
@@ -141,20 +146,9 @@ export class ConsumerCreateModalComponent {
         this.selectedGroupKeys = e;
     }
 
-    selectScopeFunc: Select<AuthScope> = (s: AuthScope): boolean => {
-        if (!this.selectedScopeKeys || this.selectedScopeKeys.length === 0) {
-            return false;
-        }
-        return !!this.selectedScopeKeys.find(k => k === s.key());
-    }
-
-    selectScopeChange(e: Array<string>) {
-        this.selectedScopeKeys = e;
-    }
-
-    clickSave(): void {
+    save(): void {
         this.newConsumer.group_ids = this.groups.filter(g => this.selectedGroupKeys.find(k => k === g.key())).map(g => g.id);
-        this.newConsumer.scopes = this.selectedScopeKeys;
+        this.newConsumer.scope_details = this.selectedScopeDetails;
 
         this.loading = true;
         this._cd.markForCheck();
@@ -166,6 +160,8 @@ export class ConsumerCreateModalComponent {
             .subscribe(res => {
                 this.newConsumer = res.consumer;
                 this.signinToken = res.token;
+
+                this.activeStep = FormStepName.TOKEN;
             });
     }
 
@@ -180,10 +176,78 @@ export class ConsumerCreateModalComponent {
         }
     }
 
-    filterScopes(f: string) {
-        const lowerFilter = f.toLowerCase();
-        return (s: AuthScope) => {
-            return s.value.toLowerCase().indexOf(lowerFilter) !== -1;
+    clickBack() {
+        switch (this.activeStep) {
+            case FormStepName.GROUPS:
+                this.activeStep = FormStepName.INFORMATIONS;
+                break;
+            case FormStepName.SCOPES:
+                this.activeStep = FormStepName.GROUPS;
+                break;
+            default:
+                return;
+        }
+        this._cd.markForCheck();
+    }
+
+    clickNext() {
+        if (!this.isValidStep(this.activeStep)) {
+            return;
+        }
+
+        switch (this.activeStep) {
+            case FormStepName.INFORMATIONS:
+                this.activeStep = FormStepName.GROUPS;
+                break;
+            case FormStepName.GROUPS:
+                this.activeStep = FormStepName.SCOPES;
+                break;
+            case FormStepName.SCOPES:
+                this.save();
+                return;
+            default:
+                return;
+        }
+        if (this.maxActivedStep < this.activeStep) {
+            this.maxActivedStep = this.activeStep;
+        }
+
+        this._cd.markForCheck();
+    }
+
+    clickOpenStep(step: FormStepName) {
+        if (step > this.activeStep && !this.isValidStep(this.activeStep)) {
+            return;
+        }
+        if (step === this.activeStep) {
+            return;
+        }
+        if (step <= this.maxActivedStep) {
+            this.activeStep = step;
+        }
+        this._cd.markForCheck();
+    }
+
+    isValidStep(step: FormStepName): boolean {
+        switch (step) {
+            case FormStepName.INFORMATIONS:
+                return this.newConsumer.name && this.newConsumer.name !== '';
+            case FormStepName.GROUPS:
+                return true;
+            case FormStepName.SCOPES:
+                return this.selectedScopeDetails.length > 0;
+            default:
+                return false;
+        }
+    }
+
+    onScopeDetailChange(detail: AuthConsumerScopeDetail) {
+        for (let i = 0; i < this.selectedScopeDetails.length; i++) {
+            if (this.selectedScopeDetails[i].scope === detail.scope) {
+                this.selectedScopeDetails[i] = detail;
+                return
+            }
         }
+        this.selectedScopeDetails.push(detail);
     }
 }
diff --git a/ui/src/app/views/settings/user/consumer-create-modal/consumer-create-modal.html b/ui/src/app/views/settings/user/consumer-create-modal/consumer-create-modal.html
index 2e96641d1..ed38ca5b0 100644
--- a/ui/src/app/views/settings/user/consumer-create-modal/consumer-create-modal.html
+++ b/ui/src/app/views/settings/user/consumer-create-modal/consumer-create-modal.html
@@ -4,44 +4,103 @@
     </div>
     <div class="content">
         <div class="ui grid">
-            <div class="ui wide column">
-                <div class="ui form" *ngIf="!newConsumer.id">
-                    <div class="ui wide field">
-                        <div class="fields">
-                            <div class="eight wide field">
-                                <label>{{'common_name' | translate}}</label>
-                                <input class="ui input" type="text" name="name" [(ngModel)]="newConsumer.name">
-                            </div>
-                            <div class="eight wide field">
-                                <label>{{'common_description' | translate}}</label>
-                                <input class="ui input" type="text" name="group" [(ngModel)]="newConsumer.description">
-                            </div>
+            <div class="ui sixteen wide column">
+                <ng-container *ngIf="!newConsumer.id">
+                    <div class="ui grid">
+                        <div class="ui sixteen wide column">
+                            <sui-accordion class="styled fluid">
+                                <sui-accordion-panel [isOpen]="activeStep === formStepName.INFORMATIONS"
+                                    [isDisabled]="true">
+                                    <div title (click)="clickOpenStep(formStepName.INFORMATIONS)">
+                                        <i class="dropdown icon"></i>
+                                        {{'common_informations' | translate}}
+                                    </div>
+                                    <div content>
+                                        <div class="ui form">
+                                            <div class="ui wide field">
+                                                <div class="fields">
+                                                    <div class="eight wide field">
+                                                        <label>{{'common_name' | translate}}</label>
+                                                        <input class="ui input" type="text" name="name"
+                                                            [(ngModel)]="newConsumer.name">
+                                                    </div>
+                                                    <div class="eight wide field">
+                                                        <label>{{'common_description' | translate}}</label>
+                                                        <input class="ui input" type="text" name="group"
+                                                            [(ngModel)]="newConsumer.description">
+                                                    </div>
+                                                </div>
+                                            </div>
+                                        </div>
+                                    </div>
+                                </sui-accordion-panel>
+                                <sui-accordion-panel [isOpen]="activeStep === formStepName.GROUPS" [isDisabled]="true">
+                                    <div title (click)="clickOpenStep(formStepName.GROUPS)">
+                                        <i class="dropdown icon"></i>
+                                        {{'user_auth_groups' | translate}}
+                                    </div>
+                                    <div content>
+                                        <div class="ui form" *ngIf="!newConsumer.id">
+                                            <div class="ui message blue">
+                                                <p>{{'auth_consumer_create_modal_info_groups' | translate}}</p>
+                                            </div>
+                                            <div class="ui wide field">
+                                                <app-data-table [withFilter]="filterGroups"
+                                                    [withSelect]="selectGroupFunc"
+                                                    (selectChange)="selectGroupChange($event)" [withPagination]="5"
+                                                    [columns]="columnsGroups" [data]="groups" [loading]="loadingGroups">
+                                                </app-data-table>
+                                            </div>
+                                        </div>
+                                    </div>
+                                </sui-accordion-panel>
+                                <sui-accordion-panel [isOpen]="activeStep === formStepName.SCOPES" [isDisabled]="true">
+                                    <div title (click)="clickOpenStep(formStepName.SCOPES)">
+                                        <i class="dropdown icon"></i>
+                                        {{'user_auth_scopes' | translate}}
+                                    </div>
+                                    <div content class="scope-list">
+                                        <table class="ui striped fixed table">
+                                            <tbody>
+                                                <tr *ngFor="let scope of scopes">
+                                                    <td class="wide">
+                                                        <app-scope-detail [scope]="scope"
+                                                            (onChange)="onScopeDetailChange($event)"></app-scope-detail>
+                                                    </td>
+                                                </tr>
+                                            </tbody>
+                                        </table>
+                                    </div>
+                                </sui-accordion-panel>
+                            </sui-accordion>
                         </div>
-                        <div class="fields">
-                            <div class="eight wide field">
-                                <label>{{'user_auth_groups' | translate}}</label>
-                                <app-data-table [withFilter]="filterGroups" [withSelect]="selectGroupFunc"
-                                    (selectChange)="selectGroupChange($event)" [withPagination]="5"
-                                    [columns]="columnsGroups" [data]="groups" [loading]="loadingGroups">
-                                </app-data-table>
-                            </div>
-                            <div class="eight wide field">
-                                <label>{{'user_auth_scopes' | translate}}</label>
-                                <app-data-table [withFilter]="filterScopes" [withSelect]="selectScopeFunc"
-                                    (selectChange)="selectScopeChange($event)" [withPagination]="5"
-                                    [columns]="columnsScopes" [data]="scopes" [loading]="loadingScopes">
-                                </app-data-table>
-                            </div>
+                        <div class="four wide column left floated">
+                            <button *ngIf="activeStep === formStepName.GROUPS || activeStep == formStepName.SCOPES"
+                                class="ui primary button" (click)="clickBack()">
+                                {{ 'btn_back' | translate }}
+                            </button>
                         </div>
-                        <div class="field inline">
-                            <div class="eight wide column centered">
-                                <button class="ui green button" [class.loading]="loading" (click)="clickSave()">
+                        <div class="eight wide column centered">
+                            <button *ngIf="activeStep !== formStepName.TOKEN" class="ui green button"
+                                [class.loading]="loading" (click)="clickNext()">
+                                <ng-container
+                                    *ngIf="activeStep === formStepName.SCOPES; then createButton; else nextButton">
+                                </ng-container>
+                                <ng-template #createButton>
                                     <i class="save icon"></i>{{ 'btn_create' | translate }}
-                                </button>
-                            </div>
+                                </ng-template>
+                                <ng-template #nextButton>
+                                    {{ 'btn_next' | translate }}
+                                </ng-template>
+                            </button>
+                        </div>
+                        <div class="four wide column right aligned">
+                            <button class="ui secondary button" (click)="clickClose()">
+                                {{ 'btn_close' | translate }}
+                            </button>
                         </div>
                     </div>
-                </div>
+                </ng-container>
                 <ng-container *ngIf="newConsumer.id">
                     <app-consumer-display-signin-token [consumer]="newConsumer" [signinToken]="signinToken">
                     </app-consumer-display-signin-token>
@@ -49,4 +108,4 @@
             </div>
         </div>
     </div>
-</ng-template>
\ No newline at end of file
+</ng-template>
diff --git a/ui/src/app/views/settings/user/consumer-create-modal/consumer-create-modal.scss b/ui/src/app/views/settings/user/consumer-create-modal/consumer-create-modal.scss
index 153e0d0de..4015b49e4 100644
--- a/ui/src/app/views/settings/user/consumer-create-modal/consumer-create-modal.scss
+++ b/ui/src/app/views/settings/user/consumer-create-modal/consumer-create-modal.scss
@@ -1,1 +1,10 @@
-@import '../../../../../common';
+@import "../../../../../common";
+
+.scope-list {
+    max-height: 200px;
+    overflow-y: auto;
+
+    td {
+        border: none !important;
+    }
+}
diff --git a/ui/src/app/views/settings/user/consumer-create-modal/scope-detail.component.ts b/ui/src/app/views/settings/user/consumer-create-modal/scope-detail.component.ts
new file mode 100644
index 000000000..2bce9bc95
--- /dev/null
+++ b/ui/src/app/views/settings/user/consumer-create-modal/scope-detail.component.ts
@@ -0,0 +1,239 @@
+import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, Output } from '@angular/core';
+import { AuthConsumerScopeDetail, AuthConsumerScopeEndpoint } from 'app/model/authentication.model';
+
+@Component({
+    selector: 'app-scope-detail',
+    templateUrl: './scope-detail.html',
+    styleUrls: ['./scope-detail.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class ScopeDetailComponent {
+    scopeDetail: AuthConsumerScopeDetail;
+    @Input() set scope(value: AuthConsumerScopeDetail) {
+        this.scopeDetail = value;
+        this.allRoutesSelected = false;
+        this.selectedRouteMethods = {};
+        this.scopeDetail.endpoints.forEach(endpoint => {
+            this.selectedRouteMethods[endpoint.route] = { ALL: false };
+            endpoint.methods.forEach(method => {
+                this.selectedRouteMethods[endpoint.route][method] = false;
+            });
+        });
+    }
+    @Output() onChange = new EventEmitter<AuthConsumerScopeDetail>();
+
+    advancedOpen: boolean;
+    allReadRoutesSelected: boolean;
+    allWriteRoutesSelected: boolean;
+    allRoutesSelected: boolean;
+    selectedRouteMethods: any;
+
+    constructor(private _cd: ChangeDetectorRef) { }
+
+    sendChangeEvent() {
+        let scopeDetail = <AuthConsumerScopeDetail>{
+            scope: this.scopeDetail.scope
+        };
+
+        // If all routes selected returns with empty endpoints list
+        if (this.allRoutesSelected) {
+            this.onChange.emit(scopeDetail);
+            return
+        }
+
+        scopeDetail.endpoints = []
+        const routes = Object.keys(this.selectedRouteMethods);
+        for (let i = 0; i < routes.length; i++) {
+            let endpoint = <AuthConsumerScopeEndpoint>{
+                route: routes[i]
+            };
+
+            // If all methods selected returns with empty methods list
+            if (this.selectedRouteMethods[routes[i]].ALL) {
+                scopeDetail.endpoints.push(endpoint);
+                continue;
+            }
+
+            endpoint.methods = [];
+            const methods = Object.keys(this.selectedRouteMethods[routes[i]]);
+            for (let j = 0; j < methods.length; j++) {
+                if (methods[j] !== 'ALL' && this.selectedRouteMethods[routes[i]][methods[j]]) {
+                    endpoint.methods.push(methods[j]);
+                }
+            }
+            if (endpoint.methods && endpoint.methods.length > 0) {
+                scopeDetail.endpoints.push(endpoint);
+            }
+        }
+        if (scopeDetail.endpoints && scopeDetail.endpoints.length > 0) {
+            this.onChange.emit(scopeDetail);
+        }
+    }
+
+    clickAdvanced() {
+        this.advancedOpen = !this.advancedOpen;
+        this._cd.markForCheck();
+    }
+
+    clickSelectAllRoutesRead() {
+        this.allReadRoutesSelected = !this.allReadRoutesSelected;
+
+        this.allRoutesSelected = this.allReadRoutesSelected && this.allWriteRoutesSelected;
+
+        const routes = Object.keys(this.selectedRouteMethods);
+        for (let i = 0; i < routes.length; i++) {
+            if (this.selectedRouteMethods[routes[i]].GET != null) {
+                this.selectedRouteMethods[routes[i]].GET = this.allReadRoutesSelected;
+            }
+
+            this.syncRouteAllCheckbox(routes[i]);
+        }
+
+        this._cd.markForCheck();
+
+        this.sendChangeEvent();
+    }
+
+    clickSelectAllRoutesWrite() {
+        this.allWriteRoutesSelected = !this.allWriteRoutesSelected;
+
+        this.allRoutesSelected = this.allReadRoutesSelected && this.allWriteRoutesSelected;
+
+        const routes = Object.keys(this.selectedRouteMethods);
+        for (let i = 0; i < routes.length; i++) {
+            if (this.selectedRouteMethods[routes[i]].POST != null) {
+                this.selectedRouteMethods[routes[i]].POST = this.allWriteRoutesSelected;
+            }
+            if (this.selectedRouteMethods[routes[i]].PUT != null) {
+                this.selectedRouteMethods[routes[i]].PUT = this.allWriteRoutesSelected;
+            }
+            if (this.selectedRouteMethods[routes[i]].DELETE != null) {
+                this.selectedRouteMethods[routes[i]].DELETE = this.allWriteRoutesSelected;
+            }
+
+            this.syncRouteAllCheckbox(routes[i]);
+        }
+
+        this._cd.markForCheck();
+
+        this.sendChangeEvent();
+    }
+
+    clickSelectAllRoutes() {
+        this.allRoutesSelected = !this.allRoutesSelected;
+
+        this.allReadRoutesSelected = this.allRoutesSelected;
+        this.allWriteRoutesSelected = this.allRoutesSelected;
+
+        // set or unset all routes
+        const routes = Object.keys(this.selectedRouteMethods);
+        for (let i = 0; i < routes.length; i++) {
+            Object.keys(this.selectedRouteMethods[routes[i]]).forEach(method => {
+                this.selectedRouteMethods[routes[i]][method] = this.allRoutesSelected;
+            });
+        }
+
+        this._cd.markForCheck();
+
+        this.sendChangeEvent();
+    }
+
+    clickMethod(route: string, method: string) {
+        if (this.selectedRouteMethods[route]) {
+            this.selectedRouteMethods[route][method] = !this.selectedRouteMethods[route][method];
+
+            this.syncRouteAllCheckbox(route);
+
+            this.syncGlobalCheckboxes();
+
+            this._cd.markForCheck();
+
+            this.sendChangeEvent();
+        }
+    }
+
+    clickAllMethods(route: string) {
+        if (this.selectedRouteMethods[route]) {
+            this.selectedRouteMethods[route].ALL = !this.selectedRouteMethods[route].ALL;
+
+            // set or unset all methods
+            Object.keys(this.selectedRouteMethods[route]).forEach(method => {
+                if (method !== 'ALL') {
+                    this.selectedRouteMethods[route][method] = this.selectedRouteMethods[route].ALL;
+                }
+            });
+
+            this.syncGlobalCheckboxes();
+
+            this._cd.markForCheck();
+
+            this.sendChangeEvent();
+        }
+    }
+
+    syncRouteAllCheckbox(key: string) {
+        // check if all methods are selected
+        let allMethodsSelected = true;
+        const methods = Object.keys(this.selectedRouteMethods[key]);
+        for (let i = 0; i < methods.length; i++) {
+            if (methods[i] !== 'ALL' && !this.selectedRouteMethods[key][methods[i]]) {
+                allMethodsSelected = false;
+                break;
+            }
+        }
+        this.selectedRouteMethods[key].ALL = allMethodsSelected;
+    }
+
+    syncGlobalCheckboxes() {
+        this.syncGlobalReadCheckbox();
+        this.syncGlobalWriteCheckbox();
+        this.syncGlobalAllCheckbox();
+    }
+
+    syncGlobalReadCheckbox() {
+        // check if all write routes are selected
+        let allReadRoutesSelected = true;
+        const routes = Object.keys(this.selectedRouteMethods);
+        for (let i = 0; i < routes.length; i++) {
+            if (this.selectedRouteMethods[routes[i]].GET != null && !this.selectedRouteMethods[routes[i]].GET) {
+                allReadRoutesSelected = false;
+                break;
+            }
+        }
+        this.allReadRoutesSelected = allReadRoutesSelected;
+    }
+
+    syncGlobalWriteCheckbox() {
+        // check if all write routes are selected
+        let allWriteRoutesSelected = true;
+        const routes = Object.keys(this.selectedRouteMethods);
+        for (let i = 0; i < routes.length; i++) {
+            if (this.selectedRouteMethods[routes[i]].POST != null && !this.selectedRouteMethods[routes[i]].POST) {
+                allWriteRoutesSelected = false;
+            }
+            if (this.selectedRouteMethods[routes[i]].PUT != null && !this.selectedRouteMethods[routes[i]].PUT) {
+                allWriteRoutesSelected = false;
+            }
+            if (this.selectedRouteMethods[routes[i]].DELETE != null && !this.selectedRouteMethods[routes[i]].DELETE) {
+                allWriteRoutesSelected = false;
+            }
+            if (!allWriteRoutesSelected) {
+                break;
+            }
+        }
+        this.allWriteRoutesSelected = allWriteRoutesSelected;
+    }
+
+    syncGlobalAllCheckbox() {
+        // check if all routes are selected
+        let allRoutesSelected = true;
+        const routes = Object.keys(this.selectedRouteMethods);
+        for (let i = 0; i < routes.length; i++) {
+            if (!this.selectedRouteMethods[routes[i]].ALL) {
+                allRoutesSelected = false;
+                break;
+            }
+        }
+        this.allRoutesSelected = allRoutesSelected;
+    }
+}
diff --git a/ui/src/app/views/settings/user/consumer-create-modal/scope-detail.html b/ui/src/app/views/settings/user/consumer-create-modal/scope-detail.html
new file mode 100644
index 000000000..c27f6cf69
--- /dev/null
+++ b/ui/src/app/views/settings/user/consumer-create-modal/scope-detail.html
@@ -0,0 +1,76 @@
+<div class="ui grid detail" *ngIf="scopeDetail">
+    <div class="ui row scope">
+        <div class="ui four wide column">
+            {{scopeDetail.scope}}
+        </div>
+        <div class="ui twelve wide column right aligned">
+            <a class="pointing advanced" (click)="clickAdvanced()">{{'common_advanced' | translate}}</a>
+            <div class="ui checkbox">
+                <input type="checkbox" [ngModel]="allReadRoutesSelected" (click)="clickSelectAllRoutesRead()">
+                <label>{{'common_read' | translate}}</label>
+            </div>
+            <div class="ui checkbox">
+                <input type="checkbox" [ngModel]="allWriteRoutesSelected" (click)="clickSelectAllRoutesWrite()">
+                <label>{{'common_write' | translate}}</label>
+            </div>
+            <div class="ui checkbox">
+                <input type="checkbox" [ngModel]="allRoutesSelected" (click)="clickSelectAllRoutes()">
+                <label>{{'common_all' | translate}}</label>
+            </div>
+        </div>
+    </div>
+    <ng-container *ngIf="advancedOpen">
+        <div *ngFor="let endpoint of scopeDetail.endpoints" class="ui row route">
+            <div class="ui eight wide column">
+                {{endpoint.route}}
+            </div>
+            <div class="ui eight wide column">
+                <div class="ui form">
+                    <div class="ui fields methods">
+                        <div class="ui checkbox">
+                            <input *ngIf="selectedRouteMethods[endpoint.route].GET != null" type="checkbox"
+                                [ngModel]="selectedRouteMethods[endpoint.route].GET"
+                                (click)="clickMethod(endpoint.route, 'GET')">
+                            <input *ngIf="selectedRouteMethods[endpoint.route].GET == null" type="checkbox"
+                                [disabled]="true">
+                            <label>GET</label>
+                        </div>
+
+                        <div class="ui checkbox">
+                            <input *ngIf="selectedRouteMethods[endpoint.route].POST != null" type="checkbox"
+                                [ngModel]="selectedRouteMethods[endpoint.route].POST"
+                                (click)="clickMethod(endpoint.route, 'POST')">
+                            <input *ngIf="selectedRouteMethods[endpoint.route].POST == null" type="checkbox"
+                                [disabled]="true">
+                            <label>POST</label>
+                        </div>
+
+                        <div class="ui checkbox">
+                            <input *ngIf="selectedRouteMethods[endpoint.route].PUT != null" type="checkbox"
+                                [ngModel]="selectedRouteMethods[endpoint.route].PUT"
+                                (click)="clickMethod(endpoint.route, 'PUT')">
+                            <input *ngIf="selectedRouteMethods[endpoint.route].PUT == null" type="checkbox"
+                                [disabled]="true">
+                            <label>PUT</label>
+                        </div>
+
+                        <div class="ui checkbox">
+                            <input *ngIf="selectedRouteMethods[endpoint.route].DELETE != null" type="checkbox"
+                                [ngModel]="selectedRouteMethods[endpoint.route].DELETE"
+                                (click)="clickMethod(endpoint.route, 'DELETE')">
+                            <input *ngIf="selectedRouteMethods[endpoint.route].DELETE == null" type="checkbox"
+                                [disabled]="true">
+                            <label>DELETE</label>
+                        </div>
+
+                        <div class="ui checkbox">
+                            <input type="checkbox" [ngModel]="selectedRouteMethods[endpoint.route].ALL"
+                                (click)="clickAllMethods(endpoint.route)">
+                            <label>{{'common_all' | translate}}</label>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </ng-container>
+</div>
diff --git a/ui/src/app/views/settings/user/consumer-create-modal/scope-detail.scss b/ui/src/app/views/settings/user/consumer-create-modal/scope-detail.scss
new file mode 100644
index 000000000..6737d2921
--- /dev/null
+++ b/ui/src/app/views/settings/user/consumer-create-modal/scope-detail.scss
@@ -0,0 +1,33 @@
+@import "../../../../../common";
+
+.detail {
+    margin-top: 0 !important;
+    margin-bottom: 0 !important;
+    margin-right: 0 !important;
+
+    .checkbox {
+        margin-right: 15px;
+    }
+}
+
+.scope {
+    padding-top: 5px !important;
+    padding-bottom: 5px !important;
+}
+
+.route {
+    padding-top: 0 !important;
+    padding-bottom: 0 !important;
+    overflow: hidden;
+}
+
+.advanced {
+    display: inline-block;
+    overflow: hidden;
+    margin-right: 40px;
+}
+
+.methods {
+    margin-bottom: 0 !important;
+    justify-content: flex-end;
+}
diff --git a/ui/src/app/views/settings/user/consumer-details-modal/consumer-details-modal.component.ts b/ui/src/app/views/settings/user/consumer-details-modal/consumer-details-modal.component.ts
index e00641df4..b5107a66c 100644
--- a/ui/src/app/views/settings/user/consumer-details-modal/consumer-details-modal.component.ts
+++ b/ui/src/app/views/settings/user/consumer-details-modal/consumer-details-modal.component.ts
@@ -85,7 +85,7 @@ export class ConsumerDetailsModalComponent {
             return (c: AuthConsumer) => {
                 return c.name.toLowerCase().indexOf(lowerFilter) !== -1 ||
                     c.description.toLowerCase().indexOf(lowerFilter) !== -1 ||
-                    c.scopes.join(' ').toLowerCase().indexOf(lowerFilter) !== -1 ||
+                    c.scope_details.map(s => s.scope).join(' ').toLowerCase().indexOf(lowerFilter) !== -1 ||
                     (c.groups && c.groups.map(g => g.name).join(' ').toLowerCase().indexOf(lowerFilter) !== -1) ||
                     (!c.groups && lowerFilter === '*');
             }
@@ -113,7 +113,7 @@ export class ConsumerDetailsModalComponent {
                 name: 'user_auth_scopes',
                 selector: (c: AuthConsumer) => {
                     return {
-                        value: c.scopes ? c.scopes.join(', ') : '*',
+                        value: c.scope_details ? c.scope_details.map(s => s.scope).join(', ') : '*',
                         icons: [
                             {
                                 label: 'user_auth_info_scopes',
@@ -236,7 +236,7 @@ export class ConsumerDetailsModalComponent {
 
         this.selectedChildDetails = null;
         this.consumerDeletedOrDetached = false;
-        this.scopes = this.consumer.scopes ? this.consumer.scopes.join(', ') : '*';
+        this.scopes = this.consumer.scope_details ? this.consumer.scope_details.map(s => s.scope).join(', ') : '*';
         this.groups = this.consumer.groups ? this.consumer.groups.map(g => g.name).join(', ') : '*';
 
         if (this.consumer.warnings && this.consumer.warnings.length > 0) {
diff --git a/ui/src/app/views/settings/user/edit/user.edit.component.ts b/ui/src/app/views/settings/user/edit/user.edit.component.ts
index 016c7eedb..5dc16500f 100644
--- a/ui/src/app/views/settings/user/edit/user.edit.component.ts
+++ b/ui/src/app/views/settings/user/edit/user.edit.component.ts
@@ -148,7 +148,7 @@ export class UserEditComponent implements OnInit {
             return (c: AuthConsumer) => {
                 return c.name.toLowerCase().indexOf(lowerFilter) !== -1 ||
                     c.description.toLowerCase().indexOf(lowerFilter) !== -1 ||
-                    c.scopes.join(' ').toLowerCase().indexOf(lowerFilter) !== -1 ||
+                    c.scope_details.map(s => s.scope).join(' ').toLowerCase().indexOf(lowerFilter) !== -1 ||
                     (c.groups && c.groups.map(g => g.name).join(' ').toLowerCase().indexOf(lowerFilter) !== -1) ||
                     (!c.groups && lowerFilter === '*');
             }
@@ -180,7 +180,7 @@ export class UserEditComponent implements OnInit {
                 name: 'user_auth_scopes',
                 selector: (c: AuthConsumer) => {
                     return {
-                        value: c.scopes.join(', '),
+                        value: c.scope_details.map(s => s.scope).join(', '),
                         icons: [
                             {
                                 label: 'user_auth_info_scopes',
diff --git a/ui/src/assets/i18n/en.json b/ui/src/assets/i18n/en.json
index 326a0d5cb..97e8dce8f 100644
--- a/ui/src/assets/i18n/en.json
+++ b/ui/src/assets/i18n/en.json
@@ -188,6 +188,9 @@
   "common_optional": "Optional",
   "common_audit": "Audit",
   "common_advanced": "Advanced",
+  "common_all": "All",
+  "common_read": "Read",
+  "common_write": "Write",
   "common_application": "Application",
   "common_application_title": "Application: ",
   "common_applications": "Applications",
@@ -305,6 +308,7 @@
   "common_here": "here",
   "common_deleted": "Deleted with success",
   "common_click_more_informations": "Click here for more information.",
+  "common_informations": "Informations",
   "coverage_branches_title": "Covered branches: ",
   "coverage_functions_title": "Covered functions: ",
   "coverage_lines_title": "Covered lines: ",
@@ -671,6 +675,7 @@
   "user_auth_consumer_warning_group_removed": "The group '{{name}}' was removed.",
   "auth_consumer_details_modal_title": "Details for consumer '{{name}}'",
   "auth_consumer_create_modal_title": "Create a new consumer",
+  "auth_consumer_create_modal_info_groups": "Let groups selection empty to create consumer with wildcard access on groups.",
   "vcs_connection": "Connection:",
   "vcs_user": "User: ",
   "vcs_password": "Password: ",
@@ -934,4 +939,4 @@
   "workflow_error_unknown_key_description": "Key not found, please check that your all keys mentioned really exist. Or you can check if your SSH key which you mentioned in your application settings for your git repository is always valid and added on your git repository to have at least READ access.",
   "workflow_error_bad_vcs_strategy_title": "Bad VCS settings on application",
   "workflow_error_bad_vcs_strategy_description": "Please check if your SSH key which you mentioned in your application settings for your git repository is always valid and added on your git repository to have at least READ access. You can also check that you have a SSH key mentioned if you selected SSH mode on your application."
-}
\ No newline at end of file
+}
diff --git a/ui/src/assets/i18n/fr.json b/ui/src/assets/i18n/fr.json
index 0b74aa913..79cac227b 100644
--- a/ui/src/assets/i18n/fr.json
+++ b/ui/src/assets/i18n/fr.json
@@ -193,7 +193,10 @@
   "common_id": "ID",
   "common_action": "Action",
   "common_admin": "Administration",
-  "common_advanced": "Administration",
+  "common_advanced": "Options avancées",
+  "common_all": "Tout",
+  "common_read": "Lecture",
+  "common_write": "Écriture",
   "common_application_title": "Application : ",
   "common_application": "Application",
   "common_applications": "Applications",
@@ -323,6 +326,7 @@
   "common_workflow": "Workflow",
   "common_workflows": "Workflows",
   "common_yes": "Oui",
+  "common_informations": "Informations",
   "coverage_branches_title": "Branches couvertes : ",
   "coverage_functions_title": "Fonctions couvertes : ",
   "coverage_lines_title": "Lignes couvertes : ",
@@ -673,6 +677,7 @@
   "user_auth_consumer_warning_group_removed": "Le groupe '{{name}}' a été supprimé.",
   "auth_consumer_details_modal_title": "Détails pour le client '{{name}}'",
   "auth_consumer_create_modal_title": "Créer un nouveau client",
+  "auth_consumer_create_modal_info_groups": "Laissez la sélection de groupes vide pour générer un client avec un accès à tous les groupes.",
   "vcs_connection": "Connexion : ",
   "vcs_password": "Mot de passe : ",
   "vcs_pgp_key": "Clé PGP",


Test patch:
diff --git a/engine/api/application_deployment_test.go b/engine/api/application_deployment_test.go
index 15c349b3e..b0fb14d93 100644
--- a/engine/api/application_deployment_test.go
+++ b/engine/api/application_deployment_test.go
@@ -302,7 +302,8 @@ func Test_postApplicationDeploymentStrategyConfigHandlerAsProvider(t *testing.T)
 	localConsumer, err := authentication.LoadConsumerByTypeAndUserID(context.TODO(), api.mustDB(), sdk.ConsumerLocal, u.ID, authentication.LoadConsumerOptions.WithAuthentifiedUser)
 	require.NoError(t, err)
 
-	_, jws, err := builtin.NewConsumer(context.TODO(), api.mustDB(), sdk.RandomString(10), sdk.RandomString(10), localConsumer, u.GetGroupIDs(), Scope(sdk.AuthConsumerScopeProject))
+	_, jws, err := builtin.NewConsumer(context.TODO(), api.mustDB(), sdk.RandomString(10), sdk.RandomString(10), localConsumer, u.GetGroupIDs(),
+		sdk.NewAuthConsumerScopeDetails(sdk.AuthConsumerScopeProject))
 
 	pkey := sdk.RandomString(10)
 	proj := assets.InsertTestProject(t, api.mustDB(), api.Cache, pkey, pkey)
diff --git a/engine/api/application_test.go b/engine/api/application_test.go
index a231073cd..0a6e2dadc 100644
--- a/engine/api/application_test.go
+++ b/engine/api/application_test.go
@@ -31,7 +31,8 @@ func Test_postApplicationMetadataHandler_AsProvider(t *testing.T) {
 	u, _ := assets.InsertAdminUser(t, api.mustDB())
 	localConsumer, err := authentication.LoadConsumerByTypeAndUserID(context.TODO(), api.mustDB(), sdk.ConsumerLocal, u.ID, authentication.LoadConsumerOptions.WithAuthentifiedUser)
 	require.NoError(t, err)
-	_, jws, err := builtin.NewConsumer(context.TODO(), api.mustDB(), sdk.RandomString(10), sdk.RandomString(10), localConsumer, u.GetGroupIDs(), Scope(sdk.AuthConsumerScopeProject))
+	_, jws, err := builtin.NewConsumer(context.TODO(), api.mustDB(), sdk.RandomString(10), sdk.RandomString(10), localConsumer, u.GetGroupIDs(),
+		sdk.NewAuthConsumerScopeDetails(sdk.AuthConsumerScopeProject))
 
 	pkey := sdk.RandomString(10)
 	proj := assets.InsertTestProject(t, api.mustDB(), api.Cache, pkey, pkey)
diff --git a/engine/api/auth_builtin_test.go b/engine/api/auth_builtin_test.go
index da5202f4c..838af3957 100644
--- a/engine/api/auth_builtin_test.go
+++ b/engine/api/auth_builtin_test.go
@@ -53,7 +53,8 @@ func Test_postAuthBuiltinSigninHandler(t *testing.T) {
 	localConsumer, err := authentication.LoadConsumerByTypeAndUserID(context.TODO(), api.mustDB(), sdk.ConsumerLocal, usr.ID, authentication.LoadConsumerOptions.WithAuthentifiedUser)
 	require.NoError(t, err)
 
-	_, jws, err := builtin.NewConsumer(context.TODO(), api.mustDB(), sdk.RandomString(10), sdk.RandomString(10), localConsumer, usr.GetGroupIDs(), Scope(sdk.AuthConsumerScopeProject))
+	_, jws, err := builtin.NewConsumer(context.TODO(), api.mustDB(), sdk.RandomString(10), sdk.RandomString(10), localConsumer, usr.GetGroupIDs(),
+		sdk.NewAuthConsumerScopeDetails(sdk.AuthConsumerScopeProject))
 	require.NoError(t, err)
 	AuthentififyBuiltinConsumer(t, api, jws)
 }
diff --git a/engine/api/auth_consumer_test.go b/engine/api/auth_consumer_test.go
index 38c7f6cab..04c8133eb 100644
--- a/engine/api/auth_consumer_test.go
+++ b/engine/api/auth_consumer_test.go
@@ -26,7 +26,8 @@ func Test_getConsumersByUserHandler(t *testing.T) {
 		authentication.LoadConsumerOptions.WithAuthentifiedUser)
 	require.NoError(t, err)
 
-	consumer, _, err := builtin.NewConsumer(context.TODO(), db, sdk.RandomString(10), "", localConsumer, nil, []sdk.AuthConsumerScope{sdk.AuthConsumerScopeUser})
+	consumer, _, err := builtin.NewConsumer(context.TODO(), db, sdk.RandomString(10), "", localConsumer, nil,
+		sdk.NewAuthConsumerScopeDetails(sdk.AuthConsumerScopeUser))
 	require.NoError(t, err)
 
 	uri := api.Router.GetRoute(http.MethodGet, api.getConsumersByUserHandler, map[string]string{
@@ -70,10 +71,10 @@ func Test_postConsumerByUserHandler(t *testing.T) {
 	_, jwtRawAdmin := assets.InsertAdminUser(t, db)
 
 	data := sdk.AuthConsumer{
-		Name:     sdk.RandomString(10),
-		GroupIDs: []int64{g.ID},
-		Scopes:   []sdk.AuthConsumerScope{sdk.AuthConsumerScopeAccessToken},
-		IssuedAt: time.Now(),
+		Name:         sdk.RandomString(10),
+		GroupIDs:     []int64{g.ID},
+		ScopeDetails: sdk.NewAuthConsumerScopeDetails(sdk.AuthConsumerScopeAccessToken),
+		IssuedAt:     time.Now(),
 	}
 
 	uri := api.Router.GetRoute(http.MethodPost, api.postConsumerByUserHandler, map[string]string{
@@ -100,8 +101,8 @@ func Test_postConsumerByUserHandler(t *testing.T) {
 	assert.Equal(t, data.Name, created.Consumer.Name)
 	require.Equal(t, 1, len(created.Consumer.GroupIDs))
 	assert.Equal(t, g.ID, created.Consumer.GroupIDs[0])
-	require.Equal(t, 1, len(created.Consumer.Scopes))
-	assert.Equal(t, sdk.AuthConsumerScopeAccessToken, created.Consumer.Scopes[0])
+	require.Equal(t, 1, len(created.Consumer.ScopeDetails))
+	assert.Equal(t, sdk.AuthConsumerScopeAccessToken, created.Consumer.ScopeDetails[0].Scope)
 	assert.Equal(t, localConsumer.ID, *created.Consumer.ParentID)
 }
 
@@ -114,7 +115,8 @@ func Test_deleteConsumerByUserHandler(t *testing.T) {
 	localConsumer, err := authentication.LoadConsumerByTypeAndUserID(context.TODO(), db, sdk.ConsumerLocal, u.ID,
 		authentication.LoadConsumerOptions.WithAuthentifiedUser)
 	require.NoError(t, err)
-	newConsumer, _, err := builtin.NewConsumer(context.TODO(), db, sdk.RandomString(10), "", localConsumer, nil, []sdk.AuthConsumerScope{sdk.AuthConsumerScopeAccessToken})
+	newConsumer, _, err := builtin.NewConsumer(context.TODO(), db, sdk.RandomString(10), "", localConsumer, nil,
+		sdk.NewAuthConsumerScopeDetails(sdk.AuthConsumerScopeAccessToken))
 	require.NoError(t, err)
 	cs, err := authentication.LoadConsumersByUserID(context.TODO(), db, u.ID)
 	require.NoError(t, err)
@@ -154,7 +156,8 @@ func Test_postConsumerRegenByUserHandler(t *testing.T) {
 	api.Router.Mux.ServeHTTP(rec, req)
 	require.Equal(t, http.StatusForbidden, rec.Code)
 
-	builtinConsumer, signinToken1, err := builtin.NewConsumer(context.TODO(), db, sdk.RandomString(10), "", localConsumer, nil, []sdk.AuthConsumerScope{sdk.AuthConsumerScopeUser, sdk.AuthConsumerScopeAccessToken})
+	builtinConsumer, signinToken1, err := builtin.NewConsumer(context.TODO(), db, sdk.RandomString(10), "", localConsumer, nil,
+		sdk.NewAuthConsumerScopeDetails(sdk.AuthConsumerScopeUser, sdk.AuthConsumerScopeAccessToken))
 	require.NoError(t, err)
 	session, err := authentication.NewSession(context.TODO(), db, builtinConsumer, 5*time.Minute, false)
 	require.NoError(t, err, "cannot create session")
@@ -243,7 +246,8 @@ func Test_getSessionsByUserHandler(t *testing.T) {
 		authentication.LoadConsumerOptions.WithAuthentifiedUser)
 	require.NoError(t, err)
 
-	consumer, _, err := builtin.NewConsumer(context.TODO(), db, sdk.RandomString(10), "", localConsumer, nil, []sdk.AuthConsumerScope{sdk.AuthConsumerScopeUser})
+	consumer, _, err := builtin.NewConsumer(context.TODO(), db, sdk.RandomString(10), "", localConsumer, nil,
+		sdk.NewAuthConsumerScopeDetails(sdk.AuthConsumerScopeUser))
 	require.NoError(t, err)
 	s2, err := authentication.NewSession(context.TODO(), db, consumer, time.Second, false)
 	require.NoError(t, err)
@@ -276,7 +280,8 @@ func Test_deleteSessionByUserHandler(t *testing.T) {
 		authentication.LoadConsumerOptions.WithAuthentifiedUser)
 	require.NoError(t, err)
 
-	consumer, _, err := builtin.NewConsumer(context.TODO(), db, sdk.RandomString(10), "", localConsumer, nil, []sdk.AuthConsumerScope{sdk.AuthConsumerScopeUser})
+	consumer, _, err := builtin.NewConsumer(context.TODO(), db, sdk.RandomString(10), "", localConsumer, nil,
+		sdk.NewAuthConsumerScopeDetails(sdk.AuthConsumerScopeUser))
 	require.NoError(t, err)
 	s2, err := authentication.NewSession(context.TODO(), db, consumer, time.Second, false)
 	require.NoError(t, err)
diff --git a/engine/api/authentication/builtin/builtin_test.go b/engine/api/authentication/builtin/builtin_test.go
new file mode 100644
index 000000000..56f9b5606
--- /dev/null
+++ b/engine/api/authentication/builtin/builtin_test.go
@@ -0,0 +1,150 @@
+package builtin
+
+import (
+	"net/http"
+	"testing"
+
+	"github.com/ovh/cds/sdk"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func Test_checkNewConsumerScopes(t *testing.T) {
+	cases := []struct {
+		Name         string
+		ParentScopes sdk.AuthConsumerScopeDetails
+		Scopes       sdk.AuthConsumerScopeDetails
+		Error        bool
+	}{
+		{
+			Name: "Parent has no scopes (ex: local consumer)",
+			Scopes: sdk.AuthConsumerScopeDetails{
+				{Scope: sdk.AuthConsumerScopeAccessToken},
+			},
+			Error: false,
+		},
+		{
+			Name: "Scope are not in parent scopes",
+			ParentScopes: sdk.AuthConsumerScopeDetails{
+				{Scope: sdk.AuthConsumerScopeAccessToken},
+			},
+			Scopes: sdk.AuthConsumerScopeDetails{
+				{Scope: sdk.AuthConsumerScopeAction},
+			},
+			Error: true,
+		},
+		{
+			Name: "Scope all routes are not in parent",
+			ParentScopes: sdk.AuthConsumerScopeDetails{
+				{
+					Scope: sdk.AuthConsumerScopeAccessToken,
+					Endpoints: sdk.AuthConsumerScopeEndpoints{
+						{Route: "/handler"},
+					},
+				},
+			},
+			Scopes: sdk.AuthConsumerScopeDetails{
+				{Scope: sdk.AuthConsumerScopeAccessToken},
+			},
+			Error: true,
+		}, {
+			Name: "Scope all routes are in parent",
+			ParentScopes: sdk.AuthConsumerScopeDetails{
+				{
+					Scope: sdk.AuthConsumerScopeAccessToken,
+				},
+				{
+					Scope: sdk.AuthConsumerScopeAction,
+					Endpoints: sdk.AuthConsumerScopeEndpoints{
+						{Route: "/handler"},
+					},
+				},
+			},
+			Scopes: sdk.AuthConsumerScopeDetails{
+				{
+					Scope: sdk.AuthConsumerScopeAccessToken,
+					Endpoints: sdk.AuthConsumerScopeEndpoints{
+						{Route: "/handler"},
+					},
+				},
+				{
+					Scope: sdk.AuthConsumerScopeAction,
+					Endpoints: sdk.AuthConsumerScopeEndpoints{
+						{Route: "/handler"},
+					},
+				},
+			},
+			Error: false,
+		},
+		{
+			Name: "Scope all route methods are not in parent",
+			ParentScopes: sdk.AuthConsumerScopeDetails{
+				{
+					Scope: sdk.AuthConsumerScopeAccessToken,
+					Endpoints: sdk.AuthConsumerScopeEndpoints{
+						{
+							Route:   "/handler",
+							Methods: []string{http.MethodGet},
+						},
+					},
+				},
+			},
+			Scopes: sdk.AuthConsumerScopeDetails{
+				{
+					Scope: sdk.AuthConsumerScopeAccessToken,
+					Endpoints: sdk.AuthConsumerScopeEndpoints{
+						{
+							Route:   "/handler",
+							Methods: []string{http.MethodPost},
+						},
+					},
+				},
+			},
+			Error: true,
+		},
+		{
+			Name: "Scope all route methods are in parent",
+			ParentScopes: sdk.AuthConsumerScopeDetails{
+				{
+					Scope: sdk.AuthConsumerScopeAccessToken,
+					Endpoints: sdk.AuthConsumerScopeEndpoints{
+						{
+							Route: "/handler1",
+						},
+						{
+							Route:   "/handler2",
+							Methods: []string{http.MethodGet},
+						},
+					},
+				},
+			},
+			Scopes: sdk.AuthConsumerScopeDetails{
+				{
+					Scope: sdk.AuthConsumerScopeAccessToken,
+					Endpoints: sdk.AuthConsumerScopeEndpoints{
+						{
+							Route:   "/handler1",
+							Methods: []string{http.MethodGet},
+						},
+						{
+							Route:   "/handler2",
+							Methods: []string{http.MethodGet},
+						},
+					},
+				},
+			},
+			Error: false,
+		},
+	}
+
+	for _, c := range cases {
+		t.Run(c.Name, func(t *testing.T) {
+			err := checkNewConsumerScopes(c.ParentScopes, c.Scopes)
+			if c.Error {
+				assert.Error(t, err)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
diff --git a/engine/api/authentication/consumer_test.go b/engine/api/authentication/consumer_test.go
index 90e85d1ae..388f31fb7 100644
--- a/engine/api/authentication/consumer_test.go
+++ b/engine/api/authentication/consumer_test.go
@@ -35,7 +35,7 @@ func TestConsumerInvalidateGroupForUser_InvalidateOneConsumerGroup(t *testing.T)
 		Name:               sdk.RandomString(10),
 		Description:        sdk.RandomString(10),
 		Type:               sdk.ConsumerLocal,
-		Scopes:             []sdk.AuthConsumerScope{sdk.AuthConsumerScopeAdmin},
+		ScopeDetails:       sdk.NewAuthConsumerScopeDetails(sdk.AuthConsumerScopeAdmin),
 		GroupIDs:           []int64{g1.ID, g2.ID},
 		AuthentifiedUserID: u.ID,
 		IssuedAt:           time.Now(),
@@ -76,7 +76,7 @@ func TestConsumerInvalidateGroupForUser_InvalidateOneConsumerGroupForAdmin(t *te
 		Name:               sdk.RandomString(10),
 		Description:        sdk.RandomString(10),
 		Type:               sdk.ConsumerLocal,
-		Scopes:             []sdk.AuthConsumerScope{sdk.AuthConsumerScopeAdmin},
+		ScopeDetails:       sdk.NewAuthConsumerScopeDetails(sdk.AuthConsumerScopeAdmin),
 		GroupIDs:           []int64{g1.ID, g2.ID},
 		AuthentifiedUserID: u.ID,
 		IssuedAt:           time.Now(),
@@ -112,7 +112,7 @@ func TestConsumerInvalidateGroupForUser_InvalidateLastConsumerGroup(t *testing.T
 		Name:               sdk.RandomString(10),
 		Description:        sdk.RandomString(10),
 		Type:               sdk.ConsumerLocal,
-		Scopes:             []sdk.AuthConsumerScope{sdk.AuthConsumerScopeAdmin},
+		ScopeDetails:       sdk.NewAuthConsumerScopeDetails(sdk.AuthConsumerScopeAdmin),
 		GroupIDs:           []int64{g1.ID},
 		AuthentifiedUserID: u.ID,
 		IssuedAt:           time.Now(),
@@ -153,7 +153,7 @@ func TestConsumerRemoveGroup_RemoveOneConsumerGroup(t *testing.T) {
 		Name:               sdk.RandomString(10),
 		Description:        sdk.RandomString(10),
 		Type:               sdk.ConsumerLocal,
-		Scopes:             []sdk.AuthConsumerScope{sdk.AuthConsumerScopeAdmin},
+		ScopeDetails:       sdk.NewAuthConsumerScopeDetails(sdk.AuthConsumerScopeAdmin),
 		GroupIDs:           []int64{g1.ID, g2.ID},
 		AuthentifiedUserID: u.ID,
 		IssuedAt:           time.Now(),
@@ -192,7 +192,7 @@ func TestConsumerRemoveGroup_RemoveOneInvalidConsumerGroup(t *testing.T) {
 		Name:               sdk.RandomString(10),
 		Description:        sdk.RandomString(10),
 		Type:               sdk.ConsumerLocal,
-		Scopes:             []sdk.AuthConsumerScope{sdk.AuthConsumerScopeAdmin},
+		ScopeDetails:       sdk.NewAuthConsumerScopeDetails(sdk.AuthConsumerScopeAdmin),
 		GroupIDs:           []int64{g2.ID},
 		InvalidGroupIDs:    []int64{g1.ID},
 		AuthentifiedUserID: u.ID,
@@ -236,7 +236,7 @@ func TestConsumerRemoveGroup_RemoveLastConsumerGroup(t *testing.T) {
 		Name:               sdk.RandomString(10),
 		Description:        sdk.RandomString(10),
 		Type:               sdk.ConsumerLocal,
-		Scopes:             []sdk.AuthConsumerScope{sdk.AuthConsumerScopeAdmin},
+		ScopeDetails:       sdk.NewAuthConsumerScopeDetails(sdk.AuthConsumerScopeAdmin),
 		GroupIDs:           []int64{g1.ID},
 		AuthentifiedUserID: u.ID,
 		IssuedAt:           time.Now(),
@@ -275,7 +275,7 @@ func TestConsumerRemoveGroup_RemoveLastInvalidConsumerGroup(t *testing.T) {
 		Name:               sdk.RandomString(10),
 		Description:        sdk.RandomString(10),
 		Type:               sdk.ConsumerLocal,
-		Scopes:             []sdk.AuthConsumerScope{sdk.AuthConsumerScopeAdmin},
+		ScopeDetails:       sdk.NewAuthConsumerScopeDetails(sdk.AuthConsumerScopeAdmin),
 		InvalidGroupIDs:    []int64{g1.ID},
 		AuthentifiedUserID: u.ID,
 		IssuedAt:           time.Now(),
@@ -326,7 +326,7 @@ func TestConsumerRestoreInvalidatedGroupForUser_RestoreInvalidatedGroup(t *testi
 		Name:               sdk.RandomString(10),
 		Description:        sdk.RandomString(10),
 		Type:               sdk.ConsumerLocal,
-		Scopes:             []sdk.AuthConsumerScope{sdk.AuthConsumerScopeAdmin},
+		ScopeDetails:       sdk.NewAuthConsumerScopeDetails(sdk.AuthConsumerScopeAdmin),
 		GroupIDs:           []int64{g2.ID},
 		InvalidGroupIDs:    []int64{g1.ID},
 		AuthentifiedUserID: u.ID,
@@ -371,7 +371,7 @@ func TestConsumerLifecycle_RestoreInvalidatedLastGroup(t *testing.T) {
 		Name:               sdk.RandomString(10),
 		Description:        sdk.RandomString(10),
 		Type:               sdk.ConsumerLocal,
-		Scopes:             []sdk.AuthConsumerScope{sdk.AuthConsumerScopeAdmin},
+		ScopeDetails:       sdk.NewAuthConsumerScopeDetails(sdk.AuthConsumerScopeAdmin),
 		InvalidGroupIDs:    []int64{g1.ID},
 		AuthentifiedUserID: u.ID,
 		IssuedAt:           time.Now(),
@@ -418,7 +418,7 @@ func TestConsumerInvalidateGroupsForUser_InvalidateLastGroups(t *testing.T) {
 		Name:               sdk.RandomString(10),
 		Description:        sdk.RandomString(10),
 		Type:               sdk.ConsumerLocal,
-		Scopes:             []sdk.AuthConsumerScope{sdk.AuthConsumerScopeAdmin},
+		ScopeDetails:       sdk.NewAuthConsumerScopeDetails(sdk.AuthConsumerScopeAdmin),
 		GroupIDs:           []int64{g1.ID},
 		InvalidGroupIDs:    []int64{g2.ID},
 		AuthentifiedUserID: u.ID,
@@ -470,7 +470,7 @@ func TestConsumerRestoreInvalidatedGroupsForUser(t *testing.T) {
 		Name:               sdk.RandomString(10),
 		Description:        sdk.RandomString(10),
 		Type:               sdk.ConsumerLocal,
-		Scopes:             []sdk.AuthConsumerScope{sdk.AuthConsumerScopeAdmin},
+		ScopeDetails:       sdk.NewAuthConsumerScopeDetails(sdk.AuthConsumerScopeAdmin),
 		InvalidGroupIDs:    []int64{g1.ID, g2.ID},
 		AuthentifiedUserID: u.ID,
 		IssuedAt:           time.Now(),
diff --git a/engine/api/authentication/dao_consumer_test.go b/engine/api/authentication/dao_consumer_test.go
index 4b579ff33..d053e9687 100644
--- a/engine/api/authentication/dao_consumer_test.go
+++ b/engine/api/authentication/dao_consumer_test.go
@@ -31,7 +31,7 @@ func TestLoadConsumer(t *testing.T) {
 		Name:               sdk.RandomString(10),
 		Description:        sdk.RandomString(10),
 		Type:               sdk.ConsumerLocal,
-		Scopes:             []sdk.AuthConsumerScope{sdk.AuthConsumerScopeAdmin},
+		ScopeDetails:       sdk.NewAuthConsumerScopeDetails(sdk.AuthConsumerScopeAdmin),
 		GroupIDs:           []int64{5, 10},
 		AuthentifiedUserID: u.ID,
 		IssuedAt:           time.Now(),
@@ -42,7 +42,7 @@ func TestLoadConsumer(t *testing.T) {
 		Name:               sdk.RandomString(10),
 		Description:        sdk.RandomString(10),
 		Type:               sdk.ConsumerBuiltin,
-		Scopes:             []sdk.AuthConsumerScope{sdk.AuthConsumerScopeAdmin},
+		ScopeDetails:       sdk.NewAuthConsumerScopeDetails(sdk.AuthConsumerScopeAdmin),
 		GroupIDs:           []int64{10, 15},
 		AuthentifiedUserID: u.ID,
 		IssuedAt:           time.Now(),
diff --git a/engine/api/authentication/loader_consumer_test.go b/engine/api/authentication/loader_consumer_test.go
index 871593e60..e6ae470e1 100644
--- a/engine/api/authentication/loader_consumer_test.go
+++ b/engine/api/authentication/loader_consumer_test.go
@@ -5,14 +5,14 @@ import (
 	"sort"
 	"testing"
 
-	"github.com/ovh/cds/engine/api/authentication/builtin"
-	"github.com/ovh/cds/engine/api/test/assets"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 
 	"github.com/ovh/cds/engine/api/authentication"
+	"github.com/ovh/cds/engine/api/authentication/builtin"
 	"github.com/ovh/cds/engine/api/bootstrap"
 	"github.com/ovh/cds/engine/api/test"
+	"github.com/ovh/cds/engine/api/test/assets"
 	"github.com/ovh/cds/sdk"
 )
 
@@ -47,7 +47,7 @@ func TestWithConsumerGroups(t *testing.T) {
 	assert.NotNil(t, 0, len(localConsumer.Groups), "no group ids on local consumer so no groups are expected")
 
 	newConsumer, _, err := builtin.NewConsumer(context.TODO(), db, sdk.RandomString(10), sdk.RandomString(10), localConsumer,
-		[]int64{g1.ID, g2.ID}, []sdk.AuthConsumerScope{sdk.AuthConsumerScopeAccessToken})
+		[]int64{g1.ID, g2.ID}, sdk.NewAuthConsumerScopeDetails(sdk.AuthConsumerScopeAccessToken))
 	require.NoError(t, err)
 	builtinConsumer, err := authentication.LoadConsumerByID(context.TODO(), db, newConsumer.ID,
 		authentication.LoadConsumerOptions.WithConsumerGroups)
diff --git a/engine/api/project_test.go b/engine/api/project_test.go
index 40835876c..d5d4d7314 100644
--- a/engine/api/project_test.go
+++ b/engine/api/project_test.go
@@ -294,7 +294,8 @@ func Test_getProjectsHandler_AsProvider(t *testing.T) {
 	localConsumer, err := authentication.LoadConsumerByTypeAndUserID(context.TODO(), api.mustDB(), sdk.ConsumerLocal, admin.ID, authentication.LoadConsumerOptions.WithAuthentifiedUser)
 	require.NoError(t, err)
 
-	_, jws, err := builtin.NewConsumer(context.TODO(), api.mustDB(), sdk.RandomString(10), sdk.RandomString(10), localConsumer, admin.GetGroupIDs(), Scope(sdk.AuthConsumerScopeProject))
+	_, jws, err := builtin.NewConsumer(context.TODO(), api.mustDB(), sdk.RandomString(10), sdk.RandomString(10), localConsumer, admin.GetGroupIDs(),
+		sdk.NewAuthConsumerScopeDetails(sdk.AuthConsumerScopeProject))
 	require.NoError(t, err)
 
 	u, _ := assets.InsertLambdaUser(t, api.mustDB())
@@ -324,7 +325,8 @@ func Test_getprojectsHandler_AsProviderWithRequestedUsername(t *testing.T) {
 	localConsumer, err := authentication.LoadConsumerByTypeAndUserID(context.TODO(), api.mustDB(), sdk.ConsumerLocal, admin.ID, authentication.LoadConsumerOptions.WithAuthentifiedUser)
 	require.NoError(t, err)
 
-	_, jws, err := builtin.NewConsumer(context.TODO(), api.mustDB(), sdk.RandomString(10), sdk.RandomString(10), localConsumer, admin.GetGroupIDs(), Scope(sdk.AuthConsumerScopeProject))
+	_, jws, err := builtin.NewConsumer(context.TODO(), api.mustDB(), sdk.RandomString(10), sdk.RandomString(10), localConsumer, admin.GetGroupIDs(),
+		sdk.NewAuthConsumerScopeDetails(sdk.AuthConsumerScopeProject))
 
 	u, _ := assets.InsertLambdaUser(t, api.mustDB())
 
diff --git a/engine/api/router_middleware_auth_test.go b/engine/api/router_middleware_auth_test.go
index 78c4a26f1..61e91ebe7 100644
--- a/engine/api/router_middleware_auth_test.go
+++ b/engine/api/router_middleware_auth_test.go
@@ -54,7 +54,7 @@ func Test_authMiddleware_WithAuthConsumerDisabled(t *testing.T) {
 	require.NoError(t, err)
 
 	builtinConsumer, _, err := builtin.NewConsumer(context.TODO(), db, "builtin", "", localConsumer, []int64{g.ID},
-		[]sdk.AuthConsumerScope{sdk.AuthConsumerScopeGroup})
+		sdk.NewAuthConsumerScopeDetails(sdk.AuthConsumerScopes...))
 	require.NoError(t, err)
 	builtinSession, err := authentication.NewSession(context.TODO(), db, builtinConsumer, time.Second*5, false)
 	require.NoError(t, err)
@@ -133,3 +133,96 @@ func Test_authMiddleware_NeedAdmin(t *testing.T) {
 	require.NotNil(t, getAPIConsumer(ctx))
 	assert.Equal(t, admin.ID, getAPIConsumer(ctx).AuthentifiedUserID)
 }
+
+func Test_authMiddleware_WithAuthConsumerScoped(t *testing.T) {
+	api, db, _, end := newTestAPI(t)
+	defer end()
+
+	g := assets.InsertGroup(t, db)
+	u, _ := assets.InsertLambdaUser(t, db, g)
+	localConsumer, err := authentication.LoadConsumerByTypeAndUserID(context.TODO(), db, sdk.ConsumerLocal, u.ID, authentication.LoadConsumerOptions.WithAuthentifiedUser)
+	require.NoError(t, err)
+
+	builtinConsumer, _, err := builtin.NewConsumer(context.TODO(), db, "builtin", "", localConsumer, []int64{g.ID}, []sdk.AuthConsumerScopeDetail{
+		{
+			Scope: sdk.AuthConsumerScopeAction,
+			Endpoints: sdk.AuthConsumerScopeEndpoints{
+				{
+					Route:   "/my-handler2",
+					Methods: []string{http.MethodGet},
+				},
+				{
+					Route: "/my-handler3",
+				},
+			},
+		},
+		{
+			Scope: sdk.AuthConsumerScopeAdmin,
+		},
+	})
+	require.NoError(t, err)
+	builtinSession, err := authentication.NewSession(context.TODO(), db, builtinConsumer, time.Second*5, false)
+	require.NoError(t, err)
+	jwt, err := authentication.NewSessionJWT(builtinSession)
+	require.NoError(t, err)
+
+	// GET /my-handler1 is forbidden (scope AccessToken required)
+	configHandler1 := &service.HandlerConfig{
+		CleanURL:      "/my-handler1",
+		AllowedScopes: []sdk.AuthConsumerScope{sdk.AuthConsumerScopeAccessToken},
+		Method:        http.MethodGet,
+	}
+	Auth(true)(configHandler1)
+	req := assets.NewJWTAuthentifiedRequest(t, jwt, configHandler1.Method, configHandler1.CleanURL, nil)
+	w := httptest.NewRecorder()
+	_, err = api.authMiddleware(context.TODO(), w, req, configHandler1)
+	assert.Error(t, err, "an error should be returned because consumer can't do GET on /my-handler1 missing scope")
+
+	// GET /my-handler2 is authorized
+	configHandler2 := &service.HandlerConfig{
+		CleanURL:      "/my-handler2",
+		AllowedScopes: []sdk.AuthConsumerScope{sdk.AuthConsumerScopeAction},
+		Method:        http.MethodGet,
+	}
+	Auth(true)(configHandler2)
+	req = assets.NewJWTAuthentifiedRequest(t, jwt, configHandler2.Method, configHandler2.CleanURL, nil)
+	w = httptest.NewRecorder()
+	_, err = api.authMiddleware(context.TODO(), w, req, configHandler2)
+	assert.NoError(t, err, "no error should be returned because consumer can do GET on /my-handler2")
+
+	// POST /my-handler2 is forbidden (missing POST method in scope Action)
+	configHandler3 := &service.HandlerConfig{
+		CleanURL:      "/my-handler2",
+		AllowedScopes: []sdk.AuthConsumerScope{sdk.AuthConsumerScopeAction},
+		Method:        http.MethodPost,
+	}
+	Auth(true)(configHandler3)
+	req = assets.NewJWTAuthentifiedRequest(t, jwt, configHandler3.Method, configHandler3.CleanURL, nil)
+	w = httptest.NewRecorder()
+	_, err = api.authMiddleware(context.TODO(), w, req, configHandler3)
+	assert.Error(t, err, "an error should be returned because consumer can't do POST on /my-handler2")
+
+	// DELETE /my-handler3 is authorized as no method restriction set on route.
+	configHandler4 := &service.HandlerConfig{
+		CleanURL:      "/my-handler3",
+		AllowedScopes: []sdk.AuthConsumerScope{sdk.AuthConsumerScopeAction},
+		Method:        http.MethodDelete,
+	}
+	Auth(true)(configHandler4)
+	req = assets.NewJWTAuthentifiedRequest(t, jwt, configHandler4.Method, configHandler4.CleanURL, nil)
+	w = httptest.NewRecorder()
+	_, err = api.authMiddleware(context.TODO(), w, req, configHandler4)
+	assert.NoError(t, err, "no error should be returned because consumer can do any methods on /my-handler3")
+
+	// PUT /my-handler4 is authorized as no route restriction set on scope.
+	configHandler5 := &service.HandlerConfig{
+		CleanURL:      "/my-handler4",
+		AllowedScopes: []sdk.AuthConsumerScope{sdk.AuthConsumerScopeAdmin},
+		Method:        http.MethodPut,
+	}
+	Auth(true)(configHandler5)
+	req = assets.NewJWTAuthentifiedRequest(t, jwt, configHandler5.Method, configHandler5.CleanURL, nil)
+	w = httptest.NewRecorder()
+	_, err = api.authMiddleware(context.TODO(), w, req, configHandler5)
+	assert.NoError(t, err, "no error should be returned because consumer can access any routes for scope Admin")
+}
diff --git a/engine/api/router_test.go b/engine/api/router_test.go
new file mode 100644
index 000000000..390c140b7
--- /dev/null
+++ b/engine/api/router_test.go
@@ -0,0 +1,56 @@
+package api
+
+import (
+	"context"
+	"net/http"
+	"sort"
+	"testing"
+
+	"github.com/gorilla/mux"
+	"github.com/ovh/cds/engine/service"
+	"github.com/ovh/cds/sdk"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func Test_computeScopeDetails(t *testing.T) {
+	r := &Router{
+		Mux:        mux.NewRouter(),
+		Background: context.TODO(),
+	}
+
+	myHandler := func() service.Handler {
+		return func(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
+			return nil
+		}
+	}
+
+	r.Handle("/handler1", Scope(sdk.AuthConsumerScopeUser), r.GET(myHandler))
+	r.Handle("/handler2", Scope(sdk.AuthConsumerScopeAction), r.GET(myHandler), r.PUT(myHandler))
+	r.Handle("/handler3", Scope(sdk.AuthConsumerScopeAccessToken, sdk.AuthConsumerScopeAction), r.GET(myHandler), r.POST(myHandler), r.DELETE(myHandler))
+
+	r.computeScopeDetails()
+
+	for i := range r.scopeDetails {
+		switch r.scopeDetails[i].Scope {
+		case sdk.AuthConsumerScopeUser:
+			require.Len(t, r.scopeDetails[i].Endpoints, 1)
+			assert.Equal(t, r.scopeDetails[i].Endpoints[0].Route, "/handler1")
+			require.Len(t, r.scopeDetails[i].Endpoints[0].Methods, 1)
+		case sdk.AuthConsumerScopeAction:
+			sort.Slice(r.scopeDetails[i].Endpoints, func(j, k int) bool {
+				return r.scopeDetails[i].Endpoints[j].Route < r.scopeDetails[i].Endpoints[k].Route
+			})
+			require.Len(t, r.scopeDetails[i].Endpoints, 2)
+			assert.Equal(t, r.scopeDetails[i].Endpoints[0].Route, "/handler2")
+			require.Len(t, r.scopeDetails[i].Endpoints[0].Methods, 2)
+			assert.Equal(t, r.scopeDetails[i].Endpoints[1].Route, "/handler3")
+			require.Len(t, r.scopeDetails[i].Endpoints[1].Methods, 3)
+		case sdk.AuthConsumerScopeAccessToken:
+			require.Len(t, r.scopeDetails[i].Endpoints, 1)
+			assert.Equal(t, r.scopeDetails[i].Endpoints[0].Route, "/handler3")
+			require.Len(t, r.scopeDetails[i].Endpoints[0].Methods, 3)
+		}
+	}
+}
diff --git a/engine/api/services_test.go b/engine/api/services_test.go
index ea8839d56..9cb9f04cc 100644
--- a/engine/api/services_test.go
+++ b/engine/api/services_test.go
@@ -23,9 +23,9 @@ func TestServicesHandlers(t *testing.T) {
 	admin, jwtRaw := assets.InsertAdminUser(t, api.mustDB())
 
 	data := sdk.AuthConsumer{
-		Name:     sdk.RandomString(10),
-		Scopes:   []sdk.AuthConsumerScope{sdk.AuthConsumerScopeService},
-		IssuedAt: time.Now(),
+		Name:         sdk.RandomString(10),
+		ScopeDetails: sdk.NewAuthConsumerScopeDetails(sdk.AuthConsumerScopeService),
+		IssuedAt:     time.Now(),
 	}
 
 	uri := api.Router.GetRoute(http.MethodPost, api.postConsumerByUserHandler, map[string]string{
diff --git a/engine/api/test/assets/assets.go b/engine/api/test/assets/assets.go
index 1c5516468..c11595bcf 100644
--- a/engine/api/test/assets/assets.go
+++ b/engine/api/test/assets/assets.go
@@ -441,7 +441,8 @@ func InsertHatchery(t *testing.T, db gorp.SqlExecutor, grp sdk.Group) (*sdk.Serv
 	consumer, err := authentication.LoadConsumerByTypeAndUserID(context.TODO(), db, sdk.ConsumerLocal, usr1.ID, authentication.LoadConsumerOptions.WithAuthentifiedUser)
 	require.NoError(t, err)
 
-	hConsumer, _, err := builtin.NewConsumer(context.TODO(), db, sdk.RandomString(10), "", consumer, []int64{grp.ID}, []sdk.AuthConsumerScope{sdk.AuthConsumerScopeHatchery, sdk.AuthConsumerScopeRunExecution})
+	hConsumer, _, err := builtin.NewConsumer(context.TODO(), db, sdk.RandomString(10), "", consumer, []int64{grp.ID},
+		sdk.NewAuthConsumerScopeDetails(sdk.AuthConsumerScopeHatchery, sdk.AuthConsumerScopeRunExecution))
 	require.NoError(t, err)
 
 	privateKey, err := jws.NewRandomRSAKey()
@@ -477,7 +478,8 @@ func InsertService(t *testing.T, db gorp.SqlExecutor, name, serviceType string,
 
 	sharedGroup, err := group.LoadByName(context.TODO(), db, sdk.SharedInfraGroupName)
 	require.NoError(t, err)
-	hConsumer, _, err := builtin.NewConsumer(context.TODO(), db, sdk.RandomString(10), "", consumer, []int64{sharedGroup.ID}, append(scopes, sdk.AuthConsumerScopeProject))
+	hConsumer, _, err := builtin.NewConsumer(context.TODO(), db, sdk.RandomString(10), "", consumer, []int64{sharedGroup.ID},
+		sdk.NewAuthConsumerScopeDetails(append(scopes, sdk.AuthConsumerScopeProject)...))
 	require.NoError(t, err)
 
 	privateKey, err := jws.NewRandomRSAKey()
diff --git a/engine/api/workflow_test.go b/engine/api/workflow_test.go
index 2df26748e..3e8b9d79f 100644
--- a/engine/api/workflow_test.go
+++ b/engine/api/workflow_test.go
@@ -381,7 +381,8 @@ func Test_getWorkflowHandler_AsProvider(t *testing.T) {
 	localConsumer, err := authentication.LoadConsumerByTypeAndUserID(context.TODO(), api.mustDB(), sdk.ConsumerLocal, admin.ID, authentication.LoadConsumerOptions.WithAuthentifiedUser)
 	require.NoError(t, err)
 
-	_, jws, err := builtin.NewConsumer(context.TODO(), api.mustDB(), sdk.RandomString(10), sdk.RandomString(10), localConsumer, admin.GetGroupIDs(), Scope(sdk.AuthConsumerScopeProject))
+	_, jws, err := builtin.NewConsumer(context.TODO(), api.mustDB(), sdk.RandomString(10), sdk.RandomString(10), localConsumer, admin.GetGroupIDs(),
+		sdk.NewAuthConsumerScopeDetails(sdk.AuthConsumerScopeProject))
 
 	u, _ := assets.InsertLambdaUser(t, api.mustDB())
 
diff --git a/sdk/token_test.go b/sdk/token_test.go
new file mode 100644
index 000000000..30fe131a7
--- /dev/null
+++ b/sdk/token_test.go
@@ -0,0 +1,111 @@
+package sdk_test
+
+import (
+	"net/http"
+	"testing"
+
+	"github.com/ovh/cds/sdk"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestAuthConsumerScopeDetailsIsValid(t *testing.T) {
+	cases := []struct {
+		Name    string
+		Details sdk.AuthConsumerScopeDetails
+		Error   bool
+	}{
+		{
+			Name: "Some uniques scopes with unique routes and methods should be valid",
+			Details: sdk.AuthConsumerScopeDetails{
+				{
+					Scope: sdk.AuthConsumerScopeAccessToken,
+					Endpoints: sdk.AuthConsumerScopeEndpoints{
+						{
+							Route:   "/handler1",
+							Methods: []string{http.MethodGet, http.MethodPost},
+						},
+						{
+							Route:   "/handler2",
+							Methods: []string{http.MethodGet, http.MethodPut},
+						},
+					},
+				},
+				{
+					Scope: sdk.AuthConsumerScopeAction,
+				},
+			},
+			Error: false,
+		},
+		{
+			Name: "Duplicate scopes should generate an error",
+			Details: sdk.AuthConsumerScopeDetails{
+				{
+					Scope: sdk.AuthConsumerScopeAccessToken,
+				},
+				{
+					Scope: sdk.AuthConsumerScopeAccessToken,
+				},
+			},
+			Error: true,
+		},
+		{
+			Name: "Duplicate routes should generate an error",
+			Details: sdk.AuthConsumerScopeDetails{
+				{
+					Scope: sdk.AuthConsumerScopeAccessToken,
+					Endpoints: sdk.AuthConsumerScopeEndpoints{
+						{
+							Route: "/handler",
+						},
+						{
+							Route: "/handler",
+						},
+					},
+				},
+			},
+			Error: true,
+		},
+		{
+			Name: "Duplicate methods should generate an error",
+			Details: sdk.AuthConsumerScopeDetails{
+				{
+					Scope: sdk.AuthConsumerScopeAccessToken,
+					Endpoints: sdk.AuthConsumerScopeEndpoints{
+						{
+							Route:   "/handler",
+							Methods: []string{http.MethodGet, http.MethodGet},
+						},
+					},
+				},
+			},
+			Error: true,
+		},
+		{
+			Name: "Invalid method should generate an error",
+			Details: sdk.AuthConsumerScopeDetails{
+				{
+					Scope: sdk.AuthConsumerScopeAccessToken,
+					Endpoints: sdk.AuthConsumerScopeEndpoints{
+						{
+							Route:   "/handler",
+							Methods: []string{"UNKNOWN"},
+						},
+					},
+				},
+			},
+			Error: true,
+		},
+	}
+
+	for _, c := range cases {
+		t.Run(c.Name, func(t *testing.T) {
+			err := c.Details.IsValid()
+			if c.Error {
+				assert.Error(t, err)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}