Repository: cerbos/cerbos (go)

Issue:
Provide environment-specific information to policies
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Feature description

[As requested in the community Slack](https://community.cerbos.dev/t/12314264/is-it-possible-to-use-environment-variables-in-policies-some#8e0feef9-7af6-40ef-becf-056bcbad3e47)

It is sometimes useful to have environment-specific rules in policies. It would be nice if the environment-specific values could be provided directly to the PDP server rather than passed through from the application in each request.



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

One option would be to provide a custom CEL function that wraps `os.Getenv`. This exposes the entire environment to the policies, which seems slightly wrong although the actual security impact is likely minimal (I guess the risk is that policy authors could gain access to secrets in the PDP environment that they are not supposed to be able to see?).

Perhaps a "safer" approach is to introduce a new PDP config option `globals`. This could be a key-value map, and thanks to the config interpolation it would be possible to use environment variables and even set defaults. This would be nice because those without defaults would be required - it wouldn't be possible to start the PDP without setting them.

```yaml
globals:
  foo: ${FOO}
  bar: ${BAR:}
```

These values would then be made available to CEL expressions as `globals.foo`, `globals.bar` etc

### Anything else?

_No response_

Pull request description:
feat: Introduce environment-specific global variables
Closes #1623

This PR introduces a new configuration section, `engine.globals`, which takes a string-to-any map of global variables to be made available in policy conditions as `globals` (aliased to `G`).

Patch:
diff --git a/cmd/cerbos/repl/internal/repl.go b/cmd/cerbos/repl/internal/repl.go
index b85d811f..00ad3e65 100644
--- a/cmd/cerbos/repl/internal/repl.go
+++ b/cmd/cerbos/repl/internal/repl.go
@@ -323,6 +323,18 @@ func (r *REPL) setSpecialVar(name, value string) error {
 		r.evalPolicyVariables()
 		r.output.PrintResult(name, r.vars[conditions.CELVariablesIdent])
 
+	case conditions.CELGlobalsIdent, conditions.CELGlobalsAbbrev:
+		var globals map[string]any
+		if err := json.Unmarshal([]byte(value), &globals); err != nil {
+			return fmt.Errorf("failed to unmarshal JSON as %q: %w", name, err)
+		}
+
+		globalsVal := r.toRefVal(globals)
+		r.vars[conditions.CELGlobalsIdent] = globalsVal
+		r.vars[conditions.CELGlobalsAbbrev] = globalsVal
+		r.evalPolicyVariables()
+		r.output.PrintResult(name, r.vars[conditions.CELGlobalsIdent])
+
 	default:
 		return fmt.Errorf("setting %q is unsupported", name)
 	}
diff --git a/docs/modules/configuration/pages/engine.adoc b/docs/modules/configuration/pages/engine.adoc
index e467bb1e..a263eb65 100644
--- a/docs/modules/configuration/pages/engine.adoc
+++ b/docs/modules/configuration/pages/engine.adoc
@@ -13,3 +13,38 @@ engine:
   defaultPolicyVersion: "default"
 ----
 
+[#globals]
+== Globals
+
+Global variables are a way to pass environment-specific information to xref:policies:conditions.adoc[policy conditions]. For example, you might want to grant additional permissions to a role in your staging environment, without creating separate policy versions for different environments.
+
+[source,yaml,linenums]
+----
+engine:
+  globals:
+    environment: "staging"
+----
+
+Values set in `globals` can then be referenced in policy conditions:
+
+[source,yaml,linenums]
+----
+rules:
+  - actions:
+      - view
+    effect: EFFECT_ALLOW
+    roles:
+      - developer
+    condition:
+      match:
+        expr: globals.environment != "production"
+----
+
+As with other configuration settings, environment variables can be used to set global values.
+
+[source,yaml,linenums]
+----
+engine:
+  globals:
+    environment: ${CERBOS_ENVIRONMENT:development}
+----
diff --git a/docs/modules/configuration/pages/index.adoc b/docs/modules/configuration/pages/index.adoc
index 286bb59b..49caef4c 100644
--- a/docs/modules/configuration/pages/index.adoc
+++ b/docs/modules/configuration/pages/index.adoc
@@ -10,7 +10,7 @@ The Cerbos server is configured with a YAML file. Start the server by passing th
 ./{app-name} server --config=/path/to/config.yaml --set=server.httpListenAddr=:3592 --set=engine.defaultPolicyVersion=staging
 ----
 
-NOTE: Config values can reference environment variables by enclosing them between `${}`. E.g. `$$${HOME}$$`.
+NOTE: Config values can reference environment variables by enclosing them between `${}`, for example `$$${HOME}$$`. Defaults can be set using `$$${VAR:default}$$`.
 
 
 == Minimal Configuration
diff --git a/docs/modules/configuration/partials/fullconfiguration.adoc b/docs/modules/configuration/partials/fullconfiguration.adoc
index 384a7db3..989617c1 100644
--- a/docs/modules/configuration/partials/fullconfiguration.adoc
+++ b/docs/modules/configuration/partials/fullconfiguration.adoc
@@ -50,6 +50,7 @@ compile:
   cacheSize: 1024 # CacheSize is the number of compiled policies to cache in memory.
 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.
 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/conditions.adoc b/docs/modules/policies/pages/conditions.adoc
index 0849ecde..cf3c6498 100644
--- a/docs/modules/policies/pages/conditions.adoc
+++ b/docs/modules/policies/pages/conditions.adoc
@@ -21,9 +21,11 @@ Within a condition block you have access to several special variables:
 
 `request`:: The entire request object containing data provided about the resource and the principal. Use dots to access nested fields. For example, the expression to get the principal's department attribute is `request.principal.attr.department`.
 `variables`:: Access variables declared in the `variables` section of the policy.
+`globals`:: Access global variables declared in the xref:configuration:engine.adoc#globals[policy engine configuration].
 `P`:: An alias for `request.principal` for accessing the principal object.
 `R`:: An alias for `request.resource` for accessing the resource object.
 `V`:: An alias for `variables` for accessing the policy variables object.
+`G`:: An alias for `globals` for accessing the global variables object.
 
 Every condition expression must evaluate to a boolean true/false value. You can combine complex expressions together in a single condition block by using the `all`, `any`, or `none` operators.
 
diff --git a/internal/conditions/cel.go b/internal/conditions/cel.go
index 35a82e1a..39d1fe51 100644
--- a/internal/conditions/cel.go
+++ b/internal/conditions/cel.go
@@ -25,6 +25,8 @@ const (
 	CELPrincipalField  = "principal"
 	CELVariablesIdent  = "variables"
 	CELVariablesAbbrev = "V"
+	CELGlobalsIdent    = "globals"
+	CELGlobalsAbbrev   = "G"
 	CELAuxDataField    = "aux_data"
 	CELAttrField       = "attr"
 )
@@ -42,6 +44,8 @@ var StdEnvDecls = []*exprpb.Decl{
 	decls.NewVar(CELResourceAbbrev, decls.NewObjectType("cerbos.engine.v1.Resource")),
 	decls.NewVar(CELVariablesIdent, decls.NewMapType(decls.String, decls.Dyn)),
 	decls.NewVar(CELVariablesAbbrev, decls.NewMapType(decls.String, decls.Dyn)),
+	decls.NewVar(CELGlobalsIdent, decls.NewMapType(decls.String, decls.Dyn)),
+	decls.NewVar(CELGlobalsAbbrev, decls.NewMapType(decls.String, decls.Dyn)),
 }
 
 func init() {
@@ -114,6 +118,8 @@ func ExpandAbbrev(s string) string {
 		expanded = Fqn(CELResourceField)
 	case CELVariablesAbbrev:
 		expanded = CELVariablesIdent
+	case CELGlobalsAbbrev:
+		expanded = CELGlobalsIdent
 	}
 
 	if ok {
@@ -136,6 +142,8 @@ func newCELQueryPlanEnvOptions() []cel.EnvOption {
 			decls.NewVar(CELResourceAbbrev, decls.NewObjectType("cerbos.engine.v1.Resource")),
 			decls.NewVar(CELVariablesIdent, decls.NewMapType(decls.String, decls.Dyn)),
 			decls.NewVar(CELVariablesAbbrev, decls.NewMapType(decls.String, decls.Dyn)),
+			decls.NewVar(CELGlobalsIdent, decls.NewMapType(decls.String, decls.Dyn)),
+			decls.NewVar(CELGlobalsAbbrev, decls.NewMapType(decls.String, decls.Dyn)),
 		),
 		ext.Strings(),
 		ext.Encoders(),
diff --git a/internal/engine/conf.go b/internal/engine/conf.go
index 19472038..cd542bfd 100644
--- a/internal/engine/conf.go
+++ b/internal/engine/conf.go
@@ -18,6 +18,8 @@ var errEmptyDefaultVersion = errors.New("engine.defaultVersion must not be an em
 
 // Conf is optional configuration for engine.
 type Conf struct {
+	// Globals are environment-specific variables to be made available to policy conditions.
+	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"`
diff --git a/internal/engine/engine.go b/internal/engine/engine.go
index 669c9226..c82829fd 100644
--- a/internal/engine/engine.go
+++ b/internal/engine/engine.go
@@ -50,13 +50,13 @@ type checkOptions struct {
 	evalParams evalParams
 }
 
-func newCheckOptions(ctx context.Context, opts ...CheckOpt) *checkOptions {
+func newCheckOptions(ctx context.Context, globals map[string]any, opts ...CheckOpt) *checkOptions {
 	var tracerSink tracer.Sink
 	if debugEnabled, ok := os.LookupEnv("CERBOS_DEBUG_ENGINE"); ok && debugEnabled != "false" {
 		tracerSink = tracer.NewZapSink(logging.FromContext(ctx).Named("tracer"))
 	}
 
-	co := &checkOptions{tracerSink: tracerSink, evalParams: defaultEvalParams()}
+	co := &checkOptions{tracerSink: tracerSink, evalParams: defaultEvalParams(globals)}
 	for _, opt := range opts {
 		opt(co)
 	}
@@ -214,7 +214,7 @@ func (engine *Engine) doPlanResources(ctx context.Context, input *enginev1.PlanR
 	result := new(planner.PolicyPlanResult)
 
 	if policy := policySet.GetPrincipalPolicy(); policy != nil {
-		policyEvaluator := planner.PrincipalPolicyEvaluator{Policy: policy}
+		policyEvaluator := planner.PrincipalPolicyEvaluator{Policy: policy, Globals: engine.conf.Globals}
 		result, err = policyEvaluator.EvaluateResourcesQueryPlan(ctx, input)
 		if err != nil {
 			return nil, err
@@ -229,7 +229,7 @@ func (engine *Engine) doPlanResources(ctx context.Context, input *enginev1.PlanR
 	}
 
 	if policy := policySet.GetResourcePolicy(); policy != nil {
-		policyEvaluator := planner.ResourcePolicyEvaluator{Policy: policy, SchemaMgr: engine.schemaMgr}
+		policyEvaluator := planner.ResourcePolicyEvaluator{Policy: policy, Globals: engine.conf.Globals, SchemaMgr: engine.schemaMgr}
 		plan, err := policyEvaluator.EvaluateResourcesQueryPlan(ctx, input)
 		if err != nil {
 			return nil, err
@@ -296,7 +296,7 @@ func (engine *Engine) Check(ctx context.Context, inputs []*enginev1.CheckInput,
 		ctx, span := tracing.StartSpan(ctx, "engine.Check")
 		defer span.End()
 
-		checkOpts := newCheckOptions(ctx, opts...)
+		checkOpts := newCheckOptions(ctx, engine.conf.Globals, opts...)
 
 		// if the number of inputs is less than the threshold, do a serial execution as it is usually faster.
 		// ditto if the worker pool is not initialized
diff --git a/internal/engine/evaluator.go b/internal/engine/evaluator.go
index c51480d0..6a7d903d 100644
--- a/internal/engine/evaluator.go
+++ b/internal/engine/evaluator.go
@@ -31,11 +31,15 @@ import (
 var ErrPolicyNotExecutable = errors.New("policy not executable")
 
 type evalParams struct {
+	globals map[string]any
 	nowFunc func() time.Time
 }
 
-func defaultEvalParams() evalParams {
-	return evalParams{nowFunc: time.Now}
+func defaultEvalParams(globals map[string]any) evalParams {
+	return evalParams{
+		globals: globals,
+		nowFunc: time.Now,
+	}
 }
 
 type Evaluator interface {
@@ -431,6 +435,8 @@ func (ep evalParams) evaluateCELExpr(expr *exprpb.CheckedExpr, variables map[str
 		conditions.CELPrincipalAbbrev: input.Principal,
 		conditions.CELVariablesIdent:  variables,
 		conditions.CELVariablesAbbrev: variables,
+		conditions.CELGlobalsIdent:    ep.globals,
+		conditions.CELGlobalsAbbrev:   ep.globals,
 	}, ep.nowFunc)
 	if err != nil {
 		// ignore expressions that access non-existent keys
diff --git a/internal/engine/planner/planner.go b/internal/engine/planner/planner.go
index 66498b75..9997da7a 100644
--- a/internal/engine/planner/planner.go
+++ b/internal/engine/planner/planner.go
@@ -49,11 +49,13 @@ type (
 
 type ResourcePolicyEvaluator struct {
 	Policy    *runtimev1.RunnableResourcePolicySet
+	Globals   map[string]any
 	SchemaMgr schema.Manager
 }
 
 type PrincipalPolicyEvaluator struct {
-	Policy *runtimev1.RunnablePrincipalPolicySet
+	Policy  *runtimev1.RunnablePrincipalPolicySet
+	Globals map[string]any
 }
 
 func CombinePlans(principalPolicyPlan, resourcePolicyPlan *PolicyPlanResult) *PolicyPlanResult {
@@ -169,7 +171,7 @@ func (ppe *PrincipalPolicyEvaluator) EvaluateResourcesQueryPlan(ctx context.Cont
 					variables[k] = v.Checked.Expr
 				}
 
-				filter, err := evaluateCondition(rule.Condition, input, variables)
+				filter, err := evaluateCondition(rule.Condition, input, ppe.Globals, variables)
 				if err != nil {
 					return nil, err
 				}
@@ -231,7 +233,7 @@ func (rpe *ResourcePolicyEvaluator) EvaluateResourcesQueryPlan(ctx context.Conte
 					for k, v := range dr.Variables {
 						drVariables[k] = v.Checked.Expr
 					}
-					node, err := evaluateCondition(dr.Condition, input, drVariables)
+					node, err := evaluateCondition(dr.Condition, input, rpe.Globals, drVariables)
 					if err != nil {
 						return nil, err
 					}
@@ -280,7 +282,7 @@ func (rpe *ResourcePolicyEvaluator) EvaluateResourcesQueryPlan(ctx context.Conte
 					variables[k] = v.Checked.Expr
 				}
 
-				node, err := evaluateCondition(rule.Condition, input, variables)
+				node, err := evaluateCondition(rule.Condition, input, rpe.Globals, variables)
 				if err != nil {
 					return nil, err
 				}
@@ -373,7 +375,7 @@ func invertNodeBooleanValue(node *enginev1.PlanResourcesAst_Node) *enginev1.Plan
 	return &qpN{Node: &qpNLO{LogicalOperation: lo}}
 }
 
-func evaluateCondition(condition *runtimev1.Condition, input *enginev1.PlanResourcesInput, variables map[string]*exprpb.Expr) (*enginev1.PlanResourcesAst_Node, error) {
+func evaluateCondition(condition *runtimev1.Condition, input *enginev1.PlanResourcesInput, globals map[string]any, variables map[string]*exprpb.Expr) (*enginev1.PlanResourcesAst_Node, error) {
 	if condition == nil {
 		return mkTrueNode(), nil
 	}
@@ -383,7 +385,7 @@ func evaluateCondition(condition *runtimev1.Condition, input *enginev1.PlanResou
 	case *runtimev1.Condition_Any:
 		nodes := make([]*qpN, 0, len(t.Any.Expr))
 		for _, c := range t.Any.Expr {
-			node, err := evaluateCondition(c, input, variables)
+			node, err := evaluateCondition(c, input, globals, variables)
 			if err != nil {
 				return nil, err
 			}
@@ -407,7 +409,7 @@ func evaluateCondition(condition *runtimev1.Condition, input *enginev1.PlanResou
 	case *runtimev1.Condition_All:
 		nodes := make([]*qpN, 0, len(t.All.Expr))
 		for _, c := range t.All.Expr {
-			node, err := evaluateCondition(c, input, variables)
+			node, err := evaluateCondition(c, input, globals, variables)
 			if err != nil {
 				return nil, err
 			}
@@ -430,7 +432,7 @@ func evaluateCondition(condition *runtimev1.Condition, input *enginev1.PlanResou
 	case *runtimev1.Condition_None:
 		nodes := make([]*qpN, 0, len(t.None.Expr))
 		for _, c := range t.None.Expr {
-			node, err := evaluateCondition(c, input, variables)
+			node, err := evaluateCondition(c, input, globals, variables)
 			if err != nil {
 				return nil, err
 			}
@@ -457,7 +459,7 @@ func evaluateCondition(condition *runtimev1.Condition, input *enginev1.PlanResou
 			res.Node = &qpNLO{LogicalOperation: mkAndLogicalOperation(nodes)}
 		}
 	case *runtimev1.Condition_Expr:
-		residual, err := evaluateConditionExpression(t.Expr.Checked, input, variables)
+		residual, err := evaluateConditionExpression(t.Expr.Checked, input, globals, variables)
 		if err != nil {
 			return nil, fmt.Errorf("error evaluating condition %q: %w", t.Expr.Original, err)
 		}
@@ -468,8 +470,8 @@ func evaluateCondition(condition *runtimev1.Condition, input *enginev1.PlanResou
 	return res, nil
 }
 
-func evaluateConditionExpression(expr *exprpb.CheckedExpr, input *enginev1.PlanResourcesInput, variables map[string]*exprpb.Expr) (*exprpb.CheckedExpr, error) {
-	p, err := newEvaluator(input)
+func evaluateConditionExpression(expr *exprpb.CheckedExpr, input *enginev1.PlanResourcesInput, globals map[string]any, variables map[string]*exprpb.Expr) (*exprpb.CheckedExpr, error) {
+	p, err := newEvaluator(input, globals)
 	if err != nil {
 		return nil, err
 	}
@@ -541,12 +543,14 @@ func (p *partialEvaluator) evalPartially(e *exprpb.Expr) (ref.Val, *exprpb.Expr,
 	return val, residual, nil
 }
 
-func newEvaluator(input *enginev1.PlanResourcesInput) (p *partialEvaluator, err error) {
+func newEvaluator(input *enginev1.PlanResourcesInput, globals map[string]any) (p *partialEvaluator, err error) {
 	p = new(partialEvaluator)
 	knownVars := make(map[string]any)
 	knownVars[conditions.CELRequestIdent] = input
 	knownVars[conditions.CELPrincipalAbbrev] = input.Principal
 	knownVars[conditions.Fqn(conditions.CELPrincipalField)] = input.Principal
+	knownVars[conditions.CELGlobalsIdent] = globals
+	knownVars[conditions.CELGlobalsAbbrev] = globals
 
 	p.env = conditions.StdPartialEnv
 	if len(input.Resource.GetAttr()) > 0 {


Test patch:
diff --git a/cmd/cerbos/repl/internal/repl_test.go b/cmd/cerbos/repl/internal/repl_test.go
index f9dcf8eb..16b2d117 100644
--- a/cmd/cerbos/repl/internal/repl_test.go
+++ b/cmd/cerbos/repl/internal/repl_test.go
@@ -273,6 +273,29 @@ func TestREPL(t *testing.T) {
 				},
 			},
 		},
+		{
+			name: "set_globals_variable",
+			directives: []DirectiveTest{
+				{
+					Directive: `:let G = {"foo":"bar"}`,
+					Check: func(t *testing.T, m *mockOutput) {
+						t.Helper()
+						require.Equal(t, "G", m.resultName)
+
+						want := map[string]any{"foo": "bar"}
+						require.Equal(t, want, m.resultVal.Value())
+					},
+				},
+				{
+					Directive: `globals.foo`,
+					Check: func(t *testing.T, m *mockOutput) {
+						t.Helper()
+						require.Equal(t, lastResultVar, m.resultName)
+						require.Equal(t, "bar", m.resultVal.Value())
+					},
+				},
+			},
+		},
 		{
 			name: "load_derived_roles",
 			directives: []DirectiveTest{
diff --git a/cmd/cerbosctl/put_test.go b/cmd/cerbosctl/put_test.go
index dfc34502..7b5af5f1 100644
--- a/cmd/cerbosctl/put_test.go
+++ b/cmd/cerbosctl/put_test.go
@@ -98,6 +98,7 @@ func testPutCmd(clientCtx *cmdclient.Context, globals *flagset.Globals) func(*te
 					"resource.album_object.vdefault",
 					"resource.equipment_request.vdefault",
 					"resource.equipment_request.vdefault/acme",
+					"resource.global.vdefault",
 					"resource.leave_request.v20210210",
 					"resource.leave_request.vdefault",
 					"resource.leave_request.vdefault/acme",
diff --git a/internal/conditions/cel_test.go b/internal/conditions/cel_test.go
index 70efd9d2..dc98fd49 100644
--- a/internal/conditions/cel_test.go
+++ b/internal/conditions/cel_test.go
@@ -44,6 +44,10 @@ func TestExpandAbbrev(t *testing.T) {
 			input: "V.is_admin",
 			want:  "variables.is_admin",
 		},
+		{
+			input: "G.environment",
+			want:  "globals.environment",
+		},
 		{
 			input: "request.principal.attr.department",
 			want:  "request.principal.attr.department",
diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go
index 92193fd5..f584e656 100644
--- a/internal/engine/engine_test.go
+++ b/internal/engine/engine_test.go
@@ -196,13 +196,16 @@ func mkEngine(tb testing.TB, p param) (*Engine, context.CancelFunc) {
 		auditLog = audit.NewNopLog()
 	}
 
-	eng, err := New(ctx, Components{
+	engineConf := &Conf{}
+	engineConf.SetDefaults()
+	engineConf.Globals = map[string]any{"environment": "test"}
+
+	eng := NewFromConf(ctx, engineConf, Components{
 		PolicyLoader:      compiler,
 		SchemaMgr:         schemaMgr,
 		AuditLog:          auditLog,
 		MetadataExtractor: audit.NewMetadataExtractorFromConf(&audit.Conf{}),
 	})
-	require.NoError(tb, err)
 
 	return eng, cancelFunc
 }
diff --git a/internal/engine/planner/planner_test.go b/internal/engine/planner/planner_test.go
index bee37c1e..6a60dd52 100644
--- a/internal/engine/planner/planner_test.go
+++ b/internal/engine/planner/planner_test.go
@@ -160,7 +160,7 @@ func Test_evaluateCondition(t *testing.T) {
 	for _, tt := range tests {
 		t.Run(fmt.Sprintf("Expr:%q", tt.args.expr), func(t *testing.T) {
 			is := require.New(t)
-			got, err := evaluateCondition(tt.args.condition, tt.args.input, nil)
+			got, err := evaluateCondition(tt.args.condition, tt.args.input, nil, nil)
 			is.NoError(err)
 			expression := got.GetExpression()
 			is.Equal(tt.want, unparse(t, expression))
@@ -216,7 +216,7 @@ func Test_evaluateCondition(t *testing.T) {
 			got, err := evaluateCondition(c, &enginev1.PlanResourcesInput{
 				Principal: &enginev1.Principal{Attr: principalAttr},
 				Resource:  &enginev1.PlanResourcesInput_Resource{Attr: resourceAttr},
-			}, nil)
+			}, nil, nil)
 			is.NotNil(got)
 			is.NoError(err)
 			operation := got.GetLogicalOperation()
diff --git a/internal/storage/blob/cloner_test.go b/internal/storage/blob/cloner_test.go
index 43112a93..9b0d0537 100644
--- a/internal/storage/blob/cloner_test.go
+++ b/internal/storage/blob/cloner_test.go
@@ -49,5 +49,6 @@ func TestCloneResult(t *testing.T) {
 		"resource_policies/policy_06.yaml",
 		"resource_policies/policy_07.yaml",
 		"resource_policies/policy_07_acme.yaml",
+		"resource_policies/policy_08.yaml",
 	}, result.updateOrAdd)
 }
diff --git a/internal/storage/index/builder_test.go b/internal/storage/index/builder_test.go
index 9e2e033a..1011c436 100644
--- a/internal/storage/index/builder_test.go
+++ b/internal/storage/index/builder_test.go
@@ -44,7 +44,7 @@ func TestBuildIndexWithDisk(t *testing.T) {
 
 	t.Run("check_contents", func(t *testing.T) {
 		data := idxImpl.Inspect()
-		require.Len(t, data, 21)
+		require.Len(t, data, 22)
 
 		rp1 := filepath.Join("resource_policies", "policy_01.yaml")
 		rp2 := filepath.Join("resource_policies", "policy_02.yaml")
diff --git a/internal/test/testdata/engine/case_18.yaml b/internal/test/testdata/engine/case_18.yaml
new file mode 100644
index 00000000..66d2edf7
--- /dev/null
+++ b/internal/test/testdata/engine/case_18.yaml
@@ -0,0 +1,30 @@
+---
+description: Globals
+inputs: [
+  {
+    "requestId": "test",
+    "actions": ["test"],
+    "principal": {
+      "id": "andy",
+      "roles": [
+        "employee"
+      ]
+    },
+    "resource": {
+      "kind": "global",
+      "id": "test"
+    }
+  }
+]
+wantOutputs: [
+  {
+    "requestId": "test",
+    "resourceId": "test",
+    "actions": {
+      "test": {
+        "effect": "EFFECT_ALLOW",
+        "policy": "resource.global.vdefault"
+      }
+    }
+  }
+]
diff --git a/internal/test/testdata/query_planner/policies/resource_policy_basics.yaml b/internal/test/testdata/query_planner/policies/resource_policy_basics.yaml
index c2e7a692..f43134c3 100644
--- a/internal/test/testdata/query_planner/policies/resource_policy_basics.yaml
+++ b/internal/test/testdata/query_planner/policies/resource_policy_basics.yaml
@@ -21,4 +21,13 @@ resourcePolicy:
       condition:
         match:
           expr: |-
-            P.attr.userQid == request.resource.id
\ No newline at end of file
+            P.attr.userQid == request.resource.id
+    - actions:
+        - reference_globals
+      effect: EFFECT_ALLOW
+      roles:
+        - USER
+      condition:
+        match:
+          expr: |-
+            R.attr.environment == G.environment
diff --git a/internal/test/testdata/query_planner/suite/basics.yaml b/internal/test/testdata/query_planner/suite/basics.yaml
index badc3824..38eeb474 100644
--- a/internal/test/testdata/query_planner/suite/basics.yaml
+++ b/internal/test/testdata/query_planner/suite/basics.yaml
@@ -32,3 +32,15 @@ tests:
             operands:
               - value: z0
               - variable: request.resource.id
+    - action: reference_globals
+      resource:
+        kind: x
+        policyVersion: default
+      want:
+        kind: KIND_CONDITIONAL
+        condition:
+          expression:
+            operator: eq
+            operands:
+              - variable: request.resource.attr.environment
+              - value: test
diff --git a/internal/test/testdata/store/resource_policies/policy_08.yaml b/internal/test/testdata/store/resource_policies/policy_08.yaml
new file mode 100644
index 00000000..95f0360f
--- /dev/null
+++ b/internal/test/testdata/store/resource_policies/policy_08.yaml
@@ -0,0 +1,15 @@
+# yaml-language-server: $schema=https://api.cerbos.dev/latest/cerbos/policy/v1/Policy.schema.json
+---
+apiVersion: api.cerbos.dev/v1
+resourcePolicy:
+  version: default
+  resource: global
+  rules:
+    - actions:
+        - test
+      effect: EFFECT_ALLOW
+      roles:
+        - employee
+      condition:
+        match:
+          expr: globals.environment == "test"