Repository: avenga/couper
Pull request #563 (2022-08-25 07:26:27)

Pre merge validation
* Move some config validation pre-merge, in order to find duplicate labels
* Repeat some validation post-merge

---
<details>
    <summary>Reviewer checklist</summary>
    <ul>
        <li>Read PR description: a summary about the changes is required</li>
        <li>Changelog updated</li>
        <li>Documentation: docs/{Reference, Cli, ...}, Docker and cli help/usage</li>
        <li>Pulled branch, manually tested</li>
        <li>Verified requirements are met</li>
        <li>Reviewed the code</li>
        <li>Reviewed the related tests</li>
    </ul>
</details>



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cb87f823..63a25ce6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,7 @@ Unreleased changes are available as `avenga/couper:edge` container.
 * **Fixed**
   * Disallow empty path parameters ([#526](https://github.com/avenga/couper/pull/526))
   * Basic Auth client authentication with OAuth2 (client ID and secret must be URL encoded) ([#537](https://github.com/avenga/couper/pull/537))
+  * Config validation, e.g. label-uniqueness checks ([#563](https://github.com/avenga/couper/pull/563))
 
 * **Removed**
   * Endpoint path normalization to better match OpenAPI behavior ([#526](https://github.com/avenga/couper/pull/526))
diff --git a/config/configload/backend.go b/config/configload/backend.go
index 0aa7381b..9295e11d 100644
--- a/config/configload/backend.go
+++ b/config/configload/backend.go
@@ -100,10 +100,7 @@ func PrepareBackend(helper *helper, attrName, attrValue string, block config.Inl
 			backendBody = hclbody.MergeBodies(defaultBackend, backendBody)
 		}
 
-		backendBody, err = newBodyWithName(anonLabel, backendBody)
-		if err != nil {
-			return nil, err
-		}
+		backendBody = newBodyWithName(anonLabel, backendBody)
 	}
 
 	// watch out for oauth blocks and nested backend definitions
@@ -255,15 +252,11 @@ func newBlock(blockType string, content hcl.Body) hcl.Body {
 	})
 }
 
-func newBodyWithName(nameValue string, config hcl.Body) (hcl.Body, error) {
-	if err := validLabel(nameValue, getRange(config)); err != nil {
-		return nil, err
-	}
-
+func newBodyWithName(nameValue string, config hcl.Body) hcl.Body {
 	return hclbody.MergeBodies(
 		config,
 		hclbody.New(hclbody.NewContentWithAttrName("name", nameValue)),
-	), nil
+	)
 }
 
 func newAnonLabel(body hcl.Body, labelRange *hcl.Range) string {
diff --git a/config/configload/environment.go b/config/configload/environment.go
index a249f15b..0bd168f6 100644
--- a/config/configload/environment.go
+++ b/config/configload/environment.go
@@ -39,8 +39,8 @@ func preprocessBody(body *hclsyntax.Body, env string) (bool, error) {
 			return true, newDiagErr(&defRange, "Missing label(s) for 'environment' block")
 		}
 
-		for _, label := range block.Labels {
-			if err := validLabel(label, getRange(block.Body)); err != nil {
+		for i, label := range block.Labels {
+			if err := validLabel(label, &block.LabelRanges[i]); err != nil {
 				return true, err
 			}
 
diff --git a/config/configload/helper.go b/config/configload/helper.go
index 25996ea7..86fd035f 100644
--- a/config/configload/helper.go
+++ b/config/configload/helper.go
@@ -51,24 +51,12 @@ func newHelper(body hcl.Body, src [][]byte, environment string) (*helper, error)
 	}, nil
 }
 
-func (h *helper) addBackend(block *hcl.Block) error {
+func (h *helper) addBackend(block *hcl.Block) {
 	name := block.Labels[0]
 
-	if _, ok := h.defsBackends[name]; ok {
-		return newDiagErr(&block.LabelRanges[0],
-			fmt.Sprintf("duplicate backend name: %q", name))
-	} else if strings.HasPrefix(name, "anonymous_") {
-		return newDiagErr(&block.LabelRanges[0],
-			fmt.Sprintf("backend name must not start with 'anonymous_': %q", name))
-	}
-
-	backendBody, err := newBodyWithName(name, block.Body)
-	if err != nil {
-		return err
-	}
+	backendBody := newBodyWithName(name, block.Body)
 
 	h.defsBackends[name] = backendBody
-	return nil
 }
 
 func (h *helper) configureDefinedBackends() error {
diff --git a/config/configload/load.go b/config/configload/load.go
index cb4b98c9..65452528 100644
--- a/config/configload/load.go
+++ b/config/configload/load.go
@@ -128,38 +128,7 @@ func parseFiles(files configfile.Files) ([]*hclsyntax.Body, [][]byte, error) {
 	return parsedBodies, srcBytes, nil
 }
 
-func LoadFiles(filesList []string, env string) (*config.Couper, error) {
-	configFiles, err := configfile.NewFiles(filesList)
-	if err != nil {
-		return nil, err
-	}
-
-	parsedBodies, srcBytes, err := parseFiles(configFiles)
-	if err != nil {
-		return nil, err
-	}
-
-	if len(srcBytes) == 0 {
-		return nil, fmt.Errorf("missing configuration files")
-	}
-
-	errorBeforeRetry := preprocessEnvironmentBlocks(parsedBodies, env)
-
-	if env == "" {
-		settingsBlock := mergeSettings(parsedBodies)
-		settings := &config.Settings{}
-		if diags := gohcl.DecodeBody(settingsBlock.Body, nil, settings); diags.HasErrors() {
-			return nil, diags
-		}
-		if settings.Environment != "" {
-			return LoadFiles(filesList, settings.Environment)
-		}
-	}
-
-	if errorBeforeRetry != nil {
-		return nil, errorBeforeRetry
-	}
-
+func bodiesToConfig(parsedBodies []*hclsyntax.Body, srcBytes [][]byte, env string) (*config.Couper, error) {
 	defaultsBlock, err := mergeDefaults(parsedBodies)
 	if err != nil {
 		return nil, err
@@ -177,6 +146,10 @@ func LoadFiles(filesList []string, env string) (*config.Couper, error) {
 		if err = absolutizePaths(body); err != nil {
 			return nil, err
 		}
+
+		if err = validateBody(body, false); err != nil {
+			return nil, err
+		}
 	}
 
 	settingsBlock := mergeSettings(parsedBodies)
@@ -200,11 +173,54 @@ func LoadFiles(filesList []string, env string) (*config.Couper, error) {
 		Blocks: configBlocks,
 	}
 
+	if err = validateBody(configBody, true); err != nil {
+		return nil, err
+	}
+
 	conf, err := LoadConfig(configBody, srcBytes, env)
 	if err != nil {
 		return nil, err
 	}
 
+	return conf, nil
+}
+
+func LoadFiles(filesList []string, env string) (*config.Couper, error) {
+	configFiles, err := configfile.NewFiles(filesList)
+	if err != nil {
+		return nil, err
+	}
+
+	parsedBodies, srcBytes, err := parseFiles(configFiles)
+	if err != nil {
+		return nil, err
+	}
+
+	if len(srcBytes) == 0 {
+		return nil, fmt.Errorf("missing configuration files")
+	}
+
+	errorBeforeRetry := preprocessEnvironmentBlocks(parsedBodies, env)
+
+	if env == "" {
+		settingsBlock := mergeSettings(parsedBodies)
+		settings := &config.Settings{}
+		if diags := gohcl.DecodeBody(settingsBlock.Body, nil, settings); diags.HasErrors() {
+			return nil, diags
+		}
+		if settings.Environment != "" {
+			return LoadFiles(filesList, settings.Environment)
+		}
+	}
+
+	if errorBeforeRetry != nil {
+		return nil, errorBeforeRetry
+	}
+
+	conf, err := bodiesToConfig(parsedBodies, srcBytes, env)
+	if err != nil {
+		return nil, err
+	}
 	conf.Files = configFiles
 
 	return conf, nil
@@ -214,12 +230,40 @@ func LoadFile(file, env string) (*config.Couper, error) {
 	return LoadFiles([]string{file}, env)
 }
 
+type testContent struct {
+	filename string
+	src      []byte
+}
+
+func loadTestContents(tcs []testContent) (*config.Couper, error) {
+	var (
+		parsedBodies []*hclsyntax.Body
+		srcs         [][]byte
+	)
+
+	for _, tc := range tcs {
+		hclBody, diags := parser.Load(tc.src, tc.filename)
+		if diags.HasErrors() {
+			return nil, diags
+		}
+
+		parsedBodies = append(parsedBodies, hclBody.(*hclsyntax.Body))
+		srcs = append(srcs, tc.src)
+	}
+
+	return bodiesToConfig(parsedBodies, srcs, "")
+}
+
 func LoadBytes(src []byte, filename string) (*config.Couper, error) {
 	hclBody, diags := parser.Load(src, filename)
 	if diags.HasErrors() {
 		return nil, diags
 	}
 
+	if err := validateBody(hclBody, false); err != nil {
+		return nil, err
+	}
+
 	return LoadConfig(hclBody, [][]byte{src}, "")
 }
 
@@ -246,9 +290,7 @@ func LoadConfig(body hcl.Body, src [][]byte, environment string) (*config.Couper
 			// backends first
 			if backendContent != nil {
 				for _, be := range backendContent.Blocks {
-					if err = helper.addBackend(be); err != nil {
-						return nil, err
-					}
+					helper.addBackend(be)
 				}
 
 				if err = helper.configureDefinedBackends(); err != nil {
@@ -316,9 +358,6 @@ func LoadConfig(body hcl.Body, src [][]byte, environment string) (*config.Couper
 
 	jwtSigningConfigs := make(map[string]*lib.JWTSigningConfig)
 	for _, profile := range helper.config.Definitions.JWTSigningProfile {
-		if _, exists := jwtSigningConfigs[profile.Name]; exists {
-			return nil, errors.Configuration.Messagef("jwt_signing_profile block with label %s already defined", profile.Name)
-		}
 		signConf, err := lib.NewJWTSigningConfigFromJWTSigningProfile(profile)
 		if err != nil {
 			return nil, errors.Configuration.Label(profile.Name).With(err)
@@ -331,9 +370,6 @@ func LoadConfig(body hcl.Body, src [][]byte, environment string) (*config.Couper
 			return nil, errors.Configuration.Label(jwt.Name).With(err)
 		}
 		if signConf != nil {
-			if _, exists := jwtSigningConfigs[jwt.Name]; exists {
-				return nil, errors.Configuration.Messagef("jwt_signing_profile or jwt with label %s already defined", jwt.Name)
-			}
 			jwtSigningConfigs[jwt.Name] = signConf
 		}
 	}
diff --git a/config/configload/merge.go b/config/configload/merge.go
index abc92260..84027b74 100644
--- a/config/configload/merge.go
+++ b/config/configload/merge.go
@@ -415,10 +415,6 @@ func mergeDefinitions(bodies []*hclsyntax.Body) (*hclsyntax.Block, error) {
 						definitionsBlock[innerBlock.Type] = make(data)
 					}
 
-					if len(innerBlock.Labels) == 0 {
-						return nil, newMergeError(errUniqueLabels, innerBlock)
-					}
-
 					definitionsBlock[innerBlock.Type][innerBlock.Labels[0]] = innerBlock
 
 					// Count the "backend" blocks and "backend" attributes to
diff --git a/config/configload/validate.go b/config/configload/validate.go
index c65d4087..44dd8870 100644
--- a/config/configload/validate.go
+++ b/config/configload/validate.go
@@ -2,14 +2,23 @@ package configload
 
 import (
 	"fmt"
+	"path"
 	"regexp"
+	"strings"
 
 	"github.com/hashicorp/hcl/v2"
+	"github.com/hashicorp/hcl/v2/hclsyntax"
+	"github.com/zclconf/go-cty/cty"
 
 	"github.com/avenga/couper/config"
+	"github.com/avenga/couper/eval"
+	"github.com/avenga/couper/utils"
 )
 
-var regexLabel = regexp.MustCompile(`^[a-zA-Z0-9_]+$`)
+var (
+	regexLabel     = regexp.MustCompile(`^[a-zA-Z0-9_]+$`)
+	reCleanPattern = regexp.MustCompile(`{([^}]+)}`)
+)
 
 // https://datatracker.ietf.org/doc/html/rfc7231#section-4
 // https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.6
@@ -23,6 +32,159 @@ func newDiagErr(subject *hcl.Range, summary string) error {
 	}}
 }
 
+func validateBody(body hcl.Body, afterMerge bool) error {
+	hsBody, ok := body.(*hclsyntax.Body)
+	if !ok {
+		return fmt.Errorf("body must be hclsyntax.Body")
+	}
+
+	for _, outerBlock := range hsBody.Blocks {
+		if outerBlock.Type == definitions {
+			uniqueBackends := make(map[string]struct{})
+			uniqueACs := make(map[string]struct{})
+			uniqueJWTSigningProfiles := make(map[string]struct{})
+			for _, innerBlock := range outerBlock.Body.Blocks {
+				if !afterMerge {
+					if len(innerBlock.Labels) == 0 {
+						return newDiagErr(&innerBlock.OpenBraceRange, "missing label")
+					}
+				}
+				label := innerBlock.Labels[0]
+				label = strings.TrimSpace(label)
+				labelRange := innerBlock.LabelRanges[0]
+
+				switch innerBlock.Type {
+				case backend:
+					if !afterMerge {
+						if err := validLabel(label, &labelRange); err != nil {
+							return err
+						}
+
+						if strings.HasPrefix(label, "anonymous_") {
+							return newDiagErr(&labelRange, "backend label must not start with 'anonymous_'")
+						}
+
+						if _, set := uniqueBackends[label]; set {
+							return newDiagErr(&labelRange, "backend labels must be unique")
+						}
+						uniqueBackends[label] = struct{}{}
+					}
+				case "basic_auth", "beta_oauth2", "oidc", "saml":
+					err := checkAC(uniqueACs, label, labelRange, afterMerge)
+					if err != nil {
+						return err
+					}
+				case "jwt":
+					err := checkAC(uniqueACs, label, labelRange, afterMerge)
+					if err != nil {
+						return err
+					}
+
+					attrs, _ := innerBlock.Body.JustAttributes() // just get attributes, ignore diags for now
+					if _, set := attrs["signing_ttl"]; set {
+						err = checkJWTSigningProfiles(uniqueJWTSigningProfiles, label, labelRange)
+						if err != nil {
+							return err
+						}
+					}
+				case "jwt_signing_profile":
+					err := checkJWTSigningProfiles(uniqueJWTSigningProfiles, label, labelRange)
+					if err != nil {
+						return err
+					}
+				}
+			}
+		} else if outerBlock.Type == server {
+			uniqueEndpoints := make(map[string]struct{})
+			serverBasePath, err := getBasePath(outerBlock)
+			if err != nil {
+				return err
+			}
+
+			serverBasePath = path.Join("/", serverBasePath)
+			for _, innerBlock := range outerBlock.Body.Blocks {
+				if innerBlock.Type == endpoint {
+					if err = registerEndpointPattern(uniqueEndpoints, serverBasePath, innerBlock); err != nil {
+						return err
+					}
+				} else if innerBlock.Type == api {
+					apiBasePath, err := getBasePath(innerBlock)
+					if err != nil {
+						return err
+					}
+
+					apiBasePath = path.Join(serverBasePath, apiBasePath)
+					for _, innerInnerBlock := range innerBlock.Body.Blocks {
+						if innerInnerBlock.Type == endpoint {
+							if err = registerEndpointPattern(uniqueEndpoints, apiBasePath, innerInnerBlock); err != nil {
+								return err
+							}
+						}
+					}
+				}
+			}
+		}
+	}
+
+	return nil
+}
+
+func getBasePath(bl *hclsyntax.Block) (string, error) {
+	basePath := ""
+	if bp, set := bl.Body.Attributes["base_path"]; set {
+		bpv, diags := bp.Expr.Value(nil)
+		if diags.HasErrors() {
+			return "", diags
+		}
+		if bpv.Type() != cty.String {
+			sr := bp.Expr.StartRange()
+			return "", newDiagErr(&sr, "base_path must evaluate to string")
+		}
+		basePath = bpv.AsString()
+	}
+
+	return basePath, nil
+}
+
+func registerEndpointPattern(endpointPatterns map[string]struct{}, basePath string, bl *hclsyntax.Block) error {
+	pattern := utils.JoinOpenAPIPath(basePath, bl.Labels[0])
+	pattern = reCleanPattern.ReplaceAllString(pattern, "{}")
+	if _, set := endpointPatterns[pattern]; set {
+		return newDiagErr(&bl.LabelRanges[0], "duplicate endpoint")
+	}
+
+	endpointPatterns[pattern] = struct{}{}
+	return nil
+}
+
+func checkAC(acLabels map[string]struct{}, label string, labelRange hcl.Range, afterMerge bool) error {
+	if !afterMerge {
+		if label == "" {
+			return newDiagErr(&labelRange, "accessControl requires a label")
+		}
+
+		if eval.IsReservedContextName(label) {
+			return newDiagErr(&labelRange, "accessControl uses reserved name as label")
+		}
+	}
+
+	if _, set := acLabels[label]; set {
+		return newDiagErr(&labelRange, "AC labels must be unique")
+	}
+
+	acLabels[label] = struct{}{}
+	return nil
+}
+
+func checkJWTSigningProfiles(spLabels map[string]struct{}, label string, labelRange hcl.Range) error {
+	if _, set := spLabels[label]; set {
+		return newDiagErr(&labelRange, "JWT signing profile labels must be unique")
+	}
+
+	spLabels[label] = struct{}{}
+	return nil
+}
+
 func validLabel(name string, subject *hcl.Range) error {
 	if name == "" {
 		return newDiagErr(subject, "label is empty")
diff --git a/config/runtime/access_control.go b/config/runtime/access_control.go
index 78c3d885..2ca05ab1 100644
--- a/config/runtime/access_control.go
+++ b/config/runtime/access_control.go
@@ -6,8 +6,6 @@ import (
 
 	"github.com/avenga/couper/accesscontrol"
 	"github.com/avenga/couper/config"
-	"github.com/avenga/couper/errors"
-	"github.com/avenga/couper/eval"
 )
 
 type ACDefinitions map[string]*AccessControl
@@ -17,23 +15,12 @@ type AccessControl struct {
 	ErrorHandler []*config.ErrorHandler
 }
 
-func (m ACDefinitions) Add(name string, ac accesscontrol.AccessControl, eh []*config.ErrorHandler) error {
+func (m ACDefinitions) Add(name string, ac accesscontrol.AccessControl, eh []*config.ErrorHandler) {
 	n := strings.TrimSpace(name)
-	if n == "" {
-		return errors.Configuration.Message("accessControl requires a label")
-	}
-	if eval.IsReservedContextName(n) {
-		return errors.Configuration.Message("accessControl uses reserved name as label")
-	}
-	if _, ok := m[n]; ok {
-		return errors.Configuration.Message("accessControl already defined")
-	}
-
 	m[n] = &AccessControl{
 		Control:      ac,
 		ErrorHandler: eh,
 	}
-	return nil
 }
 
 func (m ACDefinitions) MustExist(name string) error {
diff --git a/config/runtime/server.go b/config/runtime/server.go
index 4d7a2377..de5c53e5 100644
--- a/config/runtime/server.go
+++ b/config/runtime/server.go
@@ -245,7 +245,6 @@ func NewServerConfiguration(conf *config.Couper, log *logrus.Entry, memStore *ca
 			}
 		}
 
-		endpointPatterns := make(map[string]bool)
 		endpointsMap, err := newEndpointMap(srvConf, serverOptions)
 		if err != nil {
 			return nil, err
@@ -256,17 +255,6 @@ func NewServerConfiguration(conf *config.Couper, log *logrus.Entry, memStore *ca
 				return nil, fmt.Errorf("endpoint path pattern required")
 			}
 
-			basePath := serverOptions.SrvBasePath
-			if parentAPI != nil {
-				basePath = serverOptions.APIBasePaths[parentAPI]
-			}
-			pattern := utils.JoinOpenAPIPath(basePath, endpointConf.Pattern)
-			unique, cleanPattern := isUnique(endpointPatterns, pattern)
-			if !unique {
-				return nil, fmt.Errorf("%s: duplicate endpoint: '%s'", endpointConf.HCLBody().MissingItemRange().String(), pattern)
-			}
-			endpointPatterns[cleanPattern] = true
-
 			epOpts, err := newEndpointOptions(confCtx, endpointConf, parentAPI, serverOptions,
 				log, conf, memStore)
 			if err != nil {
@@ -375,6 +363,13 @@ func NewServerConfiguration(conf *config.Couper, log *logrus.Entry, memStore *ca
 				append(bodies, endpointConf.Remain), epHandler, epOpts.LogHandlerKind,
 			)
 
+			basePath := serverOptions.SrvBasePath
+			if parentAPI != nil {
+				basePath = serverOptions.APIBasePaths[parentAPI]
+			}
+
+			pattern := utils.JoinOpenAPIPath(basePath, endpointConf.Pattern)
+
 			endpointHandlers[endpointConf] = epHandler
 			err = setRoutesFromHosts(serverConfiguration, portsHosts, pattern, endpointHandlers[endpointConf], kind)
 			if err != nil {
@@ -477,9 +472,7 @@ func configureAccessControls(conf *config.Couper, confCtx *hcl.EvalContext, log
 				return nil, confErr.With(err)
 			}
 
-			if err = accessControls.Add(baConf.Name, basicAuth, baConf.ErrorHandler); err != nil {
-				return nil, confErr.With(err)
-			}
+			accessControls.Add(baConf.Name, basicAuth, baConf.ErrorHandler)
 		}
 
 		for _, jwtConf := range conf.Definitions.JWT {
@@ -490,9 +483,7 @@ func configureAccessControls(conf *config.Couper, confCtx *hcl.EvalContext, log
 				return nil, confErr.With(err)
 			}
 
-			if err = accessControls.Add(jwtConf.Name, jwt, jwtConf.ErrorHandler); err != nil {
-				return nil, confErr.With(err)
-			}
+			accessControls.Add(jwtConf.Name, jwt, jwtConf.ErrorHandler)
 		}
 
 		for _, saml := range conf.Definitions.SAML {
@@ -507,9 +498,7 @@ func configureAccessControls(conf *config.Couper, confCtx *hcl.EvalContext, log
 				return nil, confErr.With(err)
 			}
 
-			if err = accessControls.Add(saml.Name, s, saml.ErrorHandler); err != nil {
-				return nil, confErr.With(err)
-			}
+			accessControls.Add(saml.Name, s, saml.ErrorHandler)
 		}
 
 		for _, oauth2Conf := range conf.Definitions.OAuth2AC {
@@ -526,9 +515,7 @@ func configureAccessControls(conf *config.Couper, confCtx *hcl.EvalContext, log
 
 			oa := ac.NewOAuth2Callback(oauth2Client, oauth2Conf.Name)
 
-			if err = accessControls.Add(oauth2Conf.Name, oa, oauth2Conf.ErrorHandler); err != nil {
-				return nil, confErr.With(err)
-			}
+			accessControls.Add(oauth2Conf.Name, oa, oauth2Conf.ErrorHandler)
 		}
 
 		for _, oidcConf := range conf.Definitions.OIDC {
@@ -541,9 +528,7 @@ func configureAccessControls(conf *config.Couper, confCtx *hcl.EvalContext, log
 
 			oa := ac.NewOAuth2Callback(oidcClient, oidcConf.Name)
 
-			if err = accessControls.Add(oidcConf.Name, oa, oidcConf.ErrorHandler); err != nil {
-				return nil, confErr.With(err)
-			}
+			accessControls.Add(oidcConf.Name, oa, oidcConf.ErrorHandler)
 		}
 	}
 
diff --git a/config/runtime/server_validation.go b/config/runtime/server_validation.go
index b175f992..744e646a 100644
--- a/config/runtime/server_validation.go
+++ b/config/runtime/server_validation.go
@@ -5,11 +5,8 @@ import (
 	"regexp"
 )
 
-var (
-	// reValidFormat validates the format only, validating for a valid host or port is out of scope.
-	reValidFormat  = regexp.MustCompile(`^([a-z0-9.-]+|\*)(:\*|:\d{1,5})?$`)
-	reCleanPattern = regexp.MustCompile(`{([^}]+)}`)
-)
+// reValidFormat validates the format only, validating for a valid host or port is out of scope.
+var reValidFormat = regexp.MustCompile(`^([a-z0-9.-]+|\*)(:\*|:\d{1,5})?$`)
 
 func validateHosts(serverName string, hosts []string, isHostsMandatory bool) error {
 	if isHostsMandatory && len(hosts) == 0 {
@@ -24,9 +21,3 @@ func validateHosts(serverName string, hosts []string, isHostsMandatory bool) err
 
 	return nil
 }
-
-func isUnique(endpoints map[string]bool, pattern string) (bool, string) {
-	pattern = reCleanPattern.ReplaceAllString(pattern, "{}")
-
-	return !endpoints[pattern], pattern
-}


Test patch:
diff --git a/config/configload/validate_test.go b/config/configload/validate_test.go
index 71044204..9756fc37 100644
--- a/config/configload/validate_test.go
+++ b/config/configload/validate_test.go
@@ -2,6 +2,7 @@ package configload
 
 import (
 	"context"
+	"fmt"
 	"testing"
 
 	"github.com/hashicorp/hcl/v2"
@@ -221,6 +222,654 @@ func TestLabels(t *testing.T) {
 	}
 }
 
+func Test_validateBody(t *testing.T) {
+	tests := []struct {
+		name  string
+		hcl   string
+		error string
+	}{
+		{
+			"missing backend label",
+			`server {}
+			 definitions {
+			   backend {
+			   }
+			 }`,
+			"couper.hcl:3,15-16: missing label; ",
+		},
+		{
+			"empty backend label",
+			`server {}
+			 definitions {
+			   backend "" {
+			   }
+			 }`,
+			"couper.hcl:3,15-17: label is empty; ",
+		},
+		{
+			"whitespace backend label",
+			`server {}
+			 definitions {
+			   backend " 	" {
+			   }
+			 }`,
+			"couper.hcl:3,15-19: label is empty; ",
+		},
+		{
+			"invalid backend label",
+			`server {}
+			 definitions {
+			   backend "foo bar" {
+			   }
+			 }`,
+			"couper.hcl:3,15-24: label contains invalid character(s), allowed are 'a-z', 'A-Z', '0-9' and '_'; ",
+		},
+		{
+			"anonymous_* backend label",
+			`server {}
+			 definitions {
+			   backend "anonymous_foo" {
+			   }
+			 }`,
+			"couper.hcl:3,15-30: backend label must not start with 'anonymous_'; ",
+		},
+		{
+			"duplicate backend labels",
+			`server {}
+			 definitions {
+			   backend "foo" {
+			   }
+			   backend "foo" {
+			   }
+			 }`,
+			"couper.hcl:5,15-20: backend labels must be unique; ",
+		},
+		{
+			"missing basic_auth label",
+			`server {}
+			 definitions {
+			   basic_auth {
+			   }
+			 }`,
+			"couper.hcl:3,18-19: missing label; ",
+		},
+		{
+			"missing beta_oauth2 label",
+			`server {}
+			 definitions {
+			   beta_oauth2 {
+			   }
+			 }`,
+			"couper.hcl:3,19-20: missing label; ",
+		},
+		{
+			"missing jwt label",
+			`server {}
+			 definitions {
+			   jwt {
+			   }
+			 }`,
+			"couper.hcl:3,11-12: missing label; ",
+		},
+		{
+			"missing oidc label",
+			`server {}
+			 definitions {
+			   oidc {
+			   }
+			 }`,
+			"couper.hcl:3,12-13: missing label; ",
+		},
+		{
+			"missing saml label",
+			`server {}
+			 definitions {
+			   saml {
+			   }
+			 }`,
+			"couper.hcl:3,12-13: missing label; ",
+		},
+		{
+			"basic_auth with empty label",
+			`server {}
+			 definitions {
+			   basic_auth "" {
+			   }
+			 }`,
+			"couper.hcl:3,18-20: accessControl requires a label; ",
+		},
+		{
+			"beta_oauth2 with empty label",
+			`server {}
+			 definitions {
+			   beta_oauth2 "" {
+			   }
+			 }`,
+			"couper.hcl:3,19-21: accessControl requires a label; ",
+		},
+		{
+			"jwt with empty label",
+			`server {}
+			 definitions {
+			   jwt "" {
+			   }
+			 }`,
+			"couper.hcl:3,11-13: accessControl requires a label; ",
+		},
+		{
+			"oidc with empty label",
+			`server {}
+			 definitions {
+			   oidc "" {
+			   }
+			 }`,
+			"couper.hcl:3,12-14: accessControl requires a label; ",
+		},
+		{
+			"saml with empty label",
+			`server {}
+			 definitions {
+			   saml "" {
+			   }
+			 }`,
+			"couper.hcl:3,12-14: accessControl requires a label; ",
+		},
+		{
+			"basic_auth with whitespace label",
+			`server {}
+			 definitions {
+			   basic_auth " 	" {
+			   }
+			 }`,
+			"couper.hcl:3,18-22: accessControl requires a label; ",
+		},
+		{
+			"beta_oauth2 with whitespace label",
+			`server {}
+			 definitions {
+			   beta_oauth2 " 	" {
+			   }
+			 }`,
+			"couper.hcl:3,19-23: accessControl requires a label; ",
+		},
+		{
+			"jwt with whitespace label",
+			`server {}
+			 definitions {
+			   jwt " 	" {
+			   }
+			 }`,
+			"couper.hcl:3,11-15: accessControl requires a label; ",
+		},
+		{
+			"oidc with whitespace label",
+			`server {}
+			 definitions {
+			   oidc " 	" {
+			   }
+			 }`,
+			"couper.hcl:3,12-16: accessControl requires a label; ",
+		},
+		{
+			"saml with whitespace label",
+			`server {}
+			 definitions {
+			   saml " 	" {
+			   }
+			 }`,
+			"couper.hcl:3,12-16: accessControl requires a label; ",
+		},
+		{
+			"basic_auth reserved label beta_granted_permissions",
+			`server {}
+			 definitions {
+			   basic_auth "beta_granted_permissions" {
+			   }
+			 }`,
+			"couper.hcl:3,18-44: accessControl uses reserved name as label; ",
+		},
+		{
+			"basic_auth reserved label beta_required_permission",
+			`server {}
+			 definitions {
+			   basic_auth "beta_required_permission" {
+			   }
+			 }`,
+			"couper.hcl:3,18-44: accessControl uses reserved name as label; ",
+		},
+		{
+			"beta_oauth2 reserved label beta_granted_permissions",
+			`server {}
+			 definitions {
+			   beta_oauth2 "beta_granted_permissions" {
+			   }
+			 }`,
+			"couper.hcl:3,19-45: accessControl uses reserved name as label; ",
+		},
+		{
+			"beta_oauth2 reserved label beta_required_permission",
+			`server {}
+			 definitions {
+			   beta_oauth2 "beta_required_permission" {
+			   }
+			 }`,
+			"couper.hcl:3,19-45: accessControl uses reserved name as label; ",
+		},
+		{
+			"jwt reserved label beta_granted_permissions",
+			`server {}
+			 definitions {
+			   jwt "beta_granted_permissions" {
+			   }
+			 }`,
+			"couper.hcl:3,11-37: accessControl uses reserved name as label; ",
+		},
+		{
+			"jwt reserved label beta_required_permission",
+			`server {}
+			 definitions {
+			   jwt "beta_required_permission" {
+			   }
+			 }`,
+			"couper.hcl:3,11-37: accessControl uses reserved name as label; ",
+		},
+		{
+			"oidc reserved label beta_granted_permissions",
+			`server {}
+			 definitions {
+			   oidc "beta_granted_permissions" {
+			   }
+			 }`,
+			"couper.hcl:3,12-38: accessControl uses reserved name as label; ",
+		},
+		{
+			"oidc reserved label beta_required_permission",
+			`server {}
+			 definitions {
+			   oidc "beta_required_permission" {
+			   }
+			 }`,
+			"couper.hcl:3,12-38: accessControl uses reserved name as label; ",
+		},
+		{
+			"saml reserved label beta_granted_permissions",
+			`server {}
+			 definitions {
+			   saml "beta_granted_permissions" {
+			   }
+			 }`,
+			"couper.hcl:3,12-38: accessControl uses reserved name as label; ",
+		},
+		{
+			"saml reserved label beta_required_permission",
+			`server {}
+			 definitions {
+			   saml "beta_required_permission" {
+			   }
+			 }`,
+			"couper.hcl:3,12-38: accessControl uses reserved name as label; ",
+		},
+		{
+			"duplicate AC labels 1",
+			`server {}
+			 definitions {
+			   basic_auth "foo" {
+			   }
+			   beta_oauth2 "foo" {
+			   }
+			 }`,
+			"couper.hcl:5,19-24: AC labels must be unique; ",
+		},
+		{
+			"duplicate AC labels 2",
+			`server {}
+			 definitions {
+			   beta_oauth2 "foo" {
+			   }
+			   jwt "foo" {
+			   }
+			 }`,
+			"couper.hcl:5,11-16: AC labels must be unique; ",
+		},
+		{
+			"duplicate AC labels 3",
+			`server {}
+			 definitions {
+			   jwt "foo" {
+			   }
+			   oidc "foo" {
+			   }
+			 }`,
+			"couper.hcl:5,12-17: AC labels must be unique; ",
+		},
+		{
+			"duplicate AC labels 4",
+			`server {}
+			 definitions {
+			   oidc "foo" {
+			   }
+			   saml "foo" {
+			   }
+			 }`,
+			"couper.hcl:5,12-17: AC labels must be unique; ",
+		},
+		{
+			"duplicate AC labels 5",
+			`server {}
+			 definitions {
+			   saml "foo" {
+			   }
+			   basic_auth "foo" {
+			   }
+			 }`,
+			"couper.hcl:5,18-23: AC labels must be unique; ",
+		},
+		{
+			"duplicate signing profile labels 1",
+			`server {}
+			 definitions {
+			   jwt "foo" {
+			     signing_ttl = "1m"
+			   }
+			   jwt_signing_profile "foo" {
+			   }
+			 }`,
+			"couper.hcl:6,27-32: JWT signing profile labels must be unique; ",
+		},
+		{
+			"jwt not used as signing profile",
+			`server {}
+			 definitions {
+			   jwt_signing_profile "foo" {
+			     signature_algorithm = "HS256"
+			     key = "asdf"
+			     ttl = "1m"
+			   }
+			   jwt "foo" {
+			     signature_algorithm = "HS256"
+			     key = "sdfg"
+			   }
+			 }`,
+			"",
+		},
+		{
+			"duplicate signing profile labels 2",
+			`server {}
+			 definitions {
+			   jwt_signing_profile "foo" {
+			   }
+			   jwt "foo" {
+			     signing_ttl = "1m"
+			   }
+			 }`,
+			"couper.hcl:5,11-16: JWT signing profile labels must be unique; ",
+		},
+		{
+			"same label for backend and AC",
+			`server {}
+			 definitions {
+			   backend "foo" {
+			   }
+			   basic_auth "foo" {
+			   }
+			 }`,
+			"",
+		},
+		{
+			"duplicate endpoint pattern 1",
+			`server {
+			   base_path = "/s"
+			   api {
+			     endpoint "/" {
+			       response {
+			         body = "1"
+			       }
+			     }
+			     endpoint "/" {
+			       response {
+			         body = "2"
+			       }
+			     }
+			   }
+			 }`,
+			"couper.hcl:9,18-21: duplicate endpoint; ",
+		},
+		{
+			"duplicate endpoint pattern 2",
+			`server {
+			   base_path = "/s"
+			   api {
+			     endpoint "/" {
+			       response {
+			         body = "1"
+			       }
+			     }
+			   }
+			   api {
+			     endpoint "/" {
+			       response {
+			         body = "2"
+			       }
+			     }
+			   }
+			 }`,
+			"couper.hcl:11,18-21: duplicate endpoint; ",
+		},
+		{
+			"duplicate endpoint pattern 3",
+			`server {
+			   base_path = "/s"
+			   api {
+			     endpoint "/" {
+			       response {
+			         body = "1"
+			       }
+			     }
+			   }
+			   endpoint "/" {
+			     response {
+			       body = "2"
+			     }
+			   }
+			 }`,
+			"couper.hcl:10,16-19: duplicate endpoint; ",
+		},
+		{
+			"duplicate endpoint pattern 4",
+			`server {
+			   base_path = "/s"
+			   endpoint "/" {
+			     response {
+			       body = "1"
+			     }
+			   }
+			   api {
+			     endpoint "/" {
+			       response {
+			         body = "2"
+			       }
+			     }
+			   }
+			 }`,
+			"couper.hcl:9,18-21: duplicate endpoint; ",
+		},
+		{
+			"duplicate endpoint pattern 5",
+			`server {
+			   base_path = "/s"
+			   api {
+			     base_path = "/a"
+			     endpoint "/b/{c}" {
+			       response {
+			         body = "1"
+			       }
+			     }
+			   }
+			   api {
+			     base_path = "/a/b"
+			     endpoint "/{d}" {
+			       response {
+			         body = "2"
+			       }
+			     }
+			   }
+			 }`,
+			"couper.hcl:13,18-24: duplicate endpoint; ",
+		},
+		{
+			"duplicate endpoint pattern 6",
+			`server {
+			   endpoint "/a/b" {
+			     response {
+			       body = "1"
+			     }
+			   }
+			   api {
+			     base_path = "/a"
+			     endpoint "/b" {
+			       response {
+			         body = "2"
+			       }
+			     }
+			   }
+			 }`,
+			"couper.hcl:9,18-22: duplicate endpoint; ",
+		},
+		{
+			"distinct endpoint patterns",
+			`server {
+			   base_path = "/s"
+			   api {
+			     base_path = "/a"
+			     endpoint "/{b}/c" {
+			       response {
+			         body = "1"
+			       }
+			     }
+			   }
+			   api {
+			     base_path = "/a/b"
+			     endpoint "/c" {
+			       response {
+			         body = "2"
+			       }
+			     }
+			   }
+			 }`,
+			"",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(subT *testing.T) {
+			_, err := LoadBytes([]byte(tt.hcl), "couper.hcl")
+
+			var errMsg string
+			if err != nil {
+				errMsg = err.Error()
+			}
+
+			if tt.error != errMsg {
+				subT.Errorf("%q: Unexpected configuration error:\n\tWant: %q\n\tGot:  %q", tt.name, tt.error, errMsg)
+			}
+		})
+	}
+}
+
+func Test_validateBody_multiple(t *testing.T) {
+	tests := []struct {
+		name   string
+		hcls   []string
+		errors []string
+	}{
+		{
+			"duplicate AC labels",
+			[]string{
+				`server {}
+				 definitions {
+				   saml "foo" {
+				   }
+				 }`,
+				`server {}
+				 definitions {
+				   basic_auth "foo" {
+				   }
+				 }`,
+			},
+			[]string{"couper_0.hcl:3,13-18: AC labels must be unique; ", "couper_1.hcl:3,19-24: AC labels must be unique; "},
+		},
+		{
+			"duplicate endpoint patterns",
+			[]string{
+				`server {
+				   endpoint "/a/b" {
+				     response {
+				       body = "1"
+				     }
+				   }
+				 }`,
+				`server {
+				   api {
+				     base_path = "/a"
+				     endpoint "/b" {
+				       response {
+				         body = "2"
+				       }
+				     }
+				   }
+				 }`,
+			},
+			[]string{"couper_1.hcl:4,19-23: duplicate endpoint; "},
+		},
+		{
+			"duplicate signing profile labels",
+			[]string{
+				`server {}
+				 definitions {
+			       jwt "foo" {
+			         signing_ttl = "1m"
+			       }
+				 }`,
+				`server {}
+				 definitions {
+			       jwt_signing_profile "foo" {
+			       }
+				 }`,
+			},
+			[]string{"couper_0.hcl:3,15-20: JWT signing profile labels must be unique; ", "couper_1.hcl:3,31-36: JWT signing profile labels must be unique; "},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(subT *testing.T) {
+			var testContents []testContent
+			for i, hcl := range tt.hcls {
+				testContents = append(testContents, testContent{fmt.Sprintf("couper_%d.hcl", i), []byte(hcl)})
+			}
+
+			_, err := loadTestContents(testContents)
+
+			var errMsg string
+			if err != nil {
+				errMsg = err.Error()
+			}
+
+			if !oneOfErrorMsgs(tt.errors, errMsg) {
+				subT.Errorf("%q: Unexpected configuration error:\n\tWant one of: %v\n\tGot:         %q", tt.name, tt.errors, errMsg)
+			}
+		})
+	}
+}
+
+func oneOfErrorMsgs(msgs []string, errorMsg string) bool {
+	for _, msg := range msgs {
+		if msg == errorMsg {
+			return true
+		}
+	}
+	return false
+}
+
 func TestAttributeObjectKeys(t *testing.T) {
 	tests := []struct {
 		name  string
diff --git a/config/runtime/access_control_test.go b/config/runtime/access_control_test.go
index 4727a3ff..927d11c1 100644
--- a/config/runtime/access_control_test.go
+++ b/config/runtime/access_control_test.go
@@ -10,121 +10,9 @@ import (
 	"github.com/avenga/couper/cache"
 	"github.com/avenga/couper/config/configload"
 	"github.com/avenga/couper/config/runtime"
-	"github.com/avenga/couper/errors"
 	"github.com/avenga/couper/internal/test"
 )
 
-func TestACDefinitions_errors(t *testing.T) {
-	tests := []struct {
-		name        string
-		hcl         string
-		expectedMsg string
-	}{
-		{
-			"collision: basic_auth/jwt",
-			`
-			server "test" {
-			}
-			definitions {
-				basic_auth "foo" {
-				}
-				jwt "foo" {
-					signature_algorithm = "HS256"
-					key = "$3cRe4"
-					header = "Authorization"
-				}
-			}
-			`,
-			"configuration error: foo: accessControl already defined",
-		},
-		{
-			"collision: jwt reserved label",
-			`
-			server "test" {
-			}
-			definitions {
-				jwt "beta_granted_permissions" {
-					signature_algorithm = "HS256"
-					key = "$3cRe4"
-					header = "Authorization"
-				}
-			}
-			`,
-			"configuration error: beta_granted_permissions: accessControl uses reserved name as label",
-		},
-		{
-			"collision: basic_auth reserved label 1",
-			`
-			server "test" {
-			}
-			definitions {
-				basic_auth "beta_granted_permissions" {
-				}
-			}
-			`,
-			"configuration error: beta_granted_permissions: accessControl uses reserved name as label",
-		},
-		{
-			"collision: basic_auth reserved label 2",
-			`
-			server "test" {
-			}
-			definitions {
-				basic_auth "beta_required_permission" {
-				}
-			}
-			`,
-			"configuration error: beta_required_permission: accessControl uses reserved name as label",
-		},
-		{
-			"jwt with empty label",
-			`
-			server "test" {
-			}
-			definitions {
-				jwt "" {
-					signature_algorithm = "HS256"
-					key = "$3cRe4"
-					header = "Authorization"
-				}
-			}
-			`,
-			"configuration error: accessControl requires a label",
-		},
-		{
-			"basic_auth with empty label",
-			`
-			server "test" {
-			}
-			definitions {
-				basic_auth "" {
-				}
-			}
-			`,
-			"configuration error: accessControl requires a label",
-		},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(subT *testing.T) {
-			cf, err := configload.LoadBytes([]byte(tt.hcl), "couper.hcl")
-			if err != nil {
-				subT.Fatal(err)
-			}
-			_, err = runtime.NewServerConfiguration(cf, nil, nil)
-			if err == nil {
-				subT.Errorf("Expected error")
-			}
-			logErr, _ := err.(errors.GoError)
-			if logErr == nil {
-				subT.Error("logErr should not be nil")
-			} else if logErr.LogError() != tt.expectedMsg {
-				subT.Errorf("\nwant:\t%s\ngot:\t%v", tt.expectedMsg, logErr.LogError())
-			}
-		})
-	}
-}
-
 func TestDuplicateEndpoint(t *testing.T) {
 	tests := []struct {
 		name      string
diff --git a/config/runtime/server_internal_test.go b/config/runtime/server_internal_test.go
index 2ac27c03..548f07d5 100644
--- a/config/runtime/server_internal_test.go
+++ b/config/runtime/server_internal_test.go
@@ -13,36 +13,6 @@ import (
 	"github.com/avenga/couper/eval"
 )
 
-func TestServer_isUnique(t *testing.T) {
-	endpoints := make(map[string]bool)
-
-	type testCase struct {
-		pattern string
-		clean   string
-		unique  bool
-	}
-
-	for i, tc := range []testCase{
-		{"/abc", "/abc", true},
-		{"/abc", "/abc", false},
-		{"/abc/", "/abc/", true},
-		{"/x/{xxx}", "/x/{}", true},
-		{"/x/{yyy}", "/x/{}", false},
-		{"/x/{xxx}/a/{yyy}", "/x/{}/a/{}", true},
-		{"/x/{yyy}/a/{xxx}", "/x/{}/a/{}", false},
-	} {
-		unique, cleanPattern := isUnique(endpoints, tc.pattern)
-		endpoints[cleanPattern] = true
-
-		if unique != tc.unique {
-			t.Errorf("%d: Unexpected unique status given: %t", i+1, unique)
-		}
-		if cleanPattern != tc.clean {
-			t.Errorf("%d: Unexpected cleanPattern given: %s, want %s", i+1, cleanPattern, tc.clean)
-		}
-	}
-}
-
 func TestServer_getEndpointsList(t *testing.T) {
 	srvConf := &config.Server{
 		APIs: []*config.API{
diff --git a/eval/lib/jwt_test.go b/eval/lib/jwt_test.go
index 7218b392..53611406 100644
--- a/eval/lib/jwt_test.go
+++ b/eval/lib/jwt_test.go
@@ -696,36 +696,6 @@ func TestJwtSignConfigError(t *testing.T) {
 		claims   string
 		wantErr  string
 	}{
-		{
-			"multiple jwt_signing_profile blocks with same label",
-			`
-			server "test" {
-			}
-			definitions {
-				jwt_signing_profile "MyToken" {
-					signature_algorithm = "HS256"
-					key = "$3cRe4"
-					ttl = "0"
-					claims = {
-					  iss = to_lower("The_Issuer")
-					  aud = to_upper("The_Audience")
-					}
-				}
-				jwt_signing_profile "MyToken" {
-					signature_algorithm = "HS256"
-					key = "$3cRe4"
-					ttl = "1h"
-					claims = {
-					  iss = to_lower("The_Issuer")
-					  aud = to_upper("The_Audience")
-					}
-				}
-			}
-			`,
-			"MyToken",
-			`{"sub": "12345"}`,
-			"configuration error: jwt_signing_profile block with label MyToken already defined",
-		},
 		{
 			"unsupported signature algorithm",
 			`
@@ -844,36 +814,6 @@ func TestJwtSignConfigError(t *testing.T) {
 			`{"sub": "12345"}`,
 			"configuration error: MySelfSignedToken: invalid Key: Key must be PEM encoded PKCS1 or PKCS8 private key",
 		},
-		{
-			"jwt_signing_profile and jwt with same label",
-			`
-			server "test" {
-			}
-			definitions {
-				jwt "MySelfSignedToken" {
-					signature_algorithm = "HS256"
-					key = "$3cRe4"
-					signing_ttl = "1h"
-					claims = {
-					  iss = to_lower("The_Issuer")
-					  aud = to_upper("The_Audience")
-					}
-				}
-				jwt_signing_profile "MySelfSignedToken" {
-					signature_algorithm = "HS256"
-					key = "$3cRe4"
-					ttl = "0"
-					claims = {
-					  iss = to_lower("The_Issuer")
-					  aud = to_upper("The_Audience")
-					}
-				}
-			}
-			`,
-			"MySelfSignedToken",
-			`{"sub":"12345"}`,
-			"configuration error: jwt_signing_profile or jwt with label MySelfSignedToken already defined",
-		},
 		{
 			"user-defined alg header",
 			`