diff --git a/changelog/unreleased/enhancement-evaluate-policy-file-share-info.md b/changelog/unreleased/enhancement-evaluate-policy-file-share-info.md new file mode 100644 index 000000000..5073b2939 --- /dev/null +++ b/changelog/unreleased/enhancement-evaluate-policy-file-share-info.md @@ -0,0 +1,6 @@ +Enhancement: Evaluate policy resource information on single file shares + +The policy environment for single file shares now also includes information about the resource. +As a result, it is now possible to set up and check rules for them. + +https://github.com/owncloud/ocis/pull/6888 diff --git a/deployments/examples/service_policies/policies/proxy.rego b/deployments/examples/service_policies/policies/proxy.rego index 076bb2801..7f49842b3 100644 --- a/deployments/examples/service_policies/policies/proxy.rego +++ b/deployments/examples/service_policies/policies/proxy.rego @@ -6,14 +6,28 @@ import data.utils default granted := true granted = false if { - print("PRINT MESSAGE EXAMPLE") input.request.method == "PUT" - not startswith(input.request.path, "/ocs") - not utils.is_extension_allowed(input.request.path) + pathPrefixes := [ + "/dav", + "/remote.php/webdav", + "/remote.php/dav", + "/webdav", + ] + restricted := pathPrefixes[_] + startswith(input.request.path, restricted) + not utils.is_extension_allowed(input.resource.name) } granted = false if { input.request.method == "POST" - startswith(input.request.path, "/remote.php") + pathPrefixes := [ + "/data", + "/dav", + "/remote.php/webdav", + "/remote.php/dav", + "/webdav", + ] + restricted := pathPrefixes[_] + startswith(input.request.path, restricted) not utils.is_extension_allowed(input.resource.name) } diff --git a/services/proxy/Makefile b/services/proxy/Makefile index 44e10f95e..eec748b4d 100644 --- a/services/proxy/Makefile +++ b/services/proxy/Makefile @@ -24,7 +24,8 @@ docs-generate: config-docs-generate include ../../.make/generate.mk .PHONY: ci-go-generate -ci-go-generate: # CI runs ci-node-generate automatically before this target +ci-go-generate: $(MOCKERY) # CI runs ci-node-generate automatically before this target + $(MOCKERY) --dir ../../protogen/gen/ocis/services/policies/v0 --case underscore --name PoliciesProviderService .PHONY: ci-node-generate ci-node-generate: diff --git a/services/proxy/mocks/policies_provider_service.go b/services/proxy/mocks/policies_provider_service.go new file mode 100644 index 000000000..3ace0f522 --- /dev/null +++ b/services/proxy/mocks/policies_provider_service.go @@ -0,0 +1,66 @@ +// Code generated by mockery v2.22.1. DO NOT EDIT. + +package mocks + +import ( + context "context" + + client "go-micro.dev/v4/client" + + mock "github.com/stretchr/testify/mock" + + v0 "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/policies/v0" +) + +// PoliciesProviderService is an autogenerated mock type for the PoliciesProviderService type +type PoliciesProviderService struct { + mock.Mock +} + +// Evaluate provides a mock function with given fields: ctx, in, opts +func (_m *PoliciesProviderService) Evaluate(ctx context.Context, in *v0.EvaluateRequest, opts ...client.CallOption) (*v0.EvaluateResponse, error) { + _va := make([]interface{}, len(opts)) + for _i := range opts { + _va[_i] = opts[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, in) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *v0.EvaluateResponse + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *v0.EvaluateRequest, ...client.CallOption) (*v0.EvaluateResponse, error)); ok { + return rf(ctx, in, opts...) + } + if rf, ok := ret.Get(0).(func(context.Context, *v0.EvaluateRequest, ...client.CallOption) *v0.EvaluateResponse); ok { + r0 = rf(ctx, in, opts...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v0.EvaluateResponse) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *v0.EvaluateRequest, ...client.CallOption) error); ok { + r1 = rf(ctx, in, opts...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +type mockConstructorTestingTNewPoliciesProviderService interface { + mock.TestingT + Cleanup(func()) +} + +// NewPoliciesProviderService creates a new instance of PoliciesProviderService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewPoliciesProviderService(t mockConstructorTestingTNewPoliciesProviderService) *PoliciesProviderService { + mock := &PoliciesProviderService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/services/proxy/pkg/command/server.go b/services/proxy/pkg/command/server.go index 9e0497bb1..edc3d10bd 100644 --- a/services/proxy/pkg/command/server.go +++ b/services/proxy/pkg/command/server.go @@ -8,14 +8,19 @@ import ( "net/http" "time" - "github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool" - "github.com/cs3org/reva/v2/pkg/store" - "github.com/cs3org/reva/v2/pkg/token/manager/jwt" "github.com/go-chi/chi/v5" chimiddleware "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/render" "github.com/justinas/alice" "github.com/oklog/run" + "github.com/urfave/cli/v2" + microstore "go-micro.dev/v4/store" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "go.opentelemetry.io/otel/trace" + + "github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool" + "github.com/cs3org/reva/v2/pkg/store" + "github.com/cs3org/reva/v2/pkg/token/manager/jwt" "github.com/owncloud/ocis/v2/ocis-pkg/config/configlog" "github.com/owncloud/ocis/v2/ocis-pkg/log" pkgmiddleware "github.com/owncloud/ocis/v2/ocis-pkg/middleware" @@ -24,6 +29,7 @@ import ( "github.com/owncloud/ocis/v2/ocis-pkg/service/grpc" "github.com/owncloud/ocis/v2/ocis-pkg/tracing" "github.com/owncloud/ocis/v2/ocis-pkg/version" + policiessvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/policies/v0" settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0" storesvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/store/v0" "github.com/owncloud/ocis/v2/services/proxy/pkg/autoprovision" @@ -38,10 +44,6 @@ import ( proxyHTTP "github.com/owncloud/ocis/v2/services/proxy/pkg/server/http" "github.com/owncloud/ocis/v2/services/proxy/pkg/user/backend" "github.com/owncloud/ocis/v2/services/proxy/pkg/userroles" - "github.com/urfave/cli/v2" - microstore "go-micro.dev/v4/store" - "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" - "go.opentelemetry.io/otel/trace" ) // Server is the entrypoint for the server command. @@ -273,6 +275,7 @@ func (h *StaticRouteHandler) backchannelLogout(w http.ResponseWriter, r *http.Re func loadMiddlewares(ctx context.Context, logger log.Logger, cfg *config.Config, userInfoCache microstore.Store, traceProvider trace.TracerProvider) alice.Chain { rolesClient := settingssvc.NewRoleService("com.owncloud.api.settings", cfg.GrpcClient) + policiesProviderClient := policiessvc.NewPoliciesProviderService("com.owncloud.api.policies", cfg.GrpcClient) gatewaySelector, err := pool.GatewaySelector(cfg.Reva.Address, append(cfg.Reva.GetRevaOptions(), pool.WithRegistry(registry.GetRegistry()))...) if err != nil { logger.Fatal().Err(err).Msg("Failed to get gateway selector") @@ -411,7 +414,12 @@ func loadMiddlewares(ctx context.Context, logger log.Logger, cfg *config.Config, middleware.Logger(logger), middleware.PolicySelectorConfig(*cfg.PolicySelector), ), - middleware.Policies(logger, cfg.PoliciesMiddleware.Query, cfg.GrpcClient), + middleware.Policies( + cfg.PoliciesMiddleware.Query, + middleware.Logger(logger), + middleware.WithRevaGatewaySelector(gatewaySelector), + middleware.PoliciesProviderService(policiesProviderClient), + ), // finally, trigger home creation when a user logs in middleware.CreateHome( middleware.Logger(logger), diff --git a/services/proxy/pkg/middleware/options.go b/services/proxy/pkg/middleware/options.go index cd64b4472..a86b7874e 100644 --- a/services/proxy/pkg/middleware/options.go +++ b/services/proxy/pkg/middleware/options.go @@ -5,16 +5,19 @@ import ( "time" gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + "go-micro.dev/v4/store" + "go.opentelemetry.io/otel/trace" + "github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool" + "github.com/owncloud/ocis/v2/ocis-pkg/log" "github.com/owncloud/ocis/v2/ocis-pkg/oidc" + policiessvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/policies/v0" settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0" storesvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/store/v0" "github.com/owncloud/ocis/v2/services/proxy/pkg/config" "github.com/owncloud/ocis/v2/services/proxy/pkg/user/backend" "github.com/owncloud/ocis/v2/services/proxy/pkg/userroles" - store "go-micro.dev/v4/store" - "go.opentelemetry.io/otel/trace" ) // Option defines a single option function. @@ -36,6 +39,8 @@ type Options struct { UserRoleAssigner userroles.UserRoleAssigner // SettingsRoleService for the roles API in settings SettingsRoleService settingssvc.RoleService + // PoliciesProviderService for policy evaluation + PoliciesProviderService policiessvc.PoliciesProviderService // OIDCProviderFunc to lazily initialize an oidc provider, must be set for the oidc_auth middleware OIDCClient oidc.OIDCClient // OIDCIss is the oidcAuth-issuer @@ -118,7 +123,14 @@ func SettingsRoleService(rc settingssvc.RoleService) Option { } } -// OIDCClient provides a function to set the the oidc client option. +// PoliciesProviderService provides a function to set the policies provider option. +func PoliciesProviderService(pps policiessvc.PoliciesProviderService) Option { + return func(o *Options) { + o.PoliciesProviderService = pps + } +} + +// OIDCClient provides a function to set the oidc client option. func OIDCClient(val oidc.OIDCClient) Option { return func(o *Options) { o.OIDCClient = val @@ -139,7 +151,7 @@ func CredentialsByUserAgent(v map[string]string) Option { } } -// WithRevaGatewaySelector provides a function to set the the reva gateway service selector option. +// WithRevaGatewaySelector provides a function to set the reva gateway service selector option. func WithRevaGatewaySelector(val pool.Selectable[gateway.GatewayAPIClient]) Option { return func(o *Options) { o.RevaGatewaySelector = val diff --git a/services/proxy/pkg/middleware/policies.go b/services/proxy/pkg/middleware/policies.go index 604c17dca..590eb96f6 100644 --- a/services/proxy/pkg/middleware/policies.go +++ b/services/proxy/pkg/middleware/policies.go @@ -3,17 +3,23 @@ package middleware import ( "net/http" "path" + "path/filepath" + "strings" "time" - revactx "github.com/cs3org/reva/v2/pkg/ctx" + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/render" - "github.com/owncloud/ocis/v2/ocis-pkg/log" + tusd "github.com/tus/tusd/pkg/handler" + "google.golang.org/grpc/metadata" + + revactx "github.com/cs3org/reva/v2/pkg/ctx" + "github.com/cs3org/reva/v2/pkg/storagespace" + "github.com/cs3org/reva/v2/pkg/utils" + pMessage "github.com/owncloud/ocis/v2/protogen/gen/ocis/messages/policies/v0" pService "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/policies/v0" "github.com/owncloud/ocis/v2/services/webdav/pkg/net" - tusd "github.com/tus/tusd/pkg/handler" - "go-micro.dev/v4/client" ) type ( @@ -34,8 +40,11 @@ type ( const DeniedMessage = "Operation denied due to security policies" // Policies verifies if a request is granted or not. -func Policies(logger log.Logger, qs string, grpcClient client.Client) func(next http.Handler) http.Handler { - pClient := pService.NewPoliciesProviderService("com.owncloud.api.policies", grpcClient) +func Policies(qs string, opts ...Option) func(next http.Handler) http.Handler { + options := newOptions(opts...) + logger := options.Logger + gatewaySelector := options.RevaGatewaySelector + policiesProviderClient := options.PoliciesProviderService return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -55,11 +64,53 @@ func Policies(logger log.Logger, qs string, grpcClient client.Client) func(next }, } + resource := &pMessage.Resource{} + + // tus meta := tusd.ParseMetadataHeader(r.Header.Get(net.HeaderUploadMetadata)) - req.Environment.Resource = &pMessage.Resource{ - Name: meta["filename"], + resource.Name = meta["filename"] + + // name is part of the request path + if resource.Name == "" && filepath.Ext(r.URL.Path) != "" { + resource.Name = filepath.Base(r.URL.Path) } + // no resource info in path, stat the resource and try to obtain the file information. + // this should only be used as last bastion, every request goes through the proxy and doing stats is expensive! + // needed for: + // - if a single resource is shared -> the url only contains the resourceID (spaceRef) + if resource.Name == "" && filepath.Ext(r.URL.Path) == "" && r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/remote.php/dav/spaces") { + client, err := gatewaySelector.Next() + if err != nil { + logger.Err(err).Msg("error selecting next gateway client") + RenderError(w, r, req, http.StatusForbidden, DeniedMessage) + return + } + + resourceID, err := storagespace.ParseID(strings.TrimPrefix(r.URL.Path, "/remote.php/dav/spaces/")) + if err != nil { + logger.Debug().Err(err).Msg("error parsing the resourceId") + RenderError(w, r, req, http.StatusForbidden, DeniedMessage) + return + } + + if resourceID.StorageId == "" && resourceID.SpaceId == utils.ShareStorageSpaceID { + resourceID.StorageId = utils.ShareStorageProviderID + } + + token := r.Header.Get(revactx.TokenHeader) + ctx := metadata.AppendToOutgoingContext(r.Context(), revactx.TokenHeader, token) + sRes, err := client.Stat(ctx, &provider.StatRequest{ + Ref: &provider.Reference{ + ResourceId: &resourceID, + }, + }) + + resource.Name = sRes.GetInfo().GetName() + } + + req.Environment.Resource = resource + if user, ok := revactx.ContextGetUser(r.Context()); ok { req.Environment.User = &pMessage.User{ Id: &pMessage.User_ID{ @@ -72,7 +123,7 @@ func Policies(logger log.Logger, qs string, grpcClient client.Client) func(next } } - rsp, err := pClient.Evaluate(r.Context(), req) + rsp, err := policiesProviderClient.Evaluate(r.Context(), req) if err != nil { logger.Err(err).Msg("error evaluating request") w.WriteHeader(http.StatusInternalServerError) diff --git a/services/proxy/pkg/middleware/policies_test.go b/services/proxy/pkg/middleware/policies_test.go new file mode 100644 index 000000000..78af83c49 --- /dev/null +++ b/services/proxy/pkg/middleware/policies_test.go @@ -0,0 +1,173 @@ +package middleware_test + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + + gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + . "github.com/onsi/gomega" + "github.com/stretchr/testify/mock" + "go-micro.dev/v4/client" + "google.golang.org/grpc" + + cs3mocks "github.com/cs3org/reva/v2/tests/cs3mocks/mocks" + "github.com/owncloud/ocis/v2/services/proxy/mocks" + "github.com/owncloud/ocis/v2/services/webdav/pkg/net" + + "github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool" + pMessage "github.com/owncloud/ocis/v2/protogen/gen/ocis/messages/policies/v0" + policiesPG "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/policies/v0" + "github.com/owncloud/ocis/v2/services/proxy/pkg/middleware" +) + +func TestPolicies_NoQuery_PassThrough(t *testing.T) { + var g = NewWithT(t) + + policiesMiddleware, _, _ := prepare("") + + responseRecorder := httptest.NewRecorder() + policiesMiddleware.ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/policies", nil)) + + g.Expect(responseRecorder.Code).To(Equal(http.StatusOK)) +} + +func TestPolicies_ErrorsOnEvaluationError(t *testing.T) { + var g = NewWithT(t) + + policiesMiddleware, policiesProviderService, _ := prepare("any") + policiesProviderService.On("Evaluate", mock.Anything, mock.Anything).Return( + nil, + errors.New("any"), + ) + + responseRecorder := httptest.NewRecorder() + policiesMiddleware.ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/policies", nil)) + + g.Expect(responseRecorder.Code).To(Equal(http.StatusInternalServerError)) +} + +func TestPolicies_ErrorsOnDeny(t *testing.T) { + var g = NewWithT(t) + + policiesMiddleware, policiesProviderService, _ := prepare("any") + policiesProviderService.On("Evaluate", mock.Anything, mock.Anything).Return( + &policiesPG.EvaluateResponse{}, + nil, + ) + + responseRecorder := httptest.NewRecorder() + policiesMiddleware.ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/policies", nil)) + + result := responseRecorder.Result() + defer func() { + g.Expect(result.Body.Close()).ToNot(HaveOccurred()) + }() + + data, err := io.ReadAll(result.Body) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(data).To(ContainSubstring(middleware.DeniedMessage)) + g.Expect(responseRecorder.Code).To(Equal(http.StatusForbidden)) +} + +func TestPolicies_EvaluationEnvironment_HTTPStage(t *testing.T) { + var g = NewWithT(t) + + policiesMiddleware, policiesProviderService, _ := prepare("any") + policiesProviderService.On("Evaluate", mock.Anything, mock.Anything, mock.Anything).Return( + func(_ context.Context, in *policiesPG.EvaluateRequest, _ ...client.CallOption) (*policiesPG.EvaluateResponse, error) { + g.Expect(in.Environment.Stage).To(Equal(pMessage.Stage_STAGE_HTTP)) + + return &policiesPG.EvaluateResponse{Result: false}, nil + }, + ) + + responseRecorder := httptest.NewRecorder() + policiesMiddleware.ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/policies", nil)) +} + +func TestPolicies_EvaluationEnvironment_Request(t *testing.T) { + var g = NewWithT(t) + + policiesMiddleware, policiesProviderService, _ := prepare("any") + policiesProviderService.On("Evaluate", mock.Anything, mock.Anything, mock.Anything).Return( + func(_ context.Context, in *policiesPG.EvaluateRequest, _ ...client.CallOption) (*policiesPG.EvaluateResponse, error) { + g.Expect(in.Environment.Request.Method).To(Equal(http.MethodDelete)) + g.Expect(in.Environment.Request.Path).To(Equal("/whatever")) + + return &policiesPG.EvaluateResponse{Result: false}, nil + }, + ) + + responseRecorder := httptest.NewRecorder() + policiesMiddleware.ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodDelete, "/whatever", nil)) +} + +func TestPolicies_EvaluationEnvironment_Resource(t *testing.T) { + var g = NewWithT(t) + + policiesMiddleware, policiesProviderService, _ := prepare("any") + + // tus metadata + { + responseRecorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/remote.php/dav/spaces", nil) + request.Header.Set(net.HeaderUploadMetadata, fmt.Sprintf("filename %v", base64.StdEncoding.EncodeToString([]byte("tus-file-name.png")))) + policiesProviderService.On("Evaluate", mock.Anything, mock.Anything, mock.Anything).Return( + func(_ context.Context, in *policiesPG.EvaluateRequest, _ ...client.CallOption) (*policiesPG.EvaluateResponse, error) { + g.Expect(in.Environment.Resource.Name).To(Equal("tus-file-name.png")) + + return &policiesPG.EvaluateResponse{Result: false}, nil + }, + ).Once() + policiesMiddleware.ServeHTTP(responseRecorder, request) + } + + // url path + { + responseRecorder := httptest.NewRecorder() + policiesProviderService.On("Evaluate", mock.Anything, mock.Anything, mock.Anything).Return( + func(_ context.Context, in *policiesPG.EvaluateRequest, _ ...client.CallOption) (*policiesPG.EvaluateResponse, error) { + g.Expect(in.Environment.Resource.Name).To(Equal("simple-file-name.png")) + + return &policiesPG.EvaluateResponse{Result: false}, nil + }, + ).Once() + policiesMiddleware.ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodPut, "/remote.php/dav/spaces/simple-file-name.png", nil)) + } +} + +func prepare(q string) (http.Handler, *mocks.PoliciesProviderService, *cs3mocks.GatewayAPIClient) { + + // mocked gatewaySelector + gatewayClient := &cs3mocks.GatewayAPIClient{} + gatewaySelector := pool.GetSelector[gateway.GatewayAPIClient]( + "GatewaySelector", + "com.owncloud.api.gateway", + func(cc *grpc.ClientConn) gateway.GatewayAPIClient { + return gatewayClient + }, + ) + defer pool.RemoveSelector("GatewaySelector" + "com.owncloud.api.gateway") + + // mocked policiesProviderService + policiesProviderService := &mocks.PoliciesProviderService{} + + // spin up middleware + policiesMiddleware := middleware.Policies( + q, + middleware.WithRevaGatewaySelector(gatewaySelector), + middleware.PoliciesProviderService(policiesProviderService), + )(mockHandler{}) + + return policiesMiddleware, policiesProviderService, gatewayClient +} + +type mockHandler struct{} + +func (m mockHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {}