234 lines
6.1 KiB
Go
234 lines
6.1 KiB
Go
package policy
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/go-viper/mapstructure/v2"
|
|
"github.com/open-policy-agent/opa/v1/ast"
|
|
"github.com/open-policy-agent/opa/v1/rego"
|
|
regoprint "github.com/open-policy-agent/opa/v1/topdown/print"
|
|
|
|
"git.maze.io/maze/styx/logger"
|
|
proxy "git.maze.io/maze/styx/proxy"
|
|
)
|
|
|
|
const DefaultPackageName = "styx"
|
|
|
|
var ErrNoResult = errors.New("policy: no result")
|
|
|
|
type Policy struct {
|
|
name string
|
|
options []func(*rego.Rego)
|
|
}
|
|
|
|
func New(name, pkg string) (*Policy, error) {
|
|
p := &Policy{
|
|
name: name,
|
|
options: newRego(rego.Load([]string{name}, nil), pkg),
|
|
}
|
|
if _, err := p.Query(&Input{}); err != nil {
|
|
return nil, err
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
func NewFromString(module, pkg string) (*Policy, error) {
|
|
p := &Policy{
|
|
name: "<inline>",
|
|
options: newRego(rego.Module("styx", module), pkg),
|
|
}
|
|
if _, err := p.Query(&Input{}); err != nil {
|
|
return nil, err
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
func newRego(option func(*rego.Rego), pkg string) []func(*rego.Rego) {
|
|
if pkg == "" {
|
|
pkg = DefaultPackageName
|
|
}
|
|
|
|
capabilities := &ast.Capabilities{
|
|
Builtins: ast.DefaultBuiltins[:], // all builtins
|
|
Features: ast.Features, // all features
|
|
AllowNet: nil, // allow all
|
|
}
|
|
|
|
return []func(*rego.Rego){
|
|
rego.Dump(os.Stderr),
|
|
rego.Query("data." + pkg),
|
|
rego.Strict(true),
|
|
rego.Capabilities(capabilities),
|
|
rego.Function2(domainContainsFunc, domainContains),
|
|
rego.Function2(networkContainsFunc, networkContains),
|
|
rego.Function1(lookupIPAddrFunc, lookupIPAddr),
|
|
rego.Function2(timebetweenFunc, timeBetween),
|
|
rego.PrintHook(printHook{}),
|
|
option,
|
|
}
|
|
}
|
|
|
|
type printHook struct{}
|
|
|
|
func (printHook) Print(ctx regoprint.Context, message string) error {
|
|
logger.StandardLog.Values(logger.Values{
|
|
"where": fmt.Sprintf("%s:%d,%d", ctx.Location.File, ctx.Location.Row, ctx.Location.Col),
|
|
"from": string(ctx.Location.Text),
|
|
}).Debug(message)
|
|
return nil
|
|
}
|
|
|
|
type Result struct {
|
|
// Reject signals explicit rejection.
|
|
Reject int `json:"reject" mapstructure:"reject"`
|
|
|
|
// Permit signals explicit permission.
|
|
Permit *bool `json:"permit" mapstructure:"permit"`
|
|
|
|
// Redirect to this URL.
|
|
Redirect string `json:"redirect" mapstructure:"redirect"`
|
|
|
|
// Template to render as response body.
|
|
Template string `json:"template" mapstructure:"template"`
|
|
|
|
// Errors contains error messages.
|
|
Errors []string `json:"errors" mapstructure:"errors,omitempty"`
|
|
}
|
|
|
|
func (r *Result) Response(ctx proxy.Context) (*http.Response, error) {
|
|
log := logger.StandardLog.Values(logger.Values{
|
|
"id": ctx.ID(),
|
|
"client": ctx.RemoteAddr().String(),
|
|
})
|
|
for _, text := range r.Errors {
|
|
log.Err(errors.New(text)).Warn("Error from policy")
|
|
}
|
|
|
|
switch {
|
|
case r.Redirect != "":
|
|
log.Value("location", r.Redirect).Trace("Creating a HTTP redirect response")
|
|
response := proxy.NewResponse(http.StatusFound, nil, ctx.Request())
|
|
response.Header.Set("Server", "styx")
|
|
response.Header.Set(proxy.HeaderLocation, r.Redirect)
|
|
return response, nil
|
|
|
|
case r.Template != "":
|
|
log = log.Value("template", r.Template)
|
|
log.Trace("Creating a HTTP template response")
|
|
|
|
b := new(bytes.Buffer)
|
|
t, err := template.New(filepath.Base(r.Template)).ParseFiles(r.Template)
|
|
if err != nil {
|
|
log.Err(err).Warn("Error loading template in response")
|
|
return nil, err
|
|
}
|
|
t = t.Funcs(template.FuncMap{
|
|
"tohex": func(v any) string { return fmt.Sprintf("%x", v) },
|
|
})
|
|
if err = t.Execute(b, map[string]any{
|
|
"Context": ctx,
|
|
"Request": ctx.Request(),
|
|
"Response": ctx.Response(),
|
|
"Errors": r.Errors,
|
|
}); err != nil {
|
|
log.Err(err).Warn("Error rendering template response")
|
|
return nil, err
|
|
}
|
|
|
|
response := proxy.NewResponse(http.StatusFound, io.NopCloser(b), ctx.Request())
|
|
response.Header.Set("Server", "styx")
|
|
response.Header.Set(proxy.HeaderContentType, "text/html")
|
|
return response, nil
|
|
|
|
case r.Reject > 0:
|
|
log.Value("code", r.Reject).Trace("Creating a HTTP reject response")
|
|
body := io.NopCloser(bytes.NewBufferString(http.StatusText(r.Reject)))
|
|
response := proxy.NewResponse(r.Reject, body, ctx.Request())
|
|
response.Header.Set(proxy.HeaderContentType, "text/plain")
|
|
return response, nil
|
|
|
|
case r.Permit != nil && !*r.Permit:
|
|
log.Trace("Creating a HTTP reject response due to explicit not permit")
|
|
body := io.NopCloser(bytes.NewBufferString(http.StatusText(http.StatusForbidden)))
|
|
response := proxy.NewResponse(http.StatusForbidden, body, ctx.Request())
|
|
response.Header.Set(proxy.HeaderContentType, "text/plain")
|
|
return response, nil
|
|
|
|
default:
|
|
log.Trace("Not creating a HTTP response")
|
|
return nil, nil
|
|
}
|
|
}
|
|
|
|
func (p *Policy) Query(input *Input, options ...func(*rego.Rego)) (*Result, error) {
|
|
log := logger.StandardLog.Value("policy", p.name)
|
|
log.Trace("Evaluating policy")
|
|
|
|
var regoOptions = append(p.options, rego.Input(input))
|
|
for _, option := range options {
|
|
regoOptions = append(regoOptions, option)
|
|
}
|
|
|
|
var (
|
|
rego = rego.New(regoOptions...)
|
|
ctx = context.Background()
|
|
rs, err = rego.Eval(ctx)
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(rs) == 0 || len(rs[0].Expressions) == 0 {
|
|
return nil, ErrNoResult
|
|
}
|
|
result := &Result{}
|
|
for _, expr := range rs[0].Expressions {
|
|
if m, ok := expr.Value.(map[string]any); ok {
|
|
// Remove private variables.
|
|
for k := range m {
|
|
if len(k) > 0 && k[0] == '_' {
|
|
delete(m, k)
|
|
}
|
|
}
|
|
log.Values(m).Trace("Policy result expression")
|
|
if err = mapstructure.Decode(m, result); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// PackageFromFile reads the "package" stanza from the provided Rego policy file.
|
|
//
|
|
// If no stanza can be found, an error is returned.
|
|
func PackageFromFile(name string) (string, error) {
|
|
f, err := os.Open(name)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
|
|
scanner := bufio.NewScanner(f)
|
|
for scanner.Scan() {
|
|
text := strings.TrimSpace(scanner.Text())
|
|
part := strings.Fields(text)
|
|
if len(part) > 1 && part[0] == "package" {
|
|
return part[1], nil
|
|
}
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return "", err
|
|
}
|
|
return "", fmt.Errorf("policy: can't detemine package name of %s", name)
|
|
}
|