Composing OPA solutions

Sep 29th, 2021

Vlad Iovanov avatar

Vlad Iovanov

Open Policy Agent  |  

Engineering

OPA logo
OPA and the OPA logo design are registered trademarks of the Cloud Native Computing Foundation.

Building awesome apps with OPA just got easier

At Aserto, we use OPA heavily in our authorization API as the underlying decision engine. We’ve been looking for a simple way to compose our solution over the OPA engine, and the OPA SDK provided us with a good starting point.

We had some additional requirements, though: we wanted to

  • progressively unlock the engine’s capabilities
  • make these capabilities discoverable
  • extend the engine without sacrificing the ability to update the underlying OPA version

Bottom line, we needed a higher-level abstraction that would let us perform common tasks: build, test, run OPA policies, combined with the ability to configure them for a wide variety of use-cases in a discoverable, typed fashion.

We decided to use the options pattern as it provides a good balance between readability, discoverability of options, and the ability to grow your implementation as required.

This post will show how you can leverage the resulting “helper” package to build your OPA-based solutions, which we call runtime.

Installing

go get -u github.com/aserto-dev/runtime

Usage

Creating a new runtime is straightforward. OPA has many configuration options that can tweak the behavior of your application, so it's best to read up on that. All these configuration options are available in the runtime.Config struct.  As you'll see later, this runtime can do many things - build policy bundles, run plugins and execute queries.

// Create a runtime
r, cleanup, err := runtime.NewRuntime(
	ctx,
	&logger,
	&runtime.Config{},
)
if err != nil {
	return errors.Wrap(err, "failed to create runtime")
}
defer cleanup()

Simple build

Let's start with the simplest example - building a policy bundle.

[Error handling is omitted for brevity]

You need to provide a context, and a logger instance (we're opinionated here - we like zerolog a lot). Next, you call NewRuntime and get back your runtime instance, a cleanup function, and an error. Easy peasy. You can use the runtime instance to call Build and you're done!

package main

import (
    "context"
    "github.com/aserto-dev/runtime"
    "github.com/rs/zerolog"
    "os"
)

func main() {
    logger := zerolog.New(os.Stdout).
        With().Timestamp().Logger().Level(zerolog.ErrorLevel)

    ctx := context.Background()

    // Create a runtime
    r, cleanup, _ := runtime.NewRuntime(
        ctx,
        &logger,
        &runtime.Config{},
    )
    defer cleanup()

    // Use the runtime to build a bundle from the current directory
    r.Build(runtime.BuildParams{
        OutputFile: "my-bundle.tar.gz",
    }, []string{"."})
}

And here's a gist: example1.go

Run with a built-in

Ok, so that was super simple - what if I want to use the OPA engine to interpret queries and return the decisions?

[Error handling is omitted for brevity]

The code looks very similar. You first create your runtime instance. In this example, we're executing a query that references a custom function (or built-in).

Once the runtime struct is created, we start the plugin manager and wait for initialization to finish, so our bundles are loaded by OPA, and the runtime is ready to execute a  query.

And that's it! You've built a tiny app that can load rego bundles and answer queries.

package main

import (
    "context"
    "fmt"
    "github.com/aserto-dev/runtime"
    "github.com/open-policy-agent/opa/ast"
    "github.com/open-policy-agent/opa/rego"
    "github.com/open-policy-agent/opa/types"
    "github.com/pkg/errors"
    "github.com/rs/zerolog"
    "os"
    "time"
)

func main() {
    logger := zerolog.New(os.Stdout).
        With().Timestamp().Logger().Level(zerolog.ErrorLevel)
    ctx := context.Background()

    // Create a runtime
    r, cleanup, _ := runtime.NewRuntime(
        ctx,
        &logger,
        &runtime.Config{
            LocalBundles: runtime.LocalBundlesConfig{
                Paths: []string{"./my-bundle.tar.gz"},
            },
        },
        runtime.WithBuiltin1(&rego.Function{
            Name:    "hello",
            Memoize: false,
            Decl:    types.NewFunction(types.Args(types.S), types.S),
        }, func(ctx rego.BuiltinContext, name *ast.Term) (*ast.Term, error) {
            strName := ""
            err := ast.As(name.Value, &strName)
            if err != nil {
                return nil, errors.Wrap(err, "name parameter is not a string")
            }

            if strName == "there" {
                return ast.StringTerm("general kenobi"), nil
            }
            return nil, nil
        }),
    )
    defer cleanup()

    r.PluginsManager.Start(ctx)
    r.WaitForPlugins(ctx, time.Second*5)

    result, _ := r.Query(ctx, `x = hello("there")`,
        nil, true, false, false, "",
    )
    fmt.Printf("%+v\n", result.Result)
}

Here's the gist: example-2.go

Conclusion

At Aserto we use the “runtime” all the time. OPA is an amazing project, and we believe there's value in making it accessible to developers that also want to build cool things on top.

You can start small and simple, and grow your implementation as needed.

Cheers from Aserto!


Vlad Iovanov avatar

Vlad Iovanov

Founding Engineer