Skip to content
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
12 changes: 12 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,19 @@ jobs:
run: |
sudo ls /etc/cni/net.d
sudo rm /etc/cni/net.d/87-podman-bridge.conflist
- name: Verify Rego file presence
run: ls -l ${{ github.workspace }}/docs/sample-rego-policies/example.rego
- name: Set Rego file path
run: echo "REGO_FILE_PATH=${{ github.workspace }}/docs/sample-rego-policies/example.rego" >> $GITHUB_ENV
- name: Start finch-daemon with opa Authz
run: sudo bin/finch-daemon --debug --experimental --rego-file ${{ github.workspace }}/docs/sample-rego-policies/example.rego --skip-rego-perm-check --socket-owner $UID --socket-addr /run/finch.sock --pidfile /run/finch.pid &
- name: Run opa e2e tests
run: sudo -E make test-e2e-opa
- name: Clean up Daemon socket
run: sudo rm /run/finch.sock && sudo rm /run/finch.pid
- name: Start finch-daemon
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
11 changes: 10 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,15 @@ 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.41" \
MIDDLEWARE_E2E=1 \
TEST_E2E=0 \
DAEMON_ROOT="$(BIN)/finch-daemon" \
$(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 All @@ -126,4 +135,4 @@ coverage: linux
.PHONY: release
release: linux
@echo "$@"
@$(FINCH_DAEMON_PROJECT_ROOT)/scripts/create-releases.sh $(RELEASE_TAG)
@$(FINCH_DAEMON_PROJECT_ROOT)/scripts/create-releases.sh $(RELEASE_TAG)
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,32 @@ Getting started with Finch Daemon on Linux only requires a few steps:
5. Test any changes with `make test-unit` and `sudo make test-e2e`


## Experimental Features

Finch Daemon includes experimental features that can be enabled using the `--experimental` flag. These features are under development and may change in future releases.

### Using Experimental Features

To enable experimental features, use the `--experimental` flag when starting the daemon:

```bash
sudo bin/finch-daemon --debug --socket-owner $UID --experimental
```

### Current Experimental Features

#### OPA Authorization Middleware

The OPA (Open Policy Agent) middleware allows you to define authorization policies for API requests using Rego policy language. This feature requires both the `--experimental` flag and the `--rego-file` flag to be set.

Example usage:
```bash
sudo bin/finch-daemon --debug --socket-owner $UID --experimental --rego-file /path/to/policy.rego
```

For detailed documentation on the OPA middleware, see [opa-middleware.md](docs/opa-middleware.md).


## Creating a systemd service
If you want finch-daemon to be managed as a systemd service, for benefits like automatic
restart if it gets killed, you can configure it as a systemd service on Linux by
Expand Down
75 changes: 72 additions & 3 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,19 +51,28 @@ 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)
vr := types.VersionedRouter{Router: r}

logger := flog.NewLogrus()

if opts.RegoFilePath != "" {
regoMiddleware, err := CreateRegoMiddleware(opts.RegoFilePath, logger)
if err != nil {
return nil, err
}
r.Use(regoMiddleware)
}
vr := types.VersionedRouter{Router: r}
system.RegisterHandlers(vr, opts.SystemService, opts.Config, opts.NerdctlWrapper, logger)
image.RegisterHandlers(vr, opts.ImageService, opts.Config, logger)
container.RegisterHandlers(vr, opts.ContainerService, opts.Config, logger)
Expand All @@ -62,7 +81,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 +109,53 @@ func VersionMiddleware(next http.Handler) http.Handler {
next.ServeHTTP(w, newReq)
})
}

// CreateRegoMiddleware dynamically parses the rego file at the path specified in options
// and return a function that 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, logger *flog.Logrus) (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,
}

logger.Debugf("OPA input being evaluated: Method=%s, Path=%s", input.Method, input.Path)

rs, err := preppedQuery.Eval(r.Context(), rego.EvalInput(input))
if err != nil {
logger.Errorf("OPA policy evaluation failed: %v", err)
response.SendErrorResponse(w, http.StatusInternalServerError, errInput)
return
}

logger.Debugf("OPA evaluation results: %+v", rs)

if !rs.Allowed() {
logger.Infof("OPA request denied: Method=%s, Path=%s", r.Method, r.URL.Path)
response.SendErrorResponse(w, http.StatusForbidden,
fmt.Errorf("method %s not allowed for path %s", r.Method, r.URL.Path))
return
}
logger.Debugf("OPA request allowed: Method=%s, Path=%s", r.Method, r.URL.Path)
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()))
})
})
42 changes: 34 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
enableExperimental bool
skipRegoPermCheck bool
}

var options = new(DaemonOptions)
Expand All @@ -67,6 +70,10 @@ 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 (requires --experimental flag)")
rootCmd.Flags().BoolVar(&options.skipRegoPermCheck, "skip-rego-perm-check", false, "skip the rego file permission check (allows permissions more permissive than 0600)")
rootCmd.Flags().BoolVar(&options.enableExperimental, "experimental", false, "enable experimental features")

if err := rootCmd.Execute(); err != nil {
log.Printf("got error: %v", err)
log.Fatal(err)
Expand Down Expand Up @@ -215,8 +222,27 @@ 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.regoFilePath != "" {
if !options.enableExperimental {
return nil, fmt.Errorf("rego file provided without experimental flag - OPA middleware is an experimental feature, please enable it with '--experimental' flag")
}
regoFilePath, err = checkRegoFileValidity(options, logger)
if err != nil {
return nil, err
}
} else if options.enableExperimental {
// Only experimental flag set
logger.Info("experimental flag passed, but no experimental features enabled")
}

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
Loading
Loading