» Developing an Import Plugin

Anyone can develop a Sentinel Import plugin using the Sentinel SDK. The SDK contains a high-level framework for writing plugins in Go including a test framework. Additionally, the SDK contains the low-level gRPC protocol buffers definition for writing plugins in other languages.

The primary reasons to write an import plugin are:

  • You want to expose or access new information within your Sentinel policies. For example, you may want to query information from an external service.

  • You want to expose new functions to Sentinel policies.

This page will document how to write a new Sentinel import in Go using the Sentinel SDK and the high-level framework provided. You are expected to already know the basics of Go and have a properly configured Go installation and GOPATH.

Throughout this page, we'll be developing a simple import to access time values.

» Import Framework

The Sentinel SDK provides a high-level framework for writing imports. We recommend using this framework.

» Basic Concepts

This framework works by implementing the correct Go interfaces.

As a general overview: *framework.Import implements the sdk.Import interface and therefore is a valid import plugin. You must implement the framework.Root interface to configure the import. The root may then delegate nested access to various framework.Namespace implementations. There are many other interfaces that can be optionally implemented such as framework.Call to implement function calls.

This all may sound like a lot of interfaces, but each interface typically only requires a single function implementation and you're only required to implement a single namespace (framework.Namespace).

As we move on, we'll use real examples to make this clear.

» Implementing framework.Root

To begin, you must implement the framework.Root interface. This is the interface representing the root of your import. The root itself must be implement framework.Namespace, which allows values to be accessed. Note that the root can optionally implement other interfaces, but they're more advanced and omitted here for simplicity.

For our time example, our root may look like this:

type root struct {
    time time.Time
}

// framework.Root impl.
func (m *root) Configure(raw map[string]interface{}) error {
    if _, ok := raw["timestamp"]; !ok {
        raw["timestamp"] = time.Now().Unix()
    }

    v := raw["timestamp"]
    timestamp, ok := v.(int)
    if !ok {
        return fmt.Errorf("invalid timestamp type %T", v)
    }

    m.time = time.Unix(timestamp, 0).UTC()
    return nil
}

// framework.Namespace impl.
func (m *root) Get(key string) (interface{}, error) {
    switch key {
    case "minute":
        return m.time.Minute(), nil
    }

    return nil, nil
}

This example implements framework.Root and framework.Namespace and would enable the following within a Sentinel policy once the completed import is installed.

import "time"

print(time.minute)
main = true

» framework.Namespace

The framework.Namespace interface implements a namespace of values. You saw above that framework.Root must itself be a namespace. However, a namespace Get implementation may further return namespaces to access nested values.

This enables behavior such as time.month.string vs. time.month.index. Notice each of these examples accesses a nested value within month. This can be modeled as namespaces within the framework.

In the example below, we return a new namespace for month to do just this:

func (m *root) Get(key string) (interface{}, error) {
    switch key {
    case "month":
        return &namespaceMonth{Month: m.time.Month()}, nil
    }

    // ...
}

type namespaceMonth struct { Month time.Month }

func (m *namespaceMonth) Get(key string) (interface{}, error) {
    switch key {
    case "string":
        return m.Month.String(), nil

    case "index":
        return int(m.Month), nil
    }

    return nil nil
}

» Primitive Types and Structs

A namespace may also return any primitive Go type as well as structs. The framework automatically exposes primitive values as you would expect. For structs, exported fields are lowercased and exposed to the policies. The sentinel struct tag can be used to control how Sentinel can access the field.

In the example below, we expose a struct directly from the root namespace:

type Location struct {
    Name     string
    TimeZone string `sentinel:"time_zone"`
}

func (m *root) Get(key string) (interface{}, error) {
    switch key {
    case "location":
        return &Location{Name: "somewhere", TimeZone: "some zone"}, nil
    }

    // ...
}

This makes the following values work within a Sentinel policy: time.location.name, time.location.time_zone.

If a field has an empty sentinel struct tag (example: sentinel:""), then that field will not be accessible from a policy.

» framework.Call

The framework.Call interface can be implemented to support function calls. This interface is an optional interface on top of a framework.Namespace. Because of this, this interface can also be implemented within the root.

Implementing this interface is very similar to attribute access, except instead of returning the attribute value, you return a function. The framework uses Go reflection to determine the argument and result types and calls it. If the argument types do not match or the signature is otherwise invalid, an error is returned.

For example, let's implement a function to add months to the current month:

func (m *root) Func(key string) interface{} {
    switch key {
    case "add_month":
        return m.addMonth
    }

    return nil
}

func (m *root) addMonth(n int) *namespaceMonth {
    return &namespaceMonth{Month: m.time.Month()%12}
}

You can now call the function. If today was in the month of September, the policy below would pass:

import "time"

main = time.add_month(4).string == "January"

» Testing

The SDK exposes a testing framework to verify your plugin works as expected. This test framework integrates directly into go test so it is part of a familiar workflow.

The test framework works by dynamically building your import and running it against the sentinel CLI to verify it behaves as expected. This ensures that your plugin builds, your plugin communicates via RPC correctly, and that the behavior within a policy is also correct.

The example below shows a complete ready table-driven test for our time plugin. You can run this with a normal go test.

package main

import (
    "os"
    "testing"

    "github.com/hashicorp/sentinel-sdk"
    plugintesting "github.com/hashicorp/sentinel-sdk/testing"
)

func TestMain(m *testing.M) {
    exitCode := m.Run()
    plugintesting.Clean()
    os.Exit(exitCode)
}

func TestImport(t *testing.T) {
    cases := []struct {
        Name   string
        Data   map[string]interface{}
        Source string
    }{
        {
            "month",
            map[string]interface{}{"timestamp": 1495483674},
            `main = subject.month.string == "September"`,
        },
    }

    for _, tc := range cases {
        t.Run(tc.Name, func(t *testing.T) {
            plugintesting.TestImport(t, plugintesting.TestImportCase{
                Config: tc.Data,
                Source: tc.Source,
            })
        })
    }
}

» Building Your Import

To build your import, you must first implement the main function. The main function should just use the rpc.Serve method to serve the plugin over the Sentinel RPC layer:

package main

import (
    "github.com/hashicorp/sentinel-sdk"
    "github.com/hashicorp/sentinel-sdk/rpc"
)

func main() {
    rpc.Serve(&rpc.ServeOpts{
        ImportFunc: func() sdk.Import {
            return &framework.Import{Root: &root{}}
        },
    })
}

You can then build this using go build. The output can be named anything. Once your plugin is built, install it and try it out!