Developing Starlark extensions :: Cerbos Authorization Management Platform // Documentation

Developing Starlark extensions

Starlark is a lightweight Python dialect that can be used to drive the Synapse extension logic. They are easy to write and test because they do not require any intermediate compilation stages or specialised tools.

For an end-to-end walkthrough of building a Starlark proxy extension and running it against Synapse, see the Quick Start. This page is the reference for the Starlark runtime and the request/response shapes each extension type receives.

To use a Starlark script as the extension implementation, set the extensionURL configuration field to a valid extension URL in one of the following forms:

All Synapse extension points support running Starlark scripts. Each request is served by a fresh instance of a script and there’s no shared global state. Each extension type requires a specific set of functions that must be implemented by the script. See below for details about the API contracts for each extension type.

Starlark primer

Starlark is a lightweight dialect of Python with syntax compatible with Python 3. Refer to the language specification for supported operators, keywords and types. Synapse extends the base language with extra functions and loadable modules that are useful for authoring extensions. The built-in REPL can be used to experiment with the language and debug your scripts.

References

Constructing input/output messages for extensions

The API contracts for the extensions require constructing specific message types whose schemas are defined as protocol buffers (protobufs). The mapping rules for constructing protobuf values in Starlark are as follows:

Protobuf type Starlark constructor
Scalar value types such as strings and ints Corresponding Starlark scalar type
Message objects struct()
Maps dict or the {} short-hand
repeated fields list or the [] short-hand
Enums String value of the enum
google.protobuf.Value Corresponding Starlark scalar type

For example, a Cerbos CheckResources request would be constructed as follows:

struct( (1)
    request_id = "test", (2)
    principal = struct(id = "john", roles = ["employee"]) <3>,
    resources = [struct( (4)\
        actions = ["view"], (5)\
        resource = struct(\
            kind = "invoice",\
            id = "XX125",\
            attr = { (6)\
                "owner": "john",\
                "department": "IT",\
                "geography": "GB",\
                "groups": ["it_admins", "employees"]\
            }\
        )\
    )]
)

API contracts for extensions

Data source extensions

A script that implements a data source must export a function named lookup. This function will be called by Synapse with an object in the following shape:

Input

struct(
    data_source = "myDataSource", (1)
    query = "SELECT department FROM employees WHERE id = :employee_id", (2)
    query_parameters = { (3)
        "employee_id": "simon"
    },
    cache_options = struct( (4)
        cache_key = "simon", (5)
        cache_expiry = time.minute, (6)
        if_not_exists = True (7)
    )
)

The function must return a struct that contains a field named result with the result of the lookup.

def lookup(req):
    result = {"output": "hello from starlark lookup", "query": req.query}
    return struct(result = result)

Envoy extension

An Envoy extension must export a function named envoy_check that accepts an object in the shape of Envoy check request. The return value from the function should be one of the following:

Direct response An Envoy Check response that will be sent as-is back to Envoy.
Cerbos mapping Construct a Cerbos CheckResources request from the Envoy Check request and the Check responses corresponding to ALLOW and DENY outcomes. Synapse handles sending the Cerbos request to the PDP and sending the Envoy response that corresponds to the result.
Cerbos request Construct a Cerbos CheckResources request from the Envoy Check request. Synapse sends the request to the PDP and invokes the extension again with the result. This second invocation allows the extension to produce a dynamic Check response by injecting values from the Cerbos response into it (e.g. policy outputs).

Direct response

Return a complete Envoy check response to return back to the caller. This is useful for situations where you know the exact response to send such as a blanket deny for a hard-coded path.

def envoy_check(req):
    path = req.attributes.request.http.path
    # Deny the request if the path starts with /restricted
    if path.startswith("/restricted/"):
        return struct(envoy_check_response = struct(
            status = struct(code = 7),
            denied_response = struct(body = "Go away")
        ))

# Allow the request to go through with one of the headers removed
    return struct(envoy_check_response = struct(
        status = struct(code = 0),
        ok_response = struct(
            headers_to_remove = ["x-confidential-header"]
        )
    ))

The following example invokes a PDP call for any paths that are in the secure_paths configuration value of the extension.

Extension configuration

extensions:
  envoyExternalAuthz:
    enabled: true
    extension:
      extensionURL: /extensions/envoy.star
      configuration:
        secure_paths:
          - "/admin/dashboard"
          - "/secure/path"

Example

def envoy_check(req):
    http_req = req.attributes.request.http
    path = http_req.path

if path in context.extension_config["secure_paths"]:
        return struct(cerbos_mapping = struct(
            check_request = struct(
                principal = struct(id = "daffy", roles = ["user"]),
                resources = [struct(
                    resource = struct(id = "x", kind = "request", attr = {"path": path}),
                    actions = [http_req.method]
                )]
            ),
            allow_response = struct(
                status = struct(code = 0),
                ok_response = struct(
                    headers_to_remove = ["bar"]
                )
            ),
            deny_response = struct(
                status = struct(code = 7),
                denied_response = struct(
                    body = "no go"
                )
            )
        ))

return struct(envoy_check_response = struct(
        status = struct(code = 0),
        ok_response = struct(
            headers_to_remove = ["foo"]
        )
    ))

Cerbos mapping

Return a struct 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.

def envoy_check(req):
    # Add code here to inspect the request and extract the information you need to pass to Cerbos

# Return the Cerbos request and the corresponding Envoy response to return based on the decision from the PDP
    return struct(cerbos_mapping = struct(
        check_request = struct( (1)
            principal = struct(
                id = "daffy",
                roles = ["duck"]
            ),
            resources = [
                struct(
                    actions = ["GET"],
                    resource = struct(
                        id = "http",
                        kind = "request",
                        attr = {
                            "path": "/foo"
                        }
                    )
                )
            ]
        ),
        allow_response = struct( (2)
            status = struct(code = 0)
        ),
        deny_response = struct( (3)
            status = struct(code = 7),
            denied_response = struct(body = "Go away")
        )
    ))

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 map_cerbos_response 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.

def envoy_check(req): (1)
    # Add code here to inspect the request and extract the information you need to pass to Cerbos

# Return the Cerbos request
    return struct(cerbos_check_request = struct(
        principal = struct(
            id = "daffy",
            roles = ["duck"]
        ),
        resources = [
            struct(
                actions = ["GET"],
                resource = struct(
                    id = "http",
                    kind = "request",
                    attr = {
                        "path": "/foo"
                    }
                )
            )
        ]
    ))

def map_cerbos_response(resp): (2)
    # Inspect the Cerbos response here and construct the appropriate Envoy response

# Return the constructed Envoy response
    return struct(
        status = struct(code = 0),
        ok_response = struct(
            headers_to_remove = ["baz"]
        )
    )

Proxy extensions

A proxy extension must export at least one of the following functions.

augment_authzen_evaluation_batch_request Modify an AuthZEN AccessEvaluations request
augment_authzen_evaluation_batch_response Modify an AuthZEN AccessEvaluations response
augment_authzen_evaluation_request Modify an AuthZEN AccessEvaluation request
augment_authzen_evaluation_response Modify an AuthZEN AccessEvaluation response
augment_check_request Modify a CheckResources request
augment_check_response Modify a CheckResources response
augment_plan_request Modify a PlanResources request
augment_plan_response Modify a PlanResources response

Each function is invoked with an object in the shape of a AccessEvaluation/CheckResources/PlanResources request or response. The function can modify the object as required and return it back.

Route extensions

A route extension must export a function named handle_http_route which receives an object describing the HTTP request received and return one of the supported result types to produce the HTTP response.

Direct response A HTTP response that will be sent as-is back to the caller.
Cerbos mapping Construct a Cerbos CheckResources request from the HTTP request and the HTTP responses corresponding to ALLOW and DENY outcomes. Synapse handles sending the Cerbos request to the PDP and responding to the caller with the HTTP response that corresponds to the result.
Cerbos request Construct a Cerbos CheckResources request from the HTTP request. Synapse sends the request to the PDP and invokes the extension again with the result. This second invocation allows the extension to produce a dynamic HTTP response by injecting values from the Cerbos response into it (e.g. policy outputs).

Route extensions are mounted under the /ext/ path prefix at the Synapse listen address, and that prefix is not stripped before the request reaches the extension. For a route configured as /foo and a client request to POST /ext/foo?a=av&b=bv1&b=bv2, the input takes the following form.

struct(
    method = "POST", (1)
    headers = { (2)
        "X-Forwarded-For": struct(values = ["127.0.0.1:2090"])
    },
    raw_url = "https://example.com/ext/path/to/foo?a=av&b=bv1&b=bv2", (3)
    host = "example.com", (4)
    path = "/ext/path/to/foo", (5)
    query_params = { (6)
        "a": struct(values = ["av"]),
        "b": struct(values = ["bv1", "bv2"])
    },
    body = "Hello" (7)
)

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

Direct response

Return the complete HTTP response that should be sent back to the caller.

def handle_http_route(req):
    # Add code here to process the request and determine a response.

# Return the response
    return struct(http_response = struct(
        status = 401, (1)
        headers = { (2)
            "Content-Type": struct(values = ["application/text"])
        },
        body = "Access denied" (3)
    ))

Cerbos mapping

Return an object that contains a Cerbos CheckResources request and the HTTP response to send based on the response to that Cerbos request. 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.

def handle_http_route(req):
    # Add code here to examine the request and construct a Cerbos CheckResources request from it.

# Return the Cerbos request and the HTTP response to send based on the Cerbos response.
    return struct(cerbos_mapping = struct(
        check_request = struct( (1)
            principal = struct(
                id = "daffy",
                roles = ["duck"]
            ),
            resources = [struct(
                resource = struct(id = "x", kind = "request", attr = {"path": req.path}),
                actions = [req.method]
            )]
        ),
        allow_response = struct(
            status = 200,
            body = "Welcome"
        ),
        deny_response = struct(
            status = 401,
            body = "Access denied"
        )
    ))

Cerbos request

Return an object that contains a Cerbos CheckResources request. In this mode, the extension must export a second function named handle_cerbos_response 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.

def handle_http_route(req):
    # Add code here to inspect the request and construct a Cerbos CheckResources request from it.

# Return the Cerbos request constructed above
    return struct(check_request = struct(
        principal = struct(
            id = "daffy",
            roles = ["duck"]
        ),
        resources = [
            struct(
                actions = ["GET"],
                resource = struct(
                    id = "http",
                    kind = "request",
                    attr = {
                        "path": "/foo"
                    }
                )
            )
        ]
    ))

def handle_cerbos_response(resp):
    # Add code here to inspect the Cerbos response and construct the appropriate HTTP response

# Return the constructed HTTP response
    return struct(http_response = struct(
        status = 401,
        headers = {
            "Content-Type": struct(values = ["application/text"])
        },
        body = "Access denied"
    ))

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

struct(
    http_request = struct( ... ), (1)
    cerbos_request = struct( ... ), (2)
    cerbos_response = struct( ... ) (3)
)

Starlark REPL

Synapse ships with a REPL (read, evaluate, print loop) to help with debugging Starlark scripts.

Synapse starlark repl [--exec] [SCRIPT_FILE]

Without any arguments, the command starts the REPL where you can write expressions and evaluate them. If started with a script file as the argument, it executes the script and populates the globals with symbols exported by the script (variables, function definitions and so on). If the --exec argument is provided, the REPL exits after executing the script and printing its outputs.

Press Ctrl + D to exit the REPL. You can load script files from disk using the load function.

>>> load("my_script.star", "my_script")
>>> my_script.foo("bar") # Executes the `foo` function exported by the script.

Starlark standard environment

In addition to the built-in language constructs, the following variables and functions are available to use by any script.

Name Description
cerbos.cache_delete(key) Delete the cached value from Synapse cache
cerbos.cache_get(key) Return the cached value from Synapse cache
cerbos.cache_set(key, value, expiry?, if_not_exists?) Save a value (string or bytes) to shared Synapse cache with optional expiry and existence check
cerbos.check_resources(req) Do a CheckResources call to the PDP. Use the struct function to construct the request
cerbos.data_source_lookup(datasource, query, query_parameters?, cache_key?, cache_expiry?, cache_if_not_exists?) Do a lookup using one of the configured data sources. Cache options are passed as flat keyword arguments on the caller side; the inbound request struct received by a data source’s lookup function nests them under cache_options
cerbos.plan_resources(req) Do a PlanResources call to the PDP. Use the struct function to construct the request
context.extension_config The optional configuration map attached to the extension definition in the Synapse configuration file
context.extension_kind The kind of the extension. One of datasource, envoy, proxy or route
context.extension_name The name of this extension as defined in the Synapse configuration file
math.ceil(x) Ceiling of x
math.fabs(x) Absolute value of x as a float
math.floor(x) Floor of x
math.mod(x, y) Value of x modulo y
math.pow(x, y) Value of x raised to the power of y
math.remainder(x, y) Remainder of x/y
math.round(x) Round x to nearest integer
struct() Create structs. E.g. struct(k1 = "foo", k2 = "bar)
time.from_timestamp(sec, nsec?) Converts the given Unix time corresponding to the number of seconds and (optionally) nanoseconds since January 1, 1970 UTC into an object of type Time
time.hour A constant representing a duration of one hour
time.is_valid_timezone(loc) Reports whether loc is a valid time zone name
time.microsecond A constant representing a duration of one microsecond
time.millisecond A constant representing a duration of one millisecond
time.minute A constant representing a duration of one minute
time.nanosecond A constant representing a duration of one nanosecond
time.now() Returns the current local time
time.parse_duration(d) Parses the given duration string. For more details, refer to https://pkg.go.dev/time#ParseDuration
time.parse_time(x, format?, location?) Parses the given time string using a specific time format and location. The expected arguments are a time string (mandatory), a time format (optional, set to RFC3339 by default, e.g. "2021-03-22T23:20:50.52Z") and a name of location (optional, set to UTC by default). For more details, refer to https://pkg.go.dev/time#Parse and https://pkg.go.dev/time#ParseInLocation.
time.second A constant representing a duration of one second
time.time(year?, month?, day?, hour?, minute?, second?, nanosecond?, location?) Returns the Time corresponding to yyyy-mm-dd hh:mm:ss + nsec nanoseconds in the appropriate zone for that time in the given location. All the parameters are optional.