184 lines
8.6 KiB
Go
184 lines
8.6 KiB
Go
|
// Copyright 2021 the Pinniped contributors. All Rights Reserved.
|
||
|
// SPDX-License-Identifier: Apache-2.0
|
||
|
|
||
|
// Package starformer is an implementation of UpstreamToDownstreamTransformer using Starlark scripts.
|
||
|
// See Starlark dialect language documentation here: https://github.com/google/starlark-go/blob/master/doc/spec.md
|
||
|
// A video introduction to Starlark and how to integrate it into projects is here: https://www.youtube.com/watch?v=9P_YKVhncWI
|
||
|
package starformer
|
||
|
|
||
|
import (
|
||
|
"fmt"
|
||
|
|
||
|
"go.starlark.net/lib/json"
|
||
|
starlarkmath "go.starlark.net/lib/math"
|
||
|
"go.starlark.net/lib/time"
|
||
|
"go.starlark.net/resolve"
|
||
|
"go.starlark.net/starlark"
|
||
|
|
||
|
"go.pinniped.dev/internal/plog"
|
||
|
)
|
||
|
|
||
|
const (
|
||
|
maxExecutionSteps = 10000000
|
||
|
transformFunctionName = "transform"
|
||
|
)
|
||
|
|
||
|
// Configure some global variables in starlark-go.
|
||
|
// nolint:gochecknoinits // wish these weren't globals but oh well
|
||
|
func init() {
|
||
|
// Allow the non-standard "set" data structure to be used.
|
||
|
resolve.AllowSet = true
|
||
|
|
||
|
// Note that we could allow "while" statements and recursive functions, but the language already
|
||
|
// has "for" loops so it seems unnecessary for our use case. This is currently the default
|
||
|
// value in starlark-go but repeating it here as documentation.
|
||
|
resolve.AllowRecursion = false
|
||
|
}
|
||
|
|
||
|
type Transformer struct {
|
||
|
hook *starlark.Function
|
||
|
}
|
||
|
|
||
|
// New creates an instance of Transformer. Given some Starlark source code as a string, it loads the code.
|
||
|
// If there is any error during loading, it will return the error. It expects the loaded code to define
|
||
|
// a Starlark function called "transform" which should take two positional arguments. The returned
|
||
|
// Transformer can be safely called from multiple threads simultaneously, no matter how the Starlark
|
||
|
// source code was written, because the Starlark module has been frozen (made immutable).
|
||
|
func New(starlarkSourceCode string) (*Transformer, error) {
|
||
|
// Create a Starlark thread in which the source will be loaded.
|
||
|
thread := &starlark.Thread{
|
||
|
Name: "starlark script loader",
|
||
|
Print: func(thread *starlark.Thread, msg string) {
|
||
|
// When the script has a top-level print(), send it to the server log.
|
||
|
plog.Debug("debug message while loading starlark transform script", "msg", msg)
|
||
|
},
|
||
|
Load: func(thread *starlark.Thread, module string) (starlark.StringDict, error) {
|
||
|
// Allow starlark-go's custom built-in modules to be loaded by scripts if they desire.
|
||
|
switch module {
|
||
|
case "json.star":
|
||
|
return starlark.StringDict{"json": json.Module}, nil
|
||
|
case "time.star":
|
||
|
return starlark.StringDict{"time": time.Module}, nil
|
||
|
case "math.star":
|
||
|
return starlark.StringDict{"math": starlarkmath.Module}, nil
|
||
|
default:
|
||
|
// Don't allow any other file to be loaded.
|
||
|
return nil, fmt.Errorf("only the following modules may be loaded: json.star, time.star, math.star")
|
||
|
}
|
||
|
},
|
||
|
}
|
||
|
|
||
|
// Prevent the top-level statements of the Starlark script from accidentally running forever.
|
||
|
thread.SetMaxExecutionSteps(maxExecutionSteps)
|
||
|
|
||
|
// Start with empty predeclared names, aside from the built-ins.
|
||
|
predeclared := starlark.StringDict{}
|
||
|
|
||
|
// Load a Starlark script. Initialization of a script runs its top-level statements from top to bottom,
|
||
|
// and then "freezes" all of the values making them immutable. The result can be used in multiple threads
|
||
|
// simultaneously without interfering, communicating, or racing with each other. The filename given here
|
||
|
// will appear in some Starlark error messages.
|
||
|
globals, err := starlark.ExecFile(thread, "transform.star", starlarkSourceCode, predeclared)
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("error while loading starlark transform script: %w", err)
|
||
|
}
|
||
|
|
||
|
// Get the function called "transform" from the global state of the module that was just loaded.
|
||
|
hook, _ := globals[transformFunctionName].(*starlark.Function)
|
||
|
if hook == nil {
|
||
|
return nil, fmt.Errorf("starlark script does not define %q function", transformFunctionName)
|
||
|
}
|
||
|
|
||
|
// Check that the "transform" function takes the expected number of arguments so we can call it later.
|
||
|
if hook.NumParams() != 2 {
|
||
|
return nil, fmt.Errorf("starlark script's global %q function has %d parameters but should have 2", transformFunctionName, hook.NumParams())
|
||
|
}
|
||
|
|
||
|
return &Transformer{hook: hook}, nil
|
||
|
}
|
||
|
|
||
|
// Transform calls the Starlark "transform" function that was loaded by New. The username and groups params are
|
||
|
// passed into the Starlark function, and the return values of the Starlark function are returned. If there is an error
|
||
|
// during the call to the Starlark function (either a programming error, a runtime error, or an intentional call to
|
||
|
// Starlark's `fail` built-in function) then Transform will return the error. This function is thread-safe.
|
||
|
// The runtime of this function depends on the complexity of the Starlark source code, but for a typical Starlark
|
||
|
// function will be something on the order of 50µs on a modern laptop.
|
||
|
func (t *Transformer) Transform(username string, groups []string) (string, []string, error) {
|
||
|
// TODO: maybe add a context param for cancellation, which is supported in starlark-go by
|
||
|
// calling thread.Cancel() from any goroutine, or maybe this doesn't matter because there is
|
||
|
// already a maxExecutionSteps so scripts are guaranteed to finish within a reasonable time.
|
||
|
|
||
|
// Create a Starlark thread in which the function will be called.
|
||
|
thread := &starlark.Thread{
|
||
|
Name: "starlark script executor",
|
||
|
Print: func(thread *starlark.Thread, msg string) {
|
||
|
// When the script's 'transform' function has a print(), send it to the server log.
|
||
|
plog.Debug("debug message while running starlark transform script", "msg", msg)
|
||
|
},
|
||
|
}
|
||
|
|
||
|
// Prevent the Starlark function from accidentally running forever.
|
||
|
thread.SetMaxExecutionSteps(maxExecutionSteps)
|
||
|
|
||
|
// Prepare the function arguments as Starlark values.
|
||
|
groupsTuple := starlark.Tuple{}
|
||
|
for _, group := range groups {
|
||
|
groupsTuple = append(groupsTuple, starlark.String(group))
|
||
|
}
|
||
|
args := starlark.Tuple{starlark.String(username), groupsTuple}
|
||
|
|
||
|
// Call the Starlark hook function in the new thread and pass the arguments.
|
||
|
// Get back the function's return value or an error.
|
||
|
hookReturnValue, err := starlark.Call(thread, t.hook, args, nil)
|
||
|
|
||
|
// Errors could be programming mistakes in the script, or could be an intentional usage of the `fail` built-in.
|
||
|
// Either way, return an error to reject the login.
|
||
|
if err != nil {
|
||
|
return "", nil, fmt.Errorf("error while running starlark %q function: %w", transformFunctionName, err)
|
||
|
}
|
||
|
|
||
|
// The special Starlark value 'None' is interpreted here as a shortcut to mean make no edits.
|
||
|
if hookReturnValue == starlark.None {
|
||
|
return username, groups, nil
|
||
|
}
|
||
|
|
||
|
// TODO: maybe offer a way for the user to reject a login with a nice error message which we can distinguish from
|
||
|
// an accidental coding error, for example by returning a single string from their 'transform' function instead
|
||
|
// of a tuple, or by returning a special value that we set up in the module's state in advance like
|
||
|
// `return rejectAuthentication(message)`
|
||
|
|
||
|
// Otherwise the function should have returned a tuple with two values.
|
||
|
returnedTuple, ok := hookReturnValue.(starlark.Tuple)
|
||
|
if !ok || returnedTuple.Len() != 2 {
|
||
|
return "", nil, fmt.Errorf("expected starlark %q function to return None or a Tuple of length 2", transformFunctionName)
|
||
|
}
|
||
|
|
||
|
// The first value in the returned tuple is the username. Turn it back into a golang string.
|
||
|
transformedUsername, ok := starlark.AsString(returnedTuple.Index(0))
|
||
|
if !ok || len(transformedUsername) == 0 {
|
||
|
return "", nil, fmt.Errorf("expected starlark %q function's return tuple to have a non-empty string as the first value", transformFunctionName)
|
||
|
}
|
||
|
|
||
|
// The second value in the returned tuple is an iterable of group names.
|
||
|
returnedGroups, ok := returnedTuple.Index(1).(starlark.Iterable)
|
||
|
if !ok {
|
||
|
return "", nil, fmt.Errorf("expected starlark %q function's return tuple to have an iterable value as the second value", transformFunctionName)
|
||
|
}
|
||
|
|
||
|
// Turn the returned iterable of group names back into a golang []string, including turning an empty iterable into an empty slice.
|
||
|
transformedGroupNames := []string{}
|
||
|
groupsIterator := returnedGroups.Iterate()
|
||
|
defer groupsIterator.Done()
|
||
|
var transformedGroup starlark.Value
|
||
|
for groupsIterator.Next(&transformedGroup) {
|
||
|
transformedGroupName, ok := starlark.AsString(transformedGroup)
|
||
|
if !ok || len(transformedGroupName) == 0 {
|
||
|
return "", nil, fmt.Errorf("expected starlark %q function's return tuple's second value to contain only non-empty strings", transformFunctionName)
|
||
|
}
|
||
|
transformedGroupNames = append(transformedGroupNames, transformedGroupName)
|
||
|
}
|
||
|
|
||
|
// Got username and group names, so return them as the transformed values.
|
||
|
return transformedUsername, transformedGroupNames, nil
|
||
|
}
|