Developing Webassembly extensions

Synapse supports Webassembly (WASM) extensions using the Extism framework. While it’s possible to develop extensions without any framework, it’s recommended to use one of the supported Extism plugin developments kits as they provide helpers for sending JSON back and forth, reading configuration and other useful functions.

WASM extensions should be reactor modules that are long-lived. Note that Synapse pools multiple instances of a single extension in order to handle parallel requests (WASM memory is not thread-safe). This is an important consideration to keep in mind when creating long-lived resources such as database connections as there will be multiple independent copies of each WASM module alive at the same time on a single Synapse instance.

Common functionality

Lifecycle management

If the WASM module exports a cerbosInit function, Synapse invokes it when the extension is first initialized. This is similar to a constructor and can be used to do things such as creating long-lived objects, performing validation of configuration and so on.

When Synapse is gracefully shut down, it invokes the cerbosDeinit function on any module that exports it. This is similar to a destructor and can be used to close connections and perform other cleanup tasks.

Both cerbosInit and cerbosDeinit functions take no arguments and return an i32 where 0 denotes success and any non-zero value denotes failure. If the extension has any custom configuration, it’s accessible via the Extism.

The following snippet demonstrates how these functions might be implemented in a Go WASM module.

var sqlite *sqlx.DB

//go:wasmexport cerbosInit
func cerbosInit() int32 {
    connectionStr, exists := pdk.GetConfig("connectionString")
    if !exists {
        pdk.SetError(errors.New("connectionString configuration is required"))
        return 1
    }

	db, err = sqlx.Connect("sqlite3", connectionStr)
	if err != nil {
		pdk.SetError(err)
		return 1
	}

	sqlite = db
	return 0
}


//go:wasmexport cerbosDeinit
func cerbosDeinit() int32 {
    if err := sqlite.Close(); err != nil {
        pdk.SetError(err)
        return 1
    }

	return 0
}

Host functions

Synapse exposes the following host functions that can be called by any extension. They are all exported under the module name extism:host/user.

cacheDelete

Delete a value from the cache.

Inputs
  • key: Pointer to a string

Outputs
  • An integer value. Non-zero value indicates failure.

cacheGet

Get a value from the cache.

Inputs
  • key: Pointer to a string

Outputs
  • Pointer to a byte array containing the value.

cacheSet

Save a value to cache.

Inputs
  • key: Pointer to a string

  • value: Pointer to a byte array

  • duration: Integer value defining cache duration in milliseconds

Outputs
  • An integer value. Non-zero value indicates failure.

cacheSetIfNotExists

Save a value to cache if it doesn’t already exist.

Inputs
  • key: Pointer to a string

  • value: Pointer to a byte array

  • duration: Integer value defining cache duration in milliseconds

Outputs
  • An integer value. Non-zero value indicates failure.

checkResources

Perform a Cerbos CheckResources call.

Inputs
  • request: Pointer to a byte array containing the JSON-encoded CheckResources request

Outputs
  • Pointer to a byte array containing the JSON-encoded CheckResources response

dataSourceLookup

Lookup information from a data source.

Inputs
  • request: Pointer to a byte array containing the JSON-encoded lookup request. See Data sources.

Outputs
  • Pointer to a byte array containing the JSON-encoded lookup response. See Data sources.

planResources

Perform a Cerbos PlanResources call.

Inputs
  • request: Pointer to a byte array containing the JSON-encoded PlanResources request

Outputs
  • Pointer to a byte array containing the JSON-encoded PlanResources response

The following snippet illustrates how to access host functions from a Go WASM module.

//go:wasmimport extism:host/user cacheSet
func cacheSet(uint64, uint64, uint32) int32

func writeToCache(key string value []byte) {
	keyMem := pdk.AllocateString(key)
	defer keyMem.Free()
	valueMem := pdk.AllocateBytes(value)
	defer valueMem.Free()

	result := cacheSet(keyMem.Offset(), valueMem.Offset(), 10000)
	if result != 0 {
        // handle error
	}
}

Data source extension

A data source extension must export a function named lookup that accepts a pointer to a JSON-encoded lookup request and returns a pointer to a JSON-encoded lookup result.

Lookup request
{
    "dataSource": "myDataSource", (1)
    "query": "SELECT department FROM employees WHERE id = :employee_id", (2)
    "queryParameters": { (3)
        "employee_id": "simon"
    },
    "cacheOptions": { (4)
        "cacheKey": "simon", (5)
        "cacheExpiry": "300s", (6)
        "ifNotExists": true (7)
    }
}
1 Name of the data source to query
2 Query to execute on the data source. This is specific to the data source. The value can be any valid JSON value, including complex objects and arrays.
3 Optional query parameters
4 Optional cache options for caching the result of the lookup
5 Cache key for this lookup
6 Optional duration for caching the result of the lookup
7 Optional flag to prevent overwriting an existing cache key

Only the dataSource and query fields are required. The query field can be any valid JSON value, including complex nested objects. The API contract for the query field is specific to each data source implementation as it depends on the logic that each data source encapsulates.

The response from the data source only contains the single field result. It could contain any valid JSON value including objects and arrays. The format of the result depends on the data source.

Lookup response
{
    "result": [
      {"id": 1, "first_name": "Daffy"},
      {"id": 2, "first_name": "Elmer"}
    ]
}

The following snippet demonstrates a very simple implementation of a data source that echoes the query back to the caller.

type LookupRequest struct {
	DataSource string          `json:"dataSource"`
	Query      json.RawMessage `json:"query"`
}

type LookupResponse struct {
	Result json.RawMessage `json:"result"`
}

//go:wasmexport lookup
func lookup() int32 {
	var req LookupRequest
	if err := pdk.InputJSON(&req); err != nil {
		pdk.SetError(err)
		return 1
	}

	result := gjson.GetBytes(req.Query, "query").String()
	resp := LookupResponse{Result: fmt.Appendf(nil, "%q", result)}
	if err := pdk.OutputJSON(resp); err != nil {
		pdk.SetError(err)
		return 1
	}

	return 0
}

Envoy extension

An Envoy extension must export a function named envoyCheck that accepts a JSON-encoded Envoy check request and returns one of the following responses.

Direct response

Return a complete Envoy check response to return back to the caller.

{
    "envoyCheckResponse": {
        "status": {"code": 7},
        "deniedResponse": {
            "body": "Go away"
        }
    }
}

Cerbos mapping

Return a JSON object that contains a Cerbos CheckRequest and the Envoy check response to send based on the Cerbos response. Synapse sends the Cerbos request to the configured PDP (applying any configured proxy extensions) and responds with the appropriate Envoy response based on whether Cerbos returns ALLOW or DENY.

{
    "envoyCerbosMapping": {
        "checkRequest": { (1)
            "principal": {
                "id": "daffy_duck",
                "roles": ["duck"]
            },
            "resources": [{
                "actions": ["GET"],
                "resource": {
                    "id": "http",
                    "kind": "request",
                    "attr": {
                        "path": "/foo"
                    },
                }
            }]
        },
        "allowResponse": { (2)
            "status": {"code": 0}
        },
        "denyResponse": { (3)
            "status": {"code": 7},
            "deniedResponse": {
                "body": "Go away"
            }
        }
    }
}
1 Cerbos CheckResources request to send
2 Envoy response to send if Cerbos returns EFFECT_ALLOW
3 Envoy response to send if Cerbos returns EFFECT_DENY

Cerbos check request

Return a Cerbos CheckRequest to send to the PDP (applying any configured proxy extensions). In this mode, the extension must implement a second function named envoyMapCerbosResponse to map the CheckResources response to an Envoy response. Use this mode when you need to write complex mapping logic to convert a Cerbos response to an Envoy response.

{
    "cerbosCheckRequest": {
        "principal": {
            "id": "daffy_duck",
            "roles": ["duck"]
        },
        "resources": [{
            "actions": ["GET"],
            "resource": {
                "id": "http",
                "kind": "request",
                "attr": {
                    "path": "/foo"
                },
            }
        }]
    }
}

Synapse will send the above request to the PDP and invoke the extension a second time by calling the envoyMapCerbosResponse function with a pointer to the following JSON-encoded content.

{
    "envoyRequest": { ... }, (1)
    "cerbosRequest": { ... }, (2)
    "cerbosResponse": { ... } (3)
}
1 Original Envoy Check request that triggered this chain
2 Cerbos CheckResources request
3 Cerbos CheckResources response

The envoyMapCerbosResponse function should take the above input and return a pointer to a JSON-encoded Envoy check response which will be sent back to the caller.

{
  "status": {"code": 7},
  "deniedResponse": {
    "body": "Denied by policy secops-global"
  }
}

Proxy extension

A proxy extension must export at least one of the following functions. (It’s legal to implement more than one.)

augmentCheckRequest

Modify a CheckResources request

augmentCheckResponse

Modify a CheckResources response

augmentPlanRequest

Modify a PlanResources request

augmentPlanResponse

Modify a PlanResources response

Each function is invoked with a pointer to a JSON-encoded CheckResources/PlanResources request or response. It must return a pointer to a JSON-encoded value of the same type. Refer to https://docs.cerbos.dev/cerbos/latest/api/#_request_and_response_formats for details about the request and response types.

Route extension

A route extension must export a function named handleHTTPRoute which receives a pointer to the JSON-encoded HTTP request which takes the following form.

{
    "method": "POST", (1)
    "headers": { (2)
        "x-forwarded-for": { "values": ["127.0.0.1:2090"] }
    },
    "rawUrl": "https://example.com/path/to/foo?a=av&b=bv1&b=bv2", (3)
    "host": "example.com", (4)
    "path": "/path/to/foo", (5)
    "queryParams": { (6)
        "a": { "values": ["av"] },
        "b": { "values": ["bv1", "bv2"] }
    },
    "body": "aGVsbG8K" (7)
}
1 HTTP method
2 Header values
3 Full URL of the request
4 Host segment from the URL
5 Path segment from the URL
6 Query parameters from the URL
7 Request body as a base64-encoded string

The return value of the function must be one of the following values.

Direct response

Return a complete HTTP response to be sent back to the caller.

{
    "httpResponse": {
        "status": 401, (1)
        "headers": { (2)
            "content-type": { "values": ["application/text"] }
        },
        "body": "Z28gYXdheQo=" (3)
    }
}
1 HTTP status code
2 Headers to add to the response
3 Base64-encoded body

Cerbos mapping

Return a JSON object that contains a Cerbos CheckResources request and the HTTP response to send based on the Cerbos response. Synapse sends the Cerbos request to the configured PDP (applying any configured proxy extensions) and responds with the appropriate HTTP response based on whether Cerbos returns ALLOW or DENY.

{
    "cerbosMapping": {
        "checkRequest": { (1)
            "principal": {
                "id": "daffy_duck",
                "roles": ["duck"]
            },
            "resources": [{
                "actions": ["GET"],
                "resource": {
                    "id": "http",
                    "kind": "request",
                    "attr": {
                        "path": "/foo"
                    },
                }
            }]
        },
        "allowResponse": { (2)
            "status": 200,
            "body": "aGVsbG8K"
        },
        "denyResponse": { (3)
            "status": 401,
            "body": "Z28gYXdheQo="
        }
    }
}
1 Cerbos CheckResources request
2 HTTP response to send if Cerbos returns EFFECT_ALLOW
3 HTTP response to send if Cerbos resturns EFFECT_DENY

Cerbos request

Return a JSON object that contains the Cerbos CheckResources request. In this mode, the extension must export a second function named handleCerbosResponse that accepts the CheckResources response and returns a HTTP response. Use this mode when you want to construct a complex HTTP response using values from the Cerbos response.

{
    "cerbosMapping": {
        "checkRequest": { (1)
            "principal": {
                "id": "daffy_duck",
                "roles": ["duck"]
            },
            "resources": [{
                "actions": ["GET"],
                "resource": {
                    "id": "http",
                    "kind": "request",
                    "attr": {
                        "path": "/foo"
                    },
                }
            }]
        }
    }
}
1 Cerbos CheckResources request

Synapse sends the Check request to the PDP and then invokes the handleCerbosResponse function of the extension with the following information.

{
    "httpRequest": { ... }, (1)
    "cerbosRequest": { ... }, (2)
    "cerbosResponse": { ... } (3)
}
1 Original HTTP request that triggered this route extension
2 Cerbos CheckResources request
3 Cerbos CheckResources response

handleCerbosResponse should return a HTTP response to send back to the caller.

{
    "status": 401,
    "headers": {
        "content-type": { "values": ["application/json"] }
    },
    "body": "eyJtZXNzYWdlIjogIkRlbmllZCBieSBwb2xpY3kgc2Vjb3BzLWdsb2JhbCJ9Cg=="
}