diff --git a/cmd/cerbos/repl/internal/repl.go b/cmd/cerbos/repl/internal/repl.go index 7f2b93ea4..2dd04abab 100644 --- a/cmd/cerbos/repl/internal/repl.go +++ b/cmd/cerbos/repl/internal/repl.go @@ -17,7 +17,6 @@ import ( "sort" "strconv" "strings" - "time" "unicode" "github.com/alecthomas/participle/v2" @@ -411,7 +410,7 @@ func (r *REPL) evalExpr(expr string) (ref.Val, *exprpb.Type, error) { return nil, nil, errSilent } - val, _, err := conditions.Eval(env, ast, r.vars, time.Now) + val, _, err := conditions.Eval(env, ast, r.vars, conditions.Now()) if err != nil { return nil, nil, err } diff --git a/cmd/cerbosctl/put/put_test.go b/cmd/cerbosctl/put/put_test.go index e20e1766c..fd0da72ae 100644 --- a/cmd/cerbosctl/put/put_test.go +++ b/cmd/cerbosctl/put/put_test.go @@ -111,6 +111,7 @@ func testPutCmd(clientCtx *cmdclient.Context, globals *flagset.Globals) func(*te "resource.leave_request.vdefault/acme.hr.uk", "resource.leave_request.vstaging", "resource.missing_attr.vdefault", + "resource.output_now.vdefault", "resource.products.vdefault", "resource.purchase_order.vdefault", "resource.runtime_effective_derived_roles.vdefault", diff --git a/internal/conditions/cerbos_lib.go b/internal/conditions/cerbos_lib.go index d771e9586..ba4a6e32f 100644 --- a/internal/conditions/cerbos_lib.go +++ b/internal/conditions/cerbos_lib.go @@ -127,11 +127,21 @@ func (clib cerbosLib) ProgramOptions() []cel.ProgramOption { return nil } +type NowFunc = func() time.Time + +// Now returns a NowFunc that always returns the time at which Now was called. +func Now() NowFunc { + now := time.Now() + return func() time.Time { return now } +} + // Eval returns the result of an evaluation of the ast and environment against the input vars, // providing time-based functions with a static definition of the current time. // +// The given nowFunc must return the same timestamp each time it is called. +// // See https://pkg.go.dev/github.com/google/cel-go/cel#Program.Eval. -func Eval(env *cel.Env, ast *cel.Ast, vars any, nowFunc func() time.Time, opts ...cel.ProgramOption) (ref.Val, *cel.EvalDetails, error) { +func Eval(env *cel.Env, ast *cel.Ast, vars any, nowFunc NowFunc, opts ...cel.ProgramOption) (ref.Val, *cel.EvalDetails, error) { programOpts := append([]cel.ProgramOption{cel.CustomDecorator(newTimeDecorator(nowFunc))}, opts...) prg, err := env.Program(ast, programOpts...) if err != nil { @@ -141,13 +151,13 @@ func Eval(env *cel.Env, ast *cel.Ast, vars any, nowFunc func() time.Time, opts . return prg.Eval(vars) } -func newTimeDecorator(nowFunc func() time.Time) interpreter.InterpretableDecorator { - td := timeDecorator{now: nowFunc()} +func newTimeDecorator(nowFunc NowFunc) interpreter.InterpretableDecorator { + td := timeDecorator{nowFunc: nowFunc} return td.decorate } type timeDecorator struct { - now time.Time + nowFunc NowFunc } func (t *timeDecorator) decorate(in interpreter.Interpretable) (interpreter.Interpretable, error) { @@ -159,7 +169,7 @@ func (t *timeDecorator) decorate(in interpreter.Interpretable) (interpreter.Inte funcName := call.Function() switch funcName { case nowFn: - return interpreter.NewConstValue(call.ID(), types.DefaultTypeAdapter.NativeToValue(t.now)), nil + return interpreter.NewConstValue(call.ID(), types.DefaultTypeAdapter.NativeToValue(t.nowFunc())), nil case timeSinceFn: return interpreter.NewCall(call.ID(), funcName, call.OverloadID(), call.Args(), func(values ...ref.Val) ref.Val { if len(values) != 1 { @@ -172,7 +182,7 @@ func (t *timeDecorator) decorate(in interpreter.Interpretable) (interpreter.Inte return types.NoSuchOverloadErr() } - return types.DefaultTypeAdapter.NativeToValue(t.now.Sub(ts)) + return types.DefaultTypeAdapter.NativeToValue(t.nowFunc().Sub(ts)) }), nil default: return in, nil diff --git a/internal/conditions/cerbos_lib_test.go b/internal/conditions/cerbos_lib_test.go index d5468af0c..d0b004a45 100644 --- a/internal/conditions/cerbos_lib_test.go +++ b/internal/conditions/cerbos_lib_test.go @@ -106,7 +106,7 @@ func TestCerbosLib(t *testing.T) { ast, issues := env.Compile(tc.expr) is.NoError(issues.Err()) - have, _, err := conditions.Eval(env, ast, cel.NoVars(), time.Now) + have, _, err := conditions.Eval(env, ast, cel.NoVars(), conditions.Now()) if tc.wantErr { is.Error(err) } else { diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 6030fbc33..61810871c 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -24,6 +24,7 @@ import ( runtimev1 "github.com/cerbos/cerbos/api/genpb/cerbos/runtime/v1" schemav1 "github.com/cerbos/cerbos/api/genpb/cerbos/schema/v1" "github.com/cerbos/cerbos/internal/audit" + "github.com/cerbos/cerbos/internal/conditions" "github.com/cerbos/cerbos/internal/engine/planner" "github.com/cerbos/cerbos/internal/engine/tracer" "github.com/cerbos/cerbos/internal/namer" @@ -87,6 +88,10 @@ func newCheckOptions(ctx context.Context, conf *Conf, opts ...CheckOpt) *CheckOp opt(co) } + if co.evalParams.nowFunc == nil { + co.evalParams.nowFunc = conditions.Now() + } + return co } @@ -105,6 +110,7 @@ func WithZapTraceSink(log *zap.Logger) CheckOpt { } // WithNowFunc sets the function for determining `now` during condition evaluation. +// The function should return the same timestamp every time it is invoked. func WithNowFunc(nowFunc func() time.Time) CheckOpt { return func(co *CheckOptions) { co.evalParams.nowFunc = nowFunc @@ -262,10 +268,8 @@ func (engine *Engine) doPlanResources(ctx context.Context, input *enginev1.PlanR result := new(planner.PolicyPlanResult) auditTrail := &auditv1.AuditTrail{EffectivePolicies: make(map[string]*policyv1.SourceAttributes, 2)} //nolint:mnd - now := opts.NowFunc()() - nowFn := func() time.Time { return now } if policy := policySet.GetPrincipalPolicy(); policy != nil { - policyEvaluator := planner.PrincipalPolicyEvaluator{Policy: policy, Globals: opts.Globals(), NowFn: nowFn} + policyEvaluator := planner.PrincipalPolicyEvaluator{Policy: policy, Globals: opts.Globals(), NowFn: opts.NowFunc()} result, err = policyEvaluator.EvaluateResourcesQueryPlan(ctx, input) if err != nil { return nil, nil, err @@ -282,7 +286,7 @@ func (engine *Engine) doPlanResources(ctx context.Context, input *enginev1.PlanR } if policy := policySet.GetResourcePolicy(); policy != nil { - policyEvaluator := planner.ResourcePolicyEvaluator{Policy: policy, Globals: opts.Globals(), SchemaMgr: engine.schemaMgr, NowFn: nowFn} + policyEvaluator := planner.ResourcePolicyEvaluator{Policy: policy, Globals: opts.Globals(), SchemaMgr: engine.schemaMgr, NowFn: opts.NowFunc()} plan, err := policyEvaluator.EvaluateResourcesQueryPlan(ctx, input) if err != nil { return nil, nil, err diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index 2176d67c1..74935ca61 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -76,6 +76,47 @@ func TestCheck(t *testing.T) { )) }) } + + t.Run("deterministic_now", func(t *testing.T) { + roles := []string{"user"} + actions := []string{"a", "b", "c"} + + inputs := []*enginev1.CheckInput{ + { + Principal: &enginev1.Principal{Id: "1", Roles: roles}, + Resource: &enginev1.Resource{Kind: "output_now", Id: "1"}, + Actions: actions, + }, + { + Principal: &enginev1.Principal{Id: "2", Roles: roles}, + Resource: &enginev1.Resource{Kind: "output_now", Id: "1"}, + Actions: actions, + }, + { + Principal: &enginev1.Principal{Id: "1", Roles: roles}, + Resource: &enginev1.Resource{Kind: "output_now", Id: "2"}, + Actions: actions, + }, + { + Principal: &enginev1.Principal{Id: "2", Roles: roles}, + Resource: &enginev1.Resource{Kind: "output_now", Id: "2"}, + Actions: actions, + }, + } + + outputs, err := eng.Check(context.Background(), inputs) + require.NoError(t, err) + require.Len(t, outputs, len(inputs)) + + uniqueNows := make(map[string]struct{}) + for _, output := range outputs { + require.Len(t, output.Outputs, 3) + for _, entry := range output.Outputs { + uniqueNows[entry.Val.GetStringValue()] = struct{}{} + } + } + require.Len(t, uniqueNows, 1) + }) } func TestCheckWithLenientScopeSearch(t *testing.T) { @@ -306,19 +347,13 @@ func TestQueryPlan(t *testing.T) { IncludeMeta: true, AuxData: auxData, } - nowFnCallsCounter := 0 - nowFn := func() time.Time { - nowFnCallsCounter++ - return timestamp - } - response, err := eng.PlanResources(context.Background(), request, WithNowFunc(nowFn)) + response, err := eng.PlanResources(context.Background(), request, WithNowFunc(func() time.Time { return timestamp })) if tt.WantErr { is.Error(err) } else { is.NoError(err) is.NotNil(response) is.Empty(cmp.Diff(tt.Want, response.Filter, protocmp.Transform()), "AST: %s\n%s\n", response.FilterDebug, protojson.Format(response.Filter)) - is.Equal(1, nowFnCallsCounter, "time function should be called once") } }) } diff --git a/internal/engine/evaluator.go b/internal/engine/evaluator.go index 11868d372..099b9e7ee 100644 --- a/internal/engine/evaluator.go +++ b/internal/engine/evaluator.go @@ -9,7 +9,6 @@ import ( "fmt" "reflect" "sort" - "time" "github.com/google/cel-go/cel" "github.com/google/cel-go/common/types" @@ -41,7 +40,7 @@ var ErrPolicyNotExecutable = errors.New("policy not executable") type evalParams struct { globals map[string]any - nowFunc func() time.Time + nowFunc conditions.NowFunc defaultPolicyVersion string lenientScopeSearch bool } @@ -49,7 +48,6 @@ type evalParams struct { func defaultEvalParams(conf *Conf) evalParams { return evalParams{ globals: conf.Globals, - nowFunc: time.Now, defaultPolicyVersion: conf.DefaultPolicyVersion, lenientScopeSearch: conf.LenientScopeSearch, } diff --git a/internal/storage/index/builder_test.go b/internal/storage/index/builder_test.go index 21ba85d94..5b381bcb6 100644 --- a/internal/storage/index/builder_test.go +++ b/internal/storage/index/builder_test.go @@ -45,7 +45,7 @@ func TestBuildIndexWithDisk(t *testing.T) { t.Run("check_contents", func(t *testing.T) { data := idxImpl.Inspect() - require.Len(t, data, 38) + require.Len(t, data, 39) rp1 := filepath.Join("resource_policies", "policy_01.yaml") rp2 := filepath.Join("resource_policies", "policy_02.yaml") diff --git a/internal/test/testdata/store/resource_policies/policy_15.yaml b/internal/test/testdata/store/resource_policies/policy_15.yaml new file mode 100644 index 000000000..52577566a --- /dev/null +++ b/internal/test/testdata/store/resource_policies/policy_15.yaml @@ -0,0 +1,36 @@ +# yaml-language-server: $schema=../../../../../schema/jsonschema/cerbos/policy/v1/Policy.schema.json +--- +apiVersion: api.cerbos.dev/v1 +resourcePolicy: + version: default + resource: output_now + rules: + - name: a + actions: + - a + effect: EFFECT_ALLOW + roles: + - user + output: + when: + ruleActivated: now() + + - name: b + actions: + - b + effect: EFFECT_ALLOW + roles: + - user + output: + when: + ruleActivated: now() + + - name: c + actions: + - c + effect: EFFECT_ALLOW + roles: + - user + output: + when: + ruleActivated: now() diff --git a/private/verify/verify.go b/private/verify/verify.go index ebcfc43d1..3e6e1d85a 100644 --- a/private/verify/verify.go +++ b/private/verify/verify.go @@ -94,6 +94,7 @@ func Bundle(ctx context.Context, params BundleParams) (*policyv1.TestResults, er type CheckOptions interface { Globals() map[string]any NowFunc() func() time.Time + DefaultPolicyVersion() string LenientScopeSearch() bool }