diff --git a/portal-ui/src/screens/Console/Buckets/ListBuckets/Objects/ListObjects/ListObjects.tsx b/portal-ui/src/screens/Console/Buckets/ListBuckets/Objects/ListObjects/ListObjects.tsx index a8f64d370..c995ae8ef 100644 --- a/portal-ui/src/screens/Console/Buckets/ListBuckets/Objects/ListObjects/ListObjects.tsx +++ b/portal-ui/src/screens/Console/Buckets/ListBuckets/Objects/ListObjects/ListObjects.tsx @@ -139,6 +139,11 @@ import { isVersionedMode } from "../../../../../../utils/validationFunctions"; import { api } from "api"; import { errorToHandler } from "api/errors"; import { BucketQuota } from "api/consoleApi"; +import { + extractFileExtn, + getPolicyAllowedFileExtensions, + getSessionGrantsWildCard, +} from "../../UploadPermissionUtils"; const DeleteMultipleObjects = withSuspense( React.lazy(() => import("./DeleteMultipleObjects")) @@ -313,6 +318,28 @@ const ListObjects = () => { const fileUpload = useRef(null); const folderUpload = useRef(null); + const sessionGrants = useSelector((state: AppState) => + state.console.session ? state.console.session.permissions || {} : {} + ); + + const putObjectPermScopes = [ + IAM_SCOPES.S3_PUT_OBJECT, + IAM_SCOPES.S3_PUT_ACTIONS, + ]; + + const pathAsResourceInPolicy = uploadPath.join("/"); + const allowedFileExtensions = getPolicyAllowedFileExtensions( + sessionGrants, + pathAsResourceInPolicy, + putObjectPermScopes + ); + + const sessionGrantWildCards = getSessionGrantsWildCard( + sessionGrants, + pathAsResourceInPolicy, + putObjectPermScopes + ); + const canDownload = hasPermission(bucketName, [ IAM_SCOPES.S3_GET_OBJECT, IAM_SCOPES.S3_GET_ACTIONS, @@ -320,10 +347,8 @@ const ListObjects = () => { const canDelete = hasPermission(bucketName, [IAM_SCOPES.S3_DELETE_OBJECT]); const canUpload = hasPermission( - uploadPath, - [IAM_SCOPES.S3_PUT_OBJECT, IAM_SCOPES.S3_PUT_ACTIONS], - true, - true + [pathAsResourceInPolicy, ...sessionGrantWildCards], + putObjectPermScopes ) || anonymousMode; const displayDeleteObject = hasPermission(bucketName, [ @@ -710,7 +735,53 @@ const ListObjects = () => { (acceptedFiles: any[]) => { if (acceptedFiles && acceptedFiles.length > 0 && canUpload) { let newFolderPath: string = acceptedFiles[0].path; - uploadObject(acceptedFiles, newFolderPath); + //Should we filter by allowed file extensions if any?. + let allowedFiles = []; + if (allowedFileExtensions.length > 0) { + allowedFiles = acceptedFiles.filter((file) => { + const fileExtn = extractFileExtn(file.name); + return allowedFileExtensions.includes(fileExtn); + }); + } else { + allowedFiles = acceptedFiles; + } + + if (allowedFiles.length) { + uploadObject(allowedFiles, newFolderPath); + console.log( + `${allowedFiles.length} Allowed Files Processed out of ${acceptedFiles.length}.`, + pathAsResourceInPolicy, + ...sessionGrantWildCards + ); + + if (allowedFiles.length !== acceptedFiles.length) { + dispatch( + setErrorSnackMessage({ + errorMessage: "Upload is restricted.", + detailedError: permissionTooltipHelper( + [IAM_SCOPES.S3_PUT_OBJECT, IAM_SCOPES.S3_PUT_ACTIONS], + "upload objects to this location" + ), + }) + ); + } + } else { + dispatch( + setErrorSnackMessage({ + errorMessage: "Could not process drag and drop.", + detailedError: permissionTooltipHelper( + [IAM_SCOPES.S3_PUT_OBJECT, IAM_SCOPES.S3_PUT_ACTIONS], + "upload objects to this location" + ), + }) + ); + + console.error( + "Could not process drag and drop . upload may be restricted.", + pathAsResourceInPolicy, + ...sessionGrantWildCards + ); + } } if (!canUpload) { dispatch( @@ -1060,6 +1131,9 @@ const ListObjects = () => { { /> { if (fileUpload && fileUpload.current) { fileUpload.current.click(); diff --git a/portal-ui/src/screens/Console/Buckets/ListBuckets/UploadFilesButton.tsx b/portal-ui/src/screens/Console/Buckets/ListBuckets/UploadFilesButton.tsx index 507b1e2b9..eea09dcd6 100644 --- a/portal-ui/src/screens/Console/Buckets/ListBuckets/UploadFilesButton.tsx +++ b/portal-ui/src/screens/Console/Buckets/ListBuckets/UploadFilesButton.tsx @@ -31,6 +31,7 @@ import { hasPermission } from "../../../../common/SecureComponent"; import TooltipWrapper from "../../Common/TooltipWrapper/TooltipWrapper"; import { useSelector } from "react-redux"; import { AppState } from "../../../../store"; +import { getSessionGrantsWildCard } from "./UploadPermissionUtils"; interface IUploadFilesButton { uploadPath: string; @@ -65,6 +66,22 @@ const UploadFilesButton = ({ const anonymousMode = useSelector( (state: AppState) => state.system.anonymousMode ); + + const sessionGrants = useSelector((state: AppState) => + state.console.session ? state.console.session.permissions || {} : {} + ); + + const putObjectPermScopes = [ + IAM_SCOPES.S3_PUT_OBJECT, + IAM_SCOPES.S3_PUT_ACTIONS, + ]; + + const sessionGrantWildCards = getSessionGrantsWildCard( + sessionGrants, + uploadPath, + putObjectPermScopes + ); + const [anchorEl, setAnchorEl] = useState(null); const openUploadMenu = Boolean(anchorEl); const handleClick = (event: React.MouseEvent) => { @@ -75,13 +92,14 @@ const UploadFilesButton = ({ }; const uploadObjectAllowed = - hasPermission(uploadPath, [ - IAM_SCOPES.S3_PUT_OBJECT, - IAM_SCOPES.S3_PUT_ACTIONS, - ]) || anonymousMode; + hasPermission( + [uploadPath, ...sessionGrantWildCards], + putObjectPermScopes + ) || anonymousMode; + const uploadFolderAllowed = hasPermission( - bucketName, - [IAM_SCOPES.S3_PUT_OBJECT, IAM_SCOPES.S3_PUT_ACTIONS], + [bucketName, ...sessionGrantWildCards], + putObjectPermScopes, false, true ); diff --git a/portal-ui/src/screens/Console/Buckets/ListBuckets/UploadPermissionUtils.ts b/portal-ui/src/screens/Console/Buckets/ListBuckets/UploadPermissionUtils.ts new file mode 100644 index 000000000..84fcec4d8 --- /dev/null +++ b/portal-ui/src/screens/Console/Buckets/ListBuckets/UploadPermissionUtils.ts @@ -0,0 +1,96 @@ +// This file is part of MinIO Console Server +// Copyright (c) 2023 MinIO, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +export const extractFileExtn = (resourceStr: string) => { + //file extensions may contain query string. so exclude query strings ! + return (resourceStr.match(/\.([^.]*?)(?=\?|#|$)/) || [])[1]; +}; +export const getPolicyAllowedFileExtensions = ( + sessionGrants: Record, + uploadPath: string, + scopes: string[] = [] +) => { + const sessionGrantWildCards = getSessionGrantsWildCard( + sessionGrants, + uploadPath, + scopes + ); + + //get acceptable files if any in the policy. + const allowedFileExtensions = sessionGrantWildCards.reduce( + (acc: string[], cv: string) => { + const extension: string = extractFileExtn(cv); + if (extension) { + acc.push(`.${extension}`); //strict extension matching. + } + return acc; + }, + [] + ); + + const uniqueExtensions = [...new Set(allowedFileExtensions)]; + return uniqueExtensions.join(","); +}; + +// The resource should not have the extensions (*.ext) for the hasPermission to work. +// so sanitize this and also use to extract the allowed extensions outside of permission check. +export const getSessionGrantsWildCard = ( + sessionGrants: Record, + uploadPath: string, + scopes: string[] = [] +) => { + //get only the path matching grants to reduce processing. + const grantsWithExtension = Object.keys(sessionGrants).reduce( + (acc: Record, grantKey: string) => { + if (extractFileExtn(grantKey) && grantKey.includes(uploadPath)) { + acc[grantKey] = sessionGrants[grantKey]; + } + return acc; + }, + {} + ); + + const checkPathsForPermission = (sessionGrantKey: string) => { + const grantActions = grantsWithExtension[sessionGrantKey]; + const hasScope = grantActions.some((actionKey) => + scopes.find((scopeKey) => { + let wildCardMatch = false; + const hasWildCard = scopeKey.indexOf("*") !== -1; + if (hasWildCard) { + const scopeActionKey = scopeKey.substring(0, scopeKey.length - 1); + + wildCardMatch = actionKey.includes(scopeActionKey); + } + + return wildCardMatch || actionKey === scopeKey; + }) + ); + + const sessionGrantKeyPath = sessionGrantKey.substring( + 0, + sessionGrantKey.indexOf("/*.") //start of extension part. + ); + const isUploadPathMatching = + sessionGrantKeyPath === `arn:aws:s3:::${uploadPath}`; + + const hasGrant = + isUploadPathMatching && sessionGrantKey !== "arn:aws:s3:::*"; + + return hasScope && hasGrant; + }; + + return Object.keys(grantsWithExtension).filter(checkPathsForPermission); +}; diff --git a/portal-ui/src/screens/Console/ObjectBrowser/BrowserBreadcrumbs.tsx b/portal-ui/src/screens/Console/ObjectBrowser/BrowserBreadcrumbs.tsx index 713adf6aa..fadaaa19c 100644 --- a/portal-ui/src/screens/Console/ObjectBrowser/BrowserBreadcrumbs.tsx +++ b/portal-ui/src/screens/Console/ObjectBrowser/BrowserBreadcrumbs.tsx @@ -33,6 +33,7 @@ import withSuspense from "../Common/Components/withSuspense"; import { setSnackBarMessage } from "../../../systemSlice"; import { AppState, useAppDispatch } from "../../../store"; import { setVersionsModeEnabled } from "./objectBrowserSlice"; +import { getSessionGrantsWildCard } from "../Buckets/ListBuckets/UploadPermissionUtils"; const CreatePathModal = withSuspense( React.lazy( @@ -81,11 +82,14 @@ const BrowserBreadcrumbs = ({ const [createFolderOpen, setCreateFolderOpen] = useState(false); - const canCreatePath = - hasPermission(bucketName, [ - IAM_SCOPES.S3_PUT_OBJECT, - IAM_SCOPES.S3_PUT_ACTIONS, - ]) || anonymousMode; + const putObjectPermScopes = [ + IAM_SCOPES.S3_PUT_OBJECT, + IAM_SCOPES.S3_PUT_ACTIONS, + ]; + + const sessionGrants = useSelector((state: AppState) => + state.console.session ? state.console.session.permissions || {} : {} + ); let paths = internalPaths; @@ -96,6 +100,19 @@ const BrowserBreadcrumbs = ({ const splitPaths = paths.split("/").filter((path) => path !== ""); const lastBreadcrumbsIndex = splitPaths.length - 1; + const pathToCheckPerms = paths || bucketName; + const sessionGrantWildCards = getSessionGrantsWildCard( + sessionGrants, + pathToCheckPerms, + putObjectPermScopes + ); + + const canCreatePath = + hasPermission( + [pathToCheckPerms, ...sessionGrantWildCards], + putObjectPermScopes + ) || anonymousMode; + let breadcrumbsMap = splitPaths.map((objectItem: string, index: number) => { const subSplit = `${splitPaths.slice(0, index + 1).join("/")}/`; const route = `/browser/${bucketName}/${