Skip to content

Commit 91b9ac6

Browse files
authored
feat: Opa middleware support (Experimental) (runfinch#156)
* feat: add opa allowlisting support * chore: add e2e tests * chore: add experimental documentation --------- Signed-off-by: Arjun Raja Yogidas <[email protected]>
1 parent 98b196f commit 91b9ac6

File tree

15 files changed

+928
-81
lines changed

15 files changed

+928
-81
lines changed

.github/workflows/ci.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,19 @@ jobs:
105105
run: |
106106
sudo ls /etc/cni/net.d
107107
sudo rm /etc/cni/net.d/87-podman-bridge.conflist
108+
- name: Verify Rego file presence
109+
run: ls -l ${{ github.workspace }}/docs/sample-rego-policies/example.rego
110+
- name: Set Rego file path
111+
run: echo "REGO_FILE_PATH=${{ github.workspace }}/docs/sample-rego-policies/example.rego" >> $GITHUB_ENV
112+
- name: Start finch-daemon with opa Authz
113+
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 &
114+
- name: Run opa e2e tests
115+
run: sudo -E make test-e2e-opa
116+
- name: Clean up Daemon socket
117+
run: sudo rm /run/finch.sock && sudo rm /run/finch.pid
108118
- name: Start finch-daemon
109119
run: sudo bin/finch-daemon --debug --socket-owner $UID &
110120
- name: Run e2e test
111121
run: sudo make test-e2e
122+
- name: Clean up Daemon socket
123+
run: sudo rm /var/run/finch.sock && sudo rm /run/finch.pid

Makefile

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,15 @@ test-e2e: linux
114114
TEST_E2E=1 \
115115
$(GINKGO) $(GFLAGS) ./e2e/...
116116

117+
.PHONY: test-e2e-opa
118+
test-e2e-opa: linux
119+
DOCKER_HOST="unix:///run/finch.sock" \
120+
DOCKER_API_VERSION="v1.41" \
121+
MIDDLEWARE_E2E=1 \
122+
TEST_E2E=0 \
123+
DAEMON_ROOT="$(BIN)/finch-daemon" \
124+
$(GINKGO) $(GFLAGS) ./e2e/...
125+
117126
.PHONY: licenses
118127
licenses:
119128
PATH=$(BIN):$(PATH) go-licenses report --template="scripts/third-party-license.tpl" --ignore github.com/runfinch ./... > THIRD_PARTY_LICENSES
@@ -126,4 +135,4 @@ coverage: linux
126135
.PHONY: release
127136
release: linux
128137
@echo "$@"
129-
@$(FINCH_DAEMON_PROJECT_ROOT)/scripts/create-releases.sh $(RELEASE_TAG)
138+
@$(FINCH_DAEMON_PROJECT_ROOT)/scripts/create-releases.sh $(RELEASE_TAG)

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,32 @@ Getting started with Finch Daemon on Linux only requires a few steps:
3535
5. Test any changes with `make test-unit` and `sudo make test-e2e`
3636

3737

38+
## Experimental Features
39+
40+
Finch Daemon includes experimental features that can be enabled using the `--experimental` flag. These features are under development and may change in future releases.
41+
42+
### Using Experimental Features
43+
44+
To enable experimental features, use the `--experimental` flag when starting the daemon:
45+
46+
```bash
47+
sudo bin/finch-daemon --debug --socket-owner $UID --experimental
48+
```
49+
50+
### Current Experimental Features
51+
52+
#### OPA Authorization Middleware
53+
54+
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.
55+
56+
Example usage:
57+
```bash
58+
sudo bin/finch-daemon --debug --socket-owner $UID --experimental --rego-file /path/to/policy.rego
59+
```
60+
61+
For detailed documentation on the OPA middleware, see [opa-middleware.md](docs/opa-middleware.md).
62+
63+
3864
## Creating a systemd service
3965
If you want finch-daemon to be managed as a systemd service, for benefits like automatic
4066
restart if it gets killed, you can configure it as a systemd service on Linux by

api/router/router.go

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package router
55

66
import (
77
"context"
8+
"errors"
89
"fmt"
910
"net/http"
1011
"os"
@@ -15,6 +16,7 @@ import (
1516
"github.com/moby/moby/api/server/httputils"
1617
"github.com/moby/moby/api/types/versions"
1718

19+
"github.com/open-policy-agent/opa/v1/rego"
1820
"github.com/runfinch/finch-daemon/api/handlers/builder"
1921
"github.com/runfinch/finch-daemon/api/handlers/container"
2022
"github.com/runfinch/finch-daemon/api/handlers/distribution"
@@ -30,6 +32,14 @@ import (
3032
"github.com/runfinch/finch-daemon/version"
3133
)
3234

35+
var errRego = errors.New("error in rego policy file")
36+
var errInput = errors.New("error in HTTP request")
37+
38+
type inputRegoRequest struct {
39+
Method string
40+
Path string
41+
}
42+
3343
// Options defines the router options to be passed into the handlers.
3444
type Options struct {
3545
Config *config.Config
@@ -41,19 +51,28 @@ type Options struct {
4151
VolumeService volume.Service
4252
ExecService exec.Service
4353
DistributionService distribution.Service
54+
RegoFilePath string
4455

4556
// NerdctlWrapper wraps the interactions with nerdctl to build
4657
NerdctlWrapper *backend.NerdctlWrapper
4758
}
4859

4960
// New creates a new router and registers the handlers to it. Returns a handler object
5061
// The struct definitions of the HTTP responses come from https://github.com/moby/moby/tree/master/api/types.
51-
func New(opts *Options) http.Handler {
62+
func New(opts *Options) (http.Handler, error) {
5263
r := mux.NewRouter()
5364
r.Use(VersionMiddleware)
54-
vr := types.VersionedRouter{Router: r}
5565

5666
logger := flog.NewLogrus()
67+
68+
if opts.RegoFilePath != "" {
69+
regoMiddleware, err := CreateRegoMiddleware(opts.RegoFilePath, logger)
70+
if err != nil {
71+
return nil, err
72+
}
73+
r.Use(regoMiddleware)
74+
}
75+
vr := types.VersionedRouter{Router: r}
5776
system.RegisterHandlers(vr, opts.SystemService, opts.Config, opts.NerdctlWrapper, logger)
5877
image.RegisterHandlers(vr, opts.ImageService, opts.Config, logger)
5978
container.RegisterHandlers(vr, opts.ContainerService, opts.Config, logger)
@@ -62,7 +81,7 @@ func New(opts *Options) http.Handler {
6281
volume.RegisterHandlers(vr, opts.VolumeService, opts.Config, logger)
6382
exec.RegisterHandlers(vr, opts.ExecService, opts.Config, logger)
6483
distribution.RegisterHandlers(vr, opts.DistributionService, opts.Config, logger)
65-
return ghandlers.LoggingHandler(os.Stderr, r)
84+
return ghandlers.LoggingHandler(os.Stderr, r), nil
6685
}
6786

6887
// VersionMiddleware checks for the requested version of the api and makes sure it falls within the bounds
@@ -90,3 +109,53 @@ func VersionMiddleware(next http.Handler) http.Handler {
90109
next.ServeHTTP(w, newReq)
91110
})
92111
}
112+
113+
// CreateRegoMiddleware dynamically parses the rego file at the path specified in options
114+
// and return a function that allows or denies the request based on the policy.
115+
// Will return a nil function and an error if the given file path is blank or invalid.
116+
func CreateRegoMiddleware(regoFilePath string, logger *flog.Logrus) (func(next http.Handler) http.Handler, error) {
117+
if regoFilePath == "" {
118+
return nil, errRego
119+
}
120+
121+
query := "data.finch.authz.allow"
122+
nr := rego.New(
123+
rego.Load([]string{regoFilePath}, nil),
124+
rego.Query(query),
125+
)
126+
127+
preppedQuery, err := nr.PrepareForEval(context.Background())
128+
if err != nil {
129+
return nil, err
130+
}
131+
132+
return func(next http.Handler) http.Handler {
133+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
134+
input := inputRegoRequest{
135+
Method: r.Method,
136+
Path: r.URL.Path,
137+
}
138+
139+
logger.Debugf("OPA input being evaluated: Method=%s, Path=%s", input.Method, input.Path)
140+
141+
rs, err := preppedQuery.Eval(r.Context(), rego.EvalInput(input))
142+
if err != nil {
143+
logger.Errorf("OPA policy evaluation failed: %v", err)
144+
response.SendErrorResponse(w, http.StatusInternalServerError, errInput)
145+
return
146+
}
147+
148+
logger.Debugf("OPA evaluation results: %+v", rs)
149+
150+
if !rs.Allowed() {
151+
logger.Infof("OPA request denied: Method=%s, Path=%s", r.Method, r.URL.Path)
152+
response.SendErrorResponse(w, http.StatusForbidden,
153+
fmt.Errorf("method %s not allowed for path %s", r.Method, r.URL.Path))
154+
return
155+
}
156+
logger.Debugf("OPA request allowed: Method=%s, Path=%s", r.Method, r.URL.Path)
157+
newReq := r.WithContext(r.Context())
158+
next.ServeHTTP(w, newReq)
159+
})
160+
}, nil
161+
}

api/router/router_test.go

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88
"fmt"
99
"net/http"
1010
"net/http/httptest"
11+
"os"
12+
"path/filepath"
1113
"testing"
1214

1315
"github.com/containerd/nerdctl/v2/pkg/config"
@@ -51,8 +53,9 @@ var _ = Describe("version middleware test", func() {
5153
BuilderService: nil,
5254
VolumeService: nil,
5355
NerdctlWrapper: nil,
56+
RegoFilePath: "",
5457
}
55-
h = New(opts)
58+
h, _ = New(opts)
5659
rr = httptest.NewRecorder()
5760
expected = types.VersionInfo{
5861
Platform: struct {
@@ -126,3 +129,69 @@ var _ = Describe("version middleware test", func() {
126129
Expect(v).Should(Equal(expected))
127130
})
128131
})
132+
133+
// Unit tests for the rego handler.
134+
var _ = Describe("rego middleware test", func() {
135+
var (
136+
opts *Options
137+
rr *httptest.ResponseRecorder
138+
expected types.VersionInfo
139+
sysSvc *mocks_system.MockService
140+
regoFilePath string
141+
)
142+
143+
BeforeEach(func() {
144+
mockCtrl := gomock.NewController(GinkgoT())
145+
defer mockCtrl.Finish()
146+
147+
tempDirPath := GinkgoT().TempDir()
148+
regoFilePath = filepath.Join(tempDirPath, "authz.rego")
149+
os.Create(regoFilePath)
150+
151+
c := config.Config{}
152+
sysSvc = mocks_system.NewMockService(mockCtrl)
153+
opts = &Options{
154+
Config: &c,
155+
SystemService: sysSvc,
156+
}
157+
rr = httptest.NewRecorder()
158+
expected = types.VersionInfo{}
159+
sysSvc.EXPECT().GetVersion(gomock.Any()).Return(&expected, nil).AnyTimes()
160+
})
161+
It("should return a 200 error for calls by default", func() {
162+
h, err := New(opts)
163+
Expect(err).Should(BeNil())
164+
165+
req, _ := http.NewRequest(http.MethodGet, "/version", nil)
166+
h.ServeHTTP(rr, req)
167+
168+
Expect(rr).Should(HaveHTTPStatus(http.StatusOK))
169+
})
170+
171+
It("should return a 400 error for disallowed calls", func() {
172+
regoPolicy := `package finch.authz
173+
import rego.v1
174+
175+
default allow = false`
176+
177+
os.WriteFile(regoFilePath, []byte(regoPolicy), 0644)
178+
opts.RegoFilePath = regoFilePath
179+
h, err := New(opts)
180+
Expect(err).Should(BeNil())
181+
182+
req, _ := http.NewRequest(http.MethodGet, "/version", nil)
183+
h.ServeHTTP(rr, req)
184+
185+
Expect(rr).Should(HaveHTTPStatus(http.StatusForbidden))
186+
})
187+
188+
It("should return an error for poorly formed rego files", func() {
189+
regoPolicy := `poorly formed rego file`
190+
191+
os.WriteFile(regoFilePath, []byte(regoPolicy), 0644)
192+
opts.RegoFilePath = regoFilePath
193+
_, err := New(opts)
194+
195+
Expect(err).Should(Not(BeNil()))
196+
})
197+
})

cmd/finch-daemon/main.go

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,15 @@ const (
4343
)
4444

4545
type DaemonOptions struct {
46-
debug bool
47-
socketAddr string
48-
socketOwner int
49-
debugAddress string
50-
configPath string
51-
pidFile string
46+
debug bool
47+
socketAddr string
48+
socketOwner int
49+
debugAddress string
50+
configPath string
51+
pidFile string
52+
regoFilePath string
53+
enableExperimental bool
54+
skipRegoPermCheck bool
5255
}
5356

5457
var options = new(DaemonOptions)
@@ -67,6 +70,10 @@ func main() {
6770
rootCmd.Flags().StringVar(&options.debugAddress, "debug-addr", "", "")
6871
rootCmd.Flags().StringVar(&options.configPath, "config-file", defaultConfigPath, "Daemon Config Path")
6972
rootCmd.Flags().StringVar(&options.pidFile, "pidfile", defaultPidFile, "pid file location")
73+
rootCmd.Flags().StringVar(&options.regoFilePath, "rego-file", "", "Rego Policy Path (requires --experimental flag)")
74+
rootCmd.Flags().BoolVar(&options.skipRegoPermCheck, "skip-rego-perm-check", false, "skip the rego file permission check (allows permissions more permissive than 0600)")
75+
rootCmd.Flags().BoolVar(&options.enableExperimental, "experimental", false, "enable experimental features")
76+
7077
if err := rootCmd.Execute(); err != nil {
7178
log.Printf("got error: %v", err)
7279
log.Fatal(err)
@@ -215,8 +222,27 @@ func newRouter(options *DaemonOptions, logger *flog.Logrus) (http.Handler, error
215222
return nil, err
216223
}
217224

218-
opts := createRouterOptions(conf, clientWrapper, ncWrapper, logger)
219-
return router.New(opts), nil
225+
var regoFilePath string
226+
227+
if options.regoFilePath != "" {
228+
if !options.enableExperimental {
229+
return nil, fmt.Errorf("rego file provided without experimental flag - OPA middleware is an experimental feature, please enable it with '--experimental' flag")
230+
}
231+
regoFilePath, err = checkRegoFileValidity(options, logger)
232+
if err != nil {
233+
return nil, err
234+
}
235+
} else if options.enableExperimental {
236+
// Only experimental flag set
237+
logger.Info("experimental flag passed, but no experimental features enabled")
238+
}
239+
240+
opts := createRouterOptions(conf, clientWrapper, ncWrapper, logger, regoFilePath)
241+
newRouter, err := router.New(opts)
242+
if err != nil {
243+
return nil, err
244+
}
245+
return newRouter, nil
220246
}
221247

222248
func handleSignal(socket string, server *http.Server, logger *flog.Logrus) {

0 commit comments

Comments
 (0)