-
Notifications
You must be signed in to change notification settings - Fork 240
[full-ci] feat: [OCISDEV-533] Provide the protected-* spaces #12069
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -64,3 +64,4 @@ go.work.sum | |
| .envrc | ||
| CLAUDE.md | ||
| .claude/ | ||
| .agents/ | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -37,11 +37,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 +80,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 +101,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 +135,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) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not entirely sure about this. There are a couple of thing to notice:
I'm not against the change, but I'm not sure if it outweighs the disadvantages. |
||
| 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 +159,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 +180,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) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not fond of passing the |
||
| logger := g.logger.SubloggerWithRequestID(r.Context()) | ||
| logger.Info(). | ||
| Interface("query", r.URL.Query()). | ||
|
|
@@ -197,6 +193,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 +409,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 +470,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 +598,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 | ||
| } | ||
| } | ||
| } | ||
|
|
@@ -781,7 +783,25 @@ func (g Graph) ListStorageSpacesWithFilters(ctx context.Context, filters []*stor | |
| return nil, err | ||
| } | ||
| res, err := gatewayClient.ListStorageSpaces(ctx, lReq) | ||
| return res, err | ||
| 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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we request first the non-protected folders and then the protected ones if we have MFA? Or check the MFA first and request only the non-protected folders if we don't have MFA? |
||
| 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, nil | ||
| } | ||
|
|
||
| func (g Graph) cs3StorageSpaceToDrive(ctx context.Context, baseURL *url.URL, space *storageprovider.StorageSpace, apiVersion APIVersion) (*libregraph.Drive, error) { | ||
|
|
@@ -1016,9 +1036,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) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,135 @@ 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 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 | ||
| } | ||
|
|
||
| // 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 { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think this should be needed... Any chance to move the drive check into reva? |
||
| 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 driveType == _spaceTypeProtectedProject || driveType == _spaceTypeProtectedPersonal { | ||
| 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 { | ||
| 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 | ||
| } | ||
|
|
||
| // 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 | ||
| } | ||
|
|
||
| // 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 | ||
| // 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 | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new mount config for "protected-personal" uses
path_template: /protected-users/{{.Space.Name}}. Since protected personal spaces are created with a fixed name ("Protected Personal"), this will cause path collisions across users and won’t match the default reva template (which uses the owner id). Use the owner id in the path template (similar to the existing personal space config).There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a "special" personal folder, so it makes sense to have the same formatting as with personal folders