-
Notifications
You must be signed in to change notification settings - Fork 66
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add package starformer, uses Starlark for user-defined authn filters
- Loading branch information
Showing
4 changed files
with
546 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,183 @@ | ||
// 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 | ||
} |
Oops, something went wrong.