Build a WASM extension in Go :: Cerbos Authorization Management Platform // Documentation

Build a WASM extension in Go

This tutorial walks through building a minimal Synapse proxy extension in Go, compiling it to WebAssembly, and loading it into a running Synapse instance. For the payload shapes and full list of exports for every extension type, see Developing Webassembly extensions.

Synapse loads WASM modules using the Extism runtime. This guide uses the Extism Go PDK, which provides convenience wrappers for Go developers. There are versions of the PDK available for several other languages. If your preferred language doesn’t have a PDK but supports compiling the code into a WASI reactor, it’s still possible to develop a Synapse extension by implementing the Extism ABI in your code. Details of that are beyond the scope of this documentation.

Synapse creates multiple instances of each extension to serve parallel requests. Each instance has its own globals and is isolated from the other instances. The only way to share state is through the built-in cache or through an external data store.

Before you begin

You need:

Log in to the Cerbos distribution repository:

$ docker login CERBOS_DISTRIBUTION_REPO --username=YOUR_LICENCE_USER --password=YOUR_LICENCE_KEY

Create the project

$ mkdir -p wasm-go-tutorial/policies wasm-go-tutorial/extensions wasm-go-tutorial/src
$ cd wasm-go-tutorial/src
$ go mod init example.com/synapse-extension
$ go get github.com/extism/go-pdk@latest
$ go get github.com/tidwall/sjson@latest
$ cd ..

go-pdk provides the Extism input/output helpers and memory allocator. sjson is used to modify the request JSON without defining Go structs for every field.

Write the extension

Create src/main.go:

package main

import (
    "github.com/extism/go-pdk"
    "github.com/tidwall/sjson"
)

//go:wasmexport cerbosInit (1)
func cerbosInit() int32 { return 0 }

//go:wasmexport cerbosDeinit
func cerbosDeinit() int32 { return 0 }

//go:wasmexport augmentCheckRequest (2)
func augmentCheckRequest() int32 {
    input := pdk.Input() (3)

modified, err := sjson.SetBytes(input, "principal.attr.company", "Romaguera-Crona")
    if err != nil {
        pdk.SetError(err)
        return 1
    }

pdk.Output(modified) (4)
    return 0
}

//go:wasmexport augmentCheckResponse
func augmentCheckResponse() int32 {
    pdk.Output(pdk.Input())
    return 0
}

func main() {} (5)
1 The //go:wasmexport directive controls the WASM symbol name. It must match the Synapse ABI exactly (cerbosInit, augmentCheckRequest, and so on). The Go function name can differ.
2 One of the proxy extension hooks. Synapse invokes it before every CheckResources request reaches the PDP.
3 pdk.Input() returns the JSON-encoded request as []byte.
4 pdk.Output is mandatory on every successful invocation, even for pass-through. Returning 0 without output would be considered an error.
5 Required by the Go toolchain but never called when the module runs as an Extism reactor. Leave it empty.

Build the module

$ GOOS=wasip1 GOARCH=wasm go build -C src -o ../extensions/proxy.wasm -buildmode=c-shared main.go

GOOS=wasip1 GOARCH=wasm targets WebAssembly with the WASI preview 1 ABI. -buildmode=c-shared produces a reactor module that exports its symbols instead of running main and exiting.

TinyGo produces modules roughly 10× smaller: tinygo build -o extensions/proxy.wasm -target wasip1 -buildmode=c-shared src/main.go.

Write a policy

Create policies/invoice.yaml:

apiVersion: api.cerbos.dev/v1
resourcePolicy:
  resource: invoice
  version: default
  rules:
    - actions: [view]
      effect: EFFECT_ALLOW
      roles: [employee]
      condition:
        match:
          expr: request.resource.attr.company == request.principal.attr.company

Configure and run Synapse

Create config.yaml:

server:
  listenAddress: ":3594"

pdp:
  inProcess:
    storage:
      driver: "disk"
      disk:
        directory: /policies
    audit:
      enabled: true
      backend: file
      file:
        path: stdout

extensions:
  proxyExtensions:
    principalEnricher: (1)
      extensionURL: /extensions/proxy.wasm (2)
      required: true (3)
1 Name for this extension as it appears in logs.
2 Path inside the container. The .wasm suffix tells Synapse to load the file with the WASM runtime. See Extension URLs for other supported schemes.
3 A failure inside a required extension short-circuits the chain and returns an error to the caller. Set this to false for advisory enrichment.

Start the container:

$ docker run --rm --name synapse-wasm-go -p 3594:3594 \
    -v $(pwd)/config.yaml:/config/config.yaml:ro \
    -v $(pwd)/policies:/policies:ro \
    -v $(pwd)/extensions:/extensions:ro \
    CERBOS_DISTRIBUTION_REPO/synapse/synapse:latest \
    server --conf.path=/config/config.yaml --log.level=debug

Logs should show:

{"severity":"DEBUG","extension":"principalEnricher","runtime":"wasm","message":"Downloaded extension"}

In a second terminal, send a request without a principal.attr block:

$ curl -s -X POST http://localhost:3594/api/check/resources \
    -H 'Content-Type: application/json' \
    -d '{
          "requestId": "wasm-go-001",
          "principal": {"id": "1", "roles": ["employee"]},
          "resources": [{\
            "actions": ["view"],\
            "resource": {"kind": "invoice", "id": "inv-42",\
                         "attr": {"company": "Romaguera-Crona"}}\
          }]
        }'

Response

{
  "requestId": "wasm-go-001",
  "results": [\
    { "resource": {"id": "inv-42", "kind": "invoice"},\
      "actions": {"view": "EFFECT_ALLOW"} }\
  ]
}

The extension injected principal.attr.company before the PDP evaluated the policy. Remove the extension or change the value and the decision flips to EFFECT_DENY.

Language-specific notes

Going further

Refer to Developing Webassembly extensions for details about the functions that should be exported by different extension types and common operations such as calling host functions.