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:

  • Go 1.24 or later

  • Docker

  • A Synapse licence. If you do not already have one, sign up at cerbos.dev/workshop. Your credentials are issued together with the URL of the Cerbos distribution repository; substitute that URL wherever CERBOS_DISTRIBUTION_REPO appears below.

  • curl and a JSON formatter such as python3 -m json.tool or jq (optional).

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

The extension supplies principal.attr.company; the caller supplies request.resource.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

  • Export names are case-sensitive and must match the Synapse ABI exactly. The //go:wasmexport directive controls the symbol.

  • Only export the hooks you implement. Missing exports are ignored. Hooks that are expected to produce output must do so using pdk.Output even if it’s simply echoing the input.

  • Host functions are declared with //go:wasmimport extism:host/user <name>, invoked by passing shared-memory offsets, and read back with pdk.FindMemory. Full list and signatures: extensions/wasm-development.adoc#host-funcs.

  • encoding/json works inside WASM but produces large binaries. gjson / sjson keep the binary small when you only need to read or mutate a few fields.

  • pdk.Log(pdk.LogDebug, "msg") writes to the Synapse log, tagged with the extension name. Levels below --log.level are dropped on the host side.

  • Outbound HTTP via pdk.NewHTTPRequest is blocked unless the target is listed in allowedHosts in config.yaml.

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.