Repository: ovh/cds
Pull request #6111 (2022-03-21 10:43:26)

fix(ui): allow to edit notifications without pipelines
1. Description
1. Related issues
1. About tests
1. Mentions

@ovh/cds



Patch:
diff --git a/engine/api/api_routes.go b/engine/api/api_routes.go
index 618694678..57862e782 100644
--- a/engine/api/api_routes.go
+++ b/engine/api/api_routes.go
@@ -29,7 +29,7 @@ func (api *API) InitRouter() {
 	api.Router.SetHeaderFunc = service.DefaultHeaders
 	api.Router.Middlewares = append(api.Router.Middlewares, api.tracingMiddleware, api.jwtMiddleware)
 	api.Router.DefaultAuthMiddleware = api.authMiddleware
-	api.Router.PostAuthMiddlewares = append(api.Router.PostAuthMiddlewares, api.xsrfMiddleware, api.maintenanceMiddleware)
+	api.Router.PostAuthMiddlewares = append(api.Router.PostAuthMiddlewares, api.xsrfMiddleware, api.maintenanceMiddleware, api.rbacMiddleware)
 	api.Router.PostMiddlewares = append(api.Router.PostMiddlewares, service.TracingPostMiddleware)
 
 	r := api.Router
@@ -449,6 +449,8 @@ func (api *API) InitRouter() {
 	r.Handle("/template/{groupName}/{templateSlug}/instance/{instanceID}", Scope(sdk.AuthConsumerScopeTemplate), r.DELETE(api.deleteTemplateInstanceHandler))
 	r.Handle("/template/{groupName}/{templateSlug}/usage", Scope(sdk.AuthConsumerScopeTemplate), r.GET(api.getTemplateUsageHandler))
 
+	r.Handle("/v2/project/{projectKey}/vcs", nil, r.POSTv2(api.postVCSOnProjectHandler))
+
 	//Not Found handler
 	r.Mux.NotFoundHandler = http.HandlerFunc(r.NotFoundHandler)
 
diff --git a/engine/api/rbac/dao_rbac.go b/engine/api/rbac/dao_rbac.go
new file mode 100644
index 000000000..72ba97ddd
--- /dev/null
+++ b/engine/api/rbac/dao_rbac.go
@@ -0,0 +1,100 @@
+package rbac
+
+import (
+	"context"
+	"time"
+
+	"github.com/go-gorp/gorp"
+	"github.com/rockbears/log"
+
+	"github.com/ovh/cds/engine/api/database/gorpmapping"
+	"github.com/ovh/cds/engine/gorpmapper"
+	"github.com/ovh/cds/sdk"
+)
+
+func LoadRbacByName(ctx context.Context, db gorp.SqlExecutor, name string, opts ...LoadOptionFunc) (sdk.RBAC, error) {
+	query := `SELECT * FROM rbac WHERE name = $1`
+	return get(ctx, db, gorpmapping.NewQuery(query).Args(name), opts...)
+}
+
+// Insert a RBAC permission in database
+func Insert(ctx context.Context, db gorpmapper.SqlExecutorWithTx, rb *sdk.RBAC) error {
+	if err := sdk.IsValidRbac(rb); err != nil {
+		return err
+	}
+	if rb.UUID == "" {
+		rb.UUID = sdk.UUID()
+	}
+	if rb.Created.IsZero() {
+		rb.Created = time.Now()
+	}
+	rb.LastModified = time.Now()
+	dbRb := rbac{RBAC: *rb}
+	if err := gorpmapping.InsertAndSign(ctx, db, &dbRb); err != nil {
+		return err
+	}
+
+	for i := range rb.Globals {
+		dbRbGlobal := rbacGlobal{
+			RbacUUID:   dbRb.UUID,
+			RBACGlobal: rb.Globals[i],
+		}
+		if err := insertRbacGlobal(ctx, db, &dbRbGlobal); err != nil {
+			return err
+		}
+	}
+	for i := range rb.Projects {
+		dbRbProject := rbacProject{
+			RbacUUID:    dbRb.UUID,
+			RBACProject: rb.Projects[i],
+		}
+		if err := insertRbacProject(ctx, db, &dbRbProject); err != nil {
+			return err
+		}
+	}
+	*rb = dbRb.RBAC
+	return nil
+}
+
+func Update(ctx context.Context, db gorpmapper.SqlExecutorWithTx, rb *sdk.RBAC) error {
+	if err := Delete(ctx, db, *rb); err != nil {
+		return err
+	}
+	return Insert(ctx, db, rb)
+}
+
+func Delete(_ context.Context, db gorpmapper.SqlExecutorWithTx, rb sdk.RBAC) error {
+	dbRb := rbac{RBAC: rb}
+	if err := gorpmapping.Delete(db, &dbRb); err != nil {
+		return err
+	}
+	return nil
+}
+
+func get(ctx context.Context, db gorp.SqlExecutor, q gorpmapping.Query, opts ...LoadOptionFunc) (sdk.RBAC, error) {
+	var r sdk.RBAC
+	var rbacDB rbac
+	found, err := gorpmapping.Get(ctx, db, q, &rbacDB)
+	if err != nil {
+		return r, err
+	}
+	if !found {
+		return r, sdk.WithStack(sdk.ErrNotFound)
+	}
+
+	isValid, err := gorpmapping.CheckSignature(rbacDB, rbacDB.Signature)
+	if err != nil {
+		return r, sdk.WrapError(err, "error when checking signature for rbac %s", rbacDB.UUID)
+	}
+	if !isValid {
+		log.Error(ctx, "rbac.get> rbac %s (%s) data corrupted", rbacDB.Name, rbacDB.UUID)
+		return r, sdk.WithStack(sdk.ErrNotFound)
+	}
+	for _, f := range opts {
+		if err := f(ctx, db, &rbacDB); err != nil {
+			return r, err
+		}
+	}
+	r = rbacDB.RBAC
+	return r, nil
+}
diff --git a/engine/api/rbac/dao_rbac_global.go b/engine/api/rbac/dao_rbac_global.go
new file mode 100644
index 000000000..85cccd4eb
--- /dev/null
+++ b/engine/api/rbac/dao_rbac_global.go
@@ -0,0 +1,94 @@
+package rbac
+
+import (
+	"context"
+
+	"github.com/go-gorp/gorp"
+	"github.com/rockbears/log"
+
+	"github.com/ovh/cds/engine/api/database/gorpmapping"
+	"github.com/ovh/cds/engine/gorpmapper"
+	"github.com/ovh/cds/sdk"
+)
+
+func insertRbacGlobal(ctx context.Context, db gorpmapper.SqlExecutorWithTx, rg *rbacGlobal) error {
+	if err := gorpmapping.InsertAndSign(ctx, db, rg); err != nil {
+		return err
+	}
+
+	for _, userID := range rg.RBACUsersIDs {
+		if err := insertRbacGlobalUser(ctx, db, rg.ID, userID); err != nil {
+			return err
+		}
+	}
+	for _, groupID := range rg.RBACGroupsIDs {
+		if err := insertRbacGlobalGroup(ctx, db, rg.ID, groupID); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func insertRbacGlobalUser(ctx context.Context, db gorpmapper.SqlExecutorWithTx, rbacGlobalID int64, userID string) error {
+	rgu := rbacGlobalUser{
+		RbacGlobalID:     rbacGlobalID,
+		RbacGlobalUserID: userID,
+	}
+	if err := gorpmapping.InsertAndSign(ctx, db, &rgu); err != nil {
+		return err
+	}
+	return nil
+}
+
+func insertRbacGlobalGroup(ctx context.Context, db gorpmapper.SqlExecutorWithTx, rbacGlobalID int64, groupID int64) error {
+	rgu := rbacGlobalGroup{
+		RbacGlobalID:      rbacGlobalID,
+		RbacGlobalGroupID: groupID,
+	}
+	if err := gorpmapping.InsertAndSign(ctx, db, &rgu); err != nil {
+		return err
+	}
+	return nil
+}
+
+func getAllRBACGlobalUsers(ctx context.Context, db gorp.SqlExecutor, rbacGlobal *rbacGlobal) error {
+	q := gorpmapping.NewQuery("SELECT * FROM  rbac_global_users WHERE rbac_global_id = $1").Args(rbacGlobal.ID)
+	var rbacUserIDS []rbacGlobalUser
+	if err := gorpmapping.GetAll(ctx, db, q, &rbacUserIDS); err != nil {
+		return err
+	}
+	rbacGlobal.RBACGlobal.RBACUsersIDs = make([]string, 0, len(rbacUserIDS))
+	for _, rbacUsers := range rbacUserIDS {
+		isValid, err := gorpmapping.CheckSignature(rbacUsers, rbacUsers.Signature)
+		if err != nil {
+			return sdk.WrapError(err, "error when checking signature for rbac_global_users %d", rbacUsers.ID)
+		}
+		if !isValid {
+			log.Error(ctx, "rbac.getAllRBACGlobalUsers> rbac_global_users %d data corrupted", rbacUsers.ID)
+			continue
+		}
+		rbacGlobal.RBACGlobal.RBACUsersIDs = append(rbacGlobal.RBACGlobal.RBACUsersIDs, rbacUsers.RbacGlobalUserID)
+	}
+	return nil
+}
+
+func getAllRBACGlobalGroups(ctx context.Context, db gorp.SqlExecutor, rbacGlobal *rbacGlobal) error {
+	q := gorpmapping.NewQuery("SELECT * FROM rbac_global_groups WHERE rbac_global_id = $1").Args(rbacGlobal.ID)
+	var rbacGroupIDs []rbacGlobalGroup
+	if err := gorpmapping.GetAll(ctx, db, q, &rbacGroupIDs); err != nil {
+		return err
+	}
+	rbacGlobal.RBACGlobal.RBACGroupsIDs = make([]int64, 0, len(rbacGroupIDs))
+	for _, rbacGroups := range rbacGroupIDs {
+		isValid, err := gorpmapping.CheckSignature(rbacGroups, rbacGroups.Signature)
+		if err != nil {
+			return sdk.WrapError(err, "error when checking signature for rbac_global_groups %d", rbacGroups.ID)
+		}
+		if !isValid {
+			log.Error(ctx, "rbac.getAllRBACGlobalGroups> rbac_global_groups %d data corrupted", rbacGroups.ID)
+			continue
+		}
+		rbacGlobal.RBACGlobal.RBACGroupsIDs = append(rbacGlobal.RBACGlobal.RBACGroupsIDs, rbacGroups.RbacGlobalGroupID)
+	}
+	return nil
+}
diff --git a/engine/api/rbac/dao_rbac_project.go b/engine/api/rbac/dao_rbac_project.go
new file mode 100644
index 000000000..778c2b361
--- /dev/null
+++ b/engine/api/rbac/dao_rbac_project.go
@@ -0,0 +1,95 @@
+package rbac
+
+import (
+	"context"
+
+	"github.com/go-gorp/gorp"
+	"github.com/lib/pq"
+	"github.com/rockbears/log"
+
+	"github.com/ovh/cds/engine/api/database/gorpmapping"
+	"github.com/ovh/cds/engine/gorpmapper"
+	"github.com/ovh/cds/sdk"
+)
+
+func insertRbacProject(ctx context.Context, db gorpmapper.SqlExecutorWithTx, dbRP *rbacProject) error {
+	if err := gorpmapping.InsertAndSign(ctx, db, dbRP); err != nil {
+		return err
+	}
+
+	for _, rbProjectID := range dbRP.RBACProjectsIDs {
+		if err := insertRbacProjectIdentifiers(ctx, db, dbRP.ID, rbProjectID); err != nil {
+			return err
+		}
+	}
+	for _, rbUserID := range dbRP.RBACUsersIDs {
+		if err := insertRbacProjectUser(ctx, db, dbRP.ID, rbUserID); err != nil {
+			return err
+		}
+	}
+	for _, rbGroupID := range dbRP.RBACGroupsIDs {
+		if err := insertRbacProjectGroup(ctx, db, dbRP.ID, rbGroupID); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func insertRbacProjectIdentifiers(ctx context.Context, db gorpmapper.SqlExecutorWithTx, rbacParentID int64, projectID int64) error {
+	identifier := rbacProjectIdentifiers{
+		RbacProjectID: rbacParentID,
+		ProjectID:     projectID,
+	}
+	if err := gorpmapping.InsertAndSign(ctx, db, &identifier); err != nil {
+		return err
+	}
+	return nil
+}
+
+func insertRbacProjectUser(ctx context.Context, db gorpmapper.SqlExecutorWithTx, rbacProjectID int64, userID string) error {
+	rgu := rbacProjectUser{
+		RbacProjectID:     rbacProjectID,
+		RbacProjectUserID: userID,
+	}
+	if err := gorpmapping.InsertAndSign(ctx, db, &rgu); err != nil {
+		return err
+	}
+	return nil
+}
+
+func insertRbacProjectGroup(ctx context.Context, db gorpmapper.SqlExecutorWithTx, rbacProjectID int64, groupID int64) error {
+	rgu := rbacProjectGroup{
+		RbacProjectID:      rbacProjectID,
+		RbacProjectGroupID: groupID,
+	}
+	if err := gorpmapping.InsertAndSign(ctx, db, &rgu); err != nil {
+		return err
+	}
+	return nil
+}
+
+func getAllRbacProjects(ctx context.Context, db gorp.SqlExecutor, q gorpmapping.Query) ([]rbacProject, error) {
+	var rbacProjects []rbacProject
+	if err := gorpmapping.GetAll(ctx, db, q, &rbacProjects); err != nil {
+		return nil, err
+	}
+
+	projectsFiltered := make([]rbacProject, 0, len(rbacProjects))
+	for _, projectDatas := range rbacProjects {
+		isValid, err := gorpmapping.CheckSignature(projectDatas, projectDatas.Signature)
+		if err != nil {
+			return nil, sdk.WrapError(err, "error when checking signature for rbac_project %d", projectDatas.ID)
+		}
+		if !isValid {
+			log.Error(ctx, "rbac.getAllRbacProjects> rbac_project %d data corrupted", projectDatas.ID)
+			continue
+		}
+		projectsFiltered = append(projectsFiltered, projectDatas)
+	}
+	return projectsFiltered, nil
+}
+
+func loadRbacProjectsByRoleAndIDs(ctx context.Context, db gorp.SqlExecutor, role string, rbacProjectIDs []int64) ([]rbacProject, error) {
+	q := gorpmapping.NewQuery(`SELECT * from rbac_project WHERE role = $1 AND id = ANY($2)`).Args(role, pq.Int64Array(rbacProjectIDs))
+	return getAllRbacProjects(ctx, db, q)
+}
diff --git a/engine/api/rbac/dao_rbac_project_group.go b/engine/api/rbac/dao_rbac_project_group.go
new file mode 100644
index 000000000..a125032f2
--- /dev/null
+++ b/engine/api/rbac/dao_rbac_project_group.go
@@ -0,0 +1,64 @@
+package rbac
+
+import (
+	"context"
+
+	"github.com/go-gorp/gorp"
+	"github.com/lib/pq"
+	"github.com/rockbears/log"
+
+	"github.com/ovh/cds/engine/api/database/gorpmapping"
+	"github.com/ovh/cds/engine/api/group"
+	"github.com/ovh/cds/sdk"
+)
+
+func loadRbacProjectGroupsByUserID(ctx context.Context, db gorp.SqlExecutor, userID string) ([]rbacProjectGroup, error) {
+	groups, err := group.LoadAllByUserID(ctx, db, userID)
+	if err != nil {
+		return nil, err
+	}
+	groupIDs := make([]int64, 0, len(groups))
+	for _, g := range groups {
+		groupIDs = append(groupIDs, g.ID)
+	}
+	return loadRbacProjectGroupsByGroupIDs(ctx, db, groupIDs)
+}
+
+func loadRbacProjectGroupsByGroupIDs(ctx context.Context, db gorp.SqlExecutor, groupIDs []int64) ([]rbacProjectGroup, error) {
+	q := gorpmapping.NewQuery("SELECT * FROM rbac_project_groups WHERE group_id = ANY ($1)").Args(pq.Int64Array(groupIDs))
+	return getAllRBACProjectGroups(ctx, db, q)
+}
+
+func loadRBACProjectGroups(ctx context.Context, db gorp.SqlExecutor, rbacProject *rbacProject) error {
+	q := gorpmapping.NewQuery("SELECT * FROM rbac_project_groups WHERE rbac_project_id = $1").Args(rbacProject.ID)
+	rbacProjectGroups, err := getAllRBACProjectGroups(ctx, db, q)
+	if err != nil {
+		return err
+	}
+	rbacProject.RBACProject.RBACGroupsIDs = make([]int64, 0, len(rbacProjectGroups))
+	for _, g := range rbacProjectGroups {
+		rbacProject.RBACProject.RBACGroupsIDs = append(rbacProject.RBACProject.RBACGroupsIDs, g.RbacProjectGroupID)
+	}
+	return nil
+}
+
+func getAllRBACProjectGroups(ctx context.Context, db gorp.SqlExecutor, q gorpmapping.Query) ([]rbacProjectGroup, error) {
+	var rbacGroupIDs []rbacProjectGroup
+	if err := gorpmapping.GetAll(ctx, db, q, &rbacGroupIDs); err != nil {
+		return nil, err
+	}
+
+	groupsFiltered := make([]rbacProjectGroup, 0, len(rbacGroupIDs))
+	for _, rbacGroups := range rbacGroupIDs {
+		isValid, err := gorpmapping.CheckSignature(rbacGroups, rbacGroups.Signature)
+		if err != nil {
+			return nil, sdk.WrapError(err, "error when checking signature for rbac_project_groups %d", rbacGroups.ID)
+		}
+		if !isValid {
+			log.Error(ctx, "rbac.getAllRBACProjectGroups> rbac_project_groups %d data corrupted", rbacGroups.ID)
+			continue
+		}
+		groupsFiltered = append(groupsFiltered, rbacGroups)
+	}
+	return groupsFiltered, nil
+}
diff --git a/engine/api/rbac/dao_rbac_project_project.go b/engine/api/rbac/dao_rbac_project_project.go
new file mode 100644
index 000000000..e0bf2e520
--- /dev/null
+++ b/engine/api/rbac/dao_rbac_project_project.go
@@ -0,0 +1,104 @@
+package rbac
+
+import (
+	"context"
+	"github.com/lib/pq"
+
+	"github.com/go-gorp/gorp"
+	"github.com/rockbears/log"
+
+	"github.com/ovh/cds/engine/api/database/gorpmapping"
+	"github.com/ovh/cds/sdk"
+)
+
+func getAllRBACProjectIdentifiers(ctx context.Context, db gorp.SqlExecutor, q gorpmapping.Query) ([]rbacProjectIdentifiers, error) {
+	var rbacProjectIdentifier []rbacProjectIdentifiers
+	if err := gorpmapping.GetAll(ctx, db, q, &rbacProjectIdentifier); err != nil {
+		return nil, err
+	}
+	rbacProjectIdentifierFiltered := make([]rbacProjectIdentifiers, 0, len(rbacProjectIdentifier))
+	for _, projectDatas := range rbacProjectIdentifier {
+		isValid, err := gorpmapping.CheckSignature(projectDatas, projectDatas.Signature)
+		if err != nil {
+			return nil, sdk.WrapError(err, "error when checking signature for rbac_project_projects %d", projectDatas.ID)
+		}
+		if !isValid {
+			log.Error(ctx, "rbac.getAllRBACProjectIdentifiers> rbac_project_projects %d data corrupted", projectDatas.ID)
+			continue
+		}
+		rbacProjectIdentifierFiltered = append(rbacProjectIdentifierFiltered, projectDatas)
+	}
+	return rbacProjectIdentifierFiltered, nil
+}
+
+func loadRBACProjectIdentifiers(ctx context.Context, db gorp.SqlExecutor, rbacProject *rbacProject) error {
+	q := gorpmapping.NewQuery("SELECT * FROM  rbac_project_projects WHERE rbac_project_id = $1").Args(rbacProject.ID)
+	rbacProjectIdentifiers, err := getAllRBACProjectIdentifiers(ctx, db, q)
+	if err != nil {
+		return err
+	}
+	rbacProject.RBACProject.RBACProjectsIDs = make([]int64, 0, len(rbacProjectIdentifiers))
+	for _, projectDatas := range rbacProjectIdentifiers {
+		rbacProject.RBACProject.RBACProjectsIDs = append(rbacProject.RBACProject.RBACProjectsIDs, projectDatas.ProjectID)
+	}
+	return nil
+}
+
+func loadRRBACProjectIdentifiers(ctx context.Context, db gorp.SqlExecutor, rbacProjectIDs []int64) ([]rbacProjectIdentifiers, error) {
+	query := gorpmapping.NewQuery(`SELECT * FROM rbac_project_projects WHERE rbac_project_id = ANY($1)`).Args(pq.Int64Array(rbacProjectIDs))
+	return getAllRBACProjectIdentifiers(ctx, db, query)
+}
+
+func LoadProjectIDsByRoleAndUserID(ctx context.Context, db gorp.SqlExecutor, role string, userID string) ([]int64, error) {
+	// Get rbac_project_groups
+	rbacProjectGroups, err := loadRbacProjectGroupsByUserID(ctx, db, userID)
+	if err != nil {
+		return nil, err
+	}
+	// Get rbac_project_users
+	rbacProjectUsers, err := loadRbacProjectUsersByUserID(ctx, db, userID)
+	if err != nil {
+		return nil, err
+	}
+
+	// Deduplicate rbac_project.id
+	mapRbacProjectID := make(map[int64]struct{})
+	rbacProjectIDs := make([]int64, 0)
+	for _, rpg := range rbacProjectGroups {
+		mapRbacProjectID[rpg.RbacProjectID] = struct{}{}
+		rbacProjectIDs = append(rbacProjectIDs, rpg.RbacProjectID)
+	}
+	for _, rpu := range rbacProjectUsers {
+		if _, has := mapRbacProjectID[rpu.RbacProjectID]; !has {
+			mapRbacProjectID[rpu.RbacProjectID] = struct{}{}
+			rbacProjectIDs = append(rbacProjectIDs, rpu.RbacProjectID)
+		}
+	}
+
+	// Get rbac_project
+	rbacProjects, err := loadRbacProjectsByRoleAndIDs(ctx, db, role, rbacProjectIDs)
+	if err != nil {
+		return nil, err
+	}
+
+	// Get rbac_project_projects
+	rbacProjectIDs = make([]int64, 0, len(rbacProjects))
+	for _, rp := range rbacProjects {
+		rbacProjectIDs = append(rbacProjectIDs, rp.ID)
+	}
+	rbacProjectsIdentifiers, err := loadRRBACProjectIdentifiers(ctx, db, rbacProjectIDs)
+	if err != nil {
+		return nil, err
+	}
+
+	// Deduplicate project ID
+	projectIDs := make([]int64, 0)
+	projectMap := make(map[int64]struct{})
+	for _, rpi := range rbacProjectsIdentifiers {
+		if _, has := projectMap[rpi.ProjectID]; !has {
+			projectMap[rpi.ProjectID] = struct{}{}
+			projectIDs = append(projectIDs, rpi.ProjectID)
+		}
+	}
+	return projectIDs, nil
+}
diff --git a/engine/api/rbac/dao_rbac_project_user.go b/engine/api/rbac/dao_rbac_project_user.go
new file mode 100644
index 000000000..0ab5890c2
--- /dev/null
+++ b/engine/api/rbac/dao_rbac_project_user.go
@@ -0,0 +1,50 @@
+package rbac
+
+import (
+	"context"
+
+	"github.com/go-gorp/gorp"
+	"github.com/rockbears/log"
+
+	"github.com/ovh/cds/engine/api/database/gorpmapping"
+	"github.com/ovh/cds/sdk"
+)
+
+func loadRbacProjectUsersByUserID(ctx context.Context, db gorp.SqlExecutor, userID string) ([]rbacProjectUser, error) {
+	q := gorpmapping.NewQuery("SELECT * FROM rbac_project_users WHERE user_id = $1").Args(userID)
+	return getAllRBACProjectUsers(ctx, db, q)
+}
+
+func loadRBACProjectUsers(ctx context.Context, db gorp.SqlExecutor, rbacProject *rbacProject) error {
+	q := gorpmapping.NewQuery("SELECT * FROM rbac_project_users WHERE rbac_project_id = $1").Args(rbacProject.ID)
+	rbacUserIDS, err := getAllRBACProjectUsers(ctx, db, q)
+	if err != nil {
+		return err
+	}
+	rbacProject.RBACProject.RBACUsersIDs = make([]string, 0, len(rbacUserIDS))
+	for _, rbacUsers := range rbacUserIDS {
+		rbacProject.RBACProject.RBACUsersIDs = append(rbacProject.RBACProject.RBACUsersIDs, rbacUsers.RbacProjectUserID)
+	}
+	return nil
+}
+
+func getAllRBACProjectUsers(ctx context.Context, db gorp.SqlExecutor, q gorpmapping.Query) ([]rbacProjectUser, error) {
+	var rbacProjectUsers []rbacProjectUser
+	if err := gorpmapping.GetAll(ctx, db, q, &rbacProjectUsers); err != nil {
+		return nil, err
+	}
+
+	usersFiltered := make([]rbacProjectUser, 0, len(rbacProjectUsers))
+	for _, rbacUsers := range rbacProjectUsers {
+		isValid, err := gorpmapping.CheckSignature(rbacUsers, rbacUsers.Signature)
+		if err != nil {
+			return nil, sdk.WrapError(err, "error when checking signature for rbac_project_users %d", rbacUsers.ID)
+		}
+		if !isValid {
+			log.Error(ctx, "rbac.getAllRBACProjectUsers> rbac_project_users %d data corrupted", rbacUsers.ID)
+			continue
+		}
+		usersFiltered = append(usersFiltered, rbacUsers)
+	}
+	return usersFiltered, nil
+}
diff --git a/engine/api/rbac/gorp_model.go b/engine/api/rbac/gorp_model.go
new file mode 100644
index 000000000..fa2b5fbff
--- /dev/null
+++ b/engine/api/rbac/gorp_model.go
@@ -0,0 +1,128 @@
+package rbac
+
+import (
+	"github.com/ovh/cds/engine/api/database/gorpmapping"
+	"github.com/ovh/cds/engine/gorpmapper"
+	"github.com/ovh/cds/sdk"
+)
+
+type rbac struct {
+	sdk.RBAC
+	gorpmapper.SignedEntity
+}
+
+func (r rbac) Canonical() gorpmapper.CanonicalForms {
+	_ = []interface{}{r.UUID, r.Name}
+	return []gorpmapper.CanonicalForm{
+		"{{.UUID}}{{.Name}}",
+	}
+}
+
+type rbacGlobal struct {
+	ID       int64  `db:"id"`
+	RbacUUID string `db:"rbac_uuid"`
+	sdk.RBACGlobal
+	gorpmapper.SignedEntity
+}
+
+func (rg rbacGlobal) Canonical() gorpmapper.CanonicalForms {
+	_ = []interface{}{rg.ID, rg.RbacUUID, rg.Role}
+	return []gorpmapper.CanonicalForm{
+		"{{.ID}}{{.RbacUUID}}{{.Role}}",
+	}
+}
+
+type rbacGlobalUser struct {
+	ID               int64  `db:"id"`
+	RbacGlobalID     int64  `db:"rbac_global_id"`
+	RbacGlobalUserID string `db:"user_id"`
+	gorpmapper.SignedEntity
+}
+
+func (rgu rbacGlobalUser) Canonical() gorpmapper.CanonicalForms {
+	_ = []interface{}{rgu.ID, rgu.RbacGlobalID, rgu.RbacGlobalUserID}
+	return []gorpmapper.CanonicalForm{
+		"{{.ID}}{{.RbacGlobalID}}{{.RbacGlobalUserID}}",
+	}
+}
+
+type rbacGlobalGroup struct {
+	ID                int64 `json:"-" db:"id" yaml:"-"`
+	RbacGlobalID      int64 `db:"rbac_global_id"`
+	RbacGlobalGroupID int64 `db:"group_id"`
+	gorpmapper.SignedEntity
+}
+
+func (rgg rbacGlobalGroup) Canonical() gorpmapper.CanonicalForms {
+	_ = []interface{}{rgg.ID, rgg.RbacGlobalID, rgg.RbacGlobalGroupID}
+	return []gorpmapper.CanonicalForm{
+		"{{.ID}}{{.RbacGlobalID}}{{.RbacGlobalGroupID}}",
+	}
+}
+
+type rbacProject struct {
+	ID       int64  `json:"-" db:"id" yaml:"-"`
+	RbacUUID string `json:"-" db:"rbac_uuid" yaml:"-"`
+	sdk.RBACProject
+	gorpmapper.SignedEntity
+}
+
+func (rp rbacProject) Canonical() gorpmapper.CanonicalForms {
+	_ = []interface{}{rp.ID, rp.RbacUUID, rp.Role, rp.All}
+	return []gorpmapper.CanonicalForm{
+		"{{.ID}}{{.RbacUUID}}{{.Role}}{{.All}}",
+	}
+}
+
+type rbacProjectIdentifiers struct {
+	ID            int64 `json:"-" db:"id" yaml:"-"`
+	RbacProjectID int64 `json:"-" db:"rbac_project_id" yaml:"-"`
+	ProjectID     int64 `json:"-" db:"project_id" yaml:"-"`
+	gorpmapper.SignedEntity
+}
+
+func (rpi rbacProjectIdentifiers) Canonical() gorpmapper.CanonicalForms {
+	_ = []interface{}{rpi.ID, rpi.RbacProjectID, rpi.ProjectID}
+	return []gorpmapper.CanonicalForm{
+		"{{.ID}}{{.RbacProjectID}}{{.ProjectID}}",
+	}
+}
+
+type rbacProjectUser struct {
+	ID                int64  `json:"-" db:"id" yaml:"-"`
+	RbacProjectID     int64  `db:"rbac_project_id"`
+	RbacProjectUserID string `db:"user_id"`
+	gorpmapper.SignedEntity
+}
+
+func (rgu rbacProjectUser) Canonical() gorpmapper.CanonicalForms {
+	_ = []interface{}{rgu.ID, rgu.RbacProjectID, rgu.RbacProjectUserID}
+	return []gorpmapper.CanonicalForm{
+		"{{.ID}}{{.RbacProjectID}}{{.RbacProjectUserID}}",
+	}
+}
+
+type rbacProjectGroup struct {
+	ID                 int64 `json:"-" db:"id" yaml:"-"`
+	RbacProjectID      int64 `db:"rbac_project_id"`
+	RbacProjectGroupID int64 `db:"group_id"`
+	gorpmapper.SignedEntity
+}
+
+func (rgg rbacProjectGroup) Canonical() gorpmapper.CanonicalForms {
+	_ = []interface{}{rgg.ID, rgg.RbacProjectID, rgg.RbacProjectGroupID}
+	return []gorpmapper.CanonicalForm{
+		"{{.ID}}{{.RbacProjectID}}{{.RbacProjectGroupID}}",
+	}
+}
+
+func init() {
+	gorpmapping.Register(gorpmapping.New(rbac{}, "rbac", false, "uuid"))
+	gorpmapping.Register(gorpmapping.New(rbacGlobal{}, "rbac_global", true, "id"))
+	gorpmapping.Register(gorpmapping.New(rbacGlobalUser{}, "rbac_global_users", true, "id"))
+	gorpmapping.Register(gorpmapping.New(rbacGlobalGroup{}, "rbac_global_groups", true, "id"))
+	gorpmapping.Register(gorpmapping.New(rbacProject{}, "rbac_project", true, "id"))
+	gorpmapping.Register(gorpmapping.New(rbacProjectIdentifiers{}, "rbac_project_projects", true, "id"))
+	gorpmapping.Register(gorpmapping.New(rbacProjectUser{}, "rbac_project_users", true, "id"))
+	gorpmapping.Register(gorpmapping.New(rbacProjectGroup{}, "rbac_project_groups", true, "id"))
+}
diff --git a/engine/api/rbac/loader.go b/engine/api/rbac/loader.go
new file mode 100644
index 000000000..af8b79cd6
--- /dev/null
+++ b/engine/api/rbac/loader.go
@@ -0,0 +1,96 @@
+package rbac
+
+import (
+	"context"
+
+	"github.com/go-gorp/gorp"
+	"github.com/rockbears/log"
+
+	"github.com/ovh/cds/engine/api/database/gorpmapping"
+	"github.com/ovh/cds/sdk"
+)
+
+// LoadOptionFunc is used as options to loadProject functions
+type LoadOptionFunc func(context.Context, gorp.SqlExecutor, *rbac) error
+
+// LoadOptions provides all options on rbac loads functions
+var LoadOptions = struct {
+	Default         LoadOptionFunc
+	LoadRbacGlobal  LoadOptionFunc
+	LoadRbacProject LoadOptionFunc
+}{
+	Default:         loadDefault,
+	LoadRbacGlobal:  loadRbacGlobal,
+	LoadRbacProject: loadRbacProject,
+}
+
+func loadDefault(ctx context.Context, db gorp.SqlExecutor, rbac *rbac) error {
+	if err := loadRbacGlobal(ctx, db, rbac); err != nil {
+		return err
+	}
+	if err := loadRbacProject(ctx, db, rbac); err != nil {
+		return err
+	}
+	return nil
+}
+
+func loadRbacProject(ctx context.Context, db gorp.SqlExecutor, rbac *rbac) error {
+	query := "SELECT * FROM rbac_project WHERE rbac_uuid = $1"
+	var rbacPrj []rbacProject
+	if err := gorpmapping.GetAll(ctx, db, gorpmapping.NewQuery(query).Args(rbac.UUID), &rbacPrj); err != nil {
+		return err
+	}
+	rbac.Projects = make([]sdk.RBACProject, 0, len(rbacPrj))
+	for i := range rbacPrj {
+		rp := &rbacPrj[i]
+		isValid, err := gorpmapping.CheckSignature(rp, rp.Signature)
+		if err != nil {
+			return sdk.WrapError(err, "error when checking signature for rbac_project %d", rp.ID)
+		}
+		if !isValid {
+			log.Error(ctx, "rbac_project.get> rbac_project %d data corrupted", rp.ID)
+			continue
+		}
+		if err := loadRBACProjectIdentifiers(ctx, db, rp); err != nil {
+			return err
+		}
+		if !rp.All {
+			if err := loadRBACProjectUsers(ctx, db, rp); err != nil {
+				return err
+			}
+			if err := loadRBACProjectGroups(ctx, db, rp); err != nil {
+				return err
+			}
+		}
+		rbac.Projects = append(rbac.Projects, rp.RBACProject)
+	}
+	return nil
+}
+
+func loadRbacGlobal(ctx context.Context, db gorp.SqlExecutor, rbac *rbac) error {
+	query := "SELECT * FROM rbac_global WHERE rbac_uuid = $1"
+	var rbacGbl []rbacGlobal
+	if err := gorpmapping.GetAll(ctx, db, gorpmapping.NewQuery(query).Args(rbac.UUID), &rbacGbl); err != nil {
+		return err
+	}
+	rbac.Globals = make([]sdk.RBACGlobal, 0, len(rbacGbl))
+	for i := range rbacGbl {
+		rg := &rbacGbl[i]
+		isValid, err := gorpmapping.CheckSignature(rg, rg.Signature)
+		if err != nil {
+			return sdk.WrapError(err, "error when checking signature for rbac_global %d", rg.ID)
+		}
+		if !isValid {
+			log.Error(ctx, "rbac.loadRbacGlobal> rbac_global %d data corrupted", rg.ID)
+			continue
+		}
+		if err := getAllRBACGlobalUsers(ctx, db, rg); err != nil {
+			return err
+		}
+		if err := getAllRBACGlobalGroups(ctx, db, rg); err != nil {
+			return err
+		}
+		rbac.Globals = append(rbac.Globals, rg.RBACGlobal)
+	}
+	return nil
+}
diff --git a/engine/api/rbac/rbac.go b/engine/api/rbac/rbac.go
new file mode 100644
index 000000000..ccc1abb9b
--- /dev/null
+++ b/engine/api/rbac/rbac.go
@@ -0,0 +1,113 @@
+package rbac
+
+import (
+	"context"
+
+	"github.com/go-gorp/gorp"
+	"github.com/ovh/cds/engine/api/group"
+	"github.com/ovh/cds/engine/api/project"
+	"github.com/ovh/cds/engine/api/user"
+	"github.com/ovh/cds/sdk"
+)
+
+func fillWithIDs(ctx context.Context, db gorp.SqlExecutor, r *sdk.RBAC) error {
+	// Check existing permission
+	rbacDB, err := LoadRbacByName(ctx, db, r.Name)
+	if err != nil {
+		if !sdk.ErrorIs(err, sdk.ErrNotFound) {
+			return err
+		}
+	}
+	r.UUID = rbacDB.UUID
+
+	userCache := make(map[string]string)
+	groupCache := make(map[string]int64)
+	projectCache := make(map[string]int64)
+	for gID := range r.Globals {
+		rbacGbl := &r.Globals[gID]
+		if err := fillRbacGlobalWithID(ctx, db, rbacGbl, userCache, groupCache); err != nil {
+			return err
+		}
+	}
+	for pID := range r.Projects {
+		rbacPrj := &r.Projects[pID]
+		if err := fillRbacProjectWithID(ctx, db, rbacPrj, projectCache, userCache, groupCache); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func fillRbacProjectWithID(ctx context.Context, db gorp.SqlExecutor, rbacPrj *sdk.RBACProject, projectCache map[string]int64, userCache map[string]string, groupCache map[string]int64) error {
+	rbacPrj.RBACProjectsIDs = make([]int64, 0, len(rbacPrj.RBACProjectKeys))
+	for _, projKey := range rbacPrj.RBACProjectKeys {
+		projectID := projectCache[projKey]
+		if projectID == 0 {
+			prj, err := project.Load(ctx, db, projKey)
+			if err != nil {
+				return err
+			}
+			projectID = prj.ID
+			projectCache[projKey] = prj.ID
+		}
+		rbacPrj.RBACProjectsIDs = append(rbacPrj.RBACProjectsIDs, projectID)
+	}
+	rbacPrj.RBACUsersIDs = make([]string, 0, len(rbacPrj.RBACUsersName))
+	for _, userName := range rbacPrj.RBACUsersName {
+		userID := userCache[userName]
+		if userID == "" {
+			authentifierUser, err := user.LoadByUsername(ctx, db, userName)
+			if err != nil {
+				return err
+			}
+			userID = authentifierUser.ID
+			userCache[userName] = userID
+		}
+		rbacPrj.RBACUsersIDs = append(rbacPrj.RBACUsersIDs, userID)
+	}
+	rbacPrj.RBACGroupsIDs = make([]int64, 0, len(rbacPrj.RBACGroupsName))
+	for _, groupName := range rbacPrj.RBACGroupsName {
+		groupID := groupCache[groupName]
+		if groupID == 0 {
+			groupDB, err := group.LoadByName(ctx, db, groupName)
+			if err != nil {
+				return err
+			}
+			groupID = groupDB.ID
+			groupCache[groupDB.Name] = groupID
+		}
+		rbacPrj.RBACGroupsIDs = append(rbacPrj.RBACGroupsIDs, groupID)
+	}
+	return nil
+}
+
+func fillRbacGlobalWithID(ctx context.Context, db gorp.SqlExecutor, rbacGbl *sdk.RBACGlobal, userCache map[string]string, groupCache map[string]int64) error {
+	rbacGbl.RBACUsersIDs = make([]string, 0, len(rbacGbl.RBACUsersName))
+	for _, rbacUserName := range rbacGbl.RBACUsersName {
+		userID := userCache[rbacUserName]
+		if userID == "" {
+			authentifierUser, err := user.LoadByUsername(ctx, db, rbacUserName)
+			if err != nil {
+				return err
+			}
+			userID = authentifierUser.ID
+			userCache[rbacUserName] = userID
+		}
+		rbacGbl.RBACUsersIDs = append(rbacGbl.RBACUsersIDs, userID)
+	}
+
+	rbacGbl.RBACGroupsIDs = make([]int64, 0, len(rbacGbl.RBACGroupsName))
+	for _, groupName := range rbacGbl.RBACGroupsName {
+		groupID := groupCache[groupName]
+		if groupID == 0 {
+			groupDB, err := group.LoadByName(ctx, db, groupName)
+			if err != nil {
+				return err
+			}
+			groupID = groupDB.ID
+			groupCache[groupDB.Name] = groupID
+		}
+		rbacGbl.RBACGroupsIDs = append(rbacGbl.RBACGroupsIDs, groupID)
+	}
+	return nil
+}
diff --git a/engine/api/rbac/rule_project.go b/engine/api/rbac/rule_project.go
new file mode 100644
index 000000000..8484a6cc5
--- /dev/null
+++ b/engine/api/rbac/rule_project.go
@@ -0,0 +1,30 @@
+package rbac
+
+import (
+	"context"
+
+	"github.com/go-gorp/gorp"
+	"github.com/rockbears/log"
+
+	"github.com/ovh/cds/engine/api/project"
+	"github.com/ovh/cds/sdk"
+)
+
+func ProjectExist(ctx context.Context, db gorp.SqlExecutor, vars map[string]string) error {
+	projectKey := vars["projectKey"]
+	exist, err := project.Exist(db, projectKey)
+	if err != nil {
+		return err
+	}
+	if !exist {
+		return sdk.WithStack(sdk.ErrNotFound)
+	}
+	return nil
+}
+
+func ProjectManage(ctx context.Context, db gorp.SqlExecutor, vars map[string]string) error {
+	projectKey := vars["projectKey"]
+	// TODO Check role manage project
+	log.Debug(ctx, "Checking manage project role on %s", projectKey)
+	return nil
+}
diff --git a/engine/api/router.go b/engine/api/router.go
index 19cdeab90..13e25fda4 100644
--- a/engine/api/router.go
+++ b/engine/api/router.go
@@ -263,10 +263,7 @@ func (r *Router) handle(uri string, scope HandlerScope, handlers ...*service.Han
 	for i := range handlers {
 		handlers[i].CleanURL = cleanURL
 		handlers[i].AllowedScopes = scope
-		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)
+		name := sdk.GetFuncName(handlers[i].Handler)
 		handlers[i].Name = name
 		cfg.Config[handlers[i].Method] = handlers[i]
 	}
@@ -490,6 +487,18 @@ func (r *Router) GET(h service.HandlerFunc, cfg ...service.HandlerConfigParam) *
 	return &rc
 }
 
+func (r *Router) POSTv2(h service.HandlerFuncV2, cfg ...service.HandlerConfigParam) *service.HandlerConfig {
+	var rc service.HandlerConfig
+	handler, rbacCheckers := h()
+	rc.Handler = handler
+	rc.RbacCheckers = rbacCheckers
+	rc.Method = "POST"
+	for _, c := range cfg {
+		c(&rc)
+	}
+	return &rc
+}
+
 // POST will set given handler only for POST request
 func (r *Router) POST(h service.HandlerFunc, cfg ...service.HandlerConfigParam) *service.HandlerConfig {
 	var rc service.HandlerConfig
diff --git a/engine/api/router_middleware_rbac.go b/engine/api/router_middleware_rbac.go
new file mode 100644
index 000000000..ba0771f20
--- /dev/null
+++ b/engine/api/router_middleware_rbac.go
@@ -0,0 +1,24 @@
+package api
+
+import (
+	"context"
+	"net/http"
+
+	"github.com/gorilla/mux"
+	"github.com/rockbears/log"
+
+	"github.com/ovh/cds/engine/service"
+	"github.com/ovh/cds/sdk"
+	cdslog "github.com/ovh/cds/sdk/log"
+)
+
+func (api *API) rbacMiddleware(ctx context.Context, _ http.ResponseWriter, req *http.Request, rc *service.HandlerConfig) (context.Context, error) {
+	for _, checker := range rc.RbacCheckers {
+		ctx := context.WithValue(ctx, cdslog.RbackCheckerName, sdk.GetFuncName(checker))
+		if err := checker(ctx, api.mustDB(), mux.Vars(req)); err != nil {
+			log.ErrorWithStackTrace(ctx, err)
+			return ctx, sdk.WithStack(sdk.ErrForbidden)
+		}
+	}
+	return ctx, nil
+}
diff --git a/engine/api/router_util.go b/engine/api/router_util.go
index 96b01a132..1fc4b0139 100644
--- a/engine/api/router_util.go
+++ b/engine/api/router_util.go
@@ -35,9 +35,7 @@ func writeNoContentPostMiddleware(ctx context.Context, w http.ResponseWriter, re
 	return ctx, nil
 }
 
-// GetRoute returns the routes given a handler
-func (r *Router) GetRoute(method string, handler service.HandlerFunc, vars map[string]string) string {
-	p1 := reflect.ValueOf(handler()).Pointer()
+func (r *Router) getRoute(method string, p1 uintptr, routeName string, vars map[string]string) string {
 	var url string
 	for uri, routerConfig := range r.mapRouterConfigs {
 		rc := routerConfig.Config[method]
@@ -59,12 +57,26 @@ func (r *Router) GetRoute(method string, handler service.HandlerFunc, vars map[s
 	}
 
 	if url == "" {
-		log.Debug(context.Background(), "Cant find route for Handler %s %v", method, handler)
-	}
 
+		log.Debug(context.Background(), "Cant find route for Handler %s %v", method, routeName)
+	}
 	return url
 }
 
+func (r *Router) GetRouteV2(method string, handler service.HandlerFuncV2, vars map[string]string) string {
+	routeHandler, _ := handler()
+	p1 := reflect.ValueOf(routeHandler).Pointer()
+	routeName := sdk.GetFuncName(routeHandler)
+	return r.getRoute(method, p1, routeName, vars)
+}
+
+// GetRoute returns the routes given a handler
+func (r *Router) GetRoute(method string, handler service.HandlerFunc, vars map[string]string) string {
+	p1 := reflect.ValueOf(handler()).Pointer()
+	routeName := sdk.GetFuncName(handler)
+	return r.getRoute(method, p1, routeName, vars)
+}
+
 // FormString return a string
 func FormString(r *http.Request, s string) string {
 	return r.FormValue(s)
diff --git a/engine/api/user/gorp_model.go b/engine/api/user/gorp_model.go
index 5f157a4cf..15a66bc93 100644
--- a/engine/api/user/gorp_model.go
+++ b/engine/api/user/gorp_model.go
@@ -12,6 +12,7 @@ type authentifiedUser struct {
 }
 
 func (u authentifiedUser) Canonical() gorpmapper.CanonicalForms {
+	_ = []interface{}{u.ID, u.Username, u.Fullname, u.Ring, u.Created}
 	return []gorpmapper.CanonicalForm{
 		"{{.ID}}{{.Username}}{{.Fullname}}{{.Ring}}{{printDate .Created}}",
 	}
@@ -23,6 +24,7 @@ type userContact struct {
 }
 
 func (c userContact) Canonical() gorpmapper.CanonicalForms {
+	_ = []interface{}{c.ID, c.UserID, c.Type, c.Value, c.Primary, c.Verified}
 	return []gorpmapper.CanonicalForm{
 		"{{.ID}}{{.UserID}}{{.Type}}{{.Value}}{{.Primary}}{{.Verified}}",
 	}
diff --git a/engine/api/v2_project.go b/engine/api/v2_project.go
new file mode 100644
index 000000000..f84e15bdc
--- /dev/null
+++ b/engine/api/v2_project.go
@@ -0,0 +1,28 @@
+package api
+
+import (
+	"context"
+	"github.com/gorilla/mux"
+	"github.com/ovh/cds/engine/api/rbac"
+	"github.com/rockbears/log"
+	"net/http"
+
+	"github.com/ovh/cds/engine/service"
+)
+
+func (api *API) postVCSOnProjectHandler() (service.Handler, []service.RbacChecker) {
+	handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
+		vars := mux.Vars(r)
+		pKey := vars["projectKey"]
+
+		// my handler
+		log.Info(ctx, "My project: %s", pKey)
+
+		return nil
+	}
+	permChecker := []service.RbacChecker{
+		rbac.ProjectExist,
+		rbac.ProjectManage,
+	}
+	return handler, permChecker
+}
diff --git a/engine/api/workflow/dao.go b/engine/api/workflow/dao.go
index 6620fdd26..abfa406b1 100644
--- a/engine/api/workflow/dao.go
+++ b/engine/api/workflow/dao.go
@@ -927,7 +927,7 @@ func CompleteWorkflow(ctx context.Context, db gorp.SqlExecutor, w *sdk.Workflow,
 		if err := checkProjectIntegration(proj, w, n); err != nil {
 			return err
 		}
-		if err := checkHooks(db, w, n); err != nil {
+		if err := checkHooks(ctx, db, w, n); err != nil {
 			return err
 		}
 		if err := checkOutGoingHook(db, w, n); err != nil {
@@ -1026,7 +1026,8 @@ func checkOutGoingHook(db gorp.SqlExecutor, w *sdk.Workflow, n *sdk.Node) error
 	return nil
 }
 
-func checkHooks(db gorp.SqlExecutor, w *sdk.Workflow, n *sdk.Node) error {
+func checkHooks(ctx context.Context, db gorp.SqlExecutor, w *sdk.Workflow, n *sdk.Node) error {
+	var duplicateHookIdx []int64
 	for i := range n.Hooks {
 		h := &n.Hooks[i]
 		if h.HookModelID != 0 {
@@ -1083,11 +1084,23 @@ func checkHooks(db gorp.SqlExecutor, w *sdk.Workflow, n *sdk.Node) error {
 		for j := range n.Hooks {
 			h2 := n.Hooks[j]
 			if i != j && h.Ref() == h2.Ref() {
-				return sdk.NewErrorFrom(sdk.ErrWrongRequest, "invalid workflow: duplicate hook %s", model.Name)
+				log.ErrorWithStackTrace(ctx, sdk.NewErrorFrom(sdk.ErrWrongRequest, "invalid workflow: duplicate hook %s", model.Name))
+				if !sdk.IsInInt64Array(int64(i), duplicateHookIdx) && !sdk.IsInInt64Array(int64(i), duplicateHookIdx) {
+					duplicateHookIdx = append(duplicateHookIdx, int64(j))
+				}
 			}
 		}
 	}
 
+	// Remove duplicate hooks
+	var newHooks = make([]sdk.NodeHook, 0, len(n.Hooks))
+	for i := range n.Hooks {
+		if !sdk.IsInInt64Array(int64(i), duplicateHookIdx) {
+			newHooks = append(newHooks, n.Hooks[i])
+		}
+	}
+	n.Hooks = newHooks
+
 	return nil
 }
 
diff --git a/engine/api/workflow/process_node.go b/engine/api/workflow/process_node.go
index 678e10697..7eca0811a 100644
--- a/engine/api/workflow/process_node.go
+++ b/engine/api/workflow/process_node.go
@@ -245,6 +245,11 @@ func processNode(ctx context.Context, db gorpmapper.SqlExecutorWithTx, store cac
 			return nil, false, sdk.WrapError(sdk.ErrNoReposManagerClientAuth, "cannot get client %s %s got: %v", proj.Key, app.VCSServer, err)
 		}
 
+		// We can't have both git.branch and git.tag values
+		if currentJobGitValues["git.branch"] != "" && currentJobGitValues["git.tag"] != "" {
+			return nil, false, sdk.NewError(sdk.ErrWrongRequest, fmt.Errorf("invalid git variables"))
+		}
+
 		vcsInf, errVcs = getVCSInfos(ctx, db, store, proj.Key, vcsServer, currentJobGitValues, app.Name, app.VCSServer, app.RepositoryFullname)
 		if errVcs != nil {
 			AddWorkflowRunInfo(wr, sdk.SpawnMsgNew(*sdk.MsgWorkflowError, sdk.ExtractHTTPError(errVcs)))
diff --git a/engine/service/http.go b/engine/service/http.go
index 35892dd33..8672c6468 100644
--- a/engine/service/http.go
+++ b/engine/service/http.go
@@ -12,6 +12,7 @@ import (
 	"sync"
 	"time"
 
+	"github.com/go-gorp/gorp"
 	"github.com/rockbears/log"
 	"gopkg.in/spacemonkeygo/httpsig.v0"
 
@@ -22,6 +23,7 @@ import (
 
 // Handler defines the HTTP handler used in CDS engine
 type Handler func(ctx context.Context, w http.ResponseWriter, r *http.Request) error
+type RbacChecker func(ctx context.Context, db gorp.SqlExecutor, vars map[string]string) error
 
 // AsynchronousHandler defines the HTTP asynchronous handler used in CDS engine
 type AsynchronousHandler func(ctx context.Context, r *http.Request) error
@@ -31,6 +33,7 @@ type Middleware func(ctx context.Context, w http.ResponseWriter, req *http.Reque
 
 // HandlerFunc defines the way to instantiate a handler
 type HandlerFunc func() Handler
+type HandlerFuncV2 func() (Handler, []RbacChecker)
 
 // AsynchronousHandlerFunc defines the way to instantiate a handler
 type AsynchronousHandlerFunc func() AsynchronousHandler
@@ -54,6 +57,7 @@ type HandlerConfig struct {
 	AllowedScopes          []sdk.AuthConsumerScope
 	PermissionLevel        int
 	CleanURL               string
+	RbacCheckers           []RbacChecker
 }
 
 // Accepted is a helper function used by asynchronous handlers
diff --git a/engine/sql/api/236_rbac.sql b/engine/sql/api/236_rbac.sql
new file mode 100644
index 000000000..489e2d8cc
--- /dev/null
+++ b/engine/sql/api/236_rbac.sql
@@ -0,0 +1,106 @@
+-- +migrate Up
+CREATE TABLE rbac
+(
+    "uuid"          uuid PRIMARY KEY,
+    "name"          VARCHAR(255) NOT NULL,
+    "created"       TIMESTAMP WITH TIME ZONE DEFAULT LOCALTIMESTAMP,
+    "last_modified" TIMESTAMP WITH TIME ZONE DEFAULT LOCALTIMESTAMP,
+    sig             BYTEA,
+    signer          TEXT
+);
+SELECT create_unique_index('rbac', 'idx_unq_rbac_name', 'name');
+
+-- GLOBAL PART
+CREATE TABLE rbac_global
+(
+    "id"        BIGSERIAL PRIMARY KEY,
+    "rbac_uuid" uuid         NOT NULL,
+    "role"      VARCHAR(255) NOT NULL,
+    sig         BYTEA,
+    signer      TEXT
+);
+SELECT create_foreign_key_idx_cascade('FK_rbac_global', 'rbac_global', 'rbac', 'rbac_uuid', 'uuid');
+SELECT create_index('rbac_global', 'idx_rbac_global_role', 'role');
+
+CREATE TABLE rbac_global_users
+(
+    "id"             BIGSERIAL PRIMARY KEY,
+    "rbac_global_id" BIGINT,
+    "user_id"        character varying(36),
+    sig              BYTEA,
+    signer           TEXT
+);
+SELECT create_foreign_key_idx_cascade('FK_rbac_global_users', 'rbac_global_users', 'rbac_global', 'rbac_global_id', 'id');
+SELECT create_foreign_key_idx_cascade('FK_rbac_global_users_ids', 'rbac_global_users', 'authentified_user', 'user_id', 'id');
+SELECT create_unique_index('rbac_global_users', 'idx_unq_rbac_global_users', 'rbac_global_id,user_id');
+
+CREATE TABLE rbac_global_groups
+(
+    "id"             BIGSERIAL PRIMARY KEY,
+    "rbac_global_id" BIGINT,
+    "group_id"       BIGINT,
+    sig              BYTEA,
+    signer           TEXT
+);
+SELECT create_foreign_key_idx_cascade('FK_rbac_global_groups', 'rbac_global_groups', 'rbac_global', 'rbac_global_id', 'id');
+SELECT create_foreign_key_idx_cascade('FK_rbac_global_groups_ids', 'rbac_global_groups', 'group', 'group_id', 'id');
+SELECT create_unique_index('rbac_global_groups', 'idx_unq_rbac_global_groups', 'rbac_global_id,group_id');
+
+-- PROJECT
+CREATE TABLE rbac_project
+(
+    "id"        BIGSERIAL PRIMARY KEY,
+    "rbac_uuid" uuid         NOT NULL,
+    "all"       BOOLEAN      NOT NULL DEFAULT FALSE,
+    "role"      VARCHAR(255) NOT NULL,
+    sig         BYTEA,
+    signer      TEXT
+);
+SELECT create_foreign_key_idx_cascade('FK_rbac_project', 'rbac_project', 'rbac', 'rbac_uuid', 'uuid');
+SELECT create_index('rbac_project', 'idx_rbac_project_role', 'role');
+
+CREATE TABLE rbac_project_projects
+(
+    "id"              BIGSERIAL PRIMARY KEY,
+    "rbac_project_id" BIGINT,
+    "project_id"      BIGINT,
+    sig               BYTEA,
+    signer            TEXT
+);
+SELECT create_foreign_key_idx_cascade('FK_rbac_project_projects', 'rbac_project_projects', 'rbac_project', 'rbac_project_id', 'id');
+SELECT create_foreign_key_idx_cascade('FK_rbac_project_projects_project', 'rbac_project_projects', 'project', 'project_id', 'id');
+SELECT create_unique_index('rbac_project_projects', 'idx_unq_rbac_project_projects', 'rbac_project_id,project_id');
+
+CREATE TABLE rbac_project_users
+(
+    "id"              BIGSERIAL PRIMARY KEY,
+    "rbac_project_id" BIGINT,
+    "user_id"         character varying(36),
+    sig               BYTEA,
+    signer            TEXT
+);
+SELECT create_foreign_key_idx_cascade('FK_rbac_project_users', 'rbac_project_users', 'rbac_project', 'rbac_project_id', 'id');
+SELECT create_foreign_key_idx_cascade('FK_rbac_project_users_id', 'rbac_project_users', 'authentified_user', 'user_id', 'id');
+SELECT create_unique_index('rbac_project_users', 'idx_unq_rbac_project_users', 'rbac_project_id,user_id');
+
+CREATE TABLE rbac_project_groups
+(
+    "id"              BIGSERIAL PRIMARY KEY,
+    "rbac_project_id" BIGINT,
+    "group_id"        BIGINT,
+    sig               BYTEA,
+    signer            TEXT
+);
+SELECT create_foreign_key_idx_cascade('FK_rbac_project_groups', 'rbac_project_groups', 'rbac_project', 'rbac_project_id', 'id');
+SELECT create_foreign_key_idx_cascade('FK_rbac_project_groups_ids', 'rbac_project_groups', 'group', 'group_id', 'id');
+SELECT create_unique_index('rbac_project_groups', 'idx_unq_rbac_project_groups', 'rbac_project_id,group_id');
+
+-- +migrate Down
+DROP TABLE rbac_project_groups;
+DROP TABLE rbac_project_users;
+DROP TABLE rbac_project_projects;
+DROP TABLE rbac_project;
+DROP TABLE rbac_global_groups;
+DROP TABLE rbac_global_users;
+DROP TABLE rbac_global;
+DROP TABLE rbac;
diff --git a/engine/worker/internal/action/builtin_artifact_download.go b/engine/worker/internal/action/builtin_artifact_download.go
index d01e41fdf..63cc34fa1 100644
--- a/engine/worker/internal/action/builtin_artifact_download.go
+++ b/engine/worker/internal/action/builtin_artifact_download.go
@@ -30,7 +30,6 @@ func RunArtifactDownload(ctx context.Context, wk workerruntime.Runtime, a sdk.Ac
 	pattern := sdk.ParameterValue(a.Parameters, "pattern")
 
 	destPath := sdk.ParameterValue(a.Parameters, "path")
-	tag := sdk.ParameterValue(a.Parameters, "tag")
 
 	actionWorkflow := sdk.ParameterValue(a.Parameters, "workflow")
 	actionRunNumber := sdk.ParameterValue(a.Parameters, "number")
@@ -90,14 +89,10 @@ func RunArtifactDownload(ctx context.Context, wk workerruntime.Runtime, a sdk.Ac
 
 	// Priority:
 	// 1. Integration artifact manager on workflow
-	// 2. CDN activated or not
+	// 2. CDN
 	if pluginArtifactManagement != nil {
 		return GetArtifactFromIntegrationPlugin(ctx, wk, res, pattern, reg, destPath, pluginArtifactManagement, project, destinationWorkflow, n)
 	}
-	// GET Artifact from CDS API
-	if !wk.FeatureEnabled(sdk.FeatureCDNArtifact) {
-		return GetArtifactFromAPI(ctx, wk, project, destinationWorkflow, n, res, pattern, reg, tag, destPath, wkDirFS)
-	}
 
 	// GET Artifact from CDS CDN
 	cdnItems, err := wk.Client().WorkflowRunArtifactsLinks(project, destinationWorkflow, n)
@@ -275,62 +270,3 @@ func runGRPCIntegrationPlugin(ctx context.Context, wk workerruntime.Runtime, bin
 	}
 	return nil
 }
-
-func GetArtifactFromAPI(ctx context.Context, wk workerruntime.Runtime, project string, workflow string, n int64, res sdk.Result, pattern string, regexp *regexp.Regexp, tag string, destPath string, wkDirFS afero.Fs) (sdk.Result, error) {
-	wg := new(sync.WaitGroup)
-	artifacts, err := wk.Client().WorkflowRunArtifacts(project, workflow, n)
-	if err != nil {
-		return res, err
-	}
-	wg.Add(len(artifacts))
-	for i := range artifacts {
-		a := &artifacts[i]
-
-		if pattern != "" && !regexp.MatchString(a.Name) {
-			wk.SendLog(ctx, workerruntime.LevelInfo, fmt.Sprintf("%s does not match pattern %s - skipped", a.Name, pattern))
-			wg.Done()
-			continue
-		}
-
-		if tag != "" && a.Tag != tag {
-			wk.SendLog(ctx, workerruntime.LevelInfo, fmt.Sprintf("%s does not match tag %s - skipped", a.Name, tag))
-			wg.Done()
-			continue
-		}
-
-		go func(a *sdk.WorkflowNodeRunArtifact) {
-			defer wg.Done()
-
-			destFile := path.Join(destPath, a.Name)
-			f, err := wkDirFS.OpenFile(destFile, os.O_RDWR|os.O_CREATE|os.O_TRUNC, os.FileMode(a.Perm))
-			if err != nil {
-				res.Status = sdk.StatusFail
-				res.Reason = err.Error()
-				log.Warn(ctx, "Cannot download artifact (OpenFile) %s: %s", destFile, err)
-				wk.SendLog(ctx, workerruntime.LevelError, res.Reason)
-				return
-			}
-			wk.SendLog(ctx, workerruntime.LevelInfo, fmt.Sprintf("Downloading artifact %s from workflow %s/%s on run %d...", destFile, project, workflow, n))
-			if err := wk.Client().WorkflowNodeRunArtifactDownload(project, workflow, *a, f); err != nil {
-				res.Status = sdk.StatusFail
-				res.Reason = err.Error()
-				log.Warn(ctx, "Cannot download artifact %s: %s", destFile, err)
-				wk.SendLog(ctx, workerruntime.LevelError, res.Reason)
-				return
-			}
-			if err := f.Close(); err != nil {
-				res.Status = sdk.StatusFail
-				res.Reason = err.Error()
-				log.Warn(ctx, "Cannot download artifact %s: %s", destFile, err)
-				wk.SendLog(ctx, workerruntime.LevelError, res.Reason)
-				return
-			}
-		}(a)
-		// TODO: write here a reason why we are waiting 3 seconds
-		if len(artifacts) > 1 {
-			time.Sleep(3 * time.Second)
-		}
-	}
-	wg.Wait()
-	return res, nil
-}
diff --git a/engine/worker/internal/action/builtin_artifact_upload.go b/engine/worker/internal/action/builtin_artifact_upload.go
index 78a360f78..32ef30fc6 100644
--- a/engine/worker/internal/action/builtin_artifact_upload.go
+++ b/engine/worker/internal/action/builtin_artifact_upload.go
@@ -49,12 +49,6 @@ func RunArtifactUpload(ctx context.Context, wk workerruntime.Runtime, a sdk.Acti
 		artifactPath = filepath.Join(abs, artifactPath)
 	}
 
-	tagParam := sdk.ParameterFind(a.Parameters, "tag")
-	var tag string
-	if tagParam != nil {
-		tag = tagParam.Value
-	}
-
 	fileTypeParam := sdk.ParameterFind(a.Parameters, "type")
 	var fileType string
 	if fileTypeParam != nil {
@@ -84,39 +78,23 @@ func RunArtifactUpload(ctx context.Context, wk workerruntime.Runtime, a sdk.Acti
 		}
 	}()
 
-	cdnArtifactEnabled := wk.FeatureEnabled(sdk.FeatureCDNArtifact)
-
-	integrationName := sdk.DefaultIfEmptyStorage(strings.TrimSpace(sdk.ParameterValue(a.Parameters, "destination")))
 	projectKey := sdk.ParameterValue(wk.Parameters(), "cds.project")
 	pluginArtifactManagement := wk.GetPlugin(sdk.GRPCPluginUploadArtifact)
 
 	wg.Add(len(filesPath))
 	for _, p := range filesPath {
 		go func(path string) {
-			log.Debug(ctx, "uploading %s projectKey:%v integrationName:%v job:%d", path, projectKey, integrationName, jobID)
+			log.Debug(ctx, "uploading %s projectKey:%v job:%d", path, projectKey, jobID)
 			defer wg.Done()
 
 			// Priority:
-			// 1. Integration specified on artifact upload action ( advanced parameter )
-			// 2. Integration artifact manager on workflow
-			// 3. CDN activated or not
-			if integrationName != sdk.DefaultStorageIntegrationName {
-				if err := uploadArtifactByApiCall(path, wk, ctx, projectKey, integrationName, jobID, tag, fileType); err != nil {
-					chanError <- sdk.WrapError(err, "Error while uploading artifact by api call %s", path)
-					wgErrors.Add(1)
-				}
-				return
-			} else if pluginArtifactManagement != nil {
+			// 1. Integration artifact manager on workflow
+			// 2. CDN
+			if pluginArtifactManagement != nil {
 				if err := uploadArtifactByIntegrationPlugin(path, ctx, wk, pluginArtifactManagement, fileType); err != nil {
 					chanError <- sdk.WrapError(err, "Error while uploading artifact by plugin %s", path)
 					wgErrors.Add(1)
 				}
-			} else if !cdnArtifactEnabled {
-				if err := uploadArtifactByApiCall(path, wk, ctx, projectKey, integrationName, jobID, tag, fileType); err != nil {
-					chanError <- sdk.WrapError(err, "Error while uploading artifact by api call %s", path)
-					wgErrors.Add(1)
-				}
-				return
 			} else {
 				if err := uploadArtifactIntoCDN(path, ctx, wk); err != nil {
 					chanError <- err
@@ -124,7 +102,6 @@ func RunArtifactUpload(ctx context.Context, wk workerruntime.Runtime, a sdk.Acti
 				}
 				return
 			}
-
 		}(p)
 		if len(filesPath) > 1 {
 			//Wait 3 second to get the object storage to set up all the things
@@ -215,7 +192,7 @@ func uploadArtifactByIntegrationPlugin(path string, ctx context.Context, wk work
 		return fmt.Errorf("error uploading artifact: %v", err)
 	}
 
-	if strings.ToUpper(res.Status) != strings.ToUpper(sdk.StatusSuccess) {
+	if !strings.EqualFold(res.Status, sdk.StatusSuccess) {
 		return fmt.Errorf("plugin execution failed %s: %s", res.Status, res.Details)
 	}
 	res.Outputs[sdk.ArtifactUploadPluginOutputFileType] = fileType
@@ -248,19 +225,6 @@ func uploadArtifactIntoCDN(path string, ctx context.Context, wk workerruntime.Ru
 	return nil
 }
 
-func uploadArtifactByApiCall(path string, wk workerruntime.Runtime, ctx context.Context, projectKey string, integrationName string, jobID int64, tag, fileType string) error {
-	throughTempURL, duration, err := wk.Client().QueueArtifactUpload(ctx, projectKey, integrationName, jobID, tag, path, fileType)
-	if err != nil {
-		return err
-	}
-	if throughTempURL {
-		wk.SendLog(ctx, workerruntime.LevelInfo, fmt.Sprintf("File '%s' uploaded in %.2fs to object store", path, duration.Seconds()))
-	} else {
-		wk.SendLog(ctx, workerruntime.LevelInfo, fmt.Sprintf("File '%s' uploaded in %.2fs to CDS API", path, duration.Seconds()))
-	}
-	return nil
-}
-
 func checkArtifactUpload(ctx context.Context, wk workerruntime.Runtime, fileName string, runResultType sdk.WorkflowRunResultType) (int, error) {
 	runID, runNodeID, runJobID := wk.GetJobIdentifiers()
 	runResultCheck := sdk.WorkflowRunResultCheck{
diff --git a/engine/worker/internal/action/builtin_coverage.go b/engine/worker/internal/action/builtin_coverage.go
index 59c8af342..c4184d45a 100644
--- a/engine/worker/internal/action/builtin_coverage.go
+++ b/engine/worker/internal/action/builtin_coverage.go
@@ -71,29 +71,27 @@ func RunParseCoverageResultAction(ctx context.Context, wk workerruntime.Runtime,
 		fpath = p
 	}
 
-	if wk.FeatureEnabled(sdk.FeatureCDNArtifact) {
-		_, name := filepath.Split(fpath)
-		fileMode, err := os.Stat(fpath)
-		if err != nil {
-			return res, fmt.Errorf("coverage parser: failed to get file stat: %v", err)
+	_, name := filepath.Split(fpath)
+	fileMode, err := os.Stat(fpath)
+	if err != nil {
+		return res, fmt.Errorf("coverage parser: failed to get file stat: %v", err)
+	}
+	sig, err := wk.RunResultSignature(name, uint32(fileMode.Mode().Perm()), sdk.WorkflowRunResultTypeCoverage)
+	if err != nil {
+		return res, fmt.Errorf("coverage parser: unable to create signature: %v", err)
+	}
+
+	pluginArtifactManagement := wk.GetPlugin(sdk.GRPCPluginUploadArtifact)
+	if pluginArtifactManagement != nil {
+		if err := uploadArtifactByIntegrationPlugin(fpath, ctx, wk, pluginArtifactManagement, sdk.ArtifactFileTypeCoverage); err != nil {
+			return res, fmt.Errorf("coverage parser: unable to upload in artifact manager: %v", err)
 		}
-		sig, err := wk.RunResultSignature(name, uint32(fileMode.Mode().Perm()), sdk.WorkflowRunResultTypeCoverage)
+	} else {
+		duration, err := wk.Client().CDNItemUpload(ctx, wk.CDNHttpURL(), sig, afero.NewOsFs(), fpath)
 		if err != nil {
-			return res, fmt.Errorf("coverage parser: unable to create signature: %v", err)
-		}
-
-		pluginArtifactManagement := wk.GetPlugin(sdk.GRPCPluginUploadArtifact)
-		if pluginArtifactManagement != nil {
-			if err := uploadArtifactByIntegrationPlugin(fpath, ctx, wk, pluginArtifactManagement, sdk.ArtifactFileTypeCoverage); err != nil {
-				return res, fmt.Errorf("coverage parser: unable to upload in artifact manager: %v", err)
-			}
-		} else {
-			duration, err := wk.Client().CDNItemUpload(ctx, wk.CDNHttpURL(), sig, afero.NewOsFs(), fpath)
-			if err != nil {
-				return res, fmt.Errorf("coverage parser: unable to upload coverage report: %v", err)
-			}
-			wk.SendLog(ctx, workerruntime.LevelInfo, fmt.Sprintf("File '%s' uploaded in %.2fs to CDS CDN", name, duration.Seconds()))
+			return res, fmt.Errorf("coverage parser: unable to upload coverage report: %v", err)
 		}
+		wk.SendLog(ctx, workerruntime.LevelInfo, fmt.Sprintf("File '%s' uploaded in %.2fs to CDS CDN", name, duration.Seconds()))
 	}
 
 	if parserMode != "" {
diff --git a/engine/worker/internal/handler_artifacts.go b/engine/worker/internal/handler_artifacts.go
index ec114502c..43d16f543 100644
--- a/engine/worker/internal/handler_artifacts.go
+++ b/engine/worker/internal/handler_artifacts.go
@@ -50,12 +50,6 @@ func artifactsHandler(ctx context.Context, wk *CurrentWorker) http.HandlerFunc {
 		}
 
 		projectKey := sdk.ParameterValue(wk.currentJob.params, "cds.project")
-		artifacts, err := wk.client.WorkflowRunArtifacts(projectKey, reqArgs.Workflow, reqArgs.Number)
-		if err != nil {
-			newError := sdk.NewError(sdk.ErrWrongRequest, fmt.Errorf("cannot list artifacts with worker artifacts: %s", err))
-			writeError(w, r, newError)
-			return
-		}
 
 		regexp, errp := regexp.Compile(reqArgs.Pattern)
 		if errp != nil {
@@ -64,19 +58,7 @@ func artifactsHandler(ctx context.Context, wk *CurrentWorker) http.HandlerFunc {
 			return
 		}
 
-		artifactsJSON := []sdk.WorkflowNodeRunArtifact{}
-		for i := range artifacts {
-			a := &artifacts[i]
-
-			if reqArgs.Pattern != "" && !regexp.MatchString(a.Name) {
-				continue
-			}
-
-			if reqArgs.Tag != "" && a.Tag != reqArgs.Tag {
-				continue
-			}
-			artifactsJSON = append(artifactsJSON, *a)
-		}
+		artifactsJSON := []sdk.WorkflowRunResultArtifact{}
 
 		workflowRunResults, err := wk.client.WorkflowRunResultsList(ctx, projectKey, reqArgs.Workflow, reqArgs.Number)
 		if err != nil {
@@ -97,13 +79,7 @@ func artifactsHandler(ctx context.Context, wk *CurrentWorker) http.HandlerFunc {
 			if reqArgs.Pattern != "" && !regexp.MatchString(artData.Name) {
 				continue
 			}
-			artifactsJSON = append(artifactsJSON, sdk.WorkflowNodeRunArtifact{
-				MD5sum:  artData.MD5,
-				Name:    artData.Name,
-				Size:    artData.Size,
-				Created: result.Created,
-				Perm:    artData.Perm,
-			})
+			artifactsJSON = append(artifactsJSON, artData)
 		}
 
 		writeJSON(w, artifactsJSON, http.StatusOK)
diff --git a/engine/worker/internal/handler_cache.go b/engine/worker/internal/handler_cache.go
index b6deb01cb..ced93fe85 100644
--- a/engine/worker/internal/handler_cache.go
+++ b/engine/worker/internal/handler_cache.go
@@ -8,7 +8,6 @@ import (
 	"net/http"
 	"os"
 	"path/filepath"
-	"time"
 
 	"github.com/gorilla/mux"
 	"github.com/rockbears/log"
@@ -24,8 +23,6 @@ func cachePushHandler(ctx context.Context, wk *CurrentWorker) http.HandlerFunc {
 		ctx = workerruntime.SetStepOrder(ctx, wk.currentJob.currentStepIndex)
 		ctx = workerruntime.SetStepName(ctx, wk.currentJob.currentStepName)
 
-		cdnArtifact := wk.FeatureEnabled(sdk.FeatureCDNArtifact)
-
 		data, err := io.ReadAll(r.Body)
 		if err != nil {
 			err = sdk.Error{
@@ -81,8 +78,7 @@ func cachePushHandler(ctx context.Context, wk *CurrentWorker) http.HandlerFunc {
 			return
 		}
 
-		tarInfo, err := tarF.Stat()
-		if err != nil {
+		if _, err = tarF.Stat(); err != nil {
 			_ = tarF.Close() // nolint
 			err = sdk.Error{
 				Message: "worker cache push > Cannot get tmp tar file info : " + err.Error(),
@@ -106,8 +102,6 @@ func cachePushHandler(ctx context.Context, wk *CurrentWorker) http.HandlerFunc {
 			return
 		}
 
-		tarPath := tarF.Name()
-
 		if err := tarF.Close(); err != nil {
 			err := sdk.Error{
 				Message: "worker cache push > Cannot close file: " + err.Error(),
@@ -118,34 +112,6 @@ func cachePushHandler(ctx context.Context, wk *CurrentWorker) http.HandlerFunc {
 			return
 		}
 
-		if !cdnArtifact {
-			var errPush error
-			for i := 0; i < 10; i++ {
-				f, err := wk.BaseDir().Open(tarPath)
-				if err != nil {
-					err := sdk.Error{
-						Message: "worker cache push > Cannot open tar file: " + err.Error(),
-						Status:  http.StatusInternalServerError,
-					}
-					log.Error(ctx, "%v", err)
-					writeError(w, r, err)
-					return
-				}
-				if errPush = wk.client.WorkflowCachePush(projectKey, sdk.DefaultIfEmptyStorage(c.IntegrationName), c.Tag, f, int(tarInfo.Size())); errPush == nil {
-					return
-				}
-				log.Error(ctx, "worker cache push > cannot push cache (retry x%d) : %v", i, errPush)
-				err = sdk.Error{
-					Message: "worker cache push > Cannot push cache: " + errPush.Error(),
-					Status:  http.StatusInternalServerError,
-				}
-				time.Sleep(3 * time.Second)
-			}
-			log.Error(ctx, "%v", err)
-			writeError(w, r, err)
-			return
-		}
-
 		sig, err := wk.WorkerCacheSignature(c.Tag)
 		if err != nil {
 			err := sdk.Error{
@@ -175,15 +141,6 @@ func cachePullHandler(ctx context.Context, wk *CurrentWorker) http.HandlerFunc {
 		vars := mux.Vars(req)
 		path := req.FormValue("path")
 
-		cdnArtifact := wk.FeatureEnabled(sdk.FeatureCDNArtifact)
-		params := wk.currentJob.wJob.Parameters
-		projectKey := sdk.ParameterValue(params, "cds.project")
-
-		if !cdnArtifact {
-			getWorkerCacheFromAPI(w, req, wk, projectKey, vars, ctx, path)
-			return
-		}
-
 		// Get cache link
 		items, err := wk.client.QueueWorkerCacheLink(ctx, wk.currentJob.wJob.ID, vars["ref"])
 		if err != nil {
@@ -270,29 +227,6 @@ func cachePullHandler(ctx context.Context, wk *CurrentWorker) http.HandlerFunc {
 	}
 }
 
-func getWorkerCacheFromAPI(w http.ResponseWriter, req *http.Request, wk *CurrentWorker, projectKey string, vars map[string]string, ctx context.Context, path string) {
-	integrationName := sdk.DefaultIfEmptyStorage(req.FormValue("integration"))
-	var err error
-	reader, err := wk.client.WorkflowCachePull(projectKey, integrationName, vars["ref"])
-	if err != nil {
-		err = sdk.Error{
-			Message: "worker cache pull > Cannot pull cache: " + err.Error(),
-			Status:  http.StatusNotFound,
-		}
-		log.Error(ctx, "%v", err)
-		writeError(w, req, err)
-		return
-	}
-	log.Debug(ctx, "cachePullHandler> Start read cache tar")
-
-	if err := extractArchive(ctx, reader, path); err != nil {
-		log.Error(ctx, "%s", err.Message)
-		writeJSON(w, err, err.Status)
-		return
-	}
-	return
-}
-
 func extractArchive(ctx context.Context, r io.Reader, path string) *sdk.Error {
 	tr := tar.NewReader(r)
 	for {
diff --git a/sdk/doc/route.go b/sdk/doc/route.go
index 4dd5ef535..cefc12ac4 100644
--- a/sdk/doc/route.go
+++ b/sdk/doc/route.go
@@ -29,7 +29,7 @@ func CleanURLParameter(u string) string {
 	switch u {
 	case "consumerType":
 		u = "consumer-type"
-	case "key", "permProjectKey":
+	case "key", "permProjectKey", "projectKey":
 		u = "project-key"
 	case "permWorkflowName", "permWorkflowNameAdvanced", "workflowName":
 		u = "workflow-name"
diff --git a/sdk/log/fields.go b/sdk/log/fields.go
index de51136fd..f3ae5fd91 100644
--- a/sdk/log/fields.go
+++ b/sdk/log/fields.go
@@ -17,6 +17,7 @@ const (
 	AuthUserID         = log.Field("auth_user_id")
 	AuthUsername       = log.Field("auth_user_name")
 	AuthWorkerName     = log.Field("auth_worker_name")
+	RbackCheckerName   = log.Field("rbac_checker_name")
 	Deprecated         = log.Field("deprecated")
 	Duration           = log.Field("duration_milliseconds_num")
 	Goroutine          = log.Field("goroutine")
@@ -49,22 +50,23 @@ func init() {
 		AuthSessionID,
 		AuthSessionIAT,
 		AuthSessionTokenID,
-		IPAddress,
-		Method,
-		Route,
-		RequestURI,
 		Deprecated,
+		Duration,
+		Goroutine,
 		Handler,
+		IPAddress,
 		Latency,
 		LatencyNum,
-		Status,
-		StatusNum,
-		Goroutine,
+		Method,
+		RbackCheckerName,
+		Route,
 		RequestID,
+		RequestURI,
 		Service,
-		Stacktrace,
-		Duration,
 		Size,
+		Stacktrace,
+		Status,
+		StatusNum,
 		Sudo,
 	)
 }
diff --git a/sdk/project.go b/sdk/project.go
index 3e3cd0fff..93c11bbe0 100644
--- a/sdk/project.go
+++ b/sdk/project.go
@@ -10,6 +10,11 @@ import (
 	"github.com/mitchellh/hashstructure"
 )
 
+type ProjectIdentifiers struct {
+	ID  int64  `json:"-" yaml:"-" db:"id"`
+	Key string `json:"-" yaml:"-"db:"projectkey"`
+}
+
 type Projects []Project
 
 func (projects Projects) Keys() []string {
diff --git a/sdk/rbac.go b/sdk/rbac.go
new file mode 100644
index 000000000..7ef55aef6
--- /dev/null
+++ b/sdk/rbac.go
@@ -0,0 +1,37 @@
+package sdk
+
+import "time"
+
+const (
+	RoleRead             = "read"
+	RoleManage           = "manage"
+	RoleDelete           = "delete"
+	RoleCreateProject    = "create-project"
+	RoleManagePermission = "manage-permission"
+)
+
+type RBAC struct {
+	UUID         string        `json:"uuid" db:"uuid" yaml:"-"`
+	Name         string        `json:"name" db:"name" yaml:"name"`
+	Created      time.Time     `json:"created" db:"created" yaml:"-"`
+	LastModified time.Time     `json:"last_modified" db:"last_modified" yaml:"-"`
+	Globals      []RBACGlobal  `json:"globals" db:"-" yaml:"globals"`
+	Projects     []RBACProject `json:"projects" db:"-" yaml:"projects"`
+}
+
+func IsValidRbac(rbac *RBAC) error {
+	if rbac.Name == "" {
+		return WrapError(ErrInvalidData, "missing permission name")
+	}
+	for _, g := range rbac.Globals {
+		if err := isValidRbacGlobal(rbac.Name, g); err != nil {
+			return err
+		}
+	}
+	for _, p := range rbac.Projects {
+		if err := isValidRbacProject(rbac.Name, p); err != nil {
+			return err
+		}
+	}
+	return nil
+}
diff --git a/sdk/rbac_global.go b/sdk/rbac_global.go
new file mode 100644
index 000000000..88e786d2e
--- /dev/null
+++ b/sdk/rbac_global.go
@@ -0,0 +1,33 @@
+package sdk
+
+var (
+	GlobalRoles = []string{RoleCreateProject, RoleManagePermission}
+)
+
+type RBACGlobal struct {
+	Role           string   `json:"role" db:"role" yaml:"role"`
+	RBACUsersName  []string `json:"users" db:"-" yaml:"users"`
+	RBACGroupsName []string `json:"groups" db:"-" yaml:"groups"`
+	RBACUsersIDs   []string `json:"-" db:"-" yaml:"-"`
+	RBACGroupsIDs  []int64  `json:"-" db:"-" yaml:"-"`
+}
+
+func isValidRbacGlobal(rbacName string, rg RBACGlobal) error {
+	if len(rg.RBACGroupsIDs) == 0 && len(rg.RBACUsersIDs) == 0 {
+		return NewErrorFrom(ErrInvalidData, "rbac %s: missing groups or users on global permission", rbacName)
+	}
+	if rg.Role == "" {
+		return NewErrorFrom(ErrInvalidData, "rbac %s: role for global permission cannot be empty", rbacName)
+	}
+	roleFound := false
+	for _, r := range GlobalRoles {
+		if rg.Role == r {
+			roleFound = true
+			break
+		}
+	}
+	if !roleFound {
+		return NewErrorFrom(ErrInvalidData, "rbac %s: role %s is not allowed on a global permission", rbacName, rg.Role)
+	}
+	return nil
+}
diff --git a/sdk/rbac_project.go b/sdk/rbac_project.go
new file mode 100644
index 000000000..1bf51f108
--- /dev/null
+++ b/sdk/rbac_project.go
@@ -0,0 +1,48 @@
+package sdk
+
+var (
+	ProjectRoles = []string{RoleRead, RoleManage, RoleDelete}
+)
+
+type RBACProject struct {
+	All             bool     `json:"all" db:"all" yaml:"all"`
+	Role            string   `json:"role" db:"role" yaml:"role"`
+	RBACProjectKeys []string `json:"projects" db:"-" yaml:"projects"`
+	RBACUsersName   []string `json:"users" db:"-" yaml:"users"`
+	RBACGroupsName  []string `json:"groups" db:"-" yaml:"groups"`
+
+	RBACProjectsIDs []int64  `json:"-" db:"-" yaml:"-"`
+	RBACUsersIDs    []string `json:"-" db:"-" yaml:"-"`
+	RBACGroupsIDs   []int64  `json:"-" db:"-" yaml:"-"`
+}
+
+func isValidRbacProject(rbacName string, rp RBACProject) error {
+	// Check empty group and users
+	if len(rp.RBACGroupsIDs) == 0 && len(rp.RBACUsersIDs) == 0 {
+		return NewErrorFrom(ErrInvalidData, "rbac %s: missing groups or users on project permission", rbacName)
+	}
+
+	// Check role
+	if rp.Role == "" {
+		return NewErrorFrom(ErrInvalidData, "rbac %s: role for project permission cannot be empty", rbacName)
+	}
+	roleFound := false
+	for _, r := range ProjectRoles {
+		if r == rp.Role {
+			roleFound = true
+			break
+		}
+	}
+	if !roleFound {
+		return NewErrorFrom(ErrInvalidData, "rbac %s: role %s is not allowed on a project permission", rbacName, rp.Role)
+	}
+
+	// Check project_ids and all flag
+	if len(rp.RBACProjectsIDs) == 0 && !rp.All {
+		return NewErrorFrom(ErrInvalidData, "rbac %s: must have at least 1 project on a project permission", rbacName)
+	}
+	if len(rp.RBACProjectsIDs) > 0 && rp.All {
+		return NewErrorFrom(ErrInvalidData, "rbac %s: you can't have a list of project and the all flag checked on a project permission", rbacName)
+	}
+	return nil
+}
diff --git a/sdk/reflect.go b/sdk/reflect.go
index 1d21e12ad..09e2e8361 100644
--- a/sdk/reflect.go
+++ b/sdk/reflect.go
@@ -2,9 +2,18 @@ package sdk
 
 import (
 	"reflect"
+	"runtime"
 	"strings"
 )
 
+func GetFuncName(i interface{}) string {
+	name := runtime.FuncForPC(reflect.ValueOf(i).Pointer()).Name()
+	name = strings.Replace(name, ".func1", "", 1)
+	name = strings.Replace(name, ".1", "", 1)
+	name = strings.Replace(name, "github.com/ovh/cds/engine/", "", 1)
+	return name
+}
+
 // From https://github.com/fsamin/go-dump/blob/master/helper.go
 // Apache-2.0 License
 // https://github.com/fsamin/go-dump/blob/master/LICENSE.md
diff --git a/ui/src/app/model/workflow.run.model.ts b/ui/src/app/model/workflow.run.model.ts
index d1697490f..b0383787f 100644
--- a/ui/src/app/model/workflow.run.model.ts
+++ b/ui/src/app/model/workflow.run.model.ts
@@ -114,7 +114,6 @@ export class WorkflowNodeRun implements WithKey {
     payload: {};
     pipeline_parameters: Array<Parameter>;
     build_parameters: Array<Parameter>;
-    artifacts: Array<WorkflowNodeRunArtifact>;
     tests: Tests;
     commits: Array<Commit>;
     vulnerabilities_report: WorkflowNodeRunVulnerabilityReport;
@@ -187,22 +186,6 @@ export class WorkflowNodeOutgoingHookRunCallback {
     workflow_run_number: number;
 }
 
-// WorkflowNodeRunArtifact represents tests list
-export class WorkflowNodeRunArtifact {
-    workflow_id: number;
-    workflow_node_run_id: number;
-    id: number;
-    name: string;
-    tag: string;
-    download_hash: string;
-    size: number;
-    perm: number;
-    md5sum: string;
-    sha512sum: string;
-    object_path: string;
-    created: string;
-}
-
 // WorkflowNodeJobRun represents an job to be run
 export class WorkflowNodeJobRun {
     id: number;
diff --git a/ui/src/app/service/feature/feature.service.ts b/ui/src/app/service/feature/feature.service.ts
index 61d7c602a..0c210773b 100644
--- a/ui/src/app/service/feature/feature.service.ts
+++ b/ui/src/app/service/feature/feature.service.ts
@@ -4,7 +4,6 @@ import { FeatureEnabledResponse } from 'app/model/feature.model';
 import { Observable } from 'rxjs';
 
 export enum FeatureNames {
-    CDNArtifact = 'cdn-artifact',
     WorkflowRetentionPolicy = 'workflow-retention-policy',
     WorkflowRetentionMaxRuns = 'workflow-retention-maxruns',
     WorkflowV3 = 'workflow-v3'
diff --git a/ui/src/app/views/workflow/run/node/artifact/artifact.list.component.ts b/ui/src/app/views/workflow/run/node/artifact/artifact.list.component.ts
index 2f79537cd..58514c03f 100644
--- a/ui/src/app/views/workflow/run/node/artifact/artifact.list.component.ts
+++ b/ui/src/app/views/workflow/run/node/artifact/artifact.list.component.ts
@@ -4,7 +4,6 @@ import { ProjectIntegration } from 'app/model/integration.model';
 import {
     UIArtifact,
     WorkflowNodeRun,
-    WorkflowNodeRunArtifact,
     WorkflowRunResult
 } from 'app/model/workflow.run.model';
 import { WorkflowHelper } from 'app/service/workflow/workflow.helper';
@@ -25,8 +24,6 @@ export class WorkflowRunArtifactListComponent implements OnInit, OnDestroy {
     nodeRunSubs: Subscription;
 
     runResult: Array<WorkflowRunResult>
-    artifacts: Array<WorkflowNodeRunArtifact>;
-
     uiArtifacts: Array<UIArtifact>;
 
     filter: Filter<UIArtifact>;
@@ -78,9 +75,6 @@ export class WorkflowRunArtifactListComponent implements OnInit, OnDestroy {
             if (nr.results && (!this.runResult || nr.results.length !== this.runResult.length)) {
                 computeArtifact = true;
             }
-            if (nr.artifacts && (!this.artifacts || nr.artifacts.length !== this.artifacts.length)) {
-                computeArtifact = true;
-            }
             if (computeArtifact) {
                 let uiArtifacts: Array<UIArtifact>;
                 let uiRunResults: Array<UIArtifact>;
@@ -103,19 +97,6 @@ export class WorkflowRunArtifactListComponent implements OnInit, OnDestroy {
                     this.uiArtifacts.push(...uiRunResults);
                 }
 
-                if (nr.artifacts) {
-                    uiArtifacts = nr.artifacts.map(a => {
-                        let uiArt = new UIArtifact();
-                        uiArt.name = a.name;
-                        uiArt.size = a.size;
-                        uiArt.md5 = a.md5sum;
-                        uiArt.type = 'file';
-                        uiArt.link = `./cdsapi/workflow/artifact/${a.download_hash}`;
-                        uiArt.file_type = uiArt.type;
-                        return uiArt;
-                    });
-                    this.uiArtifacts.push(...uiArtifacts);
-                }
                 this._cd.markForCheck();
             }
         });
diff --git a/ui/src/app/views/workflow/run/node/workflow.run.node.component.ts b/ui/src/app/views/workflow/run/node/workflow.run.node.component.ts
index 048d2cec9..b38d31dc2 100644
--- a/ui/src/app/views/workflow/run/node/workflow.run.node.component.ts
+++ b/ui/src/app/views/workflow/run/node/workflow.run.node.component.ts
@@ -125,12 +125,8 @@ export class WorkflowNodeRunComponent implements OnInit, OnDestroy {
 
 
                 let artiResultsLength = nr?.results?.length ?? 0;
-                let oldArtiLength = 0;
-                if (nr.artifacts) {
-                    oldArtiLength = nr.artifacts.length;
-                }
-                if ((nr.artifacts || artiResultsLength > 0) && (oldArtiLength + artiResultsLength) !== this.artifactLength) {
-                    this.artifactLength = oldArtiLength + artiResultsLength;
+                if (artiResultsLength > 0 && artiResultsLength !== this.artifactLength) {
+                    this.artifactLength = artiResultsLength;
                     refresh = true;
                 }
                 if (nr.tests && nr.tests.total !== this.nodeRunTests?.total) {
diff --git a/ui/src/app/views/workflow/show/notification/form/workflow.notification.form.component.ts b/ui/src/app/views/workflow/show/notification/form/workflow.notification.form.component.ts
index 9005d43c0..d292154d7 100644
--- a/ui/src/app/views/workflow/show/notification/form/workflow.notification.form.component.ts
+++ b/ui/src/app/views/workflow/show/notification/form/workflow.notification.form.component.ts
@@ -121,11 +121,12 @@ export class WorkflowNotificationFormComponent implements OnInit {
     createNotification(): void {
         this.loading = true;
         this._notification.node_id = [];
-        this._notification.source_node_ref.forEach(source => {
-            let n = Workflow.getAllNodes(this.workflow).find(p => p.name === source);
-            this._notification.node_id.push(n.id);
-        });
-
+        if (this._notification.source_node_ref) {
+            this._notification.source_node_ref.forEach(source => {
+                let n = Workflow.getAllNodes(this.workflow).find(p => p.name === source);
+                this._notification.node_id.push(n.id);
+            });
+        }
         if (this.selectedUsers != null) {
             this._notification.settings.recipients = this.selectedUsers.split(',');
         }
diff --git a/ui/src/app/views/workflow/workflow.component.ts b/ui/src/app/views/workflow/workflow.component.ts
index c6b9fbca8..7e60d9951 100644
--- a/ui/src/app/views/workflow/workflow.component.ts
+++ b/ui/src/app/views/workflow/workflow.component.ts
@@ -101,16 +101,6 @@ export class WorkflowComponent implements OnInit, OnDestroy {
             }
 
             let data = { project_key: this.project.key };
-            this._featureService.isEnabled(FeatureNames.CDNArtifact, data).subscribe(f => {
-                this._store.dispatch(new AddFeatureResult(<FeaturePayload>{
-                    key: f.name,
-                    result: {
-                        paramString: JSON.stringify(data),
-                        enabled: f.enabled,
-                        exists: f.exists
-                    }
-                }));
-            });
             this._featureService.isEnabled(FeatureNames.WorkflowRetentionPolicy, data).subscribe(f => {
                 this._store.dispatch(new AddFeatureResult(<FeaturePayload>{
                     key: f.name,


Test patch:
diff --git a/engine/api/rbac/dao_rbac_test.go b/engine/api/rbac/dao_rbac_test.go
new file mode 100644
index 000000000..dcbd040a6
--- /dev/null
+++ b/engine/api/rbac/dao_rbac_test.go
@@ -0,0 +1,205 @@
+package rbac
+
+import (
+	"context"
+	"fmt"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+	"gopkg.in/yaml.v2"
+
+	"github.com/ovh/cds/engine/api/test"
+	"github.com/ovh/cds/engine/api/test/assets"
+	"github.com/ovh/cds/sdk"
+)
+
+func TestLoadRbacProject(t *testing.T) {
+	// user1  can read (proj1)
+	// Group 1 can read (proj1,prj2), manage(prj2)
+	// user2  in group 1
+	db, cache := test.SetupPG(t)
+
+	_, err := db.Exec("DELETE FROM rbac")
+	require.NoError(t, err)
+
+	key1 := sdk.RandomString(10)
+	proj1 := assets.InsertTestProject(t, db, cache, key1, key1)
+
+	key2 := sdk.RandomString(10)
+	proj2 := assets.InsertTestProject(t, db, cache, key2, key2)
+
+	grpName1 := sdk.RandomString(10)
+	group1 := assets.InsertTestGroup(t, db, grpName1)
+
+	users1, _ := assets.InsertLambdaUser(t, db)
+	users2, _ := assets.InsertLambdaUser(t, db, group1)
+
+	perm := fmt.Sprintf(`name: perm-test
+projects:
+  - role: read
+    projects: [%s]
+    users: [%s]
+    groups: [%s]
+  - role: read
+    projects: [%s]
+    groups: [%s]
+  - role: manage
+    projects: [%s]
+    groups: [%s]
+`, proj1.Key, users1.Username, group1.Name, proj2.Key, group1.Name, proj2.Key, group1.Name)
+
+	var r sdk.RBAC
+	require.NoError(t, yaml.Unmarshal([]byte(perm), &r))
+
+	err = fillWithIDs(context.TODO(), db, &r)
+	require.NoError(t, err)
+
+	require.NoError(t, Insert(context.Background(), db, &r))
+
+	prjusers1, err := LoadProjectIDsByRoleAndUserID(context.TODO(), db, sdk.RoleRead, users1.ID)
+	require.NoError(t, err)
+	t.Logf("%+v", prjusers1)
+	require.Len(t, prjusers1, 1)
+	require.Equal(t, prjusers1[0], proj1.ID)
+
+	prjusers2, err := LoadProjectIDsByRoleAndUserID(context.TODO(), db, sdk.RoleManage, users2.ID)
+	require.NoError(t, err)
+	require.Len(t, prjusers2, 1)
+	require.Equal(t, prjusers2[0], proj2.ID)
+}
+
+func TestLoadRbac(t *testing.T) {
+	db, cache := test.SetupPG(t)
+
+	_, err := db.Exec("DELETE FROM rbac WHERE name = 'perm-test'")
+	require.NoError(t, err)
+
+	key1 := sdk.RandomString(10)
+	proj1 := assets.InsertTestProject(t, db, cache, key1, key1)
+
+	key2 := sdk.RandomString(10)
+	proj2 := assets.InsertTestProject(t, db, cache, key2, key2)
+
+	grpName1 := sdk.RandomString(10)
+	group1 := assets.InsertTestGroup(t, db, grpName1)
+
+	users1, _ := assets.InsertLambdaUser(t, db)
+
+	perm := fmt.Sprintf(`name: perm-test
+projects:
+  - role: read
+    users: [%s]
+    groups: [%s]
+    projects: [%s]
+  - role: manage
+    groups: [%s]
+    projects: [%s]
+globals:
+  - role: create-project
+    users: [%s]
+`, users1.Username, group1.Name, proj1.Key, group1.Name, proj2.Key, users1.Username)
+
+	var r sdk.RBAC
+	require.NoError(t, yaml.Unmarshal([]byte(perm), &r))
+	err = fillWithIDs(context.TODO(), db, &r)
+	require.NoError(t, err)
+
+	require.NoError(t, Insert(context.Background(), db, &r))
+
+	rbacDB, err := LoadRbacByName(context.TODO(), db, r.Name, LoadOptions.Default)
+	require.NoError(t, err)
+
+	// Global part
+	require.Equal(t, len(r.Globals), len(rbacDB.Globals))
+	require.Equal(t, r.Globals[0].Role, rbacDB.Globals[0].Role)
+	require.Equal(t, users1.ID, rbacDB.Globals[0].RBACUsersIDs[0])
+
+	// Project part
+	require.Equal(t, len(r.Projects), len(rbacDB.Projects))
+
+	manageCheck := false
+	readCheck := false
+	for _, rp := range r.Projects {
+		if rp.Role == "manage" {
+			require.Equal(t, 1, len(rp.RBACGroupsName))
+			require.Equal(t, 1, len(rp.RBACGroupsIDs))
+			require.Equal(t, 1, len(rp.RBACProjectKeys))
+			require.Equal(t, 1, len(rp.RBACProjectsIDs))
+			require.Equal(t, proj2.Key, rp.RBACProjectKeys[0])
+			require.Equal(t, proj2.ID, rp.RBACProjectsIDs[0])
+			require.Equal(t, group1.Name, rp.RBACGroupsName[0])
+			require.Equal(t, group1.ID, rp.RBACGroupsIDs[0])
+			manageCheck = true
+		}
+		if rp.Role == "read" {
+			require.Equal(t, 1, len(rp.RBACGroupsName))
+			require.Equal(t, 1, len(rp.RBACGroupsIDs))
+			require.Equal(t, 1, len(rp.RBACUsersIDs))
+			require.Equal(t, 1, len(rp.RBACUsersName))
+			require.Equal(t, 1, len(rp.RBACProjectKeys))
+			require.Equal(t, 1, len(rp.RBACProjectsIDs))
+			require.Equal(t, proj1.Key, rp.RBACProjectKeys[0])
+			require.Equal(t, proj1.ID, rp.RBACProjectsIDs[0])
+			require.Equal(t, group1.Name, rp.RBACGroupsName[0])
+			require.Equal(t, group1.ID, rp.RBACGroupsIDs[0])
+			require.Equal(t, users1.Username, rp.RBACUsersName[0])
+			require.Equal(t, users1.ID, rp.RBACUsersIDs[0])
+			readCheck = true
+		}
+	}
+	require.True(t, manageCheck)
+	require.True(t, readCheck)
+}
+
+func TestUpdateRbac(t *testing.T) {
+	db, cache := test.SetupPG(t)
+
+	_, err := db.Exec("DELETE FROM rbac WHERE name = 'perm-test'")
+	require.NoError(t, err)
+
+	key1 := sdk.RandomString(10)
+	proj1 := assets.InsertTestProject(t, db, cache, key1, key1)
+
+	users1, _ := assets.InsertLambdaUser(t, db)
+
+	perm := fmt.Sprintf(`name: perm-test
+projects:
+  - role: read
+    users: [%s]
+    projects: [%s]
+
+`, users1.Username, proj1.Key)
+
+	var r sdk.RBAC
+	require.NoError(t, yaml.Unmarshal([]byte(perm), &r))
+	err = fillWithIDs(context.TODO(), db, &r)
+	require.NoError(t, err)
+
+	require.NoError(t, Insert(context.Background(), db, &r))
+
+	rbacDB, err := LoadRbacByName(context.TODO(), db, r.Name, LoadOptions.Default)
+	require.NoError(t, err)
+
+	require.Equal(t, "read", rbacDB.Projects[0].Role)
+
+	// Update change role
+	permUpdated := fmt.Sprintf(`name: perm-test
+projects:
+  - role: manage
+    users: [%s]
+    projects: [%s]
+
+`, users1.Username, proj1.Key)
+
+	var rUpdated sdk.RBAC
+	require.NoError(t, yaml.Unmarshal([]byte(permUpdated), &rUpdated))
+	err = fillWithIDs(context.TODO(), db, &rUpdated)
+	require.NoError(t, err)
+
+	require.NoError(t, Update(context.TODO(), db, &rUpdated))
+
+	rbacDBUpdate, err := LoadRbacByName(context.TODO(), db, r.Name, LoadOptions.Default)
+	require.NoError(t, err)
+	require.Equal(t, "manage", rbacDBUpdate.Projects[0].Role)
+
+}
diff --git a/engine/api/v2_project_test.go b/engine/api/v2_project_test.go
new file mode 100644
index 000000000..5edd8160f
--- /dev/null
+++ b/engine/api/v2_project_test.go
@@ -0,0 +1,30 @@
+package api
+
+import (
+	"github.com/ovh/cds/engine/api/test"
+	"github.com/ovh/cds/engine/api/test/assets"
+	"github.com/ovh/cds/sdk"
+	"github.com/stretchr/testify/require"
+	"net/http/httptest"
+	"testing"
+)
+
+func Test_addVCSOnProject(t *testing.T) {
+	api, db, _ := newTestAPI(t)
+
+	u, pass := assets.InsertLambdaUser(t, db)
+	proj := assets.InsertTestProject(t, db, api.Cache, sdk.RandomString(10), sdk.RandomString(10))
+
+	vars := map[string]string{
+		"projectKey": proj.Key,
+	}
+	uri := api.Router.GetRouteV2("POST", api.postVCSOnProjectHandler, vars)
+	test.NotEmpty(t, uri)
+	req := assets.NewAuthentifiedRequest(t, u, pass, "POST", uri, nil)
+
+	//Do the request
+	w := httptest.NewRecorder()
+	api.Router.Mux.ServeHTTP(w, req)
+	require.Equal(t, 204, w.Code)
+
+}
diff --git a/engine/api/workflow_test.go b/engine/api/workflow_test.go
index 2f3dd89f2..592099278 100644
--- a/engine/api/workflow_test.go
+++ b/engine/api/workflow_test.go
@@ -6,6 +6,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"io"
+	"io/ioutil"
 	"net/http"
 	"net/http/httptest"
 	"reflect"
@@ -1752,7 +1753,15 @@ func Test_putWorkflowWithDuplicateHooksShouldRaiseAnError(t *testing.T) {
 	req = assets.NewAuthentifiedRequest(t, u, pass, "PUT", uri, &wf)
 	w = httptest.NewRecorder()
 	router.Mux.ServeHTTP(w, req)
-	require.Equal(t, 400, w.Code)
+	require.Equal(t, 200, w.Code)
+
+	btes, err := ioutil.ReadAll(w.Body)
+	require.NoError(t, err)
+
+	var resultWorkflow sdk.Workflow
+	require.NoError(t, sdk.JSONUnmarshal(btes, &resultWorkflow))
+
+	require.Len(t, resultWorkflow.WorkflowData.Node.Hooks, 1)
 }
 
 func Test_getWorkflowsHandler_FilterByRepo(t *testing.T) {
diff --git a/engine/worker/internal/action/builtin_artifact_download_test.go b/engine/worker/internal/action/builtin_artifact_download_test.go
index c8adb6624..e3958e9ed 100644
--- a/engine/worker/internal/action/builtin_artifact_download_test.go
+++ b/engine/worker/internal/action/builtin_artifact_download_test.go
@@ -18,26 +18,34 @@ func TestRunArtifactDownload(t *testing.T) {
 
 	wk, ctx := SetupTest(t)
 
-	as := []sdk.WorkflowNodeRunArtifact{
-		{
-			ID:   1,
-			Name: "myFile.txt",
-			Tag:  "999",
-		},
-		{
-			ID:   2,
-			Name: "myFile.csv",
-			Tag:  "999",
-		},
-	}
-
 	f1 := bytes.NewBufferString("contentfile")
 
-	gock.New("http://lolcat.host").Get("/project/projKey/workflows/workflowName/runs/999/artifacts").
-		Reply(200).JSON(as)
-	gock.New("http://lolcat.host").Get("/project/projKey/workflows/workflowName/artifact/1").
+	gock.New("http://cds-cdn.local").Get("/item/run-result/the-ref/download").
 		Reply(200).Body(f1)
 
+	ref := sdk.CDNRunResultAPIRef{
+		ProjectKey:    "projKey",
+		WorkflowName:  "WorkflowName",
+		ArtifactName:  "myFile.txt",
+		RunResultType: sdk.WorkflowRunResultTypeArtifact,
+	}
+
+	it := sdk.CDNItemLinks{
+		CDNHttpURL: "http://lolcat.cdn.host",
+		Items: []sdk.CDNItem{
+			{
+				ID:         "1",
+				Type:       sdk.CDNTypeItemRunResult,
+				APIRef:     &ref,
+				APIRefHash: "the-ref",
+				MD5:        "d62dd48969b2bcf4023f51be7cc02c05",
+			},
+		},
+	}
+
+	gock.New("http://cds-api.local").Get("/project/projKey/workflows/workflowName/runs/999/artifacts/links").
+		Reply(200).JSON(it)
+
 	gock.InterceptClient(wk.Client().(cdsclient.Raw).HTTPClient())
 	gock.InterceptClient(wk.Client().(cdsclient.Raw).HTTPNoTimeoutClient())
 
@@ -88,21 +96,33 @@ func TestRunArtifactDownloadOutsideWorkspace(t *testing.T) {
 	wk, ctx := SetupTest(t)
 
 	fileName := sdk.RandomString(10)
+	f1 := bytes.NewBufferString("contentfile")
 
-	as := []sdk.WorkflowNodeRunArtifact{
-		{
-			ID:   1,
-			Name: fileName,
-			Tag:  "999",
-		},
+	gock.New("http://cds-cdn.local").Get("/item/run-result/the-ref/download").
+		Reply(200).Body(f1)
+
+	ref := sdk.CDNRunResultAPIRef{
+		ProjectKey:    "projKey",
+		WorkflowName:  "WorkflowName",
+		ArtifactName:  fileName,
+		RunResultType: sdk.WorkflowRunResultTypeArtifact,
 	}
 
-	f1 := bytes.NewBufferString("contentfile")
+	it := sdk.CDNItemLinks{
+		CDNHttpURL: "http://lolcat.cdn.host",
+		Items: []sdk.CDNItem{
+			{
+				ID:         "1",
+				Type:       sdk.CDNTypeItemRunResult,
+				APIRef:     &ref,
+				APIRefHash: "the-ref",
+				MD5:        "d62dd48969b2bcf4023f51be7cc02c05",
+			},
+		},
+	}
 
-	gock.New("http://lolcat.host").Get("/project/projKey/workflows/workflowName/runs/999/artifacts").
-		Reply(200).JSON(as)
-	gock.New("http://lolcat.host").Get("/project/projKey/workflows/workflowName/artifact/1").
-		Reply(200).Body(f1)
+	gock.New("http://cds-api.local").Get("/project/projKey/workflows/workflowName/runs/999/artifacts/links").
+		Reply(200).JSON(it)
 
 	gock.InterceptClient(wk.Client().(cdsclient.Raw).HTTPClient())
 	gock.InterceptClient(wk.Client().(cdsclient.Raw).HTTPNoTimeoutClient())
diff --git a/engine/worker/internal/action/builtin_artifact_upload_test.go b/engine/worker/internal/action/builtin_artifact_upload_test.go
index 2490dc57f..22a46996b 100644
--- a/engine/worker/internal/action/builtin_artifact_upload_test.go
+++ b/engine/worker/internal/action/builtin_artifact_upload_test.go
@@ -3,7 +3,6 @@ package action
 import (
 	"bytes"
 	"io"
-	"mime"
 	"net/http"
 	"os"
 	"path/filepath"
@@ -34,11 +33,7 @@ func TestRunArtifactUpload_Absolute(t *testing.T) {
 
 	defer os.Remove("foo")
 
-	gock.New("http://lolcat.host").Get("/project/project/storage/shared.infra").
-		Reply(200)
-
-	gock.New("http://lolcat.host").Post("/project/project/storage/shared.infra/artifact/dGFn").
-		Reply(200)
+	gock.New("http://cds-cdn.local").Post("/item/upload").Reply(200)
 
 	var checkRequest gock.ObserverFunc = func(request *http.Request, mock gock.Mock) {
 		bodyContent, err := io.ReadAll(request.Body)
@@ -47,21 +42,7 @@ func TestRunArtifactUpload_Absolute(t *testing.T) {
 		if mock != nil {
 			t.Logf("%s %s - Body: %s", mock.Request().Method, mock.Request().URLStruct.String(), string(bodyContent))
 		}
-		switch mock.Request().URLStruct.String() {
-		case "http://lolcat.host/queue/workflows/666/coverage":
-			require.NoError(t, request.ParseMultipartForm(10000))
-
-			_, params, err := mime.ParseMediaType(request.Header.Get("Content-Disposition"))
-			assert.NoError(t, err)
-
-			fileName := params["filename"]
-			assert.Equal(t, "foo", fileName)
-
-			md5 := params["md4sum"]
-			assert.Equal(t, "32c0c0a755c70c6faef2eeb98b66c3aeee4c389d62bb9b639796c37abe3d", md5)
-
-		case "/project/project/storage/shared.infra/artifact":
-		}
+		assert.Equal(t, "http://cds-cdn.local/item/upload", mock.Request().URLStruct.String())
 	}
 
 	gock.Observe(checkRequest)
@@ -98,11 +79,7 @@ func TestRunArtifactUpload_Relative(t *testing.T) {
 	fname := filepath.Join(wk.workingDirectory.Name(), "foo")
 	assert.NoError(t, afero.WriteFile(wk.workspace, fname, []byte("something"), os.ModePerm))
 
-	gock.New("http://lolcat.host").Get("/project/project/storage/shared.infra").
-		Reply(200)
-
-	gock.New("http://lolcat.host").Post("/project/project/storage/shared.infra/artifact/dGFn").
-		Reply(200)
+	gock.New("http://cds-cdn.local").Post("/item/upload").Reply(200)
 
 	var checkRequest gock.ObserverFunc = func(request *http.Request, mock gock.Mock) {
 		bodyContent, err := io.ReadAll(request.Body)
@@ -111,21 +88,7 @@ func TestRunArtifactUpload_Relative(t *testing.T) {
 		if mock != nil {
 			t.Logf("%s %s - Body: %s", mock.Request().Method, mock.Request().URLStruct.String(), string(bodyContent))
 		}
-		switch mock.Request().URLStruct.String() {
-		case "http://lolcat.host/queue/workflows/666/coverage":
-			require.NoError(t, request.ParseMultipartForm(10000))
-
-			_, params, err := mime.ParseMediaType(request.Header.Get("Content-Disposition"))
-			assert.NoError(t, err)
-
-			fileName := params["filename"]
-			assert.Equal(t, "foo", fileName)
-
-			md5 := params["md4sum"]
-			assert.Equal(t, "32c0c0a755c70c6faef2eeb98b66c3aeee4c389d62bb9b639796c37abe3d", md5)
-
-		case "/project/project/storage/shared.infra/artifact":
-		}
+		assert.Equal(t, "http://cds-cdn.local/item/upload", mock.Request().URLStruct.String())
 	}
 
 	gock.Observe(checkRequest)
diff --git a/engine/worker/internal/action/builtin_coverage_test.go b/engine/worker/internal/action/builtin_coverage_test.go
index 26fb006ac..468a24f9e 100644
--- a/engine/worker/internal/action/builtin_coverage_test.go
+++ b/engine/worker/internal/action/builtin_coverage_test.go
@@ -56,8 +56,8 @@ func TestRunCoverage_Absolute(t *testing.T) {
 	fiPath, err := filepath.Abs(fi.Name())
 	require.NoError(t, err)
 
-	gock.New("http://lolcat.host").Post("/queue/workflows/666/coverage").
-		Reply(200)
+	gock.New("http://cds-api.local").Post("/queue/workflows/666/coverage").Reply(200)
+	gock.New("http://cds-cdn.local").Post("/item/upload").Reply(200)
 
 	var checkRequest gock.ObserverFunc = func(request *http.Request, mock gock.Mock) {
 		bodyContent, err := io.ReadAll(request.Body)
@@ -66,7 +66,7 @@ func TestRunCoverage_Absolute(t *testing.T) {
 		if mock != nil {
 			t.Logf("%s %s - Body: %s", mock.Request().Method, mock.Request().URLStruct.String(), string(bodyContent))
 			switch mock.Request().URLStruct.String() {
-			case "http://lolcat.host/queue/workflows/666/coverage":
+			case "http://cds-api.local/queue/workflows/666/coverage":
 				var report coverage.Report
 				err := json.Unmarshal(bodyContent, &report)
 				assert.NoError(t, err)
@@ -110,8 +110,8 @@ func TestRunCoverage_Relative(t *testing.T) {
 	fname := filepath.Join(wk.workingDirectory.Name(), "results.xml")
 	require.NoError(t, afero.WriteFile(wk.BaseDir(), fname, []byte(cobertura_result), os.ModePerm))
 
-	gock.New("http://lolcat.host").Post("/queue/workflows/666/coverage").
-		Reply(200)
+	gock.New("http://cds-api.local").Post("/queue/workflows/666/coverage").Reply(200)
+	gock.New("http://cds-cdn.local").Post("/item/upload").Reply(200)
 
 	var checkRequest gock.ObserverFunc = func(request *http.Request, mock gock.Mock) {
 		bodyContent, err := io.ReadAll(request.Body)
@@ -120,7 +120,7 @@ func TestRunCoverage_Relative(t *testing.T) {
 		if mock != nil {
 			t.Logf("%s %s - Body: %s", mock.Request().Method, mock.Request().URLStruct.String(), string(bodyContent))
 			switch mock.Request().URLStruct.String() {
-			case "http://lolcat.host/queue/workflows/666/coverage":
+			case "http://cds-api.local/queue/workflows/666/coverage":
 				var report coverage.Report
 				err := json.Unmarshal(bodyContent, &report)
 				assert.NoError(t, err)
@@ -169,8 +169,8 @@ func TestRunCoverageMinimumFail(t *testing.T) {
 	fiPath, err := filepath.Abs(fi.Name())
 	require.NoError(t, err)
 
-	gock.New("http://lolcat.host").Post("/queue/workflows/666/coverage").
-		Reply(200)
+	gock.New("http://cds-api.local").Post("/queue/workflows/666/coverage").Reply(200)
+	gock.New("http://cds-cdn.local").Post("/item/upload").Reply(200)
 
 	var checkRequest gock.ObserverFunc = func(request *http.Request, mock gock.Mock) {
 		bodyContent, err := io.ReadAll(request.Body)
@@ -179,7 +179,7 @@ func TestRunCoverageMinimumFail(t *testing.T) {
 		if mock != nil {
 			t.Logf("%s %s - Body: %s", mock.Request().Method, mock.Request().URLStruct.String(), string(bodyContent))
 			switch mock.Request().URLStruct.String() {
-			case "http://lolcat.host/queue/workflows/666/coverage":
+			case "http://cds-api.local/queue/workflows/666/coverage":
 				var report coverage.Report
 				err := json.Unmarshal(bodyContent, &report)
 				assert.NoError(t, err)
diff --git a/engine/worker/internal/action/builtin_junit_test.go b/engine/worker/internal/action/builtin_junit_test.go
index 5c60ab420..ebce84799 100644
--- a/engine/worker/internal/action/builtin_junit_test.go
+++ b/engine/worker/internal/action/builtin_junit_test.go
@@ -50,7 +50,7 @@ func TestRunParseJunitTestResultAction_Absolute(t *testing.T) {
 	fiPath, err := filepath.Abs(fi.Name())
 	require.NoError(t, err)
 
-	gock.New("http://lolcat.host").Post("/queue/workflows/666/test").
+	gock.New("http://cds-api.local").Post("/queue/workflows/666/test").
 		Reply(200)
 
 	var checkRequest gock.ObserverFunc = func(request *http.Request, mock gock.Mock) {
@@ -60,7 +60,7 @@ func TestRunParseJunitTestResultAction_Absolute(t *testing.T) {
 		if mock != nil {
 			t.Logf("%s %s - Body: %s", mock.Request().Method, mock.Request().URLStruct.String(), string(bodyContent))
 			switch mock.Request().URLStruct.String() {
-			case "http://lolcat.host/queue/workflows/666/test":
+			case "http://cds-api.local/queue/workflows/666/test":
 				var report sdk.JUnitTestsSuites
 				require.NoError(t, json.Unmarshal(bodyContent, &report))
 				report = report.EnsureData()
@@ -115,7 +115,7 @@ func TestRunParseJunitTestResultAction_Relative(t *testing.T) {
 	fname := filepath.Join(wk.workingDirectory.Name(), "results.xml")
 	require.NoError(t, afero.WriteFile(wk.BaseDir(), fname, []byte(fileContent), os.ModePerm))
 
-	gock.New("http://lolcat.host").Post("/queue/workflows/666/test").
+	gock.New("http://cds-api.local").Post("/queue/workflows/666/test").
 		Reply(200)
 
 	var checkRequest gock.ObserverFunc = func(request *http.Request, mock gock.Mock) {
@@ -125,7 +125,7 @@ func TestRunParseJunitTestResultAction_Relative(t *testing.T) {
 		if mock != nil {
 			t.Logf("%s %s - Body: %s", mock.Request().Method, mock.Request().URLStruct.String(), string(bodyContent))
 			switch mock.Request().URLStruct.String() {
-			case "http://lolcat.host/queue/workflows/666/test":
+			case "http://cds-api.local/queue/workflows/666/test":
 				var report sdk.JUnitTestsSuites
 				require.NoError(t, json.Unmarshal(bodyContent, &report))
 				report = report.EnsureData()
diff --git a/engine/worker/internal/action/builtin_key_install_test.go b/engine/worker/internal/action/builtin_key_install_test.go
index 1dc7588b7..f628aa726 100644
--- a/engine/worker/internal/action/builtin_key_install_test.go
+++ b/engine/worker/internal/action/builtin_key_install_test.go
@@ -29,7 +29,7 @@ func TestRunInstallKeyAction_Relative(t *testing.T) {
 	cfg := &workerruntime.WorkerConfig{
 		Name:                "test-worker",
 		HatcheryName:        "test-hatchery",
-		APIEndpoint:         "http://lolcat.host",
+		APIEndpoint:         "http://cds-api.local",
 		APIToken:            "xxx-my-token",
 		APIEndpointInsecure: true,
 		Basedir:             basedir,
@@ -94,7 +94,7 @@ func TestRunInstallKeyAction_Absolute(t *testing.T) {
 	cfg := &workerruntime.WorkerConfig{
 		Name:                "test-worker",
 		HatcheryName:        "test-hatchery",
-		APIEndpoint:         "http://lolcat.host",
+		APIEndpoint:         "http://cds-api.local",
 		APIToken:            "xxx-my-token",
 		APIEndpointInsecure: true,
 		Basedir:             basedir,
diff --git a/engine/worker/internal/action/builtin_release_vcs_test.go b/engine/worker/internal/action/builtin_release_vcs_test.go
index 910329edb..eb0ef82f8 100644
--- a/engine/worker/internal/action/builtin_release_vcs_test.go
+++ b/engine/worker/internal/action/builtin_release_vcs_test.go
@@ -20,12 +20,12 @@ func TestRunRelease(t *testing.T) {
 
 	wk, ctx := SetupTest(t)
 
-	gock.New("http://lolcat.host").Get("/queue/workflows/666/infos").
+	gock.New("http://cds-api.local").Get("/queue/workflows/666/infos").
 		Reply(200).JSON(
 		sdk.WorkflowNodeJobRun{
 			WorkflowNodeRunID: 6,
 		})
-	gock.New("http://lolcat.host").Post("/project/projKey/workflows/workflowName/runs/999/nodes/6/release").
+	gock.New("http://cds-api.local").Post("/project/projKey/workflows/workflowName/runs/999/nodes/6/release").
 		Reply(200)
 
 	var checkRequest gock.ObserverFunc = func(request *http.Request, mock gock.Mock) {
@@ -35,7 +35,7 @@ func TestRunRelease(t *testing.T) {
 		if mock != nil {
 			t.Logf("%s %s - Body: %s", mock.Request().Method, mock.Request().URLStruct.String(), string(bodyContent))
 			switch mock.Request().URLStruct.String() {
-			case "http://lolcat.host/project/projKey/workflows/workflowName/runs/999/nodes/6/release":
+			case "http://cds-api.local/project/projKey/workflows/workflowName/runs/999/nodes/6/release":
 				var releaseRequest sdk.WorkflowNodeRunRelease
 				err := json.Unmarshal(bodyContent, &releaseRequest)
 				assert.NoError(t, err)
diff --git a/engine/worker/internal/action/test_helper.go b/engine/worker/internal/action/test_helper.go
index 9ab5871da..a606209c8 100644
--- a/engine/worker/internal/action/test_helper.go
+++ b/engine/worker/internal/action/test_helper.go
@@ -47,7 +47,7 @@ func (w *TestWorker) FeatureEnabled(featureName sdk.FeatureName) bool {
 }
 
 func (w *TestWorker) CDNHttpURL() string {
-	return "http://cdn.me"
+	return "http://cds-cdn.local"
 }
 
 func (w *TestWorker) WorkingDirectory() *afero.BasePathFile {
@@ -173,7 +173,7 @@ func SetupTest(t *testing.T) (*TestWorker, context.Context) {
 
 	wk.keyDirectory = fi.(*afero.BasePathFile)
 
-	wk.client = cdsclient.NewWorker("http://lolcat.host", "test-client", cdsclient.NewHTTPClient(time.Second*360, false))
+	wk.client = cdsclient.NewWorker("http://cds-api.local", "test-client", cdsclient.NewHTTPClient(time.Second*360, false))
 
 	ctx := workerruntime.SetWorkingDirectory(context.TODO(), wk.workingDirectory)
 	ctx = workerruntime.SetKeysDirectory(ctx, wk.keyDirectory)
diff --git a/engine/worker/internal/handler_cache_test.go b/engine/worker/internal/handler_cache_test.go
index 4550b4fb3..7b81ca258 100644
--- a/engine/worker/internal/handler_cache_test.go
+++ b/engine/worker/internal/handler_cache_test.go
@@ -3,6 +3,9 @@ package internal
 import (
 	"bytes"
 	"context"
+	"crypto/md5"
+	"encoding/base64"
+	"encoding/hex"
 	"encoding/json"
 	"fmt"
 	"io"
@@ -11,6 +14,7 @@ import (
 	"os"
 	"path"
 	"path/filepath"
+	"strings"
 	"testing"
 	"time"
 
@@ -24,6 +28,7 @@ import (
 	"github.com/ovh/cds/engine/worker/pkg/workerruntime"
 	"github.com/ovh/cds/sdk"
 	"github.com/ovh/cds/sdk/cdsclient/mock_cdsclient"
+	"github.com/ovh/cds/sdk/jws"
 )
 
 func Test_cachePushPullHandler(t *testing.T) {
@@ -33,6 +38,17 @@ func Test_cachePushPullHandler(t *testing.T) {
 	t.Logf("Creating worker basedir at %s", basedir)
 	require.NoError(t, fs.MkdirAll(basedir, os.FileMode(0755)))
 
+	workerKey, err := jws.NewRandomSymmetricKey(32)
+	require.NoError(t, err)
+	signingKey := base64.StdEncoding.EncodeToString(workerKey)
+
+	secretKey := make([]byte, 32)
+	_, err = base64.StdEncoding.Decode(secretKey, []byte(signingKey))
+	require.NoError(t, err)
+
+	signer, err := jws.NewHMacSigner(secretKey)
+	require.NoError(t, err)
+
 	// Setup test worker data for push and create run and tmp directories
 	ctxPush := context.Background()
 	wkPush := &CurrentWorker{
@@ -51,6 +67,7 @@ func Test_cachePushPullHandler(t *testing.T) {
 	ctxPush = workerruntime.SetTmpDirectory(ctxPush, tdPushFile)
 	t.Logf("Setup push tmp directory at %s", tdPushFile.Name())
 
+	wkPush.currentJob.signer = signer
 	wkPush.currentJob.context = ctxPush
 	wkPush.currentJob.wJob = &sdk.WorkflowNodeJobRun{
 		Parameters: []sdk.Parameter{{
@@ -67,6 +84,7 @@ func Test_cachePushPullHandler(t *testing.T) {
 	pullJobInfo := sdk.WorkflowNodeJobRunData{}
 	pullJobInfo.NodeJobRun.Job.Job.Action.Name = sdk.RandomString(10)
 
+	wkPull.currentJob.signer = signer
 	wkPull.currentJob.wJob = &sdk.WorkflowNodeJobRun{
 		Parameters: []sdk.Parameter{{
 			Name:  "cds.project",
@@ -89,29 +107,57 @@ func Test_cachePushPullHandler(t *testing.T) {
 	m := mock_cdsclient.NewMockWorkerInterface(ctrl)
 	wkPush.client = m
 	wkPull.client = m
+	wkPush.cdnHttpAddr = "https://cdn.local"
+	wkPull.cdnHttpAddr = "https://cdn.local"
+
+	var bodyBytes []byte
+	m.EXPECT().CDNItemUpload(gomock.Any(), "https://cdn.local", gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
+		func(ctx context.Context, cdnAddr string, signature string, fs afero.Fs, path string) (time.Duration, error) {
+			t0 := time.Now()
+			require.True(t, strings.Contains(path, "tar-"))
+			f, err := fs.Open(path)
+			require.NoError(t, err)
+			bodyBytes, err = io.ReadAll(f)
+			require.NotEqual(t, 0, len(bodyBytes))
+			return time.Since(t0), nil
+		},
+	)
+
+	md5Hash := md5.New()
+	md5S := hex.EncodeToString(md5Hash.Sum(nil))
+	apiRefHash := sdk.RandomString(10)
+	m.EXPECT().QueueWorkerCacheLink(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
+		func(ctx context.Context, jobID int64, tag string) (sdk.CDNItemLinks, error) {
+			return sdk.CDNItemLinks{
+				CDNHttpURL: "https://cdn.local",
+				Items: []sdk.CDNItem{
+					{
+						ID:         "foo",
+						MD5:        md5S,
+						APIRefHash: apiRefHash,
+						Type:       sdk.CDNTypeItemWorkerCache,
+					},
+				},
+			}, nil
+		},
+	)
+
+	// ctx context.Context, cdnAddr string, hash string, itemType sdk.CDNItemType, md5Sum string, writer io.WriteSeeker) error
+	m.EXPECT().CDNItemDownload(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
+		func(ctx context.Context, cdnAddr string, hash string, itemType sdk.CDNItemType, md5Sum string, writer io.WriteSeeker) error {
+			require.Equal(t, "https://cdn.local", cdnAddr)
+			require.Equal(t, sdk.CDNTypeItemWorkerCache, itemType)
+			require.Equal(t, md5S, md5Sum)
+			require.Equal(t, apiRefHash, hash)
 
-	var generatedTar bytes.Buffer
-	var retryPush int
-	m.EXPECT().WorkflowCachePush("myProject", "shared.infra", "myTag", gomock.Any(), gomock.Any()).DoAndReturn(
-		func(projectKey, integrationName, ref string, tarContent io.Reader, size int) error {
-			retryPush++
-			if retryPush == 1 {
-				partialRead := make([]byte, 10)
-				l, err := tarContent.Read(partialRead)
-				require.NoError(t, err)
-				require.Equal(t, 10, l, "we should have read only 10 bytes of the tar")
-				return fmt.Errorf("a fake error occured with http request")
-			}
-			_, err := io.Copy(&generatedTar, tarContent)
+			_, err := writer.Seek(0, io.SeekStart)
+			require.NoError(t, err)
+
+			_, err = io.Copy(writer, bytes.NewBuffer(bodyBytes))
 			require.NoError(t, err)
 			return nil
 		},
-	).Times(2)
-	m.EXPECT().WorkflowCachePull("myProject", "shared.infra", "myTag").DoAndReturn(
-		func(projectKey, integrationName, ref string) (io.Reader, error) {
-			return bytes.NewBuffer(generatedTar.Bytes()), nil
-		},
-	).Times(1)
+	)
 
 	// Send cash push request for two files, one relative to workspace and another absolute to test basedir.
 	buf, err := json.Marshal(sdk.Cache{
diff --git a/engine/worker/internal/handler_key_test.go b/engine/worker/internal/handler_key_test.go
index b5b548c4e..3d149dd3c 100644
--- a/engine/worker/internal/handler_key_test.go
+++ b/engine/worker/internal/handler_key_test.go
@@ -25,7 +25,7 @@ func Test_keyInstall(t *testing.T) {
 	cfg := &workerruntime.WorkerConfig{
 		Name:                "test-worker",
 		HatcheryName:        "test-hatchery",
-		APIEndpoint:         "http://lolcat.host",
+		APIEndpoint:         "http://cds-api.local",
 		APIToken:            "xxx-my-token",
 		APIEndpointInsecure: true,
 		Basedir:             basedir,
diff --git a/engine/worker/internal/handler_tmpl_test.go b/engine/worker/internal/handler_tmpl_test.go
index aa86d7bc6..b6e9de3d1 100644
--- a/engine/worker/internal/handler_tmpl_test.go
+++ b/engine/worker/internal/handler_tmpl_test.go
@@ -28,7 +28,7 @@ func Test_tmplHandler(t *testing.T) {
 	cfg := &workerruntime.WorkerConfig{
 		Name:                "test-worker",
 		HatcheryName:        "test-hatchery",
-		APIEndpoint:         "http://lolcat.host",
+		APIEndpoint:         "http://cds-api.local",
 		APIToken:            "xxx-my-token",
 		APIEndpointInsecure: true,
 		Basedir:             basedir,
@@ -69,7 +69,7 @@ func Test_tmplHandler(t *testing.T) {
 
 	handler := tmplHandler(context.TODO(), wk)
 	w := httptest.NewRecorder()
-	r, err := http.NewRequest("POST", "http://lolcat.host/", bytes.NewReader(btes))
+	r, err := http.NewRequest("POST", "http://cds-api.local/", bytes.NewReader(btes))
 	require.NoError(t, err)
 	handler(w, r)
 
@@ -93,7 +93,7 @@ func Test_tmplHandlerInWrongDir(t *testing.T) {
 	cfg := &workerruntime.WorkerConfig{
 		Name:                "test-worker",
 		HatcheryName:        "test-hatchery",
-		APIEndpoint:         "http://lolcat.host",
+		APIEndpoint:         "http://cds-api.local",
 		APIToken:            "xxx-my-token",
 		APIEndpointInsecure: true,
 		Basedir:             basedir,
@@ -132,7 +132,7 @@ func Test_tmplHandlerInWrongDir(t *testing.T) {
 
 	handler := tmplHandler(context.TODO(), wk)
 	w := httptest.NewRecorder()
-	r, err := http.NewRequest("POST", "http://lolcat.host/", bytes.NewReader(btes))
+	r, err := http.NewRequest("POST", "http://cds-api.local/", bytes.NewReader(btes))
 	require.NoError(t, err)
 	handler(w, r)
 
diff --git a/engine/worker/internal/keys_test.go b/engine/worker/internal/keys_test.go
index 70d605376..cfd5fd14a 100644
--- a/engine/worker/internal/keys_test.go
+++ b/engine/worker/internal/keys_test.go
@@ -60,7 +60,7 @@ func TestInstallKey_SSHKeyWithoutDestination(t *testing.T) {
 	cfg := workerruntime.WorkerConfig{
 		Name:                "test-worker",
 		HatcheryName:        "test-hatchery",
-		APIEndpoint:         "http://lolcat.host",
+		APIEndpoint:         "http://cds-api.local",
 		APIToken:            "xxx-my-token",
 		APIEndpointInsecure: true,
 		Basedir:             basedir,
@@ -108,7 +108,7 @@ func TestInstallKey_SSHKeyWithRelativeDestination(t *testing.T) {
 	cfg := workerruntime.WorkerConfig{
 		Name:                "test-worker",
 		HatcheryName:        "test-hatchery",
-		APIEndpoint:         "http://lolcat.host",
+		APIEndpoint:         "http://cds-api.local",
 		APIToken:            "xxx-my-token",
 		APIEndpointInsecure: true,
 		Basedir:             basedir,
@@ -154,7 +154,7 @@ func TestInstallKey_SSHKeyWithAbsoluteDestination(t *testing.T) {
 	cfg := &workerruntime.WorkerConfig{
 		Name:                "test-worker",
 		HatcheryName:        "test-hatchery",
-		APIEndpoint:         "http://lolcat.host",
+		APIEndpoint:         "http://cds-api.local",
 		APIToken:            "xxx-my-token",
 		APIEndpointInsecure: true,
 	}
diff --git a/engine/worker/internal/start_test.go b/engine/worker/internal/start_test.go
index 30e6192c9..8b74322cc 100644
--- a/engine/worker/internal/start_test.go
+++ b/engine/worker/internal/start_test.go
@@ -37,7 +37,7 @@ func init() {
 func TestStartWorkerWithABookedJob(t *testing.T) {
 	defer gock.Off()
 
-	gock.New("http://lolcat.host").Get("/action/requirement").
+	gock.New("http://cds-api.local").Get("/action/requirement").
 		Reply(200).
 		JSON([]sdk.Requirement{
 			{
@@ -48,7 +48,7 @@ func TestStartWorkerWithABookedJob(t *testing.T) {
 		})
 
 	modelID := int64(1)
-	gock.New("http://lolcat.host").Post("/auth/consumer/worker/signin").
+	gock.New("http://cds-api.local").Post("/auth/consumer/worker/signin").
 		HeaderPresent("Authorization").
 		Reply(201).
 		JSON(sdk.Worker{
@@ -56,7 +56,7 @@ func TestStartWorkerWithABookedJob(t *testing.T) {
 			ModelID: &modelID,
 		}).AddHeader("X-CDS-JWT", "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJJRCI6IjJiNTA3ZDZiLTlhZWYtNGNlNS04MzhlLTA1OTU5NjhjMGU5NSIsIkdyb3VwSURzIjpbMV0sIlNjb3BlcyI6WyJXb3JrZXIiLCJSdW5FeGVjdXRpb24iXSwiZXhwIjoxNTYwNTA5NTQyLCJqdGkiOiIyYjUwN2Q2Yi05YWVmLTRjZTUtODM4ZS0wNTk1OTY4YzBlOTUiLCJpYXQiOjE1NjA1MDU5NDIsImlzcyI6ImNkc190ZXN0Iiwic3ViIjoiMTU4ODY5M2YtOTE5NC00ODg5LWJmYjAtZWY3Nzc5M2QzY2ViIn0.jLot6mtYHdnNAKxUS7OK7d6fVyMQyc7fS2NW4s727dxjx01Q2pPUQJBr16gKsS4ETSKh2ik7kqGGXdOz3i67DxMlPHcs0Azka1VOlefPcA77is-oVu0MPh4JbL0KA7fCu_98VKLJH3B0jYr4HEG9285ZOjFg7L5yuR7OqeFfCE3MrigyMKaNOrNE2FohOK9o50GyW_pAr6uNXcTu-yvqQUsz2B2gsd90HK2iWnvb8pKnBVVPg9Q0VA5l2IoFZR_p_UKSJZcFyFnjWKBVy33b70xupDnCpD-3OcbIFAQ5NPRvU_BnEjj_Jm59Ljrv3pJt1ErTMTkMA9QIFdYkDp5a6Q")
 
-	gock.New("http://lolcat.host").Get("/worker/model").
+	gock.New("http://cds-api.local").Get("/worker/model").
 		HeaderPresent("Authorization").
 		Reply(200).
 		JSON([]sdk.Model{
@@ -66,11 +66,11 @@ func TestStartWorkerWithABookedJob(t *testing.T) {
 			},
 		})
 
-	gock.New("http://lolcat.host").Post("/worker/waiting").Times(2).
+	gock.New("http://cds-api.local").Post("/worker/waiting").Times(2).
 		HeaderPresent("Authorization").
 		Reply(200).JSON(nil)
 
-	gock.New("http://lolcat.host").Get("/queue/workflows/42/infos").
+	gock.New("http://cds-api.local").Get("/queue/workflows/42/infos").
 		HeaderPresent("Authorization").
 		Reply(200).
 		JSON(sdk.WorkflowNodeJobRun{
@@ -78,7 +78,7 @@ func TestStartWorkerWithABookedJob(t *testing.T) {
 			Status: sdk.StatusWaiting,
 		})
 
-	gock.New("http://lolcat.host").Get("/queue/workflows/42/infos").Times(2).
+	gock.New("http://cds-api.local").Get("/queue/workflows/42/infos").Times(2).
 		HeaderPresent("Authorization").
 		Reply(200).
 		JSON(sdk.WorkflowNodeJobRun{
@@ -86,7 +86,7 @@ func TestStartWorkerWithABookedJob(t *testing.T) {
 			Status: sdk.StatusBuilding,
 		})
 
-	gock.New("http://lolcat.host").Get("project/proj_key/workflows/workflow_name/runs/0").Times(1).
+	gock.New("http://cds-api.local").Get("project/proj_key/workflows/workflow_name/runs/0").Times(1).
 		HeaderPresent("Authorization").
 		Reply(200).
 		JSON(sdk.WorkflowRun{
@@ -101,7 +101,7 @@ func TestStartWorkerWithABookedJob(t *testing.T) {
 			},
 		})
 
-	gock.New("http://lolcat.host").Get("project/proj_key/integrations/artifactory/workerhooks").Times(1).
+	gock.New("http://cds-api.local").Get("project/proj_key/integrations/artifactory/workerhooks").Times(1).
 		HeaderPresent("Authorization").
 		Reply(200).
 		JSON(sdk.WorkerHookProjectIntegrationModel{
@@ -118,7 +118,7 @@ export FOO_FROM_HOOK=BAR`,
 			},
 		})
 
-	gock.New("http://lolcat.host").Post("/queue/workflows/42/take").
+	gock.New("http://cds-api.local").Post("/queue/workflows/42/take").
 		HeaderPresent("Authorization").
 		Reply(200).
 		JSON(
@@ -245,17 +245,17 @@ export FOO_FROM_HOOK=BAR`,
 			},
 		)
 
-	gock.New("http://lolcat.host").Post("/queue/workflows/42/step").Times(8).
+	gock.New("http://cds-api.local").Post("/queue/workflows/42/step").Times(8).
 		HeaderPresent("Authorization").
 		Reply(200).
 		JSON(nil)
 
-	gock.New("http://lolcat.host").Post("/queue/workflows/42/result").
+	gock.New("http://cds-api.local").Post("/queue/workflows/42/result").
 		HeaderPresent("Authorization").
 		Reply(200).
 		JSON(nil)
 
-	gock.New("http://lolcat.host").Post("/auth/consumer/worker/signout").
+	gock.New("http://cds-api.local").Post("/auth/consumer/worker/signout").
 		HeaderPresent("Authorization").
 		Reply(200).
 		JSON(nil)
@@ -288,7 +288,7 @@ export FOO_FROM_HOOK=BAR`,
 		request.Body = io.NopCloser(bytes.NewReader(bodyContent))
 		if mock != nil {
 			switch mock.Request().URLStruct.String() {
-			case "http://lolcat.host/queue/workflows/42/step":
+			case "http://cds-api.local/queue/workflows/42/step":
 				var result sdk.StepStatus
 				err := json.Unmarshal(bodyContent, &result)
 				assert.NoError(t, err)
@@ -318,7 +318,7 @@ export FOO_FROM_HOOK=BAR`,
 					t.Logf("This case should not happend")
 					t.Fail()
 				}
-			case "http://lolcat.host/queue/workflows/42/result":
+			case "http://cds-api.local/queue/workflows/42/result":
 				var result sdk.Result
 				err := json.Unmarshal(bodyContent, &result)
 				assert.NoError(t, err)
@@ -347,7 +347,7 @@ export FOO_FROM_HOOK=BAR`,
 	cfg := &workerruntime.WorkerConfig{
 		Name:                "test-worker",
 		HatcheryName:        "test-hatchery",
-		APIEndpoint:         "http://lolcat.host",
+		APIEndpoint:         "http://cds-api.local",
 		APIToken:            "xxx-my-token",
 		APIEndpointInsecure: true,
 		Model:               "my-model",
@@ -375,7 +375,7 @@ export FOO_FROM_HOOK=BAR`,
 		isDone = true
 		for _, m := range pending {
 			t.Logf("PENDING %s %s", m.Request().Method, m.Request().URLStruct.String())
-			if m.Request().URLStruct.String() != "http://lolcat.host/queue/workflows/42/log" {
+			if m.Request().URLStruct.String() != "http://cds-api.local/queue/workflows/42/log" {
 				isDone = false
 			}
 		}
@@ -384,7 +384,7 @@ export FOO_FROM_HOOK=BAR`,
 	if gock.HasUnmatchedRequest() {
 		reqs := gock.GetUnmatchedRequests()
 		for _, req := range reqs {
-			if req.URL.String() != "http://lolcat.host/queue/workflows/42/log" {
+			if req.URL.String() != "http://cds-api.local/queue/workflows/42/log" {
 				t.Logf("Request %s %s unmatched", req.Method, req.URL.String())
 				t.Fail()
 			}
@@ -416,7 +416,7 @@ export FOO_FROM_HOOK=BAR`,
 	assert.Equal(t, 2, strings.Count(logBuffer.String(), "my password should not be displayed here: **********\n"))
 	assert.Equal(t, 1, strings.Count(logBuffer.String(), "CDS_BUILD_NEWVAR=newval"))
 	assert.Equal(t, 1, strings.Count(logBuffer.String(), "CDS_KEY=********"))
-	assert.Equal(t, 1, strings.Count(logBuffer.String(), "CDS_API_URL=http://lolcat.host"))
+	assert.Equal(t, 1, strings.Count(logBuffer.String(), "CDS_API_URL=http://cds-api.local"))
 	assert.Equal(t, 1, strings.Count(logBuffer.String(), "CDS_SEMVER=0.1.0+cds.1"))
 	assert.Equal(t, 1, strings.Count(logBuffer.String(), "GIT_DESCRIBE=0.1.0"))
 	assert.Equal(t, 0, strings.Count(logBuffer.String(), "CDS_BUILD_CDS_BUILD"))
diff --git a/sdk/rbac_global_test.go b/sdk/rbac_global_test.go
new file mode 100644
index 000000000..1186bb5b0
--- /dev/null
+++ b/sdk/rbac_global_test.go
@@ -0,0 +1,38 @@
+package sdk
+
+import (
+	"github.com/stretchr/testify/require"
+	"testing"
+)
+
+func TestRbacGlobalInvalidGlobalRole(t *testing.T) {
+	rb := RBACGlobal{
+		Role:          "runWorkflow",
+		RBACGroupsIDs: []int64{1},
+		RBACUsersIDs:  []string{"aa-aa-aa"},
+	}
+	err := isValidRbacGlobal("myRule", rb)
+	require.Error(t, err)
+	require.Contains(t, err.Error(), "rbac myRule: role runWorkflow is not allowed on a global permission")
+}
+func TestRbacGlobalInvalidGroupAndUsers(t *testing.T) {
+	rb := RBACGlobal{
+		Role:          RoleCreateProject,
+		RBACGroupsIDs: []int64{},
+		RBACUsersIDs:  []string{},
+	}
+	err := isValidRbacGlobal("myRule", rb)
+	require.Error(t, err)
+	require.Contains(t, err.Error(), "rbac myRule: missing groups or users on global permission")
+}
+
+func TestRbacGlobalEmptyRole(t *testing.T) {
+	rb := RBACGlobal{
+		Role:          "",
+		RBACGroupsIDs: []int64{1},
+		RBACUsersIDs:  []string{},
+	}
+	err := isValidRbacGlobal("myRule", rb)
+	require.Error(t, err)
+	require.Contains(t, err.Error(), "rbac myRule: role for global permission cannot be empty")
+}
diff --git a/sdk/rbac_project_test.go b/sdk/rbac_project_test.go
new file mode 100644
index 000000000..fcb87c103
--- /dev/null
+++ b/sdk/rbac_project_test.go
@@ -0,0 +1,68 @@
+package sdk
+
+import (
+	"fmt"
+	"github.com/stretchr/testify/require"
+	"testing"
+)
+
+func TestRbacProjectInvalidRole(t *testing.T) {
+	rb := RBACProject{
+		RBACProjectsIDs: []int64{1},
+		All:             false,
+		Role:            RoleCreateProject,
+		RBACGroupsIDs:   []int64{1},
+		RBACUsersIDs:    []string{"aa-aa-aa"},
+	}
+	err := isValidRbacProject("myRule", rb)
+	require.Error(t, err)
+	require.Contains(t, err.Error(), fmt.Sprintf("rbac myRule: role %s is not allowed on a project permission", RoleCreateProject))
+}
+func TestRbacProjectInvalidGroupAndUsers(t *testing.T) {
+	rb := RBACProject{
+		RBACProjectsIDs: []int64{1},
+		All:             false,
+		Role:            RoleRead,
+		RBACGroupsIDs:   []int64{},
+		RBACUsersIDs:    []string{},
+	}
+	err := isValidRbacProject("myRule", rb)
+	require.Error(t, err)
+	require.Contains(t, err.Error(), "rbac myRule: missing groups or users on project permission")
+}
+func TestRbacProjectInvalidProjectIDs(t *testing.T) {
+	rb := RBACProject{
+		RBACProjectsIDs: []int64{},
+		All:             false,
+		Role:            RoleRead,
+		RBACGroupsIDs:   []int64{1},
+		RBACUsersIDs:    []string{},
+	}
+	err := isValidRbacProject("myRule", rb)
+	require.Error(t, err)
+	require.Contains(t, err.Error(), "rbac myRule: must have at least 1 project on a project permission")
+}
+func TestRbacProjectEmptyRole(t *testing.T) {
+	rb := RBACProject{
+		RBACProjectsIDs: []int64{1},
+		All:             false,
+		Role:            "",
+		RBACGroupsIDs:   []int64{1},
+		RBACUsersIDs:    []string{},
+	}
+	err := isValidRbacProject("myRule", rb)
+	require.Error(t, err)
+	require.Contains(t, err.Error(), "rbac myRule: role for project permission cannot be empty")
+}
+func TestRbacProjectInvalidAllAndListOfProject(t *testing.T) {
+	rb := RBACProject{
+		RBACProjectsIDs: []int64{1},
+		All:             true,
+		Role:            RoleRead,
+		RBACGroupsIDs:   []int64{1},
+		RBACUsersIDs:    []string{},
+	}
+	err := isValidRbacProject("myRule", rb)
+	require.Error(t, err)
+	require.Contains(t, err.Error(), "rbac myRule: you can't have a list of project and the all flag checked on a project permission")
+}
diff --git a/tests/02_feature.yml b/tests/02_feature.yml
new file mode 100644
index 000000000..d7a25bdf1
--- /dev/null
+++ b/tests/02_feature.yml
@@ -0,0 +1,7 @@
+name: Enable CDN
+testcases:
+- name: Enable cdn only
+  steps:
+  - script: '[ -f ./fixtures/feature-cdn.yml ]'
+  - script: "{{.cdsctl}} -f {{.cdsctl.config}} admin features import ./fixtures/feature-cdn.yml"
+  
\ No newline at end of file
diff --git a/tests/03_clictl_admin_broadcast.yml b/tests/03_clictl_admin_broadcast.yml
index 64f3a0420..bd4662f8c 100644
--- a/tests/03_clictl_admin_broadcast.yml
+++ b/tests/03_clictl_admin_broadcast.yml
@@ -4,7 +4,7 @@ testcases:
   steps:
   - script: '[ -f ./fixtures/broadcast.md ]'
     assertions:
-      - result.code ShouldEqual 0  
+      - result.code ShouldEqual 0
   - script: {{.cdsctl}} -f {{.cdsctl.config}} admin broadcasts list
   - script: {{.cdsctl}} -f {{.cdsctl.config}} admin broadcasts create "the title" < ./fixtures/broadcast.md
   - script: {{.cdsctl}} -f {{.cdsctl.config}} admin broadcasts list | grep "the title"
diff --git a/tests/04_sc_workflow_run_worker_cmd.yml b/tests/04_sc_workflow_run_worker_cmd.yml
index 1b17b290c..11e1c7952 100644
--- a/tests/04_sc_workflow_run_worker_cmd.yml
+++ b/tests/04_sc_workflow_run_worker_cmd.yml
@@ -11,7 +11,6 @@ testcases:
   - script: {{.cdsctl}} -f {{.cdsctl.config}} project remove --force 04SCWORKFLOWRUNWORKERCMD
   - script: {{.cdsctl}} -f {{.cdsctl.config}} group remove --force 04scworkflowrunworkercmd
   - script: {{.cdsctl}} -f {{.cdsctl.config}} project add 04SCWORKFLOWRUNWORKERCMD 04SCWorkflowRunWorkerCmd
-  - script: {{.cdsctl}} -f {{.cdsctl.config}} admin features import ./fixtures/04SCWorkflowRunWorkerCmd/feature.yml
 
 - name: import pipeline and workflow
   steps:
diff --git a/tests/fixtures/04SCWorkflowRunWorkerCmd/feature.yml b/tests/fixtures/04SCWorkflowRunWorkerCmd/feature.yml
deleted file mode 100644
index 85b29b481..000000000
--- a/tests/fixtures/04SCWorkflowRunWorkerCmd/feature.yml
+++ /dev/null
@@ -1,3 +0,0 @@
-name: cdn-artifact
-rule: return project_key == "04SCWORKFLOWRUNWORKERCMD"
-
diff --git a/tests/fixtures/feature-cdn.yml b/tests/fixtures/feature-cdn.yml
new file mode 100644
index 000000000..72f0d384d
--- /dev/null
+++ b/tests/fixtures/feature-cdn.yml
@@ -0,0 +1,3 @@
+name: cdn-artifact
+rule: return true
+
diff --git a/tests/test.sh b/tests/test.sh
index 46ed8e627..1c33169d6 100755
--- a/tests/test.sh
+++ b/tests/test.sh
@@ -122,7 +122,7 @@ initialization_tests() {
 smoke_tests_services() {
     echo "Running smoke tests services:"
     for f in $(ls -1 02_*.yml); do
-        CMD="${VENOM} run ${VENOM_OPTS} ${f} --var cdsctl=${CDSCTL} --var ui.url=${CDS_UI_URL} --var hatchery.url=${CDS_HATCHERY_URL} --var hooks.url=${CDS_HOOKS_URL}"
+        CMD="${VENOM} run ${VENOM_OPTS} ${f} --var cdsctl.config=${CDSCTL_CONFIG}_admin --var cdsctl=${CDSCTL} --var ui.url=${CDS_UI_URL} --var hatchery.url=${CDS_HATCHERY_URL} --var hooks.url=${CDS_HOOKS_URL}"
         echo -e "  ${YELLOW}${f} ${DARKGRAY}[${CMD}]${NOCOLOR}"
         START="$(date +%s)"
         ${CMD} >${f}.output 2>&1