Repository: cerbos/cerbos (go)

Issue:
Allow for evaluating a scope hierarchy with absent top level scope
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Feature description

Allow for policy evaluation to be performed as long as some level of the scope hierarchy is available for a resource scope. For example, given a resource scope of `a.b.c` and resource policies existing with each of scopes ``, `a`, `a.b`, the policy evaluation of the resource should use these 3 three scopes, ignoring the fact that there is no `a.b.c` policy scope defined. The current behavior results in a `NO_MATCH` and denied response.

### What would the ideal solution look like to you?

Ideally, the default behavior would be to use whatever base hierarchy exists, skipping the higher level scopes that are not defined. An alternative to changing the default behavior (which obviously has backwards compatibility issues), is to add a request option that specifies to find the defined scope hierarchy and to ignore missing top level hierarchies.

### Anything else?

_No response_

Pull request description:
feat: Lenient scope search
When configured with `--set=engine.lenientScopeSearch=true`, performs a
search down the scope tree until a policy is found. Originally, if the
request defined scope `a.b.c` a policy file with scope `a.b.c` had to
exist in the store. With this configuration if `a.b.c` doesn't exist,
the engine will try to load `a.b`, `a` and `` in that order.

Fixes #1587

Signed-off-by: Charith Ellawala <charith@cerbos.dev>


Patch:
diff --git a/docs/modules/configuration/pages/engine.adoc b/docs/modules/configuration/pages/engine.adoc
index a263eb65..047d32f6 100644
--- a/docs/modules/configuration/pages/engine.adoc
+++ b/docs/modules/configuration/pages/engine.adoc
@@ -48,3 +48,17 @@ engine:
   globals:
     environment: ${CERBOS_ENVIRONMENT:development}
 ----
+
+[#lenient_scopes]
+== Lenient scope search
+
+When working with xref:policies:scoped_policies.adoc[scopes], the default behaviour of the Cerbos engine is to expect that a policy file exists for the requested scope. For example, if the API request defines `a.b.c` as the `scope`, a policy file _must exist_ in the policy repository with the `a.b.c` scope. This behaviour can be overridden by setting `lenientScopeSearch` configuration to `true`. When lenient scope search is enabled, if a policy with scope `a.b.c` does not exist in the store, Cerbos will attempt to find scopes `a.b`, `a` and `` in that order.
+
+NOTE: This setting only affects how Cerbos treats missing leaf scopes when searching for policies. The policies stored in your policy store _must_ have unbroken scope chains (for example, if you have a scoped policy `a.b.c` in the store, the policy files for scopes `a.b`, `a` and `` must also exist).
+
+
+[source,yaml,linenums]
+----
+engine:
+  lenientScopeSearch: true
+----
diff --git a/docs/modules/configuration/partials/fullconfiguration.adoc b/docs/modules/configuration/partials/fullconfiguration.adoc
index 989617c1..f054e8e2 100644
--- a/docs/modules/configuration/partials/fullconfiguration.adoc
+++ b/docs/modules/configuration/partials/fullconfiguration.adoc
@@ -51,6 +51,7 @@ compile:
 engine:
   defaultPolicyVersion: "default" # DefaultPolicyVersion defines what version to assume if the request does not specify one.
   globals: {"environment": "staging"} # Globals are environment-specific variables to be made available to policy conditions.
+  lenientScopeSearch: false # LenientScopeSearch configures the engine to ignore missing scopes and search upwards through the scope tree until it finds a usable policy.
 schema:
   cacheSize: 1024 # CacheSize defines the number of schemas to cache in memory.
   enforcement: reject # Enforcement defines level of the validations. Possible values are none, warn, reject.
diff --git a/docs/modules/policies/pages/scoped_policies.adoc b/docs/modules/policies/pages/scoped_policies.adoc
index 16e0ee47..a3be743d 100644
--- a/docs/modules/policies/pages/scoped_policies.adoc
+++ b/docs/modules/policies/pages/scoped_policies.adoc
@@ -15,11 +15,11 @@ Cerbos resource and principal policies have an optional `scope` field that can b
 ----
 apiVersion: api.cerbos.dev/v1
 resourcePolicy:
-  version: "default" 
+  version: "default"
   scope: "acme.corp" <1>
-  resource: "album:object"  
+  resource: "album:object"
   rules:
-    - actions: ['*'] 
+    - actions: ['*']
       effect: EFFECT_ALLOW
       roles: ["admin"]
 ----
@@ -37,15 +37,15 @@ To illustrate, consider the following Check request:
 [source,json,linenums]
 ----
 {
-  "requestId":  "test01", 
-  "actions":  ["view", "comment"], 
+  "requestId":  "test01",
+  "actions":  ["view", "comment"],
   "resource":  {
-    "kind":  "album:object", 
+    "kind":  "album:object",
     "policyVersion": "default",
     "scope": "customer.abc", <1>
-    "instances": { 
-      "XX125": { 
-        "attr":  { 
+    "instances": {
+      "XX125": {
+        "attr":  {
           "owner":  "alicia",
           "public": false,
           "tags": ["x", "y"],
@@ -54,11 +54,11 @@ To illustrate, consider the following Check request:
     }
   },
   "principal":  {
-    "id":  "alicia", 
+    "id":  "alicia",
     "policyVersion": "default",
     "scope": "customer", <2>
-    "roles":  ["user"], 
-    "attr": { 
+    "roles":  ["user"],
+    "attr": {
       "geography": "GB"
     }
   }
@@ -78,5 +78,6 @@ image::decision_flow.png[]
 * There must be no gaps in the policy chain. For example, if you define a policy with scope `a.b.c`, then policies with scopes `a.b`, `a`, and no-scope should also exist in the policy repository.
 * xref:schemas.adoc[Schemas] must be the same among all the policies in the chain. The schemas used to validate the request are taken from the base policy (policy without a scope). Schemas defined in other policies of the chain will be ignored.
 * Variables and derived roles imports are not inherited between policies. Explicitly import any derived roles and re-define any variables in each policy that requires them.
-* First match wins. As illustrated in the flow chart above, scoped policies are evaluated from the most specific to the least specific. The first policy to produce a decision (ALLOW/DENY) for an action is the winner. The remaining policies cannot override the decision for that particular action (but they will still be evaluated if there are other actions that don't yet have a decision). 
+* First match wins. As illustrated in the flow chart above, scoped policies are evaluated from the most specific to the least specific. The first policy to produce a decision (ALLOW/DENY) for an action is the winner. The remaining policies cannot override the decision for that particular action (but they will still be evaluated if there are other actions that don't yet have a decision).
+* Unless xref:configuration:engine.adoc#lenient_scopes[lenient scope search] is enabled, a policy file matching the exact scope requested in the API request must exist in the store.
 
diff --git a/internal/compile/manager.go b/internal/compile/manager.go
index 72e1fbfb..77e365dc 100644
--- a/internal/compile/manager.go
+++ b/internal/compile/manager.go
@@ -7,6 +7,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"strings"
 	"time"
 
 	"github.com/bluele/gcache"
@@ -203,6 +204,54 @@ func (c *Manager) evict(modID namer.ModuleID) {
 	c.cache.Remove(modID)
 }
 
+func (c *Manager) GetFirstMatch(ctx context.Context, candidates []namer.ModuleID) (*runtimev1.RunnablePolicySet, error) {
+	keyBuilder := new(strings.Builder)
+	for _, modID := range candidates {
+		rps, err := c.cache.GetIFPresent(modID)
+		if err == nil {
+			cacheHit()
+			if rps != nil {
+				return rps.(*runtimev1.RunnablePolicySet), nil //nolint:forcetypeassert
+			}
+		}
+		keyBuilder.WriteString(modID.String())
+		keyBuilder.WriteRune('|')
+	}
+
+	cacheMiss()
+
+	key := keyBuilder.String()
+	defer c.sf.Forget(key)
+
+	rpsVal, err, _ := c.sf.Do(key, func() (any, error) {
+		cu, err := c.store.GetFirstMatch(ctx, candidates)
+		if err != nil {
+			return nil, fmt.Errorf("failed to get compilation units: %w", err)
+		}
+
+		if cu == nil {
+			return nil, nil
+		}
+
+		rps, err := c.compile(cu)
+		if err != nil {
+			return nil, PolicyCompilationErr{underlying: err}
+		}
+
+		return rps, nil
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	if rpsVal == nil {
+		return nil, nil
+	}
+
+	//nolint:forcetypeassert
+	return rpsVal.(*runtimev1.RunnablePolicySet), nil
+}
+
 func (c *Manager) GetPolicySet(ctx context.Context, modID namer.ModuleID) (*runtimev1.RunnablePolicySet, error) {
 	key := modID.String()
 	defer c.sf.Forget(key)
diff --git a/internal/engine/conf.go b/internal/engine/conf.go
index cd542bfd..acd9a95d 100644
--- a/internal/engine/conf.go
+++ b/internal/engine/conf.go
@@ -22,7 +22,9 @@ type Conf struct {
 	Globals map[string]any `yaml:"globals" conf:",example={\"environment\": \"staging\"}"`
 	// DefaultPolicyVersion defines what version to assume if the request does not specify one.
 	DefaultPolicyVersion string `yaml:"defaultPolicyVersion" conf:",example=\"default\""`
-	NumWorkers           uint   `yaml:"numWorkers" conf:",ignore"`
+	// LenientScopeSearch configures the engine to ignore missing scopes and search upwards through the scope tree until it finds a usable policy.
+	LenientScopeSearch bool `yaml:"lenientScopeSearch" conf:",example=false"`
+	NumWorkers         uint `yaml:"numWorkers" conf:",ignore"`
 }
 
 func (c *Conf) Key() string {
diff --git a/internal/engine/engine.go b/internal/engine/engine.go
index c82829fd..8bbd075a 100644
--- a/internal/engine/engine.go
+++ b/internal/engine/engine.go
@@ -42,7 +42,7 @@ const (
 )
 
 type PolicyLoader interface {
-	GetPolicySet(context.Context, namer.ModuleID) (*runtimev1.RunnablePolicySet, error)
+	GetFirstMatch(context.Context, []namer.ModuleID) (*runtimev1.RunnablePolicySet, error)
 }
 
 type checkOptions struct {
@@ -501,8 +501,8 @@ func (engine *Engine) getPrincipalPolicySet(ctx context.Context, principal, poli
 	defer span.End()
 	span.SetAttributes(tracing.PolicyName(principal), tracing.PolicyVersion(policyVer), tracing.PolicyScope(scope))
 
-	principalModID := namer.PrincipalPolicyModuleID(principal, policyVer, scope)
-	rps, err := engine.policyLoader.GetPolicySet(ctx, principalModID)
+	principalModIDs := namer.ScopedPrincipalPolicyModuleIDs(principal, policyVer, scope, engine.conf.LenientScopeSearch)
+	rps, err := engine.policyLoader.GetFirstMatch(ctx, principalModIDs)
 	if err != nil {
 		tracing.MarkFailed(span, http.StatusInternalServerError, err)
 		return nil, err
@@ -529,8 +529,8 @@ func (engine *Engine) getResourcePolicySet(ctx context.Context, resource, policy
 	defer span.End()
 	span.SetAttributes(tracing.PolicyName(resource), tracing.PolicyVersion(policyVer), tracing.PolicyScope(scope))
 
-	resourceModID := namer.ResourcePolicyModuleID(resource, policyVer, scope)
-	rps, err := engine.policyLoader.GetPolicySet(ctx, resourceModID)
+	resourceModIDs := namer.ScopedResourcePolicyModuleIDs(resource, policyVer, scope, engine.conf.LenientScopeSearch)
+	rps, err := engine.policyLoader.GetFirstMatch(ctx, resourceModIDs)
 	if err != nil {
 		tracing.MarkFailed(span, http.StatusInternalServerError, err)
 		return nil, err
diff --git a/internal/namer/namer.go b/internal/namer/namer.go
index 5434899f..243595e9 100644
--- a/internal/namer/namer.go
+++ b/internal/namer/namer.go
@@ -117,20 +117,24 @@ func FQNTree(p *policyv1.Policy) []string {
 		panic(fmt.Errorf("unknown policy type %T", pt))
 	}
 
+	return buildFQNTree(fqn, scope, func(s string) string { return s })
+}
+
+func buildFQNTree[T any](fqn, scope string, elementFn func(string) T) []T {
 	if scope == "" {
-		return []string{fqn}
+		return []T{elementFn(fqn)}
 	}
 
-	fqnTree := []string{withScope(fqn, scope)}
+	fqnTree := []T{elementFn(withScope(fqn, scope))}
 
 	for i := len(scope) - 1; i >= 0; i-- {
 		if scope[i] == '.' {
-			fqnTree = append(fqnTree, withScope(fqn, scope[:i]))
+			fqnTree = append(fqnTree, elementFn(withScope(fqn, scope[:i])))
 		}
 	}
 
 	// add the no scope FQN as the root
-	fqnTree = append(fqnTree, fqn)
+	fqnTree = append(fqnTree, elementFn(fqn))
 
 	return fqnTree
 }
@@ -161,6 +165,16 @@ func ResourcePolicyModuleID(resource, version, scope string) ModuleID {
 	return GenModuleIDFromFQN(ResourcePolicyFQN(resource, version, scope))
 }
 
+// ScopedResourcePolicyModuleIDs returns a list of module IDs for each scope segment if `strict` is false.
+// For example, if the scope is `a.b.c`, the list will contain the module IDs for scopes `a.b.c`, `a.b`, `a` and `""` in that order.
+func ScopedResourcePolicyModuleIDs(resource, version, scope string, genTree bool) []ModuleID {
+	if !genTree || scope == "" {
+		return []ModuleID{ResourcePolicyModuleID(resource, version, scope)}
+	}
+
+	return buildFQNTree(ResourcePolicyFQN(resource, version, ""), scope, GenModuleIDFromFQN)
+}
+
 // PrincipalPolicyFQN returns the fully-qualified module name for the principal policy with given principal, version and scope.
 func PrincipalPolicyFQN(principal, version, scope string) string {
 	fqn := fmt.Sprintf("%s.%s.v%s", PrincipalPoliciesPrefix, Sanitize(principal), Sanitize(version))
@@ -172,6 +186,16 @@ func PrincipalPolicyModuleID(principal, version, scope string) ModuleID {
 	return GenModuleIDFromFQN(PrincipalPolicyFQN(principal, version, scope))
 }
 
+// ScopedPrincipalPolicyModuleIDs returns a list of module IDs for each scope segment if `strict` is false.
+// For example, if the scope is `a.b.c`, the list will contain the module IDs for scopes `a.b.c`, `a.b`, `a` and `""` in that order.
+func ScopedPrincipalPolicyModuleIDs(principal, version, scope string, genTree bool) []ModuleID {
+	if !genTree || scope == "" {
+		return []ModuleID{PrincipalPolicyModuleID(principal, version, scope)}
+	}
+
+	return buildFQNTree(PrincipalPolicyFQN(principal, version, ""), scope, GenModuleIDFromFQN)
+}
+
 // DerivedRolesFQN returns the fully-qualified module name for the given derived roles set.
 func DerivedRolesFQN(roleSetName string) string {
 	return fmt.Sprintf("%s.%s", DerivedRolesPrefix, Sanitize(roleSetName))
diff --git a/internal/storage/blob/store.go b/internal/storage/blob/store.go
index 967ec213..a5f2ec81 100644
--- a/internal/storage/blob/store.go
+++ b/internal/storage/blob/store.go
@@ -306,6 +306,10 @@ func (s *Store) Driver() string {
 	return DriverName
 }
 
+func (s *Store) GetFirstMatch(_ context.Context, candidates []namer.ModuleID) (*policy.CompilationUnit, error) {
+	return s.idx.GetFirstMatch(candidates)
+}
+
 func (s *Store) GetCompilationUnits(_ context.Context, ids ...namer.ModuleID) (map[namer.ModuleID]*policy.CompilationUnit, error) {
 	return s.idx.GetCompilationUnits(ids...)
 }
diff --git a/internal/storage/bundle/bundle.go b/internal/storage/bundle/bundle.go
index 6bcc5a54..d4f43079 100644
--- a/internal/storage/bundle/bundle.go
+++ b/internal/storage/bundle/bundle.go
@@ -171,18 +171,26 @@ func loadManifest(bundleFS afero.Fs) (*bundlev1.Manifest, error) {
 	return manifest, nil
 }
 
-func (b *Bundle) GetPolicySet(_ context.Context, id namer.ModuleID) (*runtimev1.RunnablePolicySet, error) {
-	idHex := id.HexStr()
-	fileName := policyDir + idHex
+func (b *Bundle) GetFirstMatch(_ context.Context, candidates []namer.ModuleID) (*runtimev1.RunnablePolicySet, error) {
+	for _, id := range candidates {
+		idHex := id.HexStr()
+		fileName := policyDir + idHex
 
-	if _, err := b.bundleFS.Stat(fileName); err != nil {
-		if errors.Is(err, fs.ErrNotExist) {
-			return nil, nil
+		if _, err := b.bundleFS.Stat(fileName); err != nil {
+			if errors.Is(err, fs.ErrNotExist) {
+				continue
+			}
+
+			return nil, fmt.Errorf("failed to stat policy %s: %w", idHex, err)
 		}
 
-		return nil, fmt.Errorf("failed to stat policy %s: %w", idHex, err)
+		return b.loadPolicySet(idHex, fileName)
 	}
 
+	return nil, nil
+}
+
+func (b *Bundle) loadPolicySet(idHex, fileName string) (*runtimev1.RunnablePolicySet, error) {
 	f, err := b.bundleFS.Open(fileName)
 	if err != nil {
 		return nil, fmt.Errorf("failed to open policy %s: %w", idHex, err)
diff --git a/internal/storage/bundle/local_source.go b/internal/storage/bundle/local_source.go
index 19d418d0..5a2fe67f 100644
--- a/internal/storage/bundle/local_source.go
+++ b/internal/storage/bundle/local_source.go
@@ -140,9 +140,9 @@ func (ls *LocalSource) LoadSchema(ctx context.Context, id string) (schema io.Rea
 	return schema, err
 }
 
-func (ls *LocalSource) GetPolicySet(ctx context.Context, id namer.ModuleID) (ps *runtimev1.RunnablePolicySet, err error) {
+func (ls *LocalSource) GetFirstMatch(ctx context.Context, candidates []namer.ModuleID) (ps *runtimev1.RunnablePolicySet, err error) {
 	ls.mu.RLock()
-	ps, err = ls.bundle.GetPolicySet(ctx, id)
+	ps, err = ls.bundle.GetFirstMatch(ctx, candidates)
 	ls.mu.RUnlock()
 	return ps, err
 }
diff --git a/internal/storage/bundle/remote_source.go b/internal/storage/bundle/remote_source.go
index 2afdd418..964e9e99 100644
--- a/internal/storage/bundle/remote_source.go
+++ b/internal/storage/bundle/remote_source.go
@@ -409,7 +409,7 @@ func (s *RemoteSource) IsHealthy() bool {
 	return s.healthy
 }
 
-func (s *RemoteSource) GetPolicySet(ctx context.Context, id namer.ModuleID) (*runtimev1.RunnablePolicySet, error) {
+func (s *RemoteSource) GetFirstMatch(ctx context.Context, candidates []namer.ModuleID) (*runtimev1.RunnablePolicySet, error) {
 	s.mu.RLock()
 	defer s.mu.RUnlock()
 
@@ -417,7 +417,7 @@ func (s *RemoteSource) GetPolicySet(ctx context.Context, id namer.ModuleID) (*ru
 		return nil, ErrBundleNotLoaded
 	}
 
-	return s.bundle.GetPolicySet(ctx, id)
+	return s.bundle.GetFirstMatch(ctx, candidates)
 }
 
 func (s *RemoteSource) ListPolicyIDs(ctx context.Context, params storage.ListPolicyIDsParams) ([]string, error) {
diff --git a/internal/storage/bundle/source_wrappers.go b/internal/storage/bundle/source_wrappers.go
index 6ed49d8c..de579172 100644
--- a/internal/storage/bundle/source_wrappers.go
+++ b/internal/storage/bundle/source_wrappers.go
@@ -51,9 +51,9 @@ func (is instrumentedSource) LoadSchema(ctx context.Context, id string) (io.Read
 	})
 }
 
-func (is instrumentedSource) GetPolicySet(ctx context.Context, id namer.ModuleID) (*runtimev1.RunnablePolicySet, error) {
-	return measureBinaryOp(ctx, is.name, "GetPolicySet", func(ctx context.Context) (*runtimev1.RunnablePolicySet, error) {
-		return is.source.GetPolicySet(ctx, id)
+func (is instrumentedSource) GetFirstMatch(ctx context.Context, candidates []namer.ModuleID) (*runtimev1.RunnablePolicySet, error) {
+	return measureBinaryOp(ctx, is.name, "GetFirstMatch", func(ctx context.Context) (*runtimev1.RunnablePolicySet, error) {
+		return is.source.GetFirstMatch(ctx, candidates)
 	})
 }
 
diff --git a/internal/storage/bundle/store.go b/internal/storage/bundle/store.go
index ba9cc9e4..5a36a651 100644
--- a/internal/storage/bundle/store.go
+++ b/internal/storage/bundle/store.go
@@ -116,8 +116,8 @@ func (hs *HybridStore) LoadSchema(ctx context.Context, id string) (io.ReadCloser
 	return hs.withActiveSource().LoadSchema(ctx, id)
 }
 
-func (hs *HybridStore) GetPolicySet(ctx context.Context, id namer.ModuleID) (*runtimev1.RunnablePolicySet, error) {
-	return hs.withActiveSource().GetPolicySet(ctx, id)
+func (hs *HybridStore) GetFirstMatch(ctx context.Context, candidates []namer.ModuleID) (*runtimev1.RunnablePolicySet, error) {
+	return hs.withActiveSource().GetFirstMatch(ctx, candidates)
 }
 
 func (hs *HybridStore) SourceKind() string {
diff --git a/internal/storage/db/internal/db.go b/internal/storage/db/internal/db.go
index d0bfa012..9db9a1b1 100644
--- a/internal/storage/db/internal/db.go
+++ b/internal/storage/db/internal/db.go
@@ -38,6 +38,7 @@ type DBStorage interface {
 	storage.Reloadable
 	storage.Verifiable
 	AddOrUpdate(ctx context.Context, policies ...policy.Wrapper) error
+	GetFirstMatch(ctx context.Context, candidates []namer.ModuleID) (*policy.CompilationUnit, error)
 	GetCompilationUnits(ctx context.Context, ids ...namer.ModuleID) (map[namer.ModuleID]*policy.CompilationUnit, error)
 	GetDependents(ctx context.Context, ids ...namer.ModuleID) (map[namer.ModuleID][]namer.ModuleID, error)
 	HasDescendants(ctx context.Context, ids ...namer.ModuleID) (map[namer.ModuleID]bool, error)
@@ -308,6 +309,21 @@ func (s *dbStorage) AddOrUpdate(ctx context.Context, policies ...policy.Wrapper)
 	return nil
 }
 
+func (s *dbStorage) GetFirstMatch(ctx context.Context, candidates []namer.ModuleID) (*policy.CompilationUnit, error) {
+	results, err := s.GetCompilationUnits(ctx, candidates...)
+	if err != nil {
+		return nil, err
+	}
+
+	for _, id := range candidates {
+		if cu, ok := results[id]; ok {
+			return cu, nil
+		}
+	}
+
+	return nil, nil
+}
+
 func (s *dbStorage) GetCompilationUnits(ctx context.Context, ids ...namer.ModuleID) (map[namer.ModuleID]*policy.CompilationUnit, error) {
 	// SELECT p.id as parent, p.id, p.definition
 	// FROM policy p
diff --git a/internal/storage/disk/disk.go b/internal/storage/disk/disk.go
index 69e348f4..d733d248 100644
--- a/internal/storage/disk/disk.go
+++ b/internal/storage/disk/disk.go
@@ -91,6 +91,10 @@ func (s *Store) Driver() string {
 	return DriverName
 }
 
+func (s *Store) GetFirstMatch(_ context.Context, candidates []namer.ModuleID) (*policy.CompilationUnit, error) {
+	return s.idx.GetFirstMatch(candidates)
+}
+
 func (s *Store) GetCompilationUnits(_ context.Context, ids ...namer.ModuleID) (map[namer.ModuleID]*policy.CompilationUnit, error) {
 	return s.idx.GetCompilationUnits(ids...)
 }
diff --git a/internal/storage/git/store.go b/internal/storage/git/store.go
index 3d08b66f..d181ee54 100644
--- a/internal/storage/git/store.go
+++ b/internal/storage/git/store.go
@@ -134,6 +134,10 @@ func (s *Store) Driver() string {
 	return DriverName
 }
 
+func (s *Store) GetFirstMatch(_ context.Context, candidates []namer.ModuleID) (*policy.CompilationUnit, error) {
+	return s.idx.GetFirstMatch(candidates)
+}
+
 func (s *Store) GetCompilationUnits(_ context.Context, ids ...namer.ModuleID) (map[namer.ModuleID]*policy.CompilationUnit, error) {
 	return s.idx.GetCompilationUnits(ids...)
 }
diff --git a/internal/storage/index/index.go b/internal/storage/index/index.go
index 629e2ea3..9decbae6 100644
--- a/internal/storage/index/index.go
+++ b/internal/storage/index/index.go
@@ -41,6 +41,7 @@ type Entry struct {
 
 type Index interface {
 	storage.Instrumented
+	GetFirstMatch([]namer.ModuleID) (*policy.CompilationUnit, error)
 	GetCompilationUnits(...namer.ModuleID) (map[namer.ModuleID]*policy.CompilationUnit, error)
 	GetDependents(...namer.ModuleID) (map[namer.ModuleID][]namer.ModuleID, error)
 	AddOrUpdate(Entry) (storage.Event, error)
@@ -84,6 +85,50 @@ func (idx *index) GetFiles() []string {
 	return files
 }
 
+func (idx *index) GetFirstMatch(candidates []namer.ModuleID) (*policy.CompilationUnit, error) {
+	idx.mu.RLock()
+	defer idx.mu.RUnlock()
+
+	for _, id := range candidates {
+		if _, ok := idx.modIDToFile[id]; !ok {
+			continue
+		}
+
+		p, err := idx.loadPolicy(id)
+		if err != nil {
+			return nil, err
+		}
+
+		policyKey := namer.PolicyKey(p)
+
+		cu := &policy.CompilationUnit{
+			ModID:       id,
+			Definitions: map[namer.ModuleID]*policyv1.Policy{id: p},
+		}
+
+		// add dependencies
+		if err := idx.addDepsToCompilationUnit(cu, id); err != nil {
+			return nil, fmt.Errorf("failed to load dependencies of %s: %w", policyKey, err)
+		}
+
+		// load ancestors of the policy
+		for _, ancestor := range cu.Ancestors() {
+			p, err := idx.loadPolicy(ancestor)
+			if err != nil {
+				return nil, fmt.Errorf("failed to load ancestor %q of scoped policy %s: %w", ancestor.String(), policyKey, err)
+			}
+			cu.AddDefinition(ancestor, p)
+			if err := idx.addDepsToCompilationUnit(cu, ancestor); err != nil {
+				return nil, fmt.Errorf("failed to load dependencies of ancestor %q of %s: %w", ancestor.String(), policyKey, err)
+			}
+		}
+
+		return cu, nil
+	}
+
+	return nil, nil
+}
+
 func (idx *index) GetCompilationUnits(ids ...namer.ModuleID) (map[namer.ModuleID]*policy.CompilationUnit, error) {
 	result := make(map[namer.ModuleID]*policy.CompilationUnit, len(ids))
 
diff --git a/internal/storage/overlay/store.go b/internal/storage/overlay/store.go
index c567a6ab..121d072c 100644
--- a/internal/storage/overlay/store.go
+++ b/internal/storage/overlay/store.go
@@ -162,12 +162,13 @@ func withCircuitBreaker[T any](s *Store, baseFn, fallbackFn func() (T, error)) (
 // PolicyLoader interface
 //
 
-func (s *Store) GetPolicySet(ctx context.Context, id namer.ModuleID) (*runtimev1.RunnablePolicySet, error) {
-	// Both `SourceStore` (via `compile.Manager`) and `BinaryStore` implement GetPolicySet
+func (s *Store) GetFirstMatch(ctx context.Context, candidates []namer.ModuleID) (*runtimev1.RunnablePolicySet, error) {
 	return withCircuitBreaker(
 		s,
-		func() (*runtimev1.RunnablePolicySet, error) { return s.basePolicyLoader.GetPolicySet(ctx, id) },
-		func() (*runtimev1.RunnablePolicySet, error) { return s.fallbackPolicyLoader.GetPolicySet(ctx, id) },
+		func() (*runtimev1.RunnablePolicySet, error) { return s.basePolicyLoader.GetFirstMatch(ctx, candidates) },
+		func() (*runtimev1.RunnablePolicySet, error) {
+			return s.fallbackPolicyLoader.GetFirstMatch(ctx, candidates)
+		},
 	)
 }
 
diff --git a/internal/storage/store.go b/internal/storage/store.go
index aa06c5e9..3ea2c367 100644
--- a/internal/storage/store.go
+++ b/internal/storage/store.go
@@ -127,6 +127,8 @@ type Store interface {
 type SourceStore interface {
 	Store
 	Subscribable
+	// GetFirstMatch searches for the given module IDs in order and returns the first one found.
+	GetFirstMatch(context.Context, []namer.ModuleID) (*policy.CompilationUnit, error)
 	// GetCompilationUnits gets the compilation units for the given module IDs.
 	GetCompilationUnits(context.Context, ...namer.ModuleID) (map[namer.ModuleID]*policy.CompilationUnit, error)
 	// GetDependents returns the dependents of the given modules.
@@ -138,8 +140,8 @@ type SourceStore interface {
 // BinaryStore is implemented by stores that have pre-compiled policies in binary format.
 type BinaryStore interface {
 	Store
-	// GetPolicySet gets the compiled policy set identified by the given module ID.
-	GetPolicySet(context.Context, namer.ModuleID) (*runtimev1.RunnablePolicySet, error)
+	// GetFirstMatch searches for the given module IDs in order and returns the first one found.
+	GetFirstMatch(context.Context, []namer.ModuleID) (*runtimev1.RunnablePolicySet, error)
 }
 
 // MutableStore is a store that allows mutations.


Test patch:
diff --git a/internal/compile/manager_test.go b/internal/compile/manager_test.go
index 21977bb1..cd52c30f 100644
--- a/internal/compile/manager_test.go
+++ b/internal/compile/manager_test.go
@@ -255,6 +255,21 @@ func (ms *MockStore) Unsubscribe(s storage.Subscriber) {
 	ms.subscriber = nil
 }
 
+func (ms *MockStore) GetFirstMatch(ctx context.Context, candidates []namer.ModuleID) (*policy.CompilationUnit, error) {
+	args := ms.MethodCalled("GetFirstMatch", ctx, candidates)
+	res := args.Get(0)
+	switch t := res.(type) {
+	case nil:
+		return nil, args.Error(1)
+	case *policy.CompilationUnit:
+		return t, args.Error(1)
+	case func() (*policy.CompilationUnit, error):
+		return t()
+	default:
+		panic(fmt.Errorf("unknown return value type: %T", res))
+	}
+}
+
 func (ms *MockStore) GetCompilationUnits(ctx context.Context, ids ...namer.ModuleID) (map[namer.ModuleID]*policy.CompilationUnit, error) {
 	args := ms.MethodCalled("GetCompilationUnits", ctx, ids)
 	res := args.Get(0)
diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go
index f584e656..721401da 100644
--- a/internal/engine/engine_test.go
+++ b/internal/engine/engine_test.go
@@ -64,6 +64,38 @@ func TestCheck(t *testing.T) {
 	}
 }
 
+func TestCheckWithLenientScopeSearch(t *testing.T) {
+	eng, cancelFunc := mkEngine(t, param{schemaEnforcement: schema.EnforcementNone, lenientScopeSearch: true})
+	defer cancelFunc()
+
+	testCases := test.LoadTestCases(t, "engine")
+	testCases = append(testCases, test.LoadTestCases(t, "engine_lenient_scope_search")...)
+
+	for _, tcase := range testCases {
+		tcase := tcase
+		t.Run(tcase.Name, func(t *testing.T) {
+			tc := readTestCase(t, tcase.Input)
+
+			traceCollector := tracer.NewCollector()
+			haveOutputs, err := eng.Check(context.Background(), tc.Inputs, WithTraceSink(traceCollector))
+
+			if tc.WantError {
+				require.Error(t, err)
+			} else {
+				require.NoError(t, err)
+			}
+
+			for i, have := range haveOutputs {
+				require.Empty(t, cmp.Diff(tc.WantOutputs[i],
+					have,
+					protocmp.Transform(),
+					protocmp.SortRepeatedFields(&enginev1.CheckOutput{}, "effective_derived_roles"),
+				))
+			}
+		})
+	}
+}
+
 func TestSchemaValidation(t *testing.T) {
 	for _, enforcement := range []string{"warn", "reject"} {
 		enforcement := enforcement
@@ -159,9 +191,10 @@ func runBenchmarks(b *testing.B, eng *Engine, testCases []test.Case) {
 }
 
 type param struct {
-	enableAuditLog    bool
-	schemaEnforcement schema.Enforcement
-	subDir            string
+	enableAuditLog     bool
+	schemaEnforcement  schema.Enforcement
+	subDir             string
+	lenientScopeSearch bool
 }
 
 func mkEngine(tb testing.TB, p param) (*Engine, context.CancelFunc) {
@@ -199,6 +232,7 @@ func mkEngine(tb testing.TB, p param) (*Engine, context.CancelFunc) {
 	engineConf := &Conf{}
 	engineConf.SetDefaults()
 	engineConf.Globals = map[string]any{"environment": "test"}
+	engineConf.LenientScopeSearch = p.lenientScopeSearch
 
 	eng := NewFromConf(ctx, engineConf, Components{
 		PolicyLoader:      compiler,
diff --git a/internal/namer/namer_test.go b/internal/namer/namer_test.go
index 14ff9314..100892d6 100644
--- a/internal/namer/namer_test.go
+++ b/internal/namer/namer_test.go
@@ -186,3 +186,83 @@ func TestPolicyCoords(t *testing.T) {
 		})
 	}
 }
+
+func TestScopedModuleIDs(t *testing.T) {
+	testCases := []struct {
+		scope            string
+		wantCountForTree int
+	}{
+		{
+			scope:            "",
+			wantCountForTree: 1,
+		},
+		{
+			scope:            "a.b.c.d.e",
+			wantCountForTree: 6,
+		},
+	}
+
+	fns := []struct {
+		kind    string
+		fn      func(string, string, string, bool) []namer.ModuleID
+		modIDFn func(string, string, string) namer.ModuleID
+	}{
+		{
+			kind: "resource_policy",
+			fn:   namer.ScopedResourcePolicyModuleIDs,
+			modIDFn: func(resource, version, scope string) namer.ModuleID {
+				return namer.GenModuleIDFromFQN(namer.ResourcePolicyFQN(resource, version, scope))
+			},
+		},
+		{
+			kind: "principal_policy",
+			fn:   namer.ScopedPrincipalPolicyModuleIDs,
+			modIDFn: func(principal, version, scope string) namer.ModuleID {
+				return namer.GenModuleIDFromFQN(namer.PrincipalPolicyFQN(principal, version, scope))
+			},
+		},
+	}
+
+	t.Parallel()
+
+	const (
+		inputName    = "foo"
+		inputVersion = "bar"
+	)
+
+	for _, fn := range fns {
+		fn := fn
+		t.Run(fn.kind, func(t *testing.T) {
+			t.Parallel()
+
+			for _, tc := range testCases {
+				tc := tc
+				t.Run(fmt.Sprintf("scope=%s/genTree=false", tc.scope), func(t *testing.T) {
+					t.Parallel()
+
+					have := fn.fn(inputName, inputVersion, tc.scope, false)
+					require.Len(t, have, 1)
+					require.Equal(t, fn.modIDFn(inputName, inputVersion, tc.scope), have[0])
+				})
+
+				t.Run(fmt.Sprintf("scope=%s/genTree=true", tc.scope), func(t *testing.T) {
+					t.Parallel()
+
+					have := fn.fn(inputName, inputVersion, tc.scope, true)
+					require.Len(t, have, tc.wantCountForTree)
+					require.Equal(t, fn.modIDFn(inputName, inputVersion, tc.scope), have[0])
+					require.Equal(t, fn.modIDFn(inputName, inputVersion, ""), have[len(have)-1])
+
+					idx := 1
+					for i := len(tc.scope) - 1; i >= 0; i-- {
+						if tc.scope[i] == '.' {
+							wantModID := fn.modIDFn(inputName, inputVersion, tc.scope[:i])
+							require.Equal(t, wantModID, have[idx], "Unexpected modID for scope %s", tc.scope[:i])
+							idx++
+						}
+					}
+				})
+			}
+		})
+	}
+}
diff --git a/internal/storage/bundle/local_source_test.go b/internal/storage/bundle/local_source_test.go
index 1a98452c..236fe9b2 100644
--- a/internal/storage/bundle/local_source_test.go
+++ b/internal/storage/bundle/local_source_test.go
@@ -64,11 +64,13 @@ func runTests(have *bundle.LocalSource, manifest *bundlev1.Manifest) func(*testi
 			}
 		})
 
-		t.Run("getPolicySet", func(t *testing.T) {
+		t.Run("getFirstMatch", func(t *testing.T) {
+			blahMod := namer.GenModuleIDFromFQN("blah")
+
 			t.Run("existing", func(t *testing.T) {
 				for fqn := range manifest.PolicyIndex {
 					modID := namer.GenModuleIDFromFQN(fqn)
-					havePolicy, err := have.GetPolicySet(context.Background(), modID)
+					havePolicy, err := have.GetFirstMatch(context.Background(), []namer.ModuleID{blahMod, modID})
 					require.NoError(t, err, "Failed to get policy set for %q", fqn)
 					require.NotNil(t, havePolicy, "Policy set %q is nil", fqn)
 					require.Equal(t, havePolicy.Fqn, fqn, "FQN mismatch for policy set %q", fqn)
@@ -76,8 +78,7 @@ func runTests(have *bundle.LocalSource, manifest *bundlev1.Manifest) func(*testi
 			})
 
 			t.Run("nonExisting", func(t *testing.T) {
-				modID := namer.GenModuleIDFromFQN("blah")
-				havePolicy, err := have.GetPolicySet(context.Background(), modID)
+				havePolicy, err := have.GetFirstMatch(context.Background(), []namer.ModuleID{blahMod})
 				require.NoError(t, err)
 				require.Nil(t, havePolicy)
 			})
diff --git a/internal/storage/db/internal/tests.go b/internal/storage/db/internal/tests.go
index 7ac970eb..4e0731a9 100644
--- a/internal/storage/db/internal/tests.go
+++ b/internal/storage/db/internal/tests.go
@@ -187,6 +187,43 @@ func TestSuite(store DBStorage) func(*testing.T) {
 			require.Empty(t, have)
 		})
 
+		t.Run("get_first_match_resource_policy", func(t *testing.T) {
+			modIDs := namer.ScopedResourcePolicyModuleIDs(rpAcmeHR.Name, rpAcmeHR.Version, "acme.hr.france.marseille", true)
+			have, err := store.GetFirstMatch(ctx, modIDs)
+			require.NoError(t, err)
+			require.NotNil(t, have)
+			require.Equal(t, rpAcmeHR.ID, have.ModID)
+			require.Contains(t, have.Definitions, rpAcmeHR.ID)
+			require.Empty(t, cmp.Diff(rpAcmeHR, have.Definitions[rpAcmeHR.ID], protocmp.Transform()))
+			require.Contains(t, have.Definitions, rpAcme.ID)
+			require.Empty(t, cmp.Diff(rpAcme, have.Definitions[rpAcme.ID], protocmp.Transform()))
+			require.Contains(t, have.Definitions, rp.ID)
+			require.Empty(t, cmp.Diff(rp, have.Definitions[rp.ID], protocmp.Transform()))
+			require.Contains(t, have.Definitions, dr.ID)
+			require.Empty(t, cmp.Diff(dr, have.Definitions[dr.ID], protocmp.Transform()))
+		})
+
+		t.Run("get_first_match_principal_policy", func(t *testing.T) {
+			modIDs := namer.ScopedPrincipalPolicyModuleIDs(ppAcmeHR.Name, ppAcmeHR.Version, "acme.hr.france.marseille", true)
+			have, err := store.GetFirstMatch(ctx, modIDs)
+			require.NoError(t, err)
+			require.NotNil(t, have)
+			require.Equal(t, ppAcmeHR.ID, have.ModID)
+			require.Contains(t, have.Definitions, ppAcmeHR.ID)
+			require.Empty(t, cmp.Diff(ppAcmeHR, have.Definitions[ppAcmeHR.ID], protocmp.Transform()))
+			require.Contains(t, have.Definitions, ppAcme.ID)
+			require.Empty(t, cmp.Diff(ppAcme, have.Definitions[ppAcme.ID], protocmp.Transform()))
+			require.Contains(t, have.Definitions, pp.ID)
+			require.Empty(t, cmp.Diff(pp, have.Definitions[pp.ID], protocmp.Transform()))
+		})
+
+		t.Run("get_first_match_non_existent", func(t *testing.T) {
+			modIDs := namer.ScopedResourcePolicyModuleIDs("foo", "bar", "acme.hr.france.marseille", true)
+			have, err := store.GetFirstMatch(ctx, modIDs)
+			require.NoError(t, err)
+			require.Nil(t, have)
+		})
+
 		t.Run("get_dependents", func(t *testing.T) {
 			have, err := store.GetDependents(ctx, dr.ID)
 			require.NoError(t, err)
diff --git a/internal/storage/index/index_test.go b/internal/storage/index/index_test.go
index 1f5c0dda..9416ff7d 100644
--- a/internal/storage/index/index_test.go
+++ b/internal/storage/index/index_test.go
@@ -14,6 +14,7 @@ import (
 	"github.com/stretchr/testify/require"
 	"google.golang.org/protobuf/types/known/wrapperspb"
 
+	"github.com/cerbos/cerbos/internal/namer"
 	"github.com/cerbos/cerbos/internal/policy"
 	"github.com/cerbos/cerbos/internal/storage/index"
 	"github.com/cerbos/cerbos/internal/test"
@@ -115,3 +116,94 @@ func TestIndexListSchemaIDs(t *testing.T) {
 		"resources/salary_record.json",
 	}, ids)
 }
+
+func TestIndexGetFirstMatch(t *testing.T) {
+	t.Parallel()
+
+	ctx := context.Background()
+	fsys := os.DirFS(test.PathToDir(t, "."))
+
+	idx, err := index.Build(ctx, fsys, index.WithRootDir("store"))
+	require.NoError(t, err)
+
+	testCases := []struct {
+		name   string
+		modIDs []namer.ModuleID
+		want   func() namer.ModuleID
+	}{
+		{
+			name:   "resource_policy/strict/non_existent",
+			modIDs: namer.ScopedResourcePolicyModuleIDs("leave_request", "default", "acme.hr.france.marseille", false),
+		},
+		{
+			name:   "resource_policy/strict/existent",
+			modIDs: namer.ScopedResourcePolicyModuleIDs("leave_request", "default", "acme.hr", false),
+			want: func() namer.ModuleID {
+				return namer.GenModuleIDFromFQN(namer.ResourcePolicyFQN("leave_request", "default", "acme.hr"))
+			},
+		},
+		{
+			name:   "resource_policy/lenient/some_leaf_scopes_missing",
+			modIDs: namer.ScopedResourcePolicyModuleIDs("leave_request", "default", "acme.hr.france.marseille", true),
+			want: func() namer.ModuleID {
+				return namer.GenModuleIDFromFQN(namer.ResourcePolicyFQN("leave_request", "default", "acme.hr"))
+			},
+		},
+		{
+			name:   "resource_policy/lenient/all_scopes_missing",
+			modIDs: namer.ScopedResourcePolicyModuleIDs("leave_request", "default", "foo.bar.baz", true),
+			want: func() namer.ModuleID {
+				return namer.GenModuleIDFromFQN(namer.ResourcePolicyFQN("leave_request", "default", ""))
+			},
+		},
+		{
+			name:   "resource_policy/lenient/non_existent",
+			modIDs: namer.ScopedResourcePolicyModuleIDs("leave_request", "blah", "blah", true),
+		},
+		{
+			name:   "principal_policy/strict/non_existent",
+			modIDs: namer.ScopedPrincipalPolicyModuleIDs("donald_duck", "default", "acme.hr.france.marseille", false),
+		},
+		{
+			name:   "principal_policy/strict/existent",
+			modIDs: namer.ScopedPrincipalPolicyModuleIDs("donald_duck", "default", "acme.hr", false),
+			want: func() namer.ModuleID {
+				return namer.GenModuleIDFromFQN(namer.PrincipalPolicyFQN("donald_duck", "default", "acme.hr"))
+			},
+		},
+		{
+			name:   "principal_policy/lenient/some_leaf_scopes_missing",
+			modIDs: namer.ScopedPrincipalPolicyModuleIDs("donald_duck", "default", "acme.hr.france.marseille", true),
+			want: func() namer.ModuleID {
+				return namer.GenModuleIDFromFQN(namer.PrincipalPolicyFQN("donald_duck", "default", "acme.hr"))
+			},
+		},
+		{
+			name:   "principal_policy/lenient/all_scopes_missing",
+			modIDs: namer.ScopedPrincipalPolicyModuleIDs("donald_duck", "default", "foo.bar.baz", true),
+			want: func() namer.ModuleID {
+				return namer.GenModuleIDFromFQN(namer.PrincipalPolicyFQN("donald_duck", "default", ""))
+			},
+		},
+		{
+			name:   "principal_policy/lenient/non_existent",
+			modIDs: namer.ScopedPrincipalPolicyModuleIDs("donald_duck", "blah", "blah", true),
+		},
+	}
+
+	for _, tc := range testCases {
+		tc := tc
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			have, err := idx.GetFirstMatch(tc.modIDs)
+			require.NoError(t, err)
+			if tc.want == nil {
+				require.Nil(t, have)
+			} else {
+				want := tc.want()
+				require.Equal(t, want, have.ModID)
+			}
+		})
+	}
+}
diff --git a/internal/storage/overlay/store_test.go b/internal/storage/overlay/store_test.go
index d1795381..4a4b9fa1 100644
--- a/internal/storage/overlay/store_test.go
+++ b/internal/storage/overlay/store_test.go
@@ -110,8 +110,8 @@ func TestFailover(t *testing.T) {
 		nFailures := fallbackErrorThreshold - 1
 		nRequests := nFailures + 1
 		basePolicyLoader := new(MockPolicyLoader)
-		basePolicyLoader.On("GetPolicySet", ctx, mock.AnythingOfType("namer.ModuleID")).Return((*runtimev1.RunnablePolicySet)(nil), errors.New("base store error")).Times(nFailures)
-		basePolicyLoader.On("GetPolicySet", ctx, mock.AnythingOfType("namer.ModuleID")).Return(&runtimev1.RunnablePolicySet{}, nil).Once()
+		basePolicyLoader.On("GetFirstMatch", ctx, mock.AnythingOfType("[]namer.ModuleID")).Return((*runtimev1.RunnablePolicySet)(nil), errors.New("base store error")).Times(nFailures)
+		basePolicyLoader.On("GetFirstMatch", ctx, mock.AnythingOfType("[]namer.ModuleID")).Return(&runtimev1.RunnablePolicySet{}, nil).Once()
 
 		fallbackPolicyLoader := new(MockPolicyLoader)
 
@@ -123,7 +123,7 @@ func TestFailover(t *testing.T) {
 		}
 
 		for i := 0; i < nRequests; i++ {
-			_, err := wrappedSourceStore.GetPolicySet(ctx, namer.GenModuleIDFromFQN("example"))
+			_, err := wrappedSourceStore.GetFirstMatch(ctx, []namer.ModuleID{namer.GenModuleIDFromFQN("example")})
 			if i < nFailures {
 				require.Error(t, err, "expected base store to return an error")
 			} else {
@@ -141,10 +141,10 @@ func TestFailover(t *testing.T) {
 		nFailures := fallbackErrorThreshold
 		nRequests := nFailures + 1
 		basePolicyLoader := new(MockPolicyLoader)
-		basePolicyLoader.On("GetPolicySet", ctx, mock.AnythingOfType("namer.ModuleID")).Return((*runtimev1.RunnablePolicySet)(nil), errors.New("base store error")).Times(nFailures)
+		basePolicyLoader.On("GetFirstMatch", ctx, mock.AnythingOfType("[]namer.ModuleID")).Return((*runtimev1.RunnablePolicySet)(nil), errors.New("base store error")).Times(nFailures)
 
 		fallbackPolicyLoader := new(MockPolicyLoader)
-		fallbackPolicyLoader.On("GetPolicySet", ctx, mock.AnythingOfType("namer.ModuleID")).Return(&runtimev1.RunnablePolicySet{}, nil).Once()
+		fallbackPolicyLoader.On("GetFirstMatch", ctx, mock.AnythingOfType("[]namer.ModuleID")).Return(&runtimev1.RunnablePolicySet{}, nil).Once()
 
 		wrappedSourceStore := &Store{
 			log:                  zap.S(),
@@ -154,7 +154,7 @@ func TestFailover(t *testing.T) {
 		}
 
 		for i := 0; i < nRequests; i++ {
-			_, err := wrappedSourceStore.GetPolicySet(ctx, namer.GenModuleIDFromFQN("example"))
+			_, err := wrappedSourceStore.GetFirstMatch(ctx, []namer.ModuleID{namer.GenModuleIDFromFQN("example")})
 			if i < nFailures {
 				require.Error(t, err, "expected base store to return an error")
 			} else {
@@ -239,8 +239,8 @@ type MockPolicyLoader struct {
 	mock.Mock
 }
 
-func (m *MockPolicyLoader) GetPolicySet(ctx context.Context, id namer.ModuleID) (*runtimev1.RunnablePolicySet, error) {
-	args := m.Called(ctx, id)
+func (m *MockPolicyLoader) GetFirstMatch(ctx context.Context, candidates []namer.ModuleID) (*runtimev1.RunnablePolicySet, error) {
+	args := m.Called(ctx, candidates)
 	return args.Get(0).(*runtimev1.RunnablePolicySet), args.Error(1)
 }
 
@@ -282,8 +282,8 @@ type MockBinaryStore struct {
 	MockStore
 }
 
-func (m *MockBinaryStore) GetPolicySet(ctx context.Context, id namer.ModuleID) (*runtimev1.RunnablePolicySet, error) {
-	args := m.Called(ctx, id)
+func (m *MockBinaryStore) GetFirstMatch(ctx context.Context, candidates []namer.ModuleID) (*runtimev1.RunnablePolicySet, error) {
+	args := m.Called(ctx, candidates)
 	return args.Get(0).(*runtimev1.RunnablePolicySet), args.Error(1)
 }
 
diff --git a/internal/test/mocks/Index.go b/internal/test/mocks/Index.go
index 6c063016..64094de2 100644
--- a/internal/test/mocks/Index.go
+++ b/internal/test/mocks/Index.go
@@ -399,6 +399,60 @@ func (_c *Index_GetFiles_Call) RunAndReturn(run func() []string) *Index_GetFiles
 	return _c
 }
 
+// GetFirstMatch provides a mock function with given fields: _a0
+func (_m *Index) GetFirstMatch(_a0 []namer.ModuleID) (*policy.CompilationUnit, error) {
+	ret := _m.Called(_a0)
+
+	var r0 *policy.CompilationUnit
+	var r1 error
+	if rf, ok := ret.Get(0).(func([]namer.ModuleID) (*policy.CompilationUnit, error)); ok {
+		return rf(_a0)
+	}
+	if rf, ok := ret.Get(0).(func([]namer.ModuleID) *policy.CompilationUnit); ok {
+		r0 = rf(_a0)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*policy.CompilationUnit)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func([]namer.ModuleID) error); ok {
+		r1 = rf(_a0)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// Index_GetFirstMatch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetFirstMatch'
+type Index_GetFirstMatch_Call struct {
+	*mock.Call
+}
+
+// GetFirstMatch is a helper method to define mock.On call
+//   - _a0 []namer.ModuleID
+func (_e *Index_Expecter) GetFirstMatch(_a0 interface{}) *Index_GetFirstMatch_Call {
+	return &Index_GetFirstMatch_Call{Call: _e.mock.On("GetFirstMatch", _a0)}
+}
+
+func (_c *Index_GetFirstMatch_Call) Run(run func(_a0 []namer.ModuleID)) *Index_GetFirstMatch_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].([]namer.ModuleID))
+	})
+	return _c
+}
+
+func (_c *Index_GetFirstMatch_Call) Return(_a0 *policy.CompilationUnit, _a1 error) *Index_GetFirstMatch_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *Index_GetFirstMatch_Call) RunAndReturn(run func([]namer.ModuleID) (*policy.CompilationUnit, error)) *Index_GetFirstMatch_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
 // ListPolicyIDs provides a mock function with given fields: _a0
 func (_m *Index) ListPolicyIDs(_a0 context.Context) ([]string, error) {
 	ret := _m.Called(_a0)
diff --git a/internal/test/testdata/engine/case_12.yaml b/internal/test/testdata/engine/case_12.yaml
index 44bfe3d7..dd28c72e 100644
--- a/internal/test/testdata/engine/case_12.yaml
+++ b/internal/test/testdata/engine/case_12.yaml
@@ -21,7 +21,7 @@ inputs: [
       }
     },
     "resource": {
-      "kind": "leave_request",
+      "kind": "leave_request_x",
       "scope": "acme.hr.fr",
       "id": "XX125",
       "attr": {
diff --git a/internal/test/testdata/engine_lenient_scope_search/case_lenient_00.yaml b/internal/test/testdata/engine_lenient_scope_search/case_lenient_00.yaml
new file mode 100644
index 00000000..cabf81e6
--- /dev/null
+++ b/internal/test/testdata/engine_lenient_scope_search/case_lenient_00.yaml
@@ -0,0 +1,63 @@
+---
+description: "Scoped policy: lenient search"
+inputs: [
+  {
+    "requestId": "test",
+    "actions": [
+      "create",
+      "delete",
+      "view:public"
+    ],
+    "principal": {
+      "id": "john",
+      "roles": [
+        "employee"
+      ],
+      "attr": {
+        "department": "marketing",
+        "geography": "GB",
+        "team": "design",
+        "ip_address": "10.20.0.13"
+      }
+    },
+    "resource": {
+      "kind": "leave_request",
+      "scope": "acme.hr.uk.wales.cardiff",
+      "id": "XX125",
+      "attr": {
+        "department": "marketing",
+        "geography": "GB",
+        "id": "XX125",
+        "owner": "john",
+        "team": "design"
+      }
+    }
+  }
+]
+wantOutputs: [
+  {
+    "requestId": "test",
+    "resourceId": "XX125",
+    "actions": {
+      "view:public": {
+        "effect": "EFFECT_ALLOW",
+        "policy": "resource.leave_request.vdefault/acme.hr.uk",
+        "scope": "acme.hr"
+      },
+      "delete": {
+        "effect": "EFFECT_ALLOW",
+        "policy": "resource.leave_request.vdefault/acme.hr.uk",
+        "scope": "acme.hr.uk"
+      },
+      "create": {
+        "effect": "EFFECT_ALLOW",
+        "policy": "resource.leave_request.vdefault/acme.hr.uk",
+        "scope": "acme"
+      }
+    },
+    "effectiveDerivedRoles": [
+      "any_employee",
+      "employee_that_owns_the_record"
+    ]
+  }
+]
diff --git a/internal/test/testdata/engine_lenient_scope_search/case_lenient_01.yaml b/internal/test/testdata/engine_lenient_scope_search/case_lenient_01.yaml
new file mode 100644
index 00000000..e1768033
--- /dev/null
+++ b/internal/test/testdata/engine_lenient_scope_search/case_lenient_01.yaml
@@ -0,0 +1,101 @@
+---
+description: "Scoped principal policy: lenient scope search"
+inputs: [
+  {
+    "requestId": "test",
+    "actions": [
+      "view:public",
+      "delete"
+    ],
+    "principal": {
+      "id": "donald_duck",
+      "scope": "acme.hr.france.marseille",
+      "roles": [
+        "employee"
+      ],
+      "attr": {
+        "department": "marketing",
+        "geography": "GB",
+        "team": "design",
+        "managed_geographies": "GB"
+      }
+    },
+    "resource": {
+      "kind": "salary_record",
+      "id": "XX125",
+      "attr": {
+        "department": "marketing",
+        "geography": "GB",
+        "id": "XX125",
+        "owner": "john",
+        "team": "design"
+      }
+    }
+  },
+  {
+    "requestId": "test",
+    "actions": [
+      "approve",
+      "view:public"
+    ],
+    "principal": {
+      "id": "donald_duck",
+      "scope": "acme.hr",
+      "roles": [
+        "employee"
+      ],
+      "attr": {
+        "department": "marketing",
+        "geography": "GB",
+        "team": "design",
+        "managed_geographies": "GB"
+      }
+    },
+    "resource": {
+      "kind": "leave_request",
+      "id": "XX126",
+      "attr": {
+        "department": "marketing",
+        "geography": "GB",
+        "id": "XX125",
+        "owner": "mickey_mouse",
+        "team": "design",
+        "dev_record": true
+      }
+    }
+  }
+]
+wantOutputs: [
+  {
+    "requestId": "test",
+    "resourceId": "XX125",
+    "actions": {
+      "view:public": {
+        "effect": "EFFECT_ALLOW",
+        "policy": "principal.donald_duck.vdefault/acme.hr",
+        "scope": "acme.hr"
+      },
+      "delete": {
+        "effect": "EFFECT_DENY",
+        "policy": "principal.donald_duck.vdefault/acme.hr",
+        "scope": "acme"
+      }
+    }
+  },
+  {
+    "requestId": "test",
+    "resourceId": "XX126",
+    "actions": {
+      "approve": {
+        "effect": "EFFECT_DENY",
+        "policy": "principal.donald_duck.vdefault/acme.hr",
+        "scope": "acme"
+      },
+      "view:public": {
+        "effect": "EFFECT_DENY",
+        "policy": "principal.donald_duck.vdefault/acme.hr",
+        "scope": "acme"
+      }
+    }
+  }
+]