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_REPOappears below. -
curland a JSON formatter such aspython3 -m json.toolorjq(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"}}
}]
}'
{
"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:wasmexportdirective 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.Outputeven 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 withpdk.FindMemory. Full list and signatures: extensions/wasm-development.adoc#host-funcs. -
encoding/jsonworks inside WASM but produces large binaries.gjson/sjsonkeep 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.levelare dropped on the host side. -
Outbound HTTP via
pdk.NewHTTPRequestis blocked unless the target is listed inallowedHostsinconfig.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.