Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Opa middleware support #156

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,13 @@ jobs:
run: sudo bin/finch-daemon --debug --socket-owner $UID &
- name: Run e2e test
run: sudo make test-e2e
- name: Clean up Daemon socket
run: sudo rm /var/run/finch.sock && sudo rm /run/finch.pid
- name: Verify Rego file presence
run: ls -l ${{ github.workspace }}/sample.rego
- name: Set Rego file path
run: echo "REGO_FILE_PATH=${{ github.workspace }}/sample.rego" >> $GITHUB_ENV
- name: Start finch-daemon with opa Authz
run: sudo bin/finch-daemon --debug --enable-middleware --rego-file ${{ github.workspace }}/sample.rego --socket-owner $UID &
- name: Run opa e2e tests
run: sudo -E make test-e2e-opa
8 changes: 8 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,14 @@ test-e2e: linux
TEST_E2E=1 \
$(GINKGO) $(GFLAGS) ./e2e/...

.PHONY: test-e2e-opa
test-e2e-opa: linux
DOCKER_HOST="unix:///run/finch.sock" \
DOCKER_API_VERSION="v1.43" \
MIDDLEWARE_E2E=1 \
TEST_E2E=1 \
$(GINKGO) $(GFLAGS) ./e2e/...

.PHONY: licenses
licenses:
PATH=$(BIN):$(PATH) go-licenses report --template="scripts/third-party-license.tpl" --ignore github.com/runfinch ./... > THIRD_PARTY_LICENSES
Expand Down
65 changes: 63 additions & 2 deletions api/router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package router

import (
"context"
"errors"
"fmt"
"net/http"
"os"
Expand All @@ -15,6 +16,7 @@ import (
"github.com/moby/moby/api/server/httputils"
"github.com/moby/moby/api/types/versions"

"github.com/open-policy-agent/opa/v1/rego"
"github.com/runfinch/finch-daemon/api/handlers/builder"
"github.com/runfinch/finch-daemon/api/handlers/container"
"github.com/runfinch/finch-daemon/api/handlers/distribution"
Expand All @@ -30,6 +32,14 @@ import (
"github.com/runfinch/finch-daemon/version"
)

var errRego = errors.New("error in rego policy file")
var errInput = errors.New("error in HTTP request")

type inputRegoRequest struct {
Method string
Path string
}

// Options defines the router options to be passed into the handlers.
type Options struct {
Config *config.Config
Expand All @@ -41,16 +51,24 @@ type Options struct {
VolumeService volume.Service
ExecService exec.Service
DistributionService distribution.Service
RegoFilePath string

// NerdctlWrapper wraps the interactions with nerdctl to build
NerdctlWrapper *backend.NerdctlWrapper
}

// New creates a new router and registers the handlers to it. Returns a handler object
// The struct definitions of the HTTP responses come from https://github.com/moby/moby/tree/master/api/types.
func New(opts *Options) http.Handler {
func New(opts *Options) (http.Handler, error) {
r := mux.NewRouter()
r.Use(VersionMiddleware)
if opts.RegoFilePath != "" {
regoMiddleware, err := CreateRegoMiddleware(opts.RegoFilePath)
if err != nil {
return nil, err
}
r.Use(regoMiddleware)
}
vr := types.VersionedRouter{Router: r}

logger := flog.NewLogrus()
Expand All @@ -62,7 +80,7 @@ func New(opts *Options) http.Handler {
volume.RegisterHandlers(vr, opts.VolumeService, opts.Config, logger)
exec.RegisterHandlers(vr, opts.ExecService, opts.Config, logger)
distribution.RegisterHandlers(vr, opts.DistributionService, opts.Config, logger)
return ghandlers.LoggingHandler(os.Stderr, r)
return ghandlers.LoggingHandler(os.Stderr, r), nil
}

// VersionMiddleware checks for the requested version of the api and makes sure it falls within the bounds
Expand Down Expand Up @@ -90,3 +108,46 @@ func VersionMiddleware(next http.Handler) http.Handler {
next.ServeHTTP(w, newReq)
})
}

// CreateRegoMiddleware dynamically parses the rego file at the path specified in options
// and allows or denies the request based on the policy.
// Will return a nil function and an error if the given file path is blank or invalid.
func CreateRegoMiddleware(regoFilePath string) (func(next http.Handler) http.Handler, error) {
if regoFilePath == "" {
return nil, errRego
}

query := "data.finch.authz.allow"
nr := rego.New(
rego.Load([]string{regoFilePath}, nil),
rego.Query(query),
)

preppedQuery, err := nr.PrepareForEval(context.Background())
if err != nil {
return nil, err
}

return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
input := inputRegoRequest{
Method: r.Method,
Path: r.URL.Path,
}

rs, err := preppedQuery.Eval(r.Context(), rego.EvalInput(input))
if err != nil {
response.SendErrorResponse(w, http.StatusInternalServerError, errInput)
return
}

if !rs.Allowed() {
response.SendErrorResponse(w, http.StatusForbidden,
fmt.Errorf("method %s not allowed for path %s", r.Method, r.URL.Path))
return
}
newReq := r.WithContext(r.Context())
next.ServeHTTP(w, newReq)
})
}, nil
}
71 changes: 70 additions & 1 deletion api/router/router_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"

"github.com/containerd/nerdctl/v2/pkg/config"
Expand Down Expand Up @@ -51,8 +53,9 @@ var _ = Describe("version middleware test", func() {
BuilderService: nil,
VolumeService: nil,
NerdctlWrapper: nil,
RegoFilePath: "",
}
h = New(opts)
h, _ = New(opts)
rr = httptest.NewRecorder()
expected = types.VersionInfo{
Platform: struct {
Expand Down Expand Up @@ -126,3 +129,69 @@ var _ = Describe("version middleware test", func() {
Expect(v).Should(Equal(expected))
})
})

// Unit tests for the rego handler.
var _ = Describe("rego middleware test", func() {
var (
opts *Options
rr *httptest.ResponseRecorder
expected types.VersionInfo
sysSvc *mocks_system.MockService
regoFilePath string
)

BeforeEach(func() {
mockCtrl := gomock.NewController(GinkgoT())
defer mockCtrl.Finish()

tempDirPath := GinkgoT().TempDir()
regoFilePath = filepath.Join(tempDirPath, "authz.rego")
os.Create(regoFilePath)

c := config.Config{}
sysSvc = mocks_system.NewMockService(mockCtrl)
opts = &Options{
Config: &c,
SystemService: sysSvc,
}
rr = httptest.NewRecorder()
expected = types.VersionInfo{}
sysSvc.EXPECT().GetVersion(gomock.Any()).Return(&expected, nil).AnyTimes()
})
It("should return a 200 error for calls by default", func() {
h, err := New(opts)
Expect(err).Should(BeNil())

req, _ := http.NewRequest(http.MethodGet, "/version", nil)
h.ServeHTTP(rr, req)

Expect(rr).Should(HaveHTTPStatus(http.StatusOK))
})

It("should return a 400 error for disallowed calls", func() {
regoPolicy := `package finch.authz
import rego.v1

default allow = false`

os.WriteFile(regoFilePath, []byte(regoPolicy), 0644)
opts.RegoFilePath = regoFilePath
h, err := New(opts)
Expect(err).Should(BeNil())

req, _ := http.NewRequest(http.MethodGet, "/version", nil)
h.ServeHTTP(rr, req)

Expect(rr).Should(HaveHTTPStatus(http.StatusForbidden))
})

It("should return an error for poorly formed rego files", func() {
regoPolicy := `poorly formed rego file`

os.WriteFile(regoFilePath, []byte(regoPolicy), 0644)
opts.RegoFilePath = regoFilePath
_, err := New(opts)

Expect(err).Should(Not(BeNil()))
})
})
103 changes: 95 additions & 8 deletions cmd/finch-daemon/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,15 @@ const (
)

type DaemonOptions struct {
debug bool
socketAddr string
socketOwner int
debugAddress string
configPath string
pidFile string
debug bool
socketAddr string
socketOwner int
debugAddress string
configPath string
pidFile string
regoFilePath string
enableMiddleware bool
regoFileLock *flock.Flock
}

var options = new(DaemonOptions)
Expand All @@ -67,6 +70,8 @@ func main() {
rootCmd.Flags().StringVar(&options.debugAddress, "debug-addr", "", "")
rootCmd.Flags().StringVar(&options.configPath, "config-file", defaultConfigPath, "Daemon Config Path")
rootCmd.Flags().StringVar(&options.pidFile, "pidfile", defaultPidFile, "pid file location")
rootCmd.Flags().StringVar(&options.regoFilePath, "rego-file", "", "Rego Policy Path")
rootCmd.Flags().BoolVar(&options.enableMiddleware, "enable-middleware", false, "turn on middleware for allowlisting")
if err := rootCmd.Execute(); err != nil {
log.Printf("got error: %v", err)
log.Fatal(err)
Expand Down Expand Up @@ -193,6 +198,21 @@ func run(options *DaemonOptions) error {
}
}()

defer func() {
if options.regoFileLock != nil {
// unlock the rego file upon daemon exit
if err := options.regoFileLock.Unlock(); err != nil {
logrus.Errorf("failed to unlock Rego file: %v", err)
}
logger.Infof("rego file unlocked")

// make rego file editable upon daemon exit
if err := os.Chmod(options.regoFilePath, 0600); err != nil {
logrus.Errorf("failed to change file permissions of rego file: %v", err)
}
}
}()

sdNotify(daemon.SdNotifyReady, logger)
serverWg.Wait()
logger.Debugln("Server stopped. Exiting...")
Expand All @@ -215,8 +235,20 @@ func newRouter(options *DaemonOptions, logger *flog.Logrus) (http.Handler, error
return nil, err
}

opts := createRouterOptions(conf, clientWrapper, ncWrapper, logger)
return router.New(opts), nil
var regoFilePath string
if options.enableMiddleware {
regoFilePath, err = sanitizeRegoFile(options)
if err != nil {
return nil, err
}
}

opts := createRouterOptions(conf, clientWrapper, ncWrapper, logger, regoFilePath)
newRouter, err := router.New(opts)
if err != nil {
return nil, err
}
return newRouter, nil
}

func handleSignal(socket string, server *http.Server, logger *flog.Logrus) {
Expand Down Expand Up @@ -265,3 +297,58 @@ func defineDockerConfig(uid int) error {
return true
})
}

// checkRegoFileValidity verifies that the given rego file exists and has the right file extension.
func checkRegoFileValidity(filePath string) error {
if _, err := os.Stat(filePath); os.IsNotExist(err) {
return fmt.Errorf("provided Rego file path does not exist: %s", filePath)
}

// Check if the file has a valid extension (.rego)
fileExt := strings.ToLower(filepath.Ext(options.regoFilePath))

if fileExt != ".rego" {
return fmt.Errorf("invalid file extension for Rego file. Only .rego files are supported")
}

return nil
}

// sanitizeRegoFile validates and prepares the Rego policy file for use.
// It checks validates the file, acquires a file lock,
// and sets rego file to be read-only.
func sanitizeRegoFile(options *DaemonOptions) (string, error) {
if options.regoFilePath != "" {
if !options.enableMiddleware {
return "", fmt.Errorf("rego file path was provided without the --enable-middleware flag, please provide the --enable-middleware flag") // todo, can we default to setting this flag ourselves is this better UX?
}

if err := checkRegoFileValidity(options.regoFilePath); err != nil {
return "", err
}
}

if options.enableMiddleware && options.regoFilePath == "" {
return "", fmt.Errorf("rego file path not provided, please provide the policy file path using the --rego-file flag")
}

fileLock := flock.New(options.regoFilePath)

locked, err := fileLock.TryLock()
if err != nil {
return "", fmt.Errorf("error acquiring lock on rego file: %v", err)
}
if !locked {
return "", fmt.Errorf("unable to acquire lock on rego file, it may be in use by another process")
}

// Change file permissions to read-only
err = os.Chmod(options.regoFilePath, 0400)
if err != nil {
fileLock.Unlock()
return "", fmt.Errorf("error changing rego file permissions: %v", err)
}
options.regoFileLock = fileLock

return options.regoFilePath, nil
}
2 changes: 2 additions & 0 deletions cmd/finch-daemon/router_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ func createRouterOptions(
clientWrapper *backend.ContainerdClientWrapper,
ncWrapper *backend.NerdctlWrapper,
logger *flog.Logrus,
regoFilePath string,
) *router.Options {
fs := afero.NewOsFs()
tarCreator := archive.NewTarCreator(ecc.NewExecCmdCreator(), logger)
Expand All @@ -112,5 +113,6 @@ func createRouterOptions(
ExecService: exec.NewService(clientWrapper, logger),
DistributionService: distribution.NewService(clientWrapper, ncWrapper, logger),
NerdctlWrapper: ncWrapper,
RegoFilePath: regoFilePath,
}
}
Loading
Loading