diff --git a/pkg/middleware/publiclink.go b/pkg/middleware/publiclink.go new file mode 100644 index 0000000000..9ea942198b --- /dev/null +++ b/pkg/middleware/publiclink.go @@ -0,0 +1,27 @@ +package middleware + +import "net/http" + +const ( + // PublicLinkTokenName is the query parameter and header carrying a public + // link token on a request. + PublicLinkTokenName = "public-token" + + // PublicLinkAuthHeader marks the outcome of a failed public link + // authentication so a downstream service can tell "password required" from + // "wrong password" when it renders the 401. The proxy sets it, the graph + // service reads it. + PublicLinkAuthHeader = "X-Public-Link-Auth" + + // PublicLinkPasswordRequired means the link is password protected and no + // password was provided. + PublicLinkPasswordRequired = "password-required" + // PublicLinkInvalidPassword means a password was provided but rejected. + PublicLinkInvalidPassword = "invalid-password" +) + +// HasPublicLinkToken reports whether a public link token rides on the request, +// as a query parameter or header. +func HasPublicLinkToken(r *http.Request) bool { + return r.URL.Query().Get(PublicLinkTokenName) != "" || r.Header.Get(PublicLinkTokenName) != "" +} diff --git a/services/graph/pkg/errorcode/errorcode.go b/services/graph/pkg/errorcode/errorcode.go index 0347d83dbb..79c9bca432 100644 --- a/services/graph/pkg/errorcode/errorcode.go +++ b/services/graph/pkg/errorcode/errorcode.go @@ -76,6 +76,10 @@ const ( PreconditionFailed // ItemIsLocked The item is locked by another process. Try again later. ItemIsLocked + // PublicLinkPasswordRequired the public link is password protected and no password was provided. + PublicLinkPasswordRequired + // PublicLinkPasswordInvalid a password was provided for the public link but it was rejected. + PublicLinkPasswordInvalid ) var errorCodes = [...]string{ @@ -99,6 +103,8 @@ var errorCodes = [...]string{ "unauthenticated", "preconditionFailed", "itemIsLocked", + "publicLinkPasswordRequired", + "publicLinkPasswordInvalid", } // New constructs a new errorcode.Error @@ -151,6 +157,8 @@ func (e Error) Render(w http.ResponseWriter, r *http.Request) { status = http.StatusMethodNotAllowed case ItemIsLocked: status = http.StatusLocked + case PublicLinkPasswordRequired, PublicLinkPasswordInvalid: + status = http.StatusUnauthorized case PreconditionFailed: status = http.StatusPreconditionFailed default: diff --git a/services/graph/pkg/middleware/auth.go b/services/graph/pkg/middleware/auth.go index aba4d44b3d..e19b79ecbc 100644 --- a/services/graph/pkg/middleware/auth.go +++ b/services/graph/pkg/middleware/auth.go @@ -44,6 +44,23 @@ func Auth(opts ...account.Option) func(http.Handler) http.Handler { ctx := r.Context() t := r.Header.Get(revactx.TokenHeader) if t == "" { + // a public link request that failed the share auth carries a + // hint (set by the proxy) so we can tell the two cases apart; + // only trust it when a share token is actually on the request + if hint := r.Header.Get(opkgm.PublicLinkAuthHeader); hint != "" && opkgm.HasPublicLinkToken(r) { + switch hint { + // distinguish via the body only, never WWW-Authenticate: a + // Basic challenge would pop the browser's native auth dialog + // instead of the app's password field (the proxy strips it + // on public paths for the same reason) + case opkgm.PublicLinkPasswordRequired: + errorcode.PublicLinkPasswordRequired.Render(w, r, http.StatusUnauthorized, "This public link is password protected.") + return + case opkgm.PublicLinkInvalidPassword: + errorcode.PublicLinkPasswordInvalid.Render(w, r, http.StatusUnauthorized, "The password is incorrect.") + return + } + } errorcode.InvalidAuthenticationToken.Render(w, r, http.StatusUnauthorized, "Access token is empty.") /* msgraph error for GET https://graph.microsoft.com/v1.0/me { @@ -67,7 +84,8 @@ func Auth(opts ...account.Option) func(http.Handler) http.Handler { errorcode.InvalidAuthenticationToken.Render(w, r, http.StatusUnauthorized, "invalid token") return } - if ok, err := scope.VerifyScope(ctx, tokenScope, r); err != nil || !ok { + // scope handlers judge CS3 requests and url paths, not *http.Request + if ok, err := scope.VerifyScope(ctx, tokenScope, r.URL.Path); err != nil || !ok { opt.Logger.Error().Str(log.RequestIDString, r.Header.Get("X-Request-ID")).Err(err).Msg("verifying scope failed") errorcode.InvalidAuthenticationToken.Render(w, r, http.StatusUnauthorized, "verifying scope failed") return diff --git a/services/graph/pkg/middleware/path_lookup.go b/services/graph/pkg/middleware/path_lookup.go index 1fc89bcd6c..32c9cda810 100644 --- a/services/graph/pkg/middleware/path_lookup.go +++ b/services/graph/pkg/middleware/path_lookup.go @@ -203,7 +203,9 @@ func rewriteColonPath( logger.Debug().Err(err).Str("driveID", driveID).Msg("invalid drive id in colon path") return "", errInvalidRequest } - if drive.GetStorageId() != anchor.GetStorageId() || drive.GetSpaceId() != anchor.GetSpaceId() { + // items below the public share drive keep their real ids; the scope guards access + isPublicDrive := drive.GetStorageId() == utils.PublicStorageProviderID && drive.GetSpaceId() == utils.PublicStorageSpaceID + if !isPublicDrive && (drive.GetStorageId() != anchor.GetStorageId() || drive.GetSpaceId() != anchor.GetSpaceId()) { logger.Debug(). Str("driveID", driveID). Str("itemID", anchorIDStr). diff --git a/services/graph/pkg/service/v0/driveitems.go b/services/graph/pkg/service/v0/driveitems.go index 167f8b1b62..fdf74964f9 100644 --- a/services/graph/pkg/service/v0/driveitems.go +++ b/services/graph/pkg/service/v0/driveitems.go @@ -61,6 +61,76 @@ func odataListContains(r *http.Request, parameter, value string) bool { return false } +// driveItemInDrive reports whether an item id may be addressed below a drive. +// Below the public share drive items keep their real storage ids, so the +// prefix can never match; the token scope checks containment instead. +func driveItemInDrive(driveID, driveItemID *storageprovider.ResourceId) bool { + if driveID.GetStorageId() == utils.PublicStorageProviderID && driveID.GetSpaceId() == utils.PublicStorageSpaceID { + return true + } + return driveID.GetStorageId() == driveItemID.GetStorageId() && driveID.GetSpaceId() == driveItemID.GetSpaceId() +} + +// publicDriveRequest reports whether the request addresses the public share drive. +func publicDriveRequest(r *http.Request) bool { + driveID, err := parseIDParam(r, "driveID") + return err == nil && + driveID.GetStorageId() == utils.PublicStorageProviderID && + driveID.GetSpaceId() == utils.PublicStorageSpaceID +} + +// sanitizePublicDriveInfos applies the publicstorageprovider's reduction to +// infos that bypassed it (navigation by id). +func (g Graph) sanitizePublicDriveInfos(ctx context.Context, r *http.Request, infos ...*storageprovider.ResourceInfo) error { + shareRoot, grant, err := g.publicLinkOfRequest(ctx, r) + if err != nil { + return err + } + for _, info := range infos { + if info == nil { + continue + } + publicshare.FilterResourceInfo(info, shareRoot, grant) + // the favorite flag is the owner's, not the visitor's + delete(info.GetArbitraryMetadata().GetMetadata(), _favoriteMetadataKey) + // the share root's parent lies outside the share + if utils.ResourceIDEqual(info.GetId(), shareRoot.GetId()) { + info.ParentId = nil + } + } + return nil +} + +// publicLinkOfRequest resolves the link the request runs in; the token is the +// public drive's opaque id. +func (g Graph) publicLinkOfRequest(ctx context.Context, r *http.Request) (*storageprovider.ResourceInfo, *storageprovider.ResourcePermissions, error) { + driveID, err := parseIDParam(r, "driveID") + if err != nil { + return nil, nil, err + } + gatewayClient, err := g.gatewaySelector.Next() + if err != nil { + return nil, nil, err + } + shareResp, err := gatewayClient.GetPublicShare(ctx, &link.GetPublicShareRequest{ + Ref: &link.PublicShareReference{ + Spec: &link.PublicShareReference_Token{Token: driveID.GetOpaqueId()}, + }, + }) + if err := errorcode.FromCS3Status(shareResp.GetStatus(), err); err != nil { + g.logger.Error().Err(err).Msg("could not resolve the public link of the request") + return nil, nil, err + } + statResp, err := gatewayClient.Stat(ctx, &storageprovider.StatRequest{ + Ref: &storageprovider.Reference{ResourceId: shareResp.GetShare().GetResourceId()}, + }) + if err := errorcode.FromCS3Status(statResp.GetStatus(), err); err != nil { + g.logger.Error().Err(err).Msg("could not stat the public link root") + return nil, nil, err + } + return statResp.GetInfo(), shareResp.GetShare().GetPermissions().GetPermissions(), nil +} + // driveItemPropertySelected reports whether the given opt-in property was requested via $select func driveItemPropertySelected(r *http.Request, property string) bool { return odataListContains(r, "$select", property) @@ -98,7 +168,7 @@ func (g Graph) CreateUploadSession(w http.ResponseWriter, r *http.Request) { errorcode.RenderError(w, r, err) return } - if driveID.GetStorageId() != driveItemID.GetStorageId() || driveID.GetSpaceId() != driveItemID.GetSpaceId() { + if !driveItemInDrive(&driveID, &driveItemID) { errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, "Item does not exist") return } @@ -287,7 +357,7 @@ func (g Graph) GetDriveItem(w http.ResponseWriter, r *http.Request) { errorcode.RenderError(w, r, err) return } - if driveID.GetStorageId() != driveItemID.GetStorageId() || driveID.GetSpaceId() != driveItemID.GetSpaceId() { + if !driveItemInDrive(&driveID, &driveItemID) { errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, "Item does not exist") return } @@ -312,7 +382,12 @@ func (g Graph) GetDriveItem(w http.ResponseWriter, r *http.Request) { errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) return case res.GetStatus().GetCode() == cs3rpc.Code_CODE_OK: - // ok + if publicDriveRequest(r) { + if err := g.sanitizePublicDriveInfos(ctx, r, res.GetInfo()); err != nil { + errorcode.RenderError(w, r, err) + return + } + } case res.GetStatus().GetCode() == cs3rpc.Code_CODE_NOT_FOUND: errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, res.GetStatus().GetMessage()) return @@ -345,7 +420,7 @@ func (g Graph) GetDriveItem(w http.ResponseWriter, r *http.Request) { driveItem.Children = children } - if driveItemPropertySelected(r, _selectShareTypes) { + if driveItemPropertySelected(r, _selectShareTypes) && !publicDriveRequest(r) { infos := []*storageprovider.ResourceInfo{res.GetInfo()} driveItem.LibreGraphShareTypes = shareTypesOf(res.GetInfo(), g.listLinkShares(ctx, infos)) } @@ -372,7 +447,7 @@ func (g Graph) GetDriveItemChildren(w http.ResponseWriter, r *http.Request) { errorcode.RenderError(w, r, err) return } - if driveID.GetStorageId() != driveItemID.GetStorageId() || driveID.GetSpaceId() != driveItemID.GetSpaceId() { + if !driveItemInDrive(&driveID, &driveItemID) { errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, "Item does not exist") return } @@ -405,7 +480,7 @@ func (g Graph) listDriveItemChildren(w http.ResponseWriter, r *http.Request, dri childrenRequest := &storageprovider.ListContainerRequest{ Ref: &storageprovider.Reference{ResourceId: driveItemID}, } - if driveItemPropertySelected(r, _selectShareTypes) { + if driveItemPropertySelected(r, _selectShareTypes) && !publicDriveRequest(r) { childrenRequest.FieldMask = shareTypesFieldMask } @@ -430,13 +505,21 @@ func (g Graph) listDriveItemChildren(w http.ResponseWriter, r *http.Request, dri return nil, false } + if publicDriveRequest(r) { + if err := g.sanitizePublicDriveInfos(r.Context(), r, res.GetInfos()...); err != nil { + errorcode.RenderError(w, r, err) + return nil, false + } + } + files, err := formatDriveItems(g.logger, g.publicBaseURL, res.GetInfos()) if err != nil { errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) return nil, false } - if driveItemPropertySelected(r, _selectShareTypes) { + // collaborative grants are not for public link visitors + if driveItemPropertySelected(r, _selectShareTypes) && !publicDriveRequest(r) { g.addShareTypes(r.Context(), files, res.GetInfos()) } diff --git a/services/graph/pkg/service/v0/drives.go b/services/graph/pkg/service/v0/drives.go index 954cd0791d..114f59d3e0 100644 --- a/services/graph/pkg/service/v0/drives.go +++ b/services/graph/pkg/service/v0/drives.go @@ -881,6 +881,19 @@ func (g Graph) cs3StorageSpaceToDrive(ctx context.Context, baseURL *url.URL, spa // DisplayName: , TODO read and cache from users provider }, } + } else if space.GetRoot().GetStorageId() == utils.PublicStorageProviderID { + // a public share mountpoint carries no space owner; the request runs as + // the share creator (publicshares auth), so the context user is who + // shared it, the same source webdav fills oc:owner-display-name from. + if u, ok := revactx.ContextGetUser(ctx); ok && u.GetId().GetOpaqueId() != "" { + id := u.GetId().GetOpaqueId() + drive.Owner = &libregraph.IdentitySet{ + User: &libregraph.Identity{ + Id: &id, + DisplayName: u.GetDisplayName(), + }, + } + } } if space.Mtime != nil { lastModified := cs3TimestampToTime(space.Mtime) diff --git a/services/proxy/pkg/middleware/basic_auth.go b/services/proxy/pkg/middleware/basic_auth.go index bc9e1222f4..cb488587c5 100644 --- a/services/proxy/pkg/middleware/basic_auth.go +++ b/services/proxy/pkg/middleware/basic_auth.go @@ -18,7 +18,7 @@ type BasicAuthenticator struct { // Authenticate implements the authenticator interface to authenticate requests via basic auth. func (m BasicAuthenticator) Authenticate(r *http.Request) (*http.Request, bool) { - if isPublicPath(r.URL.Path) && isPublicWithShareToken(r) { + if (isPublicPath(r.URL.Path) && isPublicWithShareToken(r)) || isPublicShareGraphRequest(r) { // The authentication of public path requests is handled by another authenticator. // Since we can't guarantee the order of execution of the authenticators, we better // implement an early return here for paths we can't authenticate in this authenticator. diff --git a/services/proxy/pkg/middleware/public_share_auth.go b/services/proxy/pkg/middleware/public_share_auth.go index 047a332527..cc58a4a113 100644 --- a/services/proxy/pkg/middleware/public_share_auth.go +++ b/services/proxy/pkg/middleware/public_share_auth.go @@ -5,7 +5,9 @@ import ( "strings" gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" "github.com/opencloud-eu/opencloud/pkg/log" + ocmw "github.com/opencloud-eu/opencloud/pkg/middleware" revactx "github.com/opencloud-eu/reva/v2/pkg/ctx" "github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool" "go.opentelemetry.io/otel/attribute" @@ -14,7 +16,7 @@ import ( const ( headerRevaAccessToken = revactx.TokenHeader - headerShareToken = "public-token" + headerShareToken = ocmw.PublicLinkTokenName basicAuthPasswordPrefix = "password|" authenticationType = "publicshares" @@ -55,12 +57,30 @@ func isPublicShareAppOpen(r *http.Request) bool { // the BasicAuthenticator needs to ignore the request when the headerShareToken exist. func isPublicWithShareToken(r *http.Request) bool { return (strings.HasPrefix(r.URL.Path, "/dav/public-files") || strings.HasPrefix(r.URL.Path, "/remote.php/dav/public-files")) && - (r.URL.Query().Get(headerShareToken) != "" || r.Header.Get(headerShareToken) != "") + hasShareToken(r) +} + +// A graph request carrying a share token runs in the public share context, +// like public-files. +func isPublicShareGraphRequest(r *http.Request) bool { + return strings.HasPrefix(r.URL.Path, "/graph/") && hasShareToken(r) +} + +func hasShareToken(r *http.Request) bool { + return r.URL.Query().Get(headerShareToken) != "" || r.Header.Get(headerShareToken) != "" +} + +// shareTokenHint identifies a token in logs without spelling it out. +func shareTokenHint(token string) string { + if len(token) <= 4 { + return token + } + return token[:4] + "..." } // Authenticate implements the authenticator interface to authenticate requests via public share auth. func (a PublicShareAuthenticator) Authenticate(r *http.Request) (*http.Request, bool) { - if !isPublicPath(r.URL.Path) && !isPublicShareArchive(r) && !isPublicShareAppOpen(r) { + if !isPublicPath(r.URL.Path) && !isPublicShareArchive(r) && !isPublicShareAppOpen(r) && !isPublicShareGraphRequest(r) { return nil, false } @@ -99,7 +119,7 @@ func (a PublicShareAuthenticator) Authenticate(r *http.Request) (*http.Request, a.Logger.Error(). Err(err). Str("authenticator", "public_share"). - Str("public_share_token", shareToken). + Str("public_share_token", shareTokenHint(shareToken)). Str("path", r.URL.Path). Msg("could not select next gateway client") return nil, false @@ -115,12 +135,29 @@ func (a PublicShareAuthenticator) Authenticate(r *http.Request) (*http.Request, a.Logger.Error(). Err(err). Str("authenticator", "public_share"). - Str("public_share_token", shareToken). + Str("public_share_token", shareTokenHint(shareToken)). Str("path", r.URL.Path). Msg("failed to authenticate request") return nil, false } + if authResp.GetStatus().GetCode() != rpc.Code_CODE_OK { + // A graph request cannot render its own 401 from here (no writer), and + // the generic one cannot tell the two password cases apart. Mark the + // outcome and let the graph auth middleware render it. Other surfaces + // (webdav) are handled by their own backend, so they just fail here. + if isPublicShareGraphRequest(r) { + _, password, ok := r.BasicAuth() + if ok && password != "" { + r.Header.Set(ocmw.PublicLinkAuthHeader, ocmw.PublicLinkInvalidPassword) + } else { + r.Header.Set(ocmw.PublicLinkAuthHeader, ocmw.PublicLinkPasswordRequired) + } + return r, true + } + return nil, false + } + r.Header.Add(headerRevaAccessToken, authResp.Token) trace.SpanFromContext(r.Context()).SetAttributes(attribute.String("enduser.id", "public")) diff --git a/tests/acceptance/bootstrap/GraphContext.php b/tests/acceptance/bootstrap/GraphContext.php index 8129bb35f0..ec5cd33e11 100644 --- a/tests/acceptance/bootstrap/GraphContext.php +++ b/tests/acceptance/bootstrap/GraphContext.php @@ -3490,4 +3490,380 @@ public function userGetsDriveItemWithColonPathOfPersonalSpaceOf( $url = "/graph/$apiVersion/drives/$driveId/root:/$encoded"; $this->sendGraphRequestAndCaptureResponse($user, "GET", $url); } + + /** + * The virtual drive a public link is addressed under: the public storage + * provider/space id pair with the link token as opaque id. + * + * @param string $token + * + * @return string + */ + private function publicLinkDriveId(string $token): string { + $publicStorageId = "7993447f-687f-490d-875c-ac95e89a62a4"; + return "$publicStorageId\$$publicStorageId!$token"; + } + + /** + * Send an anonymous Graph request in the context of the last created + * public link: token via the public-token query parameter, an optional + * password as basic auth for user "public". + * + * @param string $urlSuffix part below /graph/v1.0/drives/{publicDriveId} + * @param string|null $password + * + * @return void + */ + private function publicSendsGraphDriveRequest(string $urlSuffix, ?string $password = null): void { + $token = $this->featureContext->shareNgGetLastCreatedLinkShareToken(); + $driveId = $this->publicLinkDriveId($token); + $url = $this->featureContext->getBaseUrl() + . "/graph/v1.0/drives/$driveId$urlSuffix" + . (\str_contains($urlSuffix, "?") ? "&" : "?") . "public-token=$token"; + $response = HttpRequestHelper::get( + $url, + $this->featureContext->getStepLineRef(), + $password === null ? null : "public", + $this->featureContext->getActualPassword($password) + ); + $this->featureContext->setResponse($response); + } + + /** + * @param string|null $password + * + * @return void + */ + #[When('the public lists the children of the last created public link using the Graph API')] + #[When('the public lists the children of the last created public link with password :password using the Graph API')] + public function thePublicListsTheChildrenOfTheLastCreatedPublicLink(?string $password = null): void { + $token = $this->featureContext->shareNgGetLastCreatedLinkShareToken(); + $rootId = $this->publicLinkDriveId($token); + $this->publicSendsGraphDriveRequest("/items/$rootId/children", $password); + } + + /** + * @param string $path + * @param string|null $password + * + * @return void + */ + #[When('the public lists the children of path :path of the last created public link using the Graph API')] + #[When('the public lists the children of path :path of the last created public link with password :password using the Graph API')] + public function thePublicListsTheChildrenOfPathOfTheLastCreatedPublicLink( + string $path, + ?string $password = null + ): void { + $encoded = $this->encodeColonPathSegment($path); + $this->publicSendsGraphDriveRequest("/root:/$encoded:/children", $password); + } + + /** + * @param string|null $password + * + * @return void + */ + #[When('the public gets the root of the last created public link expanding its children using the Graph API')] + #[When('the public gets the root of the last created public link expanding its children with password :password using the Graph API')] + public function thePublicGetsTheRootOfTheLastCreatedPublicLinkExpandingItsChildren(?string $password = null): void { + $token = $this->featureContext->shareNgGetLastCreatedLinkShareToken(); + $rootId = $this->publicLinkDriveId($token); + $this->publicSendsGraphDriveRequest("/items/$rootId?\$expand=children", $password); + } + + /** + * @param string $path + * @param string|null $password + * + * @return void + */ + #[When('the public gets the drive item of path :path of the last created public link selecting the allowed actions with password :password using the Graph API')] + public function thePublicGetsTheDriveItemOfPathSelectingTheAllowedActions( + string $path, + ?string $password = null + ): void { + $encoded = $this->encodeColonPathSegment($path); + $select = "%24select=%40libre.graph.permissions.actions.allowedValues"; + $this->publicSendsGraphDriveRequest("/root:/$encoded?$select", $password); + } + + /** + * @param string $urlSuffix raw suffix below /graph/v1.0, may contain :spaceOfUser + * @param string|null $password + * + * @return void + */ + private function publicSendsRawGraphRequest(string $urlSuffix, ?string $password = null): void { + $token = $this->featureContext->shareNgGetLastCreatedLinkShareToken(); + $url = $this->featureContext->getBaseUrl() . "/graph/v1.0" . $urlSuffix + . (\str_contains($urlSuffix, "?") ? "&" : "?") . "public-token=$token"; + $response = HttpRequestHelper::get( + $url, + $this->featureContext->getStepLineRef(), + $password === null ? null : "public", + $this->featureContext->getActualPassword($password) + ); + $this->featureContext->setResponse($response); + } + + /** + * @param string|null $password + * + * @return void + */ + #[When('the public lists the children of the last created public link selecting the share types with password :password using the Graph API')] + public function thePublicListsTheChildrenSelectingTheShareTypes(?string $password = null): void { + $token = $this->featureContext->shareNgGetLastCreatedLinkShareToken(); + $rootId = $this->publicLinkDriveId($token); + $select = "%24select=%40libre.graph.shareTypes"; + $this->publicSendsGraphDriveRequest("/items/$rootId/children?$select", $password); + } + + /** + * @param string|null $password + * + * @return void + */ + #[When('the public tries to list the drives using the token of the last created public link with password :password using the Graph API')] + public function thePublicTriesToListTheDrives(?string $password = null): void { + $this->publicSendsRawGraphRequest("/drives/", $password); + } + + /** + * @param string $user + * @param string|null $password + * + * @return void + */ + #[When('the public tries to get the personal drive of user :user through the last created public link with password :password using the Graph API')] + public function thePublicTriesToGetThePersonalDriveOfUser(string $user, ?string $password = null): void { + $user = $this->featureContext->getActualUsername($user); + $driveId = $this->spacesContext->getSpaceIdByName($user, "Personal"); + $this->publicSendsRawGraphRequest("/drives/$driveId", $password); + } + + /** + * Addressing an in-share item through its REAL drive id must be rejected: + * only the token's public drive is authorized. + * + * @param string $child + * @param string|null $password + * + * @return void + */ + #[When('the public tries to get the child :child of the last created public link through its real drive id with password :password using the Graph API')] + public function thePublicTriesToGetTheChildThroughItsRealDriveId(string $child, ?string $password = null): void { + $token = $this->featureContext->shareNgGetLastCreatedLinkShareToken(); + $rootId = $this->publicLinkDriveId($token); + $url = $this->featureContext->getBaseUrl() + . "/graph/v1.0/drives/$rootId/items/$rootId/children?public-token=$token"; + $listing = HttpRequestHelper::get( + $url, + $this->featureContext->getStepLineRef(), + $password === null ? null : "public", + $this->featureContext->getActualPassword($password) + ); + $children = \json_decode($listing->getBody()->getContents(), true)["value"] ?? []; + $childId = null; + foreach ($children as $entry) { + if ($entry["name"] === $child) { + $childId = $entry["id"]; + } + } + Assert::assertNotNull($childId, "child '$child' not found in the public link listing"); + $realDriveId = \explode("!", $childId)[0]; + $this->publicSendsRawGraphRequest("/drives/$realDriveId/items/$childId", $password); + } + + /** + * @param string|null $password + * + * @return void + */ + #[When('the public tries to list the children of a foreign public link drive using the last created token with password :password using the Graph API')] + public function thePublicTriesToListAForeignPublicLinkDrive(?string $password = null): void { + $foreign = $this->publicLinkDriveId("notthetokenofthislink"); + $this->publicSendsRawGraphRequest("/drives/$foreign/items/$foreign/children", $password); + } + + /** + * Mutation probes: the public link surface is read only, every write on the + * beta routes has to be rejected. + * + * @param string $action + * @param string $child + * @param string|null $password + * + * @return void + */ + #[When('/^the public tries to (create a link for|delete|rename|list the permissions of) the child "([^"]*)" of the last created public link with password "([^"]*)" using the Graph API$/')] + public function thePublicTriesToMutateTheChildOfTheLastCreatedPublicLink( + string $action, + string $child, + ?string $password = null + ): void { + $token = $this->featureContext->shareNgGetLastCreatedLinkShareToken(); + $rootId = $this->publicLinkDriveId($token); + $listing = HttpRequestHelper::get( + $this->featureContext->getBaseUrl() + . "/graph/v1.0/drives/$rootId/items/$rootId/children?public-token=$token", + $this->featureContext->getStepLineRef(), + "public", + $this->featureContext->getActualPassword($password) + ); + $children = \json_decode($listing->getBody()->getContents(), true)["value"] ?? []; + $childId = null; + foreach ($children as $entry) { + if ($entry["name"] === $child) { + $childId = $entry["id"]; + } + } + Assert::assertNotNull($childId, "child '$child' not found in the public link listing"); + + $base = "/graph/v1beta1/drives/$rootId/items/$childId"; + switch ($action) { + case "create a link for": + $method = "POST"; + $url = "$base/createLink"; + $body = \json_encode(["type" => "view", "password" => "Sup3rS3cret!x"]); + break; + case "delete": + $method = "DELETE"; + $url = $base; + $body = null; + break; + case "rename": + $method = "PATCH"; + $url = $base; + $body = \json_encode(["name" => "renamed.txt"]); + break; + case "list the permissions of": + $method = "GET"; + $url = "$base/permissions"; + $body = null; + break; + default: + throw new \Exception("unknown mutation action '$action'"); + } + $response = HttpRequestHelper::sendRequest( + $this->featureContext->getBaseUrl() . $url + . (\str_contains($url, "?") ? "&" : "?") . "public-token=$token", + $this->featureContext->getStepLineRef(), + $method, + "public", + $this->featureContext->getActualPassword($password), + ["Content-Type" => "application/json"], + $body + ); + $this->featureContext->setResponse($response); + } + + /** + * The public link drive endpoint rejects the token as a query parameter, + * so it rides in the header here. + * + * @param string|null $password + * + * @return void + */ + #[When('the public gets the drive of the last created public link with password :password using the Graph API')] + public function thePublicGetsTheDriveOfTheLastCreatedPublicLink(?string $password = null): void { + $token = $this->featureContext->shareNgGetLastCreatedLinkShareToken(); + $driveId = $this->publicLinkDriveId($token); + $response = HttpRequestHelper::get( + $this->featureContext->getBaseUrl() . "/graph/v1.0/drives/$driveId", + $this->featureContext->getStepLineRef(), + $password === null ? null : "public", + $this->featureContext->getActualPassword($password), + ["public-token" => $token] + ); + $this->featureContext->setResponse($response); + } + + /** + * Item anchored colon path: the anchor id is resolved through the public + * children listing, so the step stays within the public API. + * + * @param string $path + * @param string $child + * @param string|null $password + * + * @return void + */ + #[When('the public gets the drive item :path below the child :child of the last created public link with password :password using the Graph API')] + public function thePublicGetsTheDriveItemBelowTheChildOfTheLastCreatedPublicLink( + string $path, + string $child, + ?string $password = null + ): void { + $token = $this->featureContext->shareNgGetLastCreatedLinkShareToken(); + $rootId = $this->publicLinkDriveId($token); + $url = $this->featureContext->getBaseUrl() + . "/graph/v1.0/drives/$rootId/items/$rootId/children?public-token=$token"; + $response = HttpRequestHelper::get( + $url, + $this->featureContext->getStepLineRef(), + $password === null ? null : "public", + $this->featureContext->getActualPassword($password) + ); + $children = \json_decode($response->getBody()->getContents(), true)["value"] ?? []; + $childId = null; + foreach ($children as $entry) { + if ($entry["name"] === $child) { + $childId = $entry["id"]; + } + } + Assert::assertNotNull($childId, "child '$child' not found in the public link listing"); + $encoded = $this->encodeColonPathSegment($path); + $this->publicSendsGraphDriveRequest("/items/$childId:/$encoded", $password); + } + + /** + * @param string $name + * @param string|null $password + * + * @return void + */ + #[When('the public creates an upload session for :name in the last created public link using the Graph API')] + #[When('the public creates an upload session for :name in the last created public link with password :password using the Graph API')] + public function thePublicCreatesAnUploadSessionForInTheLastCreatedPublicLink( + string $name, + ?string $password = null + ): void { + $token = $this->featureContext->shareNgGetLastCreatedLinkShareToken(); + $rootId = $this->publicLinkDriveId($token); + $url = $this->featureContext->getBaseUrl() + . "/graph/v1.0/drives/$rootId/items/$rootId/createUploadSession?public-token=$token"; + $response = HttpRequestHelper::post( + $url, + $this->featureContext->getStepLineRef(), + $password === null ? null : "public", + $this->featureContext->getActualPassword($password), + ["Content-Type" => "application/json"], + \json_encode(["item" => ["name" => $name, "fileSize" => 6]]) + ); + $this->featureContext->setResponse($response); + } + + /** + * The security probe: an id of a resource that is NOT inside the public + * link must not be readable through the link's token. + * + * @param string $path + * @param string $user + * @param string|null $password + * + * @return void + */ + #[When('the public tries to get the resource :path of user :user through the last created public link using the Graph API')] + #[When('the public tries to get the resource :path of user :user through the last created public link with password :password using the Graph API')] + public function thePublicTriesToGetTheResourceOfUserThroughTheLastCreatedPublicLink( + string $path, + string $user, + ?string $password = null + ): void { + $user = $this->featureContext->getActualUsername($user); + $resourceId = $this->featureContext->getFileIdForPath($user, $path); + $this->publicSendsGraphDriveRequest("/items/$resourceId", $password); + } } diff --git a/tests/acceptance/features/apiGraph/publicLinkDriveItemListing.feature b/tests/acceptance/features/apiGraph/publicLinkDriveItemListing.feature new file mode 100644 index 0000000000..db605dbd50 --- /dev/null +++ b/tests/acceptance/features/apiGraph/publicLinkDriveItemListing.feature @@ -0,0 +1,365 @@ +Feature: listing the content of a public link via the Graph API + As an anonymous visitor of a public link + I want to list the shared folder through the Graph API + So that clients can browse public links without WebDAV + + Background: + Given user "Alice" has been created with default attributes + And user "Alice" has created folder "publicfolder" + And user "Alice" has created folder "publicfolder/sub" + And user "Alice" has uploaded file with content "hello public" to "publicfolder/a.txt" + And user "Alice" has uploaded file with content "nested" to "publicfolder/sub/b.txt" + And user "Alice" has uploaded file with content "not shared" to "private.txt" + And user "Alice" has created the following resource link share: + | resource | publicfolder | + | space | Personal | + | permissionsRole | view | + | password | %public% | + + + Scenario: the public lists the children of a public link + When the public lists the children of the last created public link with password "%public%" using the Graph API + Then the HTTP status code should be "200" + And the JSON data of the response should match + """ + { + "type": "object", + "required": ["value"], + "properties": { + "value": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "uniqueItems": true, + "items": { + "oneOf": [ + { + "type": "object", + "required": ["name", "folder"], + "properties": { + "name": { "const": "sub" } + } + }, + { + "type": "object", + "required": ["name", "file", "size"], + "properties": { + "name": { "const": "a.txt" }, + "size": { "const": 12 } + } + } + ] + } + } + } + } + """ + + + Scenario: the public lists a subfolder of a public link by path + When the public lists the children of path "sub" of the last created public link with password "%public%" using the Graph API + Then the HTTP status code should be "200" + And the JSON data of the response should match + """ + { + "type": "object", + "required": ["value"], + "properties": { + "value": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": { + "type": "object", + "required": ["name", "file"], + "properties": { + "name": { "const": "b.txt" } + } + } + } + } + } + """ + + + Scenario: the public expands the children of the public link root + When the public gets the root of the last created public link expanding its children with password "%public%" using the Graph API + Then the HTTP status code should be "200" + And the JSON data of the response should match + """ + { + "type": "object", + "required": ["folder", "children"], + "properties": { + "children": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "uniqueItems": true, + "items": { + "type": "object", + "required": ["name"] + } + } + } + } + """ + + + Scenario: the public gets a file through an item anchored colon path + When the public gets the drive item "b.txt" below the child "sub" of the last created public link with password "%public%" using the Graph API + Then the HTTP status code should be "200" + And the JSON data of the response should match + """ + { + "type": "object", + "required": ["name", "file", "parentReference"], + "properties": { + "name": { "const": "b.txt" }, + "parentReference": { + "type": "object", + "required": ["path"], + "properties": { + "path": { "const": "/sub" } + } + } + } + } + """ + + + Scenario: the public must not see the owner's paths above the share + Given user "Alice" has created folder "deep" + And user "Alice" has created folder "deep/shared" + And user "Alice" has uploaded file with content "x" to "deep/shared/c.txt" + And user "Alice" has created the following resource link share: + | resource | deep/shared | + | space | Personal | + | permissionsRole | view | + | password | %public% | + When the public lists the children of the last created public link with password "%public%" using the Graph API + Then the HTTP status code should be "200" + And the JSON data of the response should match + """ + { + "type": "object", + "required": ["value"], + "properties": { + "value": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "uniqueItems": true, + "items": { + "type": "object", + "properties": { + "parentReference": { + "type": "object", + "properties": { + "path": { + "not": { "pattern": "deep" } + } + } + } + } + } + } + } + } + """ + + + Scenario: advertised permissions inside a public link stay within the link role + When the public gets the drive item of path "sub" of the last created public link selecting the allowed actions with password "%public%" using the Graph API + Then the HTTP status code should be "200" + And the JSON data of the response should match + """ + { + "type": "object", + "required": ["@libre.graph.permissions.actions.allowedValues"], + "properties": { + "@libre.graph.permissions.actions.allowedValues": { + "type": "array", + "minItems": 6, + "maxItems": 6, + "uniqueItems": true, + "items": { + "type": "string", + "not": { "pattern": "/(delete|create|update|deny)$" } + } + } + } + } + """ + + + Scenario: listing a password protected public link without the password reports that a password is required + When the public lists the children of the last created public link using the Graph API + Then the HTTP status code should be "401" + And the JSON data of the response should match + """ + { + "type": "object", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code"], + "properties": { + "code": { "const": "publicLinkPasswordRequired" } + } + } + } + } + """ + + + Scenario: listing a password protected public link with a wrong password reports an invalid password + When the public lists the children of the last created public link with password "wrong" using the Graph API + Then the HTTP status code should be "401" + And the JSON data of the response should match + """ + { + "type": "object", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code"], + "properties": { + "code": { "const": "publicLinkPasswordInvalid" } + } + } + } + } + """ + + + Scenario: an editable public link grants an upload session + Given user "Alice" has created the following resource link share: + | resource | publicfolder | + | space | Personal | + | permissionsRole | edit | + | password | %public% | + When the public creates an upload session for "up.txt" in the last created public link with password "%public%" using the Graph API + Then the HTTP status code should be "200" + And the JSON data of the response should match + """ + { + "type": "object", + "required": ["UploadURL"], + "properties": { + "UploadURL": { + "type": "string", + "pattern": "/data/" + } + } + } + """ + + + Scenario: a view only public link does not grant an upload session + When the public creates an upload session for "up.txt" in the last created public link with password "%public%" using the Graph API + Then the HTTP status code should be "404" + + + Scenario: a resource outside the public link is not readable through its token + When the public tries to get the resource "private.txt" of user "Alice" through the last created public link with password "%public%" using the Graph API + Then the HTTP status code should be "404" + + + Scenario: a public token cannot list drives + When the public tries to list the drives using the token of the last created public link with password "%public%" using the Graph API + Then the HTTP status code should be "401" + + + Scenario: a public token cannot read the owner's personal drive + When the public tries to get the personal drive of user "Alice" through the last created public link with password "%public%" using the Graph API + Then the HTTP status code should be "401" + + + Scenario: an in-share item is not addressable through its real drive id + When the public tries to get the child "sub" of the last created public link through its real drive id with password "%public%" using the Graph API + Then the HTTP status code should be "401" + + + Scenario: a token does not open another link's drive + When the public tries to list the children of a foreign public link drive using the last created token with password "%public%" using the Graph API + Then the HTTP status code should be "401" + + + Scenario: collaborative share types are not disclosed to the public + Given user "Brian" has been created with default attributes + And user "Alice" has sent the following resource share invitation: + | resource | publicfolder/sub | + | space | Personal | + | sharee | Brian | + | shareType | user | + | permissionsRole | Viewer | + When the public lists the children of the last created public link selecting the share types with password "%public%" using the Graph API + Then the HTTP status code should be "200" + And the JSON data of the response should match + """ + { + "type": "object", + "required": ["value"], + "properties": { + "value": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "uniqueItems": true, + "items": { + "type": "object", + "not": { "required": ["@libre.graph.shareTypes"] } + } + } + } + } + """ + + + Scenario Outline: writes through the public link surface are rejected + Given user "Alice" has created the following resource link share: + | resource | publicfolder | + | space | Personal | + | permissionsRole | edit | + | password | %public% | + When the public tries to the child "a.txt" of the last created public link with password "%public%" using the Graph API + Then the HTTP status code should be "" + And as "Alice" file "publicfolder/a.txt" should exist + + Examples: + | action | code | + | create a link for | 404 | + | delete | 400 | + | rename | 400 | + | list the permissions of | 404 | + + + Scenario: the public sees who shared the link on the drive + When the public gets the drive of the last created public link with password "%public%" using the Graph API + Then the HTTP status code should be "200" + And the JSON data of the response should match + """ + { + "type": "object", + "required": ["driveType", "owner"], + "properties": { + "driveType": { "const": "mountpoint" }, + "owner": { + "type": "object", + "required": ["user"], + "properties": { + "user": { + "type": "object", + "required": ["id", "displayName"], + "properties": { + "displayName": { "const": "Alice Hansen" } + } + } + } + } + } + } + """ diff --git a/vendor/github.com/opencloud-eu/reva/v2/internal/grpc/services/publicstorageprovider/publicstorageprovider.go b/vendor/github.com/opencloud-eu/reva/v2/internal/grpc/services/publicstorageprovider/publicstorageprovider.go index e2643547ba..7b569bb63f 100644 --- a/vendor/github.com/opencloud-eu/reva/v2/internal/grpc/services/publicstorageprovider/publicstorageprovider.go +++ b/vendor/github.com/opencloud-eu/reva/v2/internal/grpc/services/publicstorageprovider/publicstorageprovider.go @@ -23,7 +23,6 @@ package publicstorageprovider import ( "context" "encoding/json" - "path" "strings" gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" @@ -37,6 +36,7 @@ import ( "github.com/opencloud-eu/reva/v2/pkg/appctx" ctxpkg "github.com/opencloud-eu/reva/v2/pkg/ctx" "github.com/opencloud-eu/reva/v2/pkg/errtypes" + "github.com/opencloud-eu/reva/v2/pkg/publicshare" "github.com/opencloud-eu/reva/v2/pkg/rgrpc" "github.com/opencloud-eu/reva/v2/pkg/rgrpc/status" "github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool" @@ -769,15 +769,7 @@ func (s *service) augmentStatResponse(ctx context.Context, statInfo *provider.Re appctx.GetLogger(ctx).Error().Err(err).Interface("share", share).Interface("info", statInfo).Msg("error when adding share") } - var sharePath string - if shareInfo.Type == provider.ResourceType_RESOURCE_TYPE_FILE { - sharePath = path.Base(shareInfo.Path) - } else { - sharePath = strings.TrimPrefix(statInfo.Path, shareInfo.Path) - } - - statInfo.Path = path.Join("/", sharePath) - filterPermissions(statInfo.PermissionSet, shareInfo.PermissionSet) + publicshare.FilterResourceInfo(statInfo, shareInfo, shareInfo.GetPermissionSet()) } } @@ -843,7 +835,7 @@ func (s *service) ListContainer(ctx context.Context, req *provider.ListContainer for i := range listContainerR.Infos { // FIXME how do we reduce permissions to what is granted by the public link? // only a problem for id based access -> middleware - filterPermissions(listContainerR.Infos[i].PermissionSet, info.PermissionSet) + publicshare.FilterPermissions(listContainerR.Infos[i].PermissionSet, info.PermissionSet) if err := addShare(listContainerR.Infos[i], share); err != nil { appctx.GetLogger(ctx).Error().Err(err).Interface("share", share).Interface("info", listContainerR.Infos[i]).Msg("error when adding share") } @@ -852,27 +844,6 @@ func (s *service) ListContainer(ctx context.Context, req *provider.ListContainer return listContainerR, nil } -func filterPermissions(l *provider.ResourcePermissions, r *provider.ResourcePermissions) { - l.AddGrant = l.AddGrant && r.AddGrant - l.CreateContainer = l.CreateContainer && r.CreateContainer - l.Delete = l.Delete && r.Delete - l.GetPath = l.GetPath && r.GetPath - l.GetQuota = l.GetQuota && r.GetQuota - l.InitiateFileDownload = l.InitiateFileDownload && r.InitiateFileDownload - l.InitiateFileUpload = l.InitiateFileUpload && r.InitiateFileUpload - l.ListContainer = l.ListContainer && r.ListContainer - l.ListFileVersions = l.ListFileVersions && r.ListFileVersions - l.ListGrants = l.ListGrants && r.ListGrants - l.ListRecycle = l.ListRecycle && r.ListRecycle - l.Move = l.Move && r.Move - l.PurgeRecycle = l.PurgeRecycle && r.PurgeRecycle - l.RemoveGrant = l.RemoveGrant && r.RemoveGrant - l.RestoreFileVersion = l.RestoreFileVersion && r.RestoreFileVersion - l.RestoreRecycleItem = l.RestoreRecycleItem && r.RestoreRecycleItem - l.Stat = l.Stat && r.Stat - l.UpdateGrant = l.UpdateGrant && r.UpdateGrant -} - func (s *service) ListFileVersions(ctx context.Context, req *provider.ListFileVersionsRequest) (*provider.ListFileVersionsResponse, error) { return nil, gstatus.Errorf(codes.Unimplemented, "method not implemented") } diff --git a/vendor/github.com/opencloud-eu/reva/v2/pkg/auth/scope/publicshare.go b/vendor/github.com/opencloud-eu/reva/v2/pkg/auth/scope/publicshare.go index ce1a9e29e2..da9e272e42 100644 --- a/vendor/github.com/opencloud-eu/reva/v2/pkg/auth/scope/publicshare.go +++ b/vendor/github.com/opencloud-eu/reva/v2/pkg/auth/scope/publicshare.go @@ -20,6 +20,7 @@ package scope import ( "context" + "path" "strings" appprovider "github.com/cs3org/go-cs3apis/cs3/app/provider/v1beta1" @@ -143,7 +144,7 @@ func publicshareScope(ctx context.Context, scope *authpb.Scope, resource interfa // public links must not leak info about collaborative shares return false, nil case string: - return checkResourcePath(v), nil + return checkResourcePath(v) || checkGraphDrivesPath(v, share.Token), nil } msg := "public resource type assertion failed" @@ -151,6 +152,23 @@ func publicshareScope(ctx context.Context, scope *authpb.Scope, resource interfa return false, errtypes.InternalError(msg) } +// checkGraphDrivesPath opens the graph drive routes of exactly the link's own +// public drive; every other drive stays closed, notably the drives collection +// and real space ids. Per CS3 request checks enforce what may be read below it. +func checkGraphDrivesPath(p, token string) bool { + if token == "" { + return false + } + p = path.Clean(p) + drive := PublicStorageProviderID + "$" + PublicStorageProviderID + "!" + token + for _, prefix := range []string{"/graph/v1.0/drives/", "/graph/v1beta1/drives/"} { + if p == prefix+drive || strings.HasPrefix(p, prefix+drive+"/") { + return true + } + } + return false +} + func checkStorageRef(ctx context.Context, s *link.PublicShare, r *provider.Reference) bool { // r: path:$path > > if utils.ResourceIDEqual(s.ResourceId, r.GetResourceId()) { diff --git a/vendor/github.com/opencloud-eu/reva/v2/pkg/publicshare/filter.go b/vendor/github.com/opencloud-eu/reva/v2/pkg/publicshare/filter.go new file mode 100644 index 0000000000..e46c0bbc89 --- /dev/null +++ b/vendor/github.com/opencloud-eu/reva/v2/pkg/publicshare/filter.go @@ -0,0 +1,69 @@ +// Copyright 2018-2026 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package publicshare + +import ( + "path" + "strings" + + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" +) + +// FilterResourceInfo rewrites a resource info for a public link consumer: the +// path becomes share-root relative, the permissions are cut to the grant. +func FilterResourceInfo(info, shareRoot *provider.ResourceInfo, grant *provider.ResourcePermissions) { + if info == nil { + return + } + + var sharePath string + if shareRoot.GetType() == provider.ResourceType_RESOURCE_TYPE_FILE { + sharePath = path.Base(shareRoot.GetPath()) + } else { + sharePath = strings.TrimPrefix(info.GetPath(), shareRoot.GetPath()) + } + info.Path = path.Join("/", sharePath) + + if info.PermissionSet != nil { + FilterPermissions(info.PermissionSet, grant) + } +} + +// FilterPermissions reduces l to what r also grants. A nil r clears l. +func FilterPermissions(l, r *provider.ResourcePermissions) { + l.AddGrant = l.AddGrant && r.GetAddGrant() + l.CreateContainer = l.CreateContainer && r.GetCreateContainer() + l.Delete = l.Delete && r.GetDelete() + l.DenyGrant = l.DenyGrant && r.GetDenyGrant() + l.GetPath = l.GetPath && r.GetGetPath() + l.GetQuota = l.GetQuota && r.GetGetQuota() + l.InitiateFileDownload = l.InitiateFileDownload && r.GetInitiateFileDownload() + l.InitiateFileUpload = l.InitiateFileUpload && r.GetInitiateFileUpload() + l.ListContainer = l.ListContainer && r.GetListContainer() + l.ListFileVersions = l.ListFileVersions && r.GetListFileVersions() + l.ListGrants = l.ListGrants && r.GetListGrants() + l.ListRecycle = l.ListRecycle && r.GetListRecycle() + l.Move = l.Move && r.GetMove() + l.PurgeRecycle = l.PurgeRecycle && r.GetPurgeRecycle() + l.RemoveGrant = l.RemoveGrant && r.GetRemoveGrant() + l.RestoreFileVersion = l.RestoreFileVersion && r.GetRestoreFileVersion() + l.RestoreRecycleItem = l.RestoreRecycleItem && r.GetRestoreRecycleItem() + l.Stat = l.Stat && r.GetStat() + l.UpdateGrant = l.UpdateGrant && r.GetUpdateGrant() +}