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.

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. Other interfaces are implemented to provide functionality past what is provided by the basic framework.Namespace implementation - these are documented below.

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.

Optional Interfaces

The following are optional interfaces that can be implemented on top of framework.Namespace. All of these interfaces can be implemented at any level, except for framework.New, which only works at the root level.

framework.Call

The framework.Call interface can be implemented to support function calls.

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.AddDate(0, n, 0).Month()}
}

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"

framework.Map

The optional framework.Map interface allows an alternative method to to present a namespace back to a Sentinel policy as a map.

framework.Map is best paired with framework.MapFromKeys, which allows you to quickly fetch the necessary data via Get calls on the namespace:

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
}

func (m *namespaceMonth) Map() (map[string]interface{}, error) {
    return framework.MapFromKeys(m, []string{"string", "index"})
}

framework.New

The optional framework.New interface can be implemented on a root namespace to add methods to your namespaces.

Data returned from a namespace is memoized and returned as a map, and is normally not callable - to call a function to operate on the data, you would need to create a new namespace from the top-level of the import, and then make a function call on the result within the same expression.

Let's consider the root example. Let's say, instead of hard-coding the time value at configuration, we wanted to load it on-demand using a time.now key, and allow add_month to be callable on that value. Our root and de-coupled time namespace would now look like:

type root struct{}

func (m *root) Configure(raw map[string]interface{}) error { return nil }

func (m *root) Get(key string) (interface{}, error) {
    switch key {
    case "now":
        return &namespaceTime{Time: time.Now()}
    }

    return nil
}

func (m *root) New(data map[string]interface{}) (framework.Namespace, error) {
    if v, ok := data["unix"]; ok {
        if t, ok := v.(int64); !ok {
            return &namespaceTime{Time: time.Unix(t, 0)}
        }

        return nil, fmt.Errorf("expected timestamp to be int64, got %T", v)
    }

    return nil, nil
}

type namespaceTime struct {
    Time time.Time
}

func (m *namespaceTime) Get(key string) interface{} {
    switch key {
    case "unix":
        return m.Time.Unix()

  // case ...
    }

    return nil
}

func (m *namespaceTime) Get(key string) (interface{}, error) {
    return framework.MapFromKeys(m, []string{"unix", "..."})
}

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

    return nil
}

func (m *namespaceTime) addMonth(n int) *namespaceMonth {
    return &namespaceMonth{Month: m.Time.AddDate(0, n, 0).Month()}
}

Via this example, we can now create and assign a namespaceTime using t = time.now, and then call t.add_month(months_to_add) to return a namespaceMonth for the corresponding month.

Methods on a namespace can also modfiy the receiver data. So you can, for example, add a method named increment_month that takes no arguments, but increments the time stored in namespaceTime.Time by a month. Subsequent statements using the receiver would see the new value.

Note that there are some restrictions and guidelines that you should take into account when using framework.New:

  • Only data that makes it back to a policy can be used as receiver data to instantiate new namespaces. As such it's recommended to make use of framework.Map and framework.MapFromKeys to return all of the attribute data you need to construct new objects.
  • framework.New is only supported on the root namespace and has no effect on namespaces below the root. Check all possible cases of receiver data within the top-level New function and return the appropriate namespace based on the input data.
  • Do not return general errors from your New method. When an unknown lookup is made off of memoized data, it will hit your New method for possible instantiation and key calls. This will ensure undefined is correctly returned for these cases.
  • framework.New is designed to add utility to imports where calling methods on a value is more intuitive than just making a function call, or just returning all of a data set as a memoized map. Use it sparingly and avoid using it on recursively complex data sets.

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!