From 21310ea0e819dfda84007ee013443b4a3136fae3 Mon Sep 17 00:00:00 2001 From: Roman Perekhod <2403905@gmail.com> Date: Tue, 24 Feb 2026 15:31:17 +0100 Subject: [PATCH 1/5] refactor the validation --- services/graph/pkg/service/v0/graph.go | 76 ++++++++++++++++++++----- services/graph/pkg/service/v0/groups.go | 30 +--------- services/graph/pkg/service/v0/users.go | 46 +-------------- 3 files changed, 66 insertions(+), 86 deletions(-) diff --git a/services/graph/pkg/service/v0/graph.go b/services/graph/pkg/service/v0/graph.go index 6ceebe3a962..80f5c82da3f 100644 --- a/services/graph/pkg/service/v0/graph.go +++ b/services/graph/pkg/service/v0/graph.go @@ -21,6 +21,7 @@ import ( "github.com/owncloud/reva/v2/pkg/storagespace" "github.com/owncloud/ocis/v2/ocis-pkg/keycloak" + "github.com/owncloud/ocis/v2/ocis-pkg/mfa" ehsvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/eventhistory/v0" searchsvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/search/v0" settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0" @@ -154,38 +155,85 @@ func parseIDParam(r *http.Request, param string) (storageprovider.ResourceId, er return id, nil } -// regular users can only search for terms with a minimum length -func hasAcceptableSearch(query *godata.GoDataQuery, minSearchLength int) bool { +// QueryValidator defines a strategy for validating godata queries. +type QueryValidator interface { + Validate(query *godata.GoDataQuery) error +} + +// SearchValidator validates the search query. +type SearchValidator struct { + MinLength int +} + +// Validate checks if the search term is acceptable. +func (v SearchValidator) Validate(query *godata.GoDataQuery) error { if query == nil || query.Search == nil { - return false + return errors.New("search term too short") } + minSearchLength := v.MinLength if strings.HasPrefix(query.Search.RawValue, "\"") { // if search starts with double quotes then it must finish with double quotes // add +2 to the minimum search length in this case minSearchLength += 2 } - return len(query.Search.RawValue) >= minSearchLength + if len(query.Search.RawValue) < minSearchLength { + return errors.New("search term too short") + } + return nil } -// regular users can only filter by userType -func hasAcceptableFilter(query *godata.GoDataQuery) bool { +// FilterValidator validates the filter query. +type FilterValidator struct{} + +// Validate checks if the filter applies forbidden elements. +func (v FilterValidator) Validate(query *godata.GoDataQuery) error { switch { case query == nil || query.Filter == nil: - return true + return nil case query.Filter.Tree.Token.Type != godata.ExpressionTokenLogical: - return false + return errors.New("filter has forbidden elements for regular users") case query.Filter.Tree.Token.Value != "eq": - return false + return errors.New("filter has forbidden elements for regular users") case query.Filter.Tree.Children[0].Token.Value != "userType": - return false + return errors.New("filter has forbidden elements for regular users") } - return true + return nil } -// regular users can only use basic queries without any expansions, computes or applies -func hasAcceptableQuery(query *godata.GoDataQuery) bool { - return query != nil && query.Apply == nil && query.Expand == nil && query.Compute == nil +// BasicQueryValidator validates basic queries. +type BasicQueryValidator struct{} + +// Validate checks if the query contains unsupported operations like apply, expand, or compute. +func (v BasicQueryValidator) Validate(query *godata.GoDataQuery) error { + if query != nil && (query.Apply != nil || query.Expand != nil || query.Compute != nil) { + return errors.New("query has forbidden elements for regular users") + } + return nil +} + +// validateQuery validates the godata query based on provided validators. +// It handles user permissions and MFA requirements centrally. +func (g Graph) validateQuery(r *http.Request, w http.ResponseWriter, query *godata.GoDataQuery, validators ...QueryValidator) bool { + ctxHasFullPerms := g.contextUserHasFullAccountPerms(r.Context()) + hasMFA := mfa.Has(r.Context()) + logger := g.logger.SubloggerWithRequestID(r.Context()) + + for _, v := range validators { + if err := v.Validate(query); err != nil { + if !ctxHasFullPerms { + logger.Debug().Interface("query", r.URL.Query()).Msg(err.Error()) + errorcode.AccessDenied.Render(w, r, http.StatusForbidden, err.Error()) + return false + } + if !hasMFA { + logger.Error().Str("path", r.URL.Path).Msg("MFA required but not satisfied") + mfa.SetRequiredStatus(w) + return false + } + } + } + return true } diff --git a/services/graph/pkg/service/v0/groups.go b/services/graph/pkg/service/v0/groups.go index f412faa3154..7f7e8837093 100644 --- a/services/graph/pkg/service/v0/groups.go +++ b/services/graph/pkg/service/v0/groups.go @@ -10,7 +10,6 @@ import ( "github.com/CiscoM31/godata" libregraph "github.com/owncloud/libre-graph-api-go" - "github.com/owncloud/ocis/v2/ocis-pkg/mfa" "github.com/owncloud/ocis/v2/services/graph/pkg/errorcode" "github.com/go-chi/chi/v5" @@ -33,34 +32,9 @@ func (g Graph) GetGroups(w http.ResponseWriter, r *http.Request) { return } ctxHasFullPerms := g.contextUserHasFullAccountPerms(r.Context()) - hasMFA := mfa.Has(r.Context()) - if !hasAcceptableSearch(odataReq.Query, g.config.API.IdentitySearchMinLength) { - if !ctxHasFullPerms { - // for regular user the search term must have a minimum length - logger.Debug().Interface("query", r.URL.Query()).Msgf("search with less than %d chars for a regular user", g.config.API.IdentitySearchMinLength) - errorcode.AccessDenied.Render(w, r, http.StatusForbidden, "search term too short") - return - } - if !hasMFA { - logger.Error().Str("path", r.URL.Path).Msg("MFA required but not satisfied") - mfa.SetRequiredStatus(w) - return - } - } - if !hasAcceptableQuery(odataReq.Query) { - if !ctxHasFullPerms { - // regular users can't use filter, apply, expand and compute - logger.Debug().Interface("query", r.URL.Query()).Msg("forbidden query elements for a regular user") - errorcode.AccessDenied.Render(w, r, http.StatusForbidden, "query has forbidden elements for regular users") - return - } - - if !hasMFA { - logger.Error().Str("path", r.URL.Path).Msg("MFA required but not satisfied") - mfa.SetRequiredStatus(w) - return - } + if !g.validateQuery(r, w, odataReq.Query, SearchValidator{MinLength: g.config.API.IdentitySearchMinLength}, BasicQueryValidator{}) { + return } groups, err := g.identityBackend.GetGroups(r.Context(), odataReq) diff --git a/services/graph/pkg/service/v0/users.go b/services/graph/pkg/service/v0/users.go index 903cca937c6..2e57940f9b5 100644 --- a/services/graph/pkg/service/v0/users.go +++ b/services/graph/pkg/service/v0/users.go @@ -22,7 +22,6 @@ import ( "github.com/go-chi/render" "github.com/google/uuid" libregraph "github.com/owncloud/libre-graph-api-go" - "github.com/owncloud/ocis/v2/ocis-pkg/mfa" settingsmsg "github.com/owncloud/ocis/v2/protogen/gen/ocis/messages/settings/v0" settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0" "github.com/owncloud/ocis/v2/services/graph/pkg/errorcode" @@ -325,50 +324,9 @@ func (g Graph) GetUsers(w http.ResponseWriter, r *http.Request) { } ctxHasFullPerms := g.contextUserHasFullAccountPerms(r.Context()) - hasMFA := mfa.Has(r.Context()) - if !hasAcceptableSearch(odataReq.Query, g.config.API.IdentitySearchMinLength) { - if !ctxHasFullPerms { - // for regular user the search term must have a minimum length - logger.Debug().Interface("query", r.URL.Query()).Msgf("search with less than %d chars for a regular user", g.config.API.IdentitySearchMinLength) - errorcode.AccessDenied.Render(w, r, http.StatusForbidden, "search term too short") - return - } - if !hasMFA { - logger.Error().Str("path", r.URL.Path).Msg("MFA required but not satisfied") - mfa.SetRequiredStatus(w) - return - } - } - - if !hasAcceptableFilter(odataReq.Query) { - if !ctxHasFullPerms { - // regular users are allowed to filter only by userType - logger.Debug().Interface("query", r.URL.Query()).Msg("forbidden filter for a regular user") - errorcode.AccessDenied.Render(w, r, http.StatusForbidden, "filter has forbidden elements for regular users") - return - } - - if !hasMFA { - logger.Error().Str("path", r.URL.Path).Msg("MFA required but not satisfied") - mfa.SetRequiredStatus(w) - return - } - } - - if !hasAcceptableQuery(odataReq.Query) { - if !ctxHasFullPerms { - // regular users can't use filter, apply, expand and compute - logger.Debug().Interface("query", r.URL.Query()).Msg("forbidden query elements for a regular user") - errorcode.AccessDenied.Render(w, r, http.StatusForbidden, "query has forbidden elements for regular users") - return - } - - if !hasMFA { - logger.Error().Str("path", r.URL.Path).Msg("MFA required but not satisfied") - mfa.SetRequiredStatus(w) - return - } + if !g.validateQuery(r, w, odataReq.Query, SearchValidator{MinLength: g.config.API.IdentitySearchMinLength}, FilterValidator{}, BasicQueryValidator{}) { + return } logger.Debug().Interface("query", r.URL.Query()).Msg("calling get users on backend") From 34f38b8f66d9c4773fa659308d5e5feee045aec4 Mon Sep 17 00:00:00 2001 From: Roman Perekhod <2403905@gmail.com> Date: Fri, 27 Feb 2026 12:17:11 +0100 Subject: [PATCH 2/5] added protected-* spaces get and create --- .gitignore | 1 + services/gateway/pkg/revaconfig/config.go | 8 ++++ services/graph/pkg/service/v0/drives.go | 48 +++++++++++--------- services/graph/pkg/service/v0/graph.go | 50 +++++++++++++++++++++ services/graph/pkg/service/v0/graph_test.go | 47 +++++++++++++++++++ services/graph/pkg/service/v0/groups.go | 3 +- services/graph/pkg/service/v0/users.go | 4 +- 7 files changed, 135 insertions(+), 26 deletions(-) diff --git a/.gitignore b/.gitignore index 778edd15ff2..33e55761570 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,4 @@ go.work.sum .envrc CLAUDE.md .claude/ +.agents/ \ No newline at end of file diff --git a/services/gateway/pkg/revaconfig/config.go b/services/gateway/pkg/revaconfig/config.go index d4589e5d7fb..aaea6b0f1a1 100644 --- a/services/gateway/pkg/revaconfig/config.go +++ b/services/gateway/pkg/revaconfig/config.go @@ -160,6 +160,14 @@ func spacesProviders(cfg *config.Config, logger log.Logger) map[string]map[strin "mount_point": "/projects", "path_template": "/projects/{{.Space.Name}}", }, + "protected-personal": map[string]interface{}{ + "mount_point": "/protected-users", + "path_template": "/protected-users/{{.Space.Owner.Id.OpaqueId}}", + }, + "protected-project": map[string]interface{}{ + "mount_point": "/protected-projects", + "path_template": "/protected-projects/{{.Space.Name}}", + }, }, }, cfg.StorageSharesEndpoint: { diff --git a/services/graph/pkg/service/v0/drives.go b/services/graph/pkg/service/v0/drives.go index c4657b32054..0eea4e84d17 100644 --- a/services/graph/pkg/service/v0/drives.go +++ b/services/graph/pkg/service/v0/drives.go @@ -29,7 +29,6 @@ import ( "google.golang.org/protobuf/proto" "github.com/owncloud/ocis/v2/ocis-pkg/l10n" - "github.com/owncloud/ocis/v2/ocis-pkg/mfa" v0 "github.com/owncloud/ocis/v2/protogen/gen/ocis/messages/settings/v0" settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0" "github.com/owncloud/ocis/v2/services/graph/pkg/errorcode" @@ -37,11 +36,13 @@ import ( ) const ( - _spaceTypePersonal = "personal" - _spaceTypeProject = "project" - _spaceTypeVirtual = "virtual" - _spaceTypeMountpoint = "mountpoint" - _spaceStateTrashed = "trashed" + _spaceTypePersonal = "personal" + _spaceTypeProject = "project" + _spaceTypeProtectedPersonal = "protected-personal" + _spaceTypeProtectedProject = "protected-project" + _spaceTypeVirtual = "virtual" + _spaceTypeMountpoint = "mountpoint" + _spaceStateTrashed = "trashed" _sortDescending = "desc" ) @@ -78,7 +79,7 @@ func (g Graph) GetDrives(version APIVersion) http.HandlerFunc { // GetDrivesV1 attempts to retrieve the current users drives; // it lists all drives the current user has access to. func (g Graph) GetDrivesV1(w http.ResponseWriter, r *http.Request) { - spaces, errCode := g.getDrives(r, false, APIVersion_1) + spaces, errCode := g.getDrives(w, r, false, APIVersion_1) if errCode != nil { errorcode.RenderError(w, r, errCode) return @@ -99,7 +100,7 @@ func (g Graph) GetDrivesV1(w http.ResponseWriter, r *http.Request) { // it includes the grantedtoV2 property // it uses unified roles instead of the cs3 representations func (g Graph) GetDrivesV1Beta1(w http.ResponseWriter, r *http.Request) { - spaces, errCode := g.getDrives(r, false, APIVersion_1_Beta_1) + spaces, errCode := g.getDrives(w, r, false, APIVersion_1_Beta_1) if errCode != nil { errorcode.RenderError(w, r, errCode) return @@ -133,14 +134,11 @@ func (g Graph) GetAllDrives(version APIVersion) http.HandlerFunc { // GetAllDrivesV1 attempts to retrieve the current users drives; // it includes another user's drives, if the current user has the permission. func (g Graph) GetAllDrivesV1(w http.ResponseWriter, r *http.Request) { - if !mfa.Has(r.Context()) { - logger := g.logger.SubloggerWithRequestID(r.Context()) - logger.Error().Str("path", r.URL.Path).Msg("MFA required but not satisfied") - mfa.SetRequiredStatus(w) + if !g.validateMFA(r, w) { return } - spaces, errCode := g.getDrives(r, true, APIVersion_1) + spaces, errCode := g.getDrives(w, r, true, APIVersion_1) if errCode != nil { errorcode.RenderError(w, r, errCode) return @@ -160,14 +158,11 @@ func (g Graph) GetAllDrivesV1(w http.ResponseWriter, r *http.Request) { // it includes the grantedtoV2 property // it uses unified roles instead of the cs3 representations func (g Graph) GetAllDrivesV1Beta1(w http.ResponseWriter, r *http.Request) { - if !mfa.Has(r.Context()) { - logger := g.logger.SubloggerWithRequestID(r.Context()) - logger.Error().Str("path", r.URL.Path).Msg("MFA required but not satisfied") - mfa.SetRequiredStatus(w) + if !g.validateMFA(r, w) { return } - drives, errCode := g.getDrives(r, true, APIVersion_1_Beta_1) + drives, errCode := g.getDrives(w, r, true, APIVersion_1_Beta_1) if errCode != nil { errorcode.RenderError(w, r, errCode) return @@ -184,7 +179,7 @@ func (g Graph) GetAllDrivesV1Beta1(w http.ResponseWriter, r *http.Request) { } // getDrives implements the Service interface. -func (g Graph) getDrives(r *http.Request, unrestricted bool, apiVersion APIVersion) ([]*libregraph.Drive, error) { +func (g Graph) getDrives(w http.ResponseWriter, r *http.Request, unrestricted bool, apiVersion APIVersion) ([]*libregraph.Drive, error) { logger := g.logger.SubloggerWithRequestID(r.Context()) logger.Info(). Interface("query", r.URL.Query()). @@ -197,6 +192,10 @@ func (g Graph) getDrives(r *http.Request, unrestricted bool, apiVersion APIVersi logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("could not get drives: query error") return nil, errorcode.New(errorcode.InvalidRequest, err.Error()) } + + if !g.validateQueryMFA(r, w, odataReq.Query, DriveTypeValidator{}) { + return nil, errorcode.New(errorcode.AccessDenied, "") + } ctx := r.Context() filters, err := generateCs3Filters(odataReq) @@ -409,6 +408,8 @@ func (g Graph) createDrive(w http.ResponseWriter, r *http.Request, apiVersion AP switch driveType { case "", _spaceTypeProject: driveType = _spaceTypeProject + case _spaceTypeProtectedProject: + driveType = _spaceTypeProtectedProject default: logger.Debug().Str("type", driveType).Msg("could not create drive: drives of this type cannot be created via this api") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "drives of this type cannot be created via this api") @@ -468,7 +469,7 @@ func (g Graph) createDrive(w http.ResponseWriter, r *http.Request, apiVersion AP } space := resp.GetStorageSpace() - if t := r.URL.Query().Get(TemplateParameter); t != "" && driveType == _spaceTypeProject { + if t := r.URL.Query().Get(TemplateParameter); t != "" && (driveType == _spaceTypeProject || driveType == _spaceTypeProtectedProject) { loc := l10n.MustGetUserLocale(ctx, us.GetId().GetOpaqueId(), r.Header.Get(HeaderAcceptLanguage), g.valueService) if err := g.applySpaceTemplate(ctx, gatewayClient, space.GetRoot(), t, loc); err != nil { logger.Error().Err(err).Msg("could not apply template to space") @@ -596,7 +597,7 @@ func (g Graph) updateDrive(w http.ResponseWriter, r *http.Request, apiVersion AP for _, sp := range res.StorageSpaces { id, _ := storagespace.ParseID(sp.GetId().GetOpaqueId()) if id.GetSpaceId() == rid.GetSpaceId() { - dt = _spaceTypeProject + dt = sp.SpaceType } } } @@ -1016,9 +1017,12 @@ func getQuota(quota *libregraph.Quota, defaultQuota string) *storageprovider.Quo func (g Graph) canSetSpaceQuota(ctx context.Context, _ *userv1beta1.User, typ string) (bool, error) { permID := settingsServiceExt.SetPersonalSpaceQuotaPermission(0).Id - if typ == _spaceTypeProject { + if typ == _spaceTypeProject || typ == _spaceTypeProtectedProject { permID = settingsServiceExt.SetProjectSpaceQuotaPermission(0).Id } + if typ == _spaceTypeProtectedPersonal { + permID = settingsServiceExt.SetPersonalSpaceQuotaPermission(0).Id + } _, err := g.permissionsService.GetPermissionByID(ctx, &settingssvc.GetPermissionByIDRequest{PermissionId: permID}) if err != nil { merror := merrors.FromError(err) diff --git a/services/graph/pkg/service/v0/graph.go b/services/graph/pkg/service/v0/graph.go index 80f5c82da3f..1183dc3a273 100644 --- a/services/graph/pkg/service/v0/graph.go +++ b/services/graph/pkg/service/v0/graph.go @@ -214,6 +214,25 @@ func (v BasicQueryValidator) Validate(query *godata.GoDataQuery) error { return nil } +// DriveTypeValidator validates if the driveType filter contains protected types. +type DriveTypeValidator struct{} + +// Validate checks if the driveType filter contains protected types. +func (v DriveTypeValidator) Validate(query *godata.GoDataQuery) error { + if query == nil || query.Filter == nil { + return nil + } + + if query.Filter.Tree.Token.Value == "eq" && query.Filter.Tree.Children[0].Token.Value == "driveType" { + driveType := strings.Trim(query.Filter.Tree.Children[1].Token.Value, "'") + if strings.HasPrefix(driveType, "protected-") { + return errors.New("mfa required for protected spaces") + } + } + + return nil +} + // validateQuery validates the godata query based on provided validators. // It handles user permissions and MFA requirements centrally. func (g Graph) validateQuery(r *http.Request, w http.ResponseWriter, query *godata.GoDataQuery, validators ...QueryValidator) bool { @@ -237,3 +256,34 @@ func (g Graph) validateQuery(r *http.Request, w http.ResponseWriter, query *goda } return true } + +// validateQueryMFA validates the godata query based on provided validators. +// It only handles MFA requirements and does not check for user permissions. +func (g Graph) validateQueryMFA(r *http.Request, w http.ResponseWriter, query *godata.GoDataQuery, validators ...QueryValidator) bool { + hasMFA := mfa.Has(r.Context()) + logger := g.logger.SubloggerWithRequestID(r.Context()) + + for _, v := range validators { + if err := v.Validate(query); err != nil { + if !hasMFA { + logger.Error().Str("path", r.URL.Path).Msg("MFA required but not satisfied") + mfa.SetRequiredStatus(w) + return false + } + } + } + return true +} + +// validateMFA validates the MFA requirement. +func (g Graph) validateMFA(r *http.Request, w http.ResponseWriter) bool { + hasMFA := mfa.Has(r.Context()) + logger := g.logger.SubloggerWithRequestID(r.Context()) + + if !hasMFA { + logger.Error().Str("path", r.URL.Path).Msg("MFA required but not satisfied") + mfa.SetRequiredStatus(w) + return false + } + return true +} diff --git a/services/graph/pkg/service/v0/graph_test.go b/services/graph/pkg/service/v0/graph_test.go index 1112dacb2bd..6e7533f1d60 100644 --- a/services/graph/pkg/service/v0/graph_test.go +++ b/services/graph/pkg/service/v0/graph_test.go @@ -811,6 +811,53 @@ var _ = Describe("Graph", func() { Expect(*response.DriveAlias).To(Equal("project/testspace")) Expect(*response.Description).To(Equal("This space is for testing")) }) + It("can create a protected-project space", func() { + gatewayClient.On("CreateStorageSpace", mock.Anything, mock.Anything).Return(&provider.CreateStorageSpaceResponse{ + Status: status.NewOK(ctx), + StorageSpace: &provider.StorageSpace{ + Id: &provider.StorageSpaceId{OpaqueId: "newID"}, + Name: "Top Secret Project", + SpaceType: "protected-project", + Root: &provider.ResourceId{ + StorageId: "pro-1", + SpaceId: "newID", + OpaqueId: "newID", + }, + Opaque: &typesv1beta1.Opaque{ + Map: map[string]*typesv1beta1.OpaqueEntry{ + "description": {Decoder: "plain", Value: []byte("This space is protected")}, + "spaceAlias": {Decoder: "plain", Value: []byte("protected-project/topsecretproject")}, + }, + }, + }, + }, nil) + + permissionService.On("GetPermissionByID", mock.Anything, mock.Anything).Return(&settingssvc.GetPermissionByIDResponse{ + Permission: &v0.Permission{ + Operation: v0.Permission_OPERATION_READWRITE, + Constraint: v0.Permission_CONSTRAINT_ALL, + }, + }, nil) + + gatewayClient.On("GetQuota", mock.Anything, mock.Anything).Return(&provider.GetQuotaResponse{ + Status: status.NewOK(ctx), + TotalBytes: 500, + }, nil) + + jsonBody := []byte(`{"name": "Top Secret Project", "driveType": "protected-project", "description": "This space is protected"}`) + r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/drives", bytes.NewBuffer(jsonBody)).WithContext(ctx) + rr := httptest.NewRecorder() + svc.CreateDrive(rr, r) + Expect(rr.Code).To(Equal(http.StatusCreated)) + + body, _ := io.ReadAll(rr.Body) + var response libregraph.Drive + err := json.Unmarshal(body, &response) + Expect(err).ToNot(HaveOccurred()) + Expect(response.Name).To(Equal("Top Secret Project")) + Expect(*response.DriveType).To(Equal("protected-project")) + Expect(*response.Description).To(Equal("This space is protected")) + }) It("Incomplete space", func() { gatewayClient.On("CreateStorageSpace", mock.Anything, mock.Anything).Return(&provider.CreateStorageSpaceResponse{ Status: status.NewOK(ctx), diff --git a/services/graph/pkg/service/v0/groups.go b/services/graph/pkg/service/v0/groups.go index 7f7e8837093..114befb438b 100644 --- a/services/graph/pkg/service/v0/groups.go +++ b/services/graph/pkg/service/v0/groups.go @@ -31,7 +31,6 @@ func (g Graph) GetGroups(w http.ResponseWriter, r *http.Request) { errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error()) return } - ctxHasFullPerms := g.contextUserHasFullAccountPerms(r.Context()) if !g.validateQuery(r, w, odataReq.Query, SearchValidator{MinLength: g.config.API.IdentitySearchMinLength}, BasicQueryValidator{}) { return @@ -44,7 +43,7 @@ func (g Graph) GetGroups(w http.ResponseWriter, r *http.Request) { return } // If the user isn't admin, we'll show just the minimum group attibutes - if !ctxHasFullPerms { + if !g.contextUserHasFullAccountPerms(r.Context()) { finalGroups := make([]*libregraph.Group, len(groups)) for i, grp := range groups { finalGroups[i] = &libregraph.Group{ diff --git a/services/graph/pkg/service/v0/users.go b/services/graph/pkg/service/v0/users.go index 2e57940f9b5..bdf135695d3 100644 --- a/services/graph/pkg/service/v0/users.go +++ b/services/graph/pkg/service/v0/users.go @@ -323,8 +323,6 @@ func (g Graph) GetUsers(w http.ResponseWriter, r *http.Request) { return } - ctxHasFullPerms := g.contextUserHasFullAccountPerms(r.Context()) - if !g.validateQuery(r, w, odataReq.Query, SearchValidator{MinLength: g.config.API.IdentitySearchMinLength}, FilterValidator{}, BasicQueryValidator{}) { return } @@ -393,6 +391,8 @@ func (g Graph) GetUsers(w http.ResponseWriter, r *http.Request) { usersWithAttributes := make([]*UserWithAttributes, 0, len(users)) displayedAttributes := g.config.API.UserSearchDisplayedAttributes + ctxHasFullPerms := g.contextUserHasFullAccountPerms(r.Context()) + for _, user := range users { attributes, err := getUsersAttributes(displayedAttributes, user) if err != nil { From ad5268dd6f3e0226bdacb2a264399c0ba119f8c6 Mon Sep 17 00:00:00 2001 From: Roman Perekhod <2403905@gmail.com> Date: Fri, 27 Feb 2026 14:09:37 +0100 Subject: [PATCH 3/5] added protected-personal autoprovision added MFA enforcement for the ocdav and arciver --- go.mod | 2 +- go.sum | 4 +- services/gateway/pkg/revaconfig/config.go | 2 +- services/graph/pkg/service/v0/drives.go | 13 +++++ services/graph/pkg/service/v0/graph.go | 2 +- services/graph/pkg/service/v0/graph_test.go | 57 +++++++++++++++++++ services/proxy/pkg/middleware/create_home.go | 31 ++++++++++ services/search/pkg/search/service.go | 18 ++++-- .../services/gateway/storageprovidercache.go | 8 +++ .../storageprovider/storageprovider.go | 4 +- .../http/services/archiver/handler.go | 52 ++++++++++++++++- .../http/services/owncloud/ocdav/copy.go | 7 +++ .../http/services/owncloud/ocdav/dav.go | 25 ++++++++ .../http/services/owncloud/ocdav/get.go | 8 +++ .../http/services/owncloud/ocdav/spaces.go | 24 +++++++- .../v2/pkg/storage/registry/spaces/spaces.go | 16 +++--- .../permissions/spacepermissions.go | 10 ++-- .../pkg/storage/utils/decomposedfs/spaces.go | 28 +++++---- vendor/modules.txt | 2 +- 19 files changed, 271 insertions(+), 42 deletions(-) diff --git a/go.mod b/go.mod index ddd167c8d0c..95b73d3f161 100644 --- a/go.mod +++ b/go.mod @@ -64,7 +64,7 @@ require ( github.com/open-policy-agent/opa v1.12.3 github.com/orcaman/concurrent-map v1.0.0 github.com/owncloud/libre-graph-api-go v1.0.5-0.20260216101009-eeac018af245 - github.com/owncloud/reva/v2 v2.0.0-20260223124048-61ee39d95d5f + github.com/owncloud/reva/v2 v2.0.0-20260303153746-059bcdfc4fbb github.com/pkg/errors v0.9.1 github.com/pkg/xattr v0.4.12 github.com/prometheus/client_golang v1.23.2 diff --git a/go.sum b/go.sum index 4dce4ab5eef..9e6ccf8e79e 100644 --- a/go.sum +++ b/go.sum @@ -742,8 +742,8 @@ github.com/orcaman/concurrent-map v1.0.0 h1:I/2A2XPCb4IuQWcQhBhSwGfiuybl/J0ev9HD github.com/orcaman/concurrent-map v1.0.0/go.mod h1:Lu3tH6HLW3feq74c2GC+jIMS/K2CFcDWnWD9XkenwhI= github.com/owncloud/libre-graph-api-go v1.0.5-0.20260216101009-eeac018af245 h1:JRidLTAKhnvyLMRtVtSF4lhBa0NSAOs6fof+d6JnKII= github.com/owncloud/libre-graph-api-go v1.0.5-0.20260216101009-eeac018af245/go.mod h1:z61VMGAJRtR1nbgXWiNoCkxUXP1B3Je9rMuJbnGd+Og= -github.com/owncloud/reva/v2 v2.0.0-20260223124048-61ee39d95d5f h1:M935ztFcjtRbP3ns8Fgb2XL5jalNd3sXYMUXf0kAwTQ= -github.com/owncloud/reva/v2 v2.0.0-20260223124048-61ee39d95d5f/go.mod h1:+rCy6oGYb2/qs5gmQa8y/pHARw634vB73MZGDY2SBIQ= +github.com/owncloud/reva/v2 v2.0.0-20260303153746-059bcdfc4fbb h1:+g9i1ZCb5EAufhjX967R2Zkw3UtgKk6XfXNQ4LQg9iE= +github.com/owncloud/reva/v2 v2.0.0-20260303153746-059bcdfc4fbb/go.mod h1:+rCy6oGYb2/qs5gmQa8y/pHARw634vB73MZGDY2SBIQ= github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw= github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0= github.com/pablodz/inotifywaitgo v0.0.9 h1:njquRbBU7fuwIe5rEvtaniVBjwWzcpdUVptSgzFqZsw= diff --git a/services/gateway/pkg/revaconfig/config.go b/services/gateway/pkg/revaconfig/config.go index aaea6b0f1a1..f6703407db3 100644 --- a/services/gateway/pkg/revaconfig/config.go +++ b/services/gateway/pkg/revaconfig/config.go @@ -162,7 +162,7 @@ func spacesProviders(cfg *config.Config, logger log.Logger) map[string]map[strin }, "protected-personal": map[string]interface{}{ "mount_point": "/protected-users", - "path_template": "/protected-users/{{.Space.Owner.Id.OpaqueId}}", + "path_template": "/protected-users/{{.Space.Name}}", }, "protected-project": map[string]interface{}{ "mount_point": "/protected-projects", diff --git a/services/graph/pkg/service/v0/drives.go b/services/graph/pkg/service/v0/drives.go index 0eea4e84d17..78272f9577d 100644 --- a/services/graph/pkg/service/v0/drives.go +++ b/services/graph/pkg/service/v0/drives.go @@ -29,6 +29,7 @@ import ( "google.golang.org/protobuf/proto" "github.com/owncloud/ocis/v2/ocis-pkg/l10n" + "github.com/owncloud/ocis/v2/ocis-pkg/mfa" v0 "github.com/owncloud/ocis/v2/protogen/gen/ocis/messages/settings/v0" settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0" "github.com/owncloud/ocis/v2/services/graph/pkg/errorcode" @@ -782,6 +783,18 @@ func (g Graph) ListStorageSpacesWithFilters(ctx context.Context, filters []*stor return nil, err } res, err := gatewayClient.ListStorageSpaces(ctx, lReq) + + // Filter out protected spaces if MFA is not enabled + if !mfa.Has(ctx) { + var filtered []*storageprovider.StorageSpace + for _, s := range res.StorageSpaces { + if s.SpaceType != _spaceTypeProtectedPersonal && s.SpaceType != _spaceTypeProtectedProject { + filtered = append(filtered, s) + } + } + res.StorageSpaces = filtered + } + return res, err } diff --git a/services/graph/pkg/service/v0/graph.go b/services/graph/pkg/service/v0/graph.go index 1183dc3a273..2e82c8b5d93 100644 --- a/services/graph/pkg/service/v0/graph.go +++ b/services/graph/pkg/service/v0/graph.go @@ -225,7 +225,7 @@ func (v DriveTypeValidator) Validate(query *godata.GoDataQuery) error { if query.Filter.Tree.Token.Value == "eq" && query.Filter.Tree.Children[0].Token.Value == "driveType" { driveType := strings.Trim(query.Filter.Tree.Children[1].Token.Value, "'") - if strings.HasPrefix(driveType, "protected-") { + if driveType == _spaceTypeProtectedProject || driveType == _spaceTypeProtectedPersonal { return errors.New("mfa required for protected spaces") } } diff --git a/services/graph/pkg/service/v0/graph_test.go b/services/graph/pkg/service/v0/graph_test.go index 6e7533f1d60..a45483bf1d2 100644 --- a/services/graph/pkg/service/v0/graph_test.go +++ b/services/graph/pkg/service/v0/graph_test.go @@ -192,6 +192,63 @@ var _ = Describe("Graph", func() { } `)) }) + It("filters out protected spaces when not having mfa", func() { + gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Times(1).Return(&provider.ListStorageSpacesResponse{ + Status: status.NewOK(ctx), + StorageSpaces: []*provider.StorageSpace{ + { + Id: &provider.StorageSpaceId{OpaqueId: "p-personal"}, + SpaceType: "protected-personal", + Root: &provider.ResourceId{ + StorageId: "pro-1", + SpaceId: "p-personal", + OpaqueId: "p-personal", + }, + Name: "Protected Personal", + }, + { + Id: &provider.StorageSpaceId{OpaqueId: "p-project"}, + SpaceType: "protected-project", + Root: &provider.ResourceId{ + StorageId: "pro-1", + SpaceId: "p-project", + OpaqueId: "p-project", + }, + Name: "Protected Project", + }, + { + Id: &provider.StorageSpaceId{OpaqueId: "normal"}, + SpaceType: "personal", + Root: &provider.ResourceId{ + StorageId: "pro-1", + SpaceId: "normal", + OpaqueId: "normal", + }, + Name: "Normal Personal", + }, + }, + }, nil) + gatewayClient.On("InitiateFileDownload", mock.Anything, mock.Anything).Return(&gateway.InitiateFileDownloadResponse{ + Status: status.NewNotFound(ctx, "not found"), + }, nil) + gatewayClient.On("GetQuota", mock.Anything, mock.Anything).Return(&provider.GetQuotaResponse{ + Status: status.NewUnimplemented(ctx, fmt.Errorf("not supported"), "not supported"), + }, nil) + + r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives", nil) + r = r.WithContext(ctx) + rr := httptest.NewRecorder() + svc.GetDrivesV1(rr, r) + + Expect(rr.Code).To(Equal(http.StatusOK)) + + body, _ := io.ReadAll(rr.Body) + var response map[string][]libregraph.Drive + err := json.Unmarshal(body, &response) + Expect(err).ToNot(HaveOccurred()) + Expect(len(response["value"])).To(Equal(1)) + Expect(*response["value"][0].DriveType).To(Equal("personal")) + }) It("can list a spaces with sort", func() { gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{ Status: status.NewOK(ctx), diff --git a/services/proxy/pkg/middleware/create_home.go b/services/proxy/pkg/middleware/create_home.go index a71f3825354..dfb09bb32a8 100644 --- a/services/proxy/pkg/middleware/create_home.go +++ b/services/proxy/pkg/middleware/create_home.go @@ -8,6 +8,7 @@ import ( userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + "github.com/google/uuid" "github.com/owncloud/ocis/v2/ocis-pkg/log" "github.com/owncloud/ocis/v2/services/graph/pkg/errorcode" revactx "github.com/owncloud/reva/v2/pkg/ctx" @@ -77,6 +78,36 @@ func (m createHome) ServeHTTP(w http.ResponseWriter, req *http.Request) { if createHomeRes.Status.Code != rpc.Code_CODE_ALREADY_EXISTS { m.logger.Err(err).Msg("error when calling Createhome") } + } else if createHomeRes.Status.Code == rpc.Code_CODE_OK { + if u != nil { + // Create the "protected-personal" space. + // Use the user ID as the space ID if it is a valid uuid or generate a new one. + // Replace last 4 chars of the space id with "0000" to make it visible. + spaceID := u.Id.OpaqueId + if err := uuid.Validate(spaceID); err != nil { + spaceID = uuid.New().String() + } + spaceID = spaceID[:len(spaceID)-4] + "0000" + + createProtectedSpaceReq := &provider.CreateStorageSpaceRequest{ + Type: "protected-personal", + Name: "Protected Personal", + Owner: &userv1beta1.User{ + Id: u.Id, + }, + Opaque: utils.AppendPlainToOpaque(nil, "spaceid", spaceID), + } + + createProtectedSpaceRes, err := client.CreateStorageSpace(ctx, createProtectedSpaceReq) + if err != nil { + m.logger.Err(err).Msg("error calling CreateStorageSpace for protected-personal") + } else if createProtectedSpaceRes.Status.Code != rpc.Code_CODE_OK && createProtectedSpaceRes.Status.Code != rpc.Code_CODE_ALREADY_EXISTS { + m.logger.Error(). + Interface("status", createProtectedSpaceRes.Status). + Str("userid", u.Id.OpaqueId). + Msg("error when creating protected-personal space") + } + } } } diff --git a/services/search/pkg/search/service.go b/services/search/pkg/search/service.go index 0ec760e39f2..c4cd3f222f6 100644 --- a/services/search/pkg/search/service.go +++ b/services/search/pkg/search/service.go @@ -35,12 +35,14 @@ import ( ) const ( - _spaceStateTrashed = "trashed" - _spaceTypeMountpoint = "mountpoint" - _spaceTypePersonal = "personal" - _spaceTypeProject = "project" - _spaceTypeGrant = "grant" - _slowQueryDuration = 500 * time.Millisecond + _spaceStateTrashed = "trashed" + _spaceTypeMountpoint = "mountpoint" + _spaceTypePersonal = "personal" + _spaceTypeProject = "project" + _spaceTypeProtectedPersonal = "protected_personal" + _spaceTypeProtectedProject = "protected_project" + _spaceTypeGrant = "grant" + _slowQueryDuration = 500 * time.Millisecond ) var ( @@ -168,6 +170,10 @@ func (s *Service) Search(ctx context.Context, req *searchsvc.SearchRequest) (*se // We still need the mountpoint in order to map the result paths to the according share continue } + if space.SpaceType == _spaceTypeProtectedPersonal || space.SpaceType == _spaceTypeProtectedProject { + // Exclude protected spaces from search results + continue + } spaces = append(spaces, space) } diff --git a/vendor/github.com/owncloud/reva/v2/internal/grpc/services/gateway/storageprovidercache.go b/vendor/github.com/owncloud/reva/v2/internal/grpc/services/gateway/storageprovidercache.go index c5fc1ba8c43..7cee898e46b 100644 --- a/vendor/github.com/owncloud/reva/v2/internal/grpc/services/gateway/storageprovidercache.go +++ b/vendor/github.com/owncloud/reva/v2/internal/grpc/services/gateway/storageprovidercache.go @@ -105,6 +105,10 @@ func (c *cachedSpacesAPIClient) CreateStorageSpace(ctx context.Context, in *prov if key != "" { s := &provider.CreateStorageSpaceResponse{} if err := c.createPersonalSpaceCache.PullFromCache(key, s); err == nil { + if s.Status == nil { + s.Status = &rpc.Status{} + } + s.Status.Code = rpc.Code_CODE_ALREADY_EXISTS return s, nil } } @@ -163,6 +167,10 @@ func (c *cachedAPIClient) CreateHome(ctx context.Context, in *provider.CreateHom if key != "" { s := &provider.CreateHomeResponse{} if err := c.createPersonalSpaceCache.PullFromCache(key, s); err == nil { + if s.Status == nil { + s.Status = &rpc.Status{} + } + s.Status.Code = rpc.Code_CODE_ALREADY_EXISTS return s, nil } } diff --git a/vendor/github.com/owncloud/reva/v2/internal/grpc/services/storageprovider/storageprovider.go b/vendor/github.com/owncloud/reva/v2/internal/grpc/services/storageprovider/storageprovider.go index d790bf2c1d5..8bb94afa14b 100644 --- a/vendor/github.com/owncloud/reva/v2/internal/grpc/services/storageprovider/storageprovider.go +++ b/vendor/github.com/owncloud/reva/v2/internal/grpc/services/storageprovider/storageprovider.go @@ -33,6 +33,7 @@ import ( rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" typesv1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" + "github.com/mitchellh/mapstructure" "github.com/owncloud/reva/v2/pkg/appctx" "github.com/owncloud/reva/v2/pkg/conversions" ctxpkg "github.com/owncloud/reva/v2/pkg/ctx" @@ -47,7 +48,6 @@ import ( "github.com/owncloud/reva/v2/pkg/storage/fs/registry" "github.com/owncloud/reva/v2/pkg/storagespace" "github.com/owncloud/reva/v2/pkg/utils" - "github.com/mitchellh/mapstructure" "github.com/pkg/errors" "github.com/rs/zerolog" "go.opentelemetry.io/otel/attribute" @@ -519,7 +519,7 @@ func (s *Service) CreateStorageSpace(ctx context.Context, req *provider.CreateSt // if trying to create a user home fall back to CreateHome if u, ok := ctxpkg.ContextGetUser(ctx); ok && req.Type == "personal" && utils.UserEqual(req.GetOwner().Id, u.Id) { if err := s.Storage.CreateHome(ctx); err != nil { - st = status.NewInternal(ctx, "error creating home") + st = status.NewStatusFromErrType(ctx, "error creating home", err) } else { st = status.NewOK(ctx) // TODO we cannot return a space, but the gateway currently does not expect one... diff --git a/vendor/github.com/owncloud/reva/v2/internal/http/services/archiver/handler.go b/vendor/github.com/owncloud/reva/v2/internal/http/services/archiver/handler.go index c2e2dfdc73a..287140f6a06 100644 --- a/vendor/github.com/owncloud/reva/v2/internal/http/services/archiver/handler.go +++ b/vendor/github.com/owncloud/reva/v2/internal/http/services/archiver/handler.go @@ -31,6 +31,8 @@ import ( rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + "github.com/gdexlab/go-render/render" + "github.com/mitchellh/mapstructure" "github.com/owncloud/reva/v2/internal/http/services/archiver/manager" "github.com/owncloud/reva/v2/pkg/errtypes" "github.com/owncloud/reva/v2/pkg/rgrpc/todo/pool" @@ -40,11 +42,17 @@ import ( "github.com/owncloud/reva/v2/pkg/storage/utils/downloader" "github.com/owncloud/reva/v2/pkg/storage/utils/walker" "github.com/owncloud/reva/v2/pkg/storagespace" - "github.com/gdexlab/go-render/render" - "github.com/mitchellh/mapstructure" "github.com/rs/zerolog" ) +const ( + mfaHeader = "X-Multi-Factor-Authentication" + mfaRequiredHeader = "X-Ocis-Mfa-Required" + + _spaceTypeProtectedPersonal = "protected-personal" + _spaceTypeProtectedProject = "protected-project" +) + type svc struct { config *Config gatewaySelector pool.Selectable[gateway.GatewayAPIClient] @@ -251,6 +259,38 @@ func (s *svc) Handler() http.Handler { return } + // MFA check + gatewayClient, err := s.gatewaySelector.Next() + if err != nil { + s.log.Error().Err(err).Msg("failed to get gateway client") + s.writeHTTPError(rw, err) + return + } + for _, resource := range resources { + res, err := gatewayClient.Stat(ctx, &provider.StatRequest{ + Ref: &provider.Reference{ + ResourceId: resource, + Path: ".", + }, + }) + if err != nil { + s.log.Error().Err(err).Interface("resource", resource).Msg("failed to stat resource") + s.writeHTTPError(rw, err) + return + } + if res.Status.Code != rpc.Code_CODE_OK { + s.log.Error().Interface("resource", resource).Str("status", res.Status.Message).Msg("failed to stat resource") + s.writeHTTPError(rw, errors.New(res.Status.Message)) + return + } + if isProtectedSpaceType(res.Info.GetSpace().GetSpaceType()) && !isMfaSet(r) { + s.log.Debug().Interface("resource", resource).Str("spacetype", res.Info.GetSpace().GetSpaceType()).Msg("MFA required for protected space") + rw.Header().Set(mfaRequiredHeader, "true") + rw.WriteHeader(http.StatusForbidden) + return + } + } + archName := s.config.Name if format == "tar" { archName += ".tar" @@ -291,3 +331,11 @@ func (s *svc) Close() error { func (s *svc) Unprotected() []string { return nil } + +func isMfaSet(r *http.Request) bool { + return r.Header.Get(mfaHeader) == "true" +} + +func isProtectedSpaceType(spaceType string) bool { + return spaceType == _spaceTypeProtectedPersonal || spaceType == _spaceTypeProtectedProject +} diff --git a/vendor/github.com/owncloud/reva/v2/internal/http/services/owncloud/ocdav/copy.go b/vendor/github.com/owncloud/reva/v2/internal/http/services/owncloud/ocdav/copy.go index 307bed917f2..0cbea56bcc8 100644 --- a/vendor/github.com/owncloud/reva/v2/internal/http/services/owncloud/ocdav/copy.go +++ b/vendor/github.com/owncloud/reva/v2/internal/http/services/owncloud/ocdav/copy.go @@ -671,6 +671,13 @@ func (s *svc) prepareCopy(ctx context.Context, w http.ResponseWriter, r *http.Re return nil } + // Disallow copying from poctected space to unprotected space + if isUnprotectedSpaceType(dstStatRes.GetInfo().GetSpace().GetSpaceType()) && isProtectedSpaceType(srcStatRes.GetInfo().GetSpace().GetSpaceType()) { + log.Error().Msg("the unprotected destination is disallowed") + w.WriteHeader(http.StatusBadRequest) + return nil + } + successCode := http.StatusCreated // 201 if new resource was created, see https://tools.ietf.org/html/rfc4918#section-9.8.5 if dstStatRes.Status.Code == rpc.Code_CODE_OK { successCode = http.StatusNoContent // 204 if target already existed, see https://tools.ietf.org/html/rfc4918#section-9.8.5 diff --git a/vendor/github.com/owncloud/reva/v2/internal/http/services/owncloud/ocdav/dav.go b/vendor/github.com/owncloud/reva/v2/internal/http/services/owncloud/ocdav/dav.go index e14317ad25f..5610fba5f4b 100644 --- a/vendor/github.com/owncloud/reva/v2/internal/http/services/owncloud/ocdav/dav.go +++ b/vendor/github.com/owncloud/reva/v2/internal/http/services/owncloud/ocdav/dav.go @@ -50,6 +50,17 @@ const ( // WwwAuthenticate captures the Www-Authenticate header string. WwwAuthenticate = "Www-Authenticate" + + // mfaHeader is the header used to forward the MFA authentication status. + mfaHeader = "X-Multi-Factor-Authentication" + + // mfaRequiredHeader is the response header indicating that MFA step-up authentication is required. + mfaRequiredHeader = "X-Ocis-Mfa-Required" + + _spaceTypePersonal = "personal" + _spaceTypeProject = "project" + _spaceTypeProtectedPersonal = "protected-personal" + _spaceTypeProtectedProject = "protected-project" ) const ( @@ -455,3 +466,17 @@ func handleOCMAuth(ctx context.Context, c gatewayv1beta1.GatewayAPIClient, ocmsh ClientSecret: sharedSecret, }) } + +func isMfaSet(r *http.Request) bool { + return r.Header.Get(mfaHeader) == "true" +} + +// isUnprotectedSpaceType returns true if the space type is unprotected. +func isUnprotectedSpaceType(spaceType string) bool { + return spaceType == _spaceTypePersonal || spaceType == _spaceTypeProject +} + +// isProtectedSpaceType returns true if the space type requires MFA authentication. +func isProtectedSpaceType(spaceType string) bool { + return spaceType == _spaceTypeProtectedPersonal || spaceType == _spaceTypeProtectedProject +} diff --git a/vendor/github.com/owncloud/reva/v2/internal/http/services/owncloud/ocdav/get.go b/vendor/github.com/owncloud/reva/v2/internal/http/services/owncloud/ocdav/get.go index 4b8ccb27c0c..13524f4fd2a 100644 --- a/vendor/github.com/owncloud/reva/v2/internal/http/services/owncloud/ocdav/get.go +++ b/vendor/github.com/owncloud/reva/v2/internal/http/services/owncloud/ocdav/get.go @@ -79,6 +79,14 @@ func (s *svc) handleGet(ctx context.Context, w http.ResponseWriter, r *http.Requ return } + // MFA enforcement. + if isProtectedSpaceType(sRes.GetInfo().GetSpace().GetSpaceType()) && !isMfaSet(r) { + log.Debug().Interface("ref", ref).Str("spacetype", sRes.GetInfo().GetSpace().GetSpaceType()).Msg("MFA required for protected space") + w.Header().Set(mfaRequiredHeader, "true") + w.WriteHeader(http.StatusForbidden) + return + } + if sRes.Info.Type != provider.ResourceType_RESOURCE_TYPE_FILE { w.Header().Set("Content-Length", "0") w.WriteHeader(http.StatusOK) diff --git a/vendor/github.com/owncloud/reva/v2/internal/http/services/owncloud/ocdav/spaces.go b/vendor/github.com/owncloud/reva/v2/internal/http/services/owncloud/ocdav/spaces.go index 4f1d783e036..3ccef7c1fb9 100644 --- a/vendor/github.com/owncloud/reva/v2/internal/http/services/owncloud/ocdav/spaces.go +++ b/vendor/github.com/owncloud/reva/v2/internal/http/services/owncloud/ocdav/spaces.go @@ -23,11 +23,13 @@ import ( "path" "strings" + rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" "github.com/owncloud/reva/v2/internal/http/services/owncloud/ocdav/config" "github.com/owncloud/reva/v2/internal/http/services/owncloud/ocdav/errors" "github.com/owncloud/reva/v2/internal/http/services/owncloud/ocdav/net" "github.com/owncloud/reva/v2/internal/http/services/owncloud/ocdav/propfind" + "github.com/owncloud/reva/v2/internal/http/services/owncloud/ocdav/spacelookup" "github.com/owncloud/reva/v2/pkg/appctx" "github.com/owncloud/reva/v2/pkg/rhttp/router" "github.com/owncloud/reva/v2/pkg/storagespace" @@ -53,8 +55,8 @@ func (h *SpacesHandler) init(c *config.Config) error { func (h *SpacesHandler) Handler(s *svc, trashbinHandler *TrashbinHandler) http.Handler { config := s.Config() return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // ctx := r.Context() - // log := appctx.GetLogger(ctx) + ctx := r.Context() + log := appctx.GetLogger(ctx) if r.Method == http.MethodOptions { s.handleOptions(w, r) @@ -76,6 +78,22 @@ func (h *SpacesHandler) Handler(s *svc, trashbinHandler *TrashbinHandler) http.H spaceID := segment + // MFA enforcement: look up the space type and require MFA for protected spaces. + if client, err := s.gatewaySelector.Next(); err == nil { + space, st, err := spacelookup.LookUpStorageSpaceByID(ctx, client, spaceID) + if err != nil { + log.Error().Err(err).Str("spaceid", spaceID).Msg("error looking up space for MFA check") + w.WriteHeader(http.StatusInternalServerError) + return + } + if st.GetCode() == rpc.Code_CODE_OK && isProtectedSpaceType(space.GetSpaceType()) && !isMfaSet(r) { + log.Debug().Str("spaceid", spaceID).Str("spacetype", space.GetSpaceType()).Msg("MFA required for protected space") + w.Header().Set(mfaRequiredHeader, "true") + w.WriteHeader(http.StatusForbidden) + return + } + } + // TODO initialize status with http.StatusBadRequest // TODO initialize err with errors.ErrUnsupportedMethod var status int // status 0 means the handler already sent the response @@ -124,7 +142,7 @@ func (h *SpacesHandler) Handler(s *svc, trashbinHandler *TrashbinHandler) http.H } } if err != nil { - appctx.GetLogger(r.Context()).Error().Err(err).Msg(err.Error()) + log.Error().Err(err).Msg(err.Error()) } }) } diff --git a/vendor/github.com/owncloud/reva/v2/pkg/storage/registry/spaces/spaces.go b/vendor/github.com/owncloud/reva/v2/pkg/storage/registry/spaces/spaces.go index ac586e96d39..e854a40fdd0 100644 --- a/vendor/github.com/owncloud/reva/v2/pkg/storage/registry/spaces/spaces.go +++ b/vendor/github.com/owncloud/reva/v2/pkg/storage/registry/spaces/spaces.go @@ -34,6 +34,7 @@ import ( providerpb "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" registrypb "github.com/cs3org/go-cs3apis/cs3/storage/registry/v1beta1" typesv1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" + "github.com/mitchellh/mapstructure" "github.com/owncloud/reva/v2/pkg/appctx" ctxpkg "github.com/owncloud/reva/v2/pkg/ctx" "github.com/owncloud/reva/v2/pkg/errtypes" @@ -44,7 +45,6 @@ import ( pkgregistry "github.com/owncloud/reva/v2/pkg/storage/registry/registry" "github.com/owncloud/reva/v2/pkg/storagespace" "github.com/owncloud/reva/v2/pkg/utils" - "github.com/mitchellh/mapstructure" "google.golang.org/grpc" ) @@ -101,12 +101,14 @@ func (c *config) init() { c.Providers = map[string]*Provider{ sharedconf.GetGatewaySVC(""): { Spaces: map[string]*spaceConfig{ - "personal": {MountPoint: "/users", PathTemplate: "/users/{{.Space.Owner.Id.OpaqueId}}"}, - "project": {MountPoint: "/projects", PathTemplate: "/projects/{{.Space.Name}}"}, - "virtual": {MountPoint: "/users/{{.CurrentUser.Id.OpaqueId}}/Shares"}, - "grant": {MountPoint: "."}, - "mountpoint": {MountPoint: "/users/{{.CurrentUser.Id.OpaqueId}}/Shares", PathTemplate: "/users/{{.CurrentUser.Id.OpaqueId}}/Shares/{{.Space.Name}}"}, - "public": {MountPoint: "/public"}, + "personal": {MountPoint: "/users", PathTemplate: "/users/{{.Space.Owner.Id.OpaqueId}}"}, + "project": {MountPoint: "/projects", PathTemplate: "/projects/{{.Space.Name}}"}, + "protected-personal": {MountPoint: "/protected-users", PathTemplate: "/protected-users/{{.Space.Owner.Id.OpaqueId}}"}, + "protected-project": {MountPoint: "/protected-projects", PathTemplate: "/protected-projects/{{.Space.Name}}"}, + "virtual": {MountPoint: "/users/{{.CurrentUser.Id.OpaqueId}}/Shares"}, + "grant": {MountPoint: "."}, + "mountpoint": {MountPoint: "/users/{{.CurrentUser.Id.OpaqueId}}/Shares", PathTemplate: "/users/{{.CurrentUser.Id.OpaqueId}}/Shares/{{.Space.Name}}"}, + "public": {MountPoint: "/public"}, }, }, } diff --git a/vendor/github.com/owncloud/reva/v2/pkg/storage/utils/decomposedfs/permissions/spacepermissions.go b/vendor/github.com/owncloud/reva/v2/pkg/storage/utils/decomposedfs/permissions/spacepermissions.go index bb8fec0f318..c585da40962 100644 --- a/vendor/github.com/owncloud/reva/v2/pkg/storage/utils/decomposedfs/permissions/spacepermissions.go +++ b/vendor/github.com/owncloud/reva/v2/pkg/storage/utils/decomposedfs/permissions/spacepermissions.go @@ -25,8 +25,10 @@ func init() { } const ( - _spaceTypePersonal = "personal" - _spaceTypeProject = "project" + _spaceTypePersonal = "personal" + _spaceTypeProject = "project" + _spaceTypeProtectedPersonal = "protected-personal" + _spaceTypeProtectedProject = "protected-project" ) // PermissionsChecker defines an interface for checking permissions on a Node @@ -75,9 +77,9 @@ func (p Permissions) SetSpaceQuota(ctx context.Context, spaceid string, spaceTyp switch spaceType { default: return false // only quotas of personal and project space may be changed - case _spaceTypePersonal: + case _spaceTypePersonal, _spaceTypeProtectedPersonal: return p.checkPermission(ctx, "Drives.ReadWritePersonalQuota", spaceRef(spaceid)) - case _spaceTypeProject: + case _spaceTypeProject, _spaceTypeProtectedProject: return p.checkPermission(ctx, "Drives.ReadWriteProjectQuota", spaceRef(spaceid)) } } diff --git a/vendor/github.com/owncloud/reva/v2/pkg/storage/utils/decomposedfs/spaces.go b/vendor/github.com/owncloud/reva/v2/pkg/storage/utils/decomposedfs/spaces.go index 1abf6376925..8baa4c38dd9 100644 --- a/vendor/github.com/owncloud/reva/v2/pkg/storage/utils/decomposedfs/spaces.go +++ b/vendor/github.com/owncloud/reva/v2/pkg/storage/utils/decomposedfs/spaces.go @@ -56,11 +56,13 @@ import ( ) const ( - _spaceTypePersonal = "personal" - _spaceTypeProject = "project" - spaceTypeShare = "share" - spaceTypeAny = "*" - spaceIDAny = "*" + _spaceTypePersonal = "personal" + _spaceTypeProject = "project" + _spaceTypeProtectedPersonal = "protected-personal" + _spaceTypeProtectedProject = "protected-project" + spaceTypeShare = "share" + spaceTypeAny = "*" + spaceIDAny = "*" quotaUnrestricted = 0 ) @@ -82,7 +84,7 @@ func (fs *Decomposedfs) CreateStorageSpace(ctx context.Context, req *provider.Cr // Check if space already exists rootPath := "" switch req.Type { - case _spaceTypePersonal: + case _spaceTypePersonal, _spaceTypeProtectedPersonal: if fs.o.PersonalSpacePathTemplate != "" { rootPath = filepath.Join(fs.o.Root, templates.WithUser(u, fs.o.PersonalSpacePathTemplate)) } @@ -102,7 +104,7 @@ func (fs *Decomposedfs) CreateStorageSpace(ctx context.Context, req *provider.Cr if alias == "" { alias = templates.WithSpacePropertiesAndUser(u, req.Type, req.Name, spaceID, fs.o.GeneralSpaceAliasTemplate) } - if req.Type == _spaceTypePersonal { + if req.Type == _spaceTypePersonal || req.Type == _spaceTypeProtectedPersonal { alias = templates.WithSpacePropertiesAndUser(u, req.Type, req.Name, spaceID, fs.o.PersonalSpaceAliasTemplate) } @@ -199,7 +201,7 @@ func (fs *Decomposedfs) CreateStorageSpace(ctx context.Context, req *provider.Cr ctx = storageprovider.WithSpaceType(ctx, req.Type) - if req.Type != _spaceTypePersonal { + if req.Type != _spaceTypePersonal && req.Type != _spaceTypeProtectedPersonal { if err := fs.AddGrant(ctx, &provider.Reference{ ResourceId: &provider.ResourceId{ SpaceId: spaceID, @@ -375,9 +377,11 @@ func (fs *Decomposedfs) ListStorageSpaces(ctx context.Context, filter []*provide if _, ok := spaceTypes[spaceTypeAny]; ok { // TODO do not hardcode dirs spaceTypes = map[string]struct{}{ - "personal": {}, - "project": {}, - "share": {}, + "personal": {}, + "project": {}, + "share": {}, + "protected-personal": {}, + "protected-project": {}, } } @@ -1164,7 +1168,7 @@ func (fs *Decomposedfs) getSpaceRoot(spaceID string) string { // - a project space can always be enabled/disabled/deleted by its manager (i.e. users have the "remove" grant) func canDeleteSpace(ctx context.Context, spaceID string, typ string, purge bool, n *node.Node, p permissions.Permissions) error { // delete-all-home spaces allows to disable and delete a personal space - if typ == "personal" { + if typ == _spaceTypePersonal || typ == _spaceTypeProtectedPersonal { if p.DeleteAllHomeSpaces(ctx) { return nil } diff --git a/vendor/modules.txt b/vendor/modules.txt index e773788b433..1ef57694da3 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1316,7 +1316,7 @@ github.com/orcaman/concurrent-map # github.com/owncloud/libre-graph-api-go v1.0.5-0.20260216101009-eeac018af245 ## explicit; go 1.18 github.com/owncloud/libre-graph-api-go -# github.com/owncloud/reva/v2 v2.0.0-20260223124048-61ee39d95d5f +# github.com/owncloud/reva/v2 v2.0.0-20260303153746-059bcdfc4fbb ## explicit; go 1.24.0 github.com/owncloud/reva/v2/cmd/revad/internal/grace github.com/owncloud/reva/v2/cmd/revad/runtime From b44091a077637cbb7e002bc69142960c4d3618f6 Mon Sep 17 00:00:00 2001 From: Roman Perekhod <2403905@gmail.com> Date: Wed, 4 Mar 2026 13:24:28 +0100 Subject: [PATCH 4/5] fix responce --- services/graph/pkg/service/v0/drives.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/services/graph/pkg/service/v0/drives.go b/services/graph/pkg/service/v0/drives.go index 78272f9577d..a0f4bfc6e7c 100644 --- a/services/graph/pkg/service/v0/drives.go +++ b/services/graph/pkg/service/v0/drives.go @@ -783,6 +783,12 @@ func (g Graph) ListStorageSpacesWithFilters(ctx context.Context, filters []*stor return nil, err } res, err := gatewayClient.ListStorageSpaces(ctx, lReq) + if err != nil { + return nil, err + } + if res.GetStatus().GetCode() != cs3rpc.Code_CODE_OK { + return res, nil + } // Filter out protected spaces if MFA is not enabled if !mfa.Has(ctx) { @@ -795,7 +801,7 @@ func (g Graph) ListStorageSpacesWithFilters(ctx context.Context, filters []*stor res.StorageSpaces = filtered } - return res, err + return res, nil } func (g Graph) cs3StorageSpaceToDrive(ctx context.Context, baseURL *url.URL, space *storageprovider.StorageSpace, apiVersion APIVersion) (*libregraph.Drive, error) { From 0151111d59ef7646f1a97bf4dc22c5b07ea7525a Mon Sep 17 00:00:00 2001 From: Roman Perekhod <2403905@gmail.com> Date: Thu, 5 Mar 2026 14:47:39 +0100 Subject: [PATCH 5/5] fix persanal space creating --- services/proxy/pkg/middleware/create_home.go | 68 ++++++++++---------- services/search/pkg/search/service.go | 4 +- 2 files changed, 36 insertions(+), 36 deletions(-) diff --git a/services/proxy/pkg/middleware/create_home.go b/services/proxy/pkg/middleware/create_home.go index dfb09bb32a8..ca2f3115fb1 100644 --- a/services/proxy/pkg/middleware/create_home.go +++ b/services/proxy/pkg/middleware/create_home.go @@ -12,7 +12,6 @@ import ( "github.com/owncloud/ocis/v2/ocis-pkg/log" "github.com/owncloud/ocis/v2/services/graph/pkg/errorcode" revactx "github.com/owncloud/reva/v2/pkg/ctx" - "github.com/owncloud/reva/v2/pkg/rgrpc/status" "github.com/owncloud/reva/v2/pkg/rgrpc/todo/pool" "github.com/owncloud/reva/v2/pkg/utils" "google.golang.org/grpc/metadata" @@ -73,40 +72,41 @@ func (m createHome) ServeHTTP(w http.ResponseWriter, req *http.Request) { createHomeRes, err := client.CreateHome(ctx, createHomeReq) if err != nil { m.logger.Err(err).Msg("error calling CreateHome") - } else if createHomeRes.Status.Code != rpc.Code_CODE_OK { - err := status.NewErrorFromCode(createHomeRes.Status.Code, "gateway") - if createHomeRes.Status.Code != rpc.Code_CODE_ALREADY_EXISTS { - m.logger.Err(err).Msg("error when calling Createhome") + } + switch { + case createHomeRes.GetStatus().GetCode() == rpc.Code_CODE_OK: + m.logger.Info().Interface("userID", u.GetId().GetOpaqueId()).Msg("personal space created") + case createHomeRes.GetStatus().GetCode() != rpc.Code_CODE_ALREADY_EXISTS: + m.logger.Error().Interface("userID", u.GetId().GetOpaqueId()).Interface("status", createHomeRes.GetStatus()).Msg("personal space creation failed") + } + + if spaceID := u.GetId().GetOpaqueId(); spaceID != "" { + // Create the "protected-personal" space. + // Use the user ID as the space ID if it is a valid uuid or generate a new one. + // Replace last 4 chars of the space id with "0000" to make it visible. + if err := uuid.Validate(spaceID); err != nil { + spaceID = uuid.New().String() + } + spaceID = spaceID[:len(spaceID)-4] + "0000" + + createProtectedSpaceReq := &provider.CreateStorageSpaceRequest{ + Type: "protected-personal", + Name: "Protected Personal", + Owner: &userv1beta1.User{ + Id: u.Id, + }, + Opaque: utils.AppendPlainToOpaque(nil, "spaceid", spaceID), + } + + cpsRes, err := client.CreateStorageSpace(ctx, createProtectedSpaceReq) + if err != nil { + m.logger.Err(err).Msg("error calling CreateStorageSpace for protected-personal") } - } else if createHomeRes.Status.Code == rpc.Code_CODE_OK { - if u != nil { - // Create the "protected-personal" space. - // Use the user ID as the space ID if it is a valid uuid or generate a new one. - // Replace last 4 chars of the space id with "0000" to make it visible. - spaceID := u.Id.OpaqueId - if err := uuid.Validate(spaceID); err != nil { - spaceID = uuid.New().String() - } - spaceID = spaceID[:len(spaceID)-4] + "0000" - - createProtectedSpaceReq := &provider.CreateStorageSpaceRequest{ - Type: "protected-personal", - Name: "Protected Personal", - Owner: &userv1beta1.User{ - Id: u.Id, - }, - Opaque: utils.AppendPlainToOpaque(nil, "spaceid", spaceID), - } - - createProtectedSpaceRes, err := client.CreateStorageSpace(ctx, createProtectedSpaceReq) - if err != nil { - m.logger.Err(err).Msg("error calling CreateStorageSpace for protected-personal") - } else if createProtectedSpaceRes.Status.Code != rpc.Code_CODE_OK && createProtectedSpaceRes.Status.Code != rpc.Code_CODE_ALREADY_EXISTS { - m.logger.Error(). - Interface("status", createProtectedSpaceRes.Status). - Str("userid", u.Id.OpaqueId). - Msg("error when creating protected-personal space") - } + switch { + case cpsRes.GetStatus().GetCode() == rpc.Code_CODE_OK: + m.logger.Info().Interface("userID", u.GetId().GetOpaqueId()).Msg("protected-personal space created") + case cpsRes.GetStatus().GetCode() != rpc.Code_CODE_ALREADY_EXISTS: + m.logger.Error().Interface("userID", u.GetId().GetOpaqueId()).Interface("status", cpsRes.GetStatus()).Msg("protected-personal space creation failed") } } } diff --git a/services/search/pkg/search/service.go b/services/search/pkg/search/service.go index c4cd3f222f6..187518858c6 100644 --- a/services/search/pkg/search/service.go +++ b/services/search/pkg/search/service.go @@ -39,8 +39,8 @@ const ( _spaceTypeMountpoint = "mountpoint" _spaceTypePersonal = "personal" _spaceTypeProject = "project" - _spaceTypeProtectedPersonal = "protected_personal" - _spaceTypeProtectedProject = "protected_project" + _spaceTypeProtectedPersonal = "protected-personal" + _spaceTypeProtectedProject = "protected-project" _spaceTypeGrant = "grant" _slowQueryDuration = 500 * time.Millisecond )