diff --git a/config.js b/config.js index 68a3709de8..f143904834 100644 --- a/config.js +++ b/config.js @@ -1018,6 +1018,9 @@ config.NSFS_LIST_IGNORE_ENTRY_ON_EACCES = true; // we will for now handle the same way also EINVAL error - for gpfs stat issues on list (.snapshots) config.NSFS_LIST_IGNORE_ENTRY_ON_EINVAL = true; +config.NSFS_CUSTOM_BUCKET_PATH_HTTP_HEADER = 'x-noobaa-custom-bucket-path'; +config.NSFS_CUSTOM_BUCKET_PATH_ALLOWED_LIST = ''; // colon separated list of paths prefixes + //////////////////////////// // NSFS NON CONTAINERIZED // //////////////////////////// diff --git a/src/api/account_api.js b/src/api/account_api.js index f1eb1e7133..fd97bfe3e8 100644 --- a/src/api/account_api.js +++ b/src/api/account_api.js @@ -308,6 +308,7 @@ module.exports = { supplemental_groups: { $ref: 'common_api#/definitions/supplemental_groups' }, + custom_bucket_path_allowed_list: { type: 'string' }, } }, }, diff --git a/src/api/bucket_api.js b/src/api/bucket_api.js index 8689c17d76..4a15b8e2c9 100644 --- a/src/api/bucket_api.js +++ b/src/api/bucket_api.js @@ -33,6 +33,7 @@ module.exports = { }, bucket_claim: { $ref: '#/definitions/bucket_claim' }, force_md5_etag: { type: 'boolean' }, + custom_bucket_path: { type: 'string' } } }, reply: { diff --git a/src/api/common_api.js b/src/api/common_api.js index 2d77b87cb9..8a16aa516f 100644 --- a/src/api/common_api.js +++ b/src/api/common_api.js @@ -1454,6 +1454,7 @@ module.exports = { supplemental_groups: { $ref: '#/definitions/supplemental_groups' }, + custom_bucket_path_allowed_list: { type: 'string' }, } }, { type: 'object', @@ -1461,7 +1462,8 @@ module.exports = { properties: { distinguished_name: { wrapper: SensitiveString }, new_buckets_path: { type: 'string' }, - nsfs_only: { type: 'boolean' } + nsfs_only: { type: 'boolean' }, + custom_bucket_path_allowed_list: { type: 'string' }, } }] }, diff --git a/src/cmd/manage_nsfs.js b/src/cmd/manage_nsfs.js index ac48758002..f8e117529d 100644 --- a/src/cmd/manage_nsfs.js +++ b/src/cmd/manage_nsfs.js @@ -503,7 +503,8 @@ async function fetch_account_data(action, user_input) { uid: user_input.user ? undefined : user_input.uid, gid: user_input.user ? undefined : user_input.gid, new_buckets_path: user_input.new_buckets_path, - fs_backend: user_input.fs_backend ? String(user_input.fs_backend) : config.NSFS_NC_STORAGE_BACKEND + fs_backend: user_input.fs_backend ? String(user_input.fs_backend) : config.NSFS_NC_STORAGE_BACKEND, + custom_bucket_path_allowed_list: user_input.custom_bucket_path_allowed_list, }, default_connection: user_input.default_connection === undefined ? undefined : String(user_input.default_connection) }; @@ -542,6 +543,8 @@ async function fetch_account_data(action, user_input) { } else { // string of true or false data.allow_bucket_creation = user_input.allow_bucket_creation.toLowerCase() === 'true'; } + // custom_bucket_path_allowed_list deletion specified with empty string '' + data.nsfs_account_config.custom_bucket_path_allowed_list = data.nsfs_account_config.custom_bucket_path_allowed_list || undefined; return data; } diff --git a/src/endpoint/s3/ops/s3_put_bucket.js b/src/endpoint/s3/ops/s3_put_bucket.js index c930c1baaf..b74d9bfbeb 100644 --- a/src/endpoint/s3/ops/s3_put_bucket.js +++ b/src/endpoint/s3/ops/s3_put_bucket.js @@ -9,7 +9,8 @@ const config = require('../../../../config'); async function put_bucket(req, res) { const lock_enabled = config.WORM_ENABLED ? req.headers['x-amz-bucket-object-lock-enabled'] && req.headers['x-amz-bucket-object-lock-enabled'].toUpperCase() === 'TRUE' : undefined; - await req.object_sdk.create_bucket({ name: req.params.bucket, lock_enabled: lock_enabled }); + const custom_bucket_path = req.headers[config.NSFS_CUSTOM_BUCKET_PATH_HTTP_HEADER]; + await req.object_sdk.create_bucket({ name: req.params.bucket, lock_enabled, custom_bucket_path }); if (config.allow_anonymous_access_in_test && req.headers['x-amz-acl'] === 'public-read') { // For now we will enable only for tests const policy = { Version: '2012-10-17', diff --git a/src/manage_nsfs/manage_nsfs_constants.js b/src/manage_nsfs/manage_nsfs_constants.js index 3a3d9561ae..cc239687e9 100644 --- a/src/manage_nsfs/manage_nsfs_constants.js +++ b/src/manage_nsfs/manage_nsfs_constants.js @@ -46,8 +46,8 @@ const FROM_FILE = 'from_file'; const ANONYMOUS = 'anonymous'; const VALID_OPTIONS_ACCOUNT = { - 'add': new Set(['name', 'uid', 'gid', 'supplemental_groups', 'new_buckets_path', 'user', 'access_key', 'secret_key', 'fs_backend', 'allow_bucket_creation', 'force_md5_etag', 'iam_operate_on_root_account', 'default_connection', FROM_FILE, ...CLI_MUTUAL_OPTIONS]), - 'update': new Set(['name', 'uid', 'gid', 'supplemental_groups', 'new_buckets_path', 'user', 'access_key', 'secret_key', 'fs_backend', 'allow_bucket_creation', 'force_md5_etag', 'iam_operate_on_root_account', 'new_name', 'regenerate', 'default_connection', ...CLI_MUTUAL_OPTIONS]), + 'add': new Set(['name', 'uid', 'gid', 'supplemental_groups', 'new_buckets_path', 'custom_bucket_path_allowed_list', 'user', 'access_key', 'secret_key', 'fs_backend', 'allow_bucket_creation', 'force_md5_etag', 'iam_operate_on_root_account', 'default_connection', FROM_FILE, ...CLI_MUTUAL_OPTIONS]), + 'update': new Set(['name', 'uid', 'gid', 'supplemental_groups', 'new_buckets_path', 'custom_bucket_path_allowed_list', 'user', 'access_key', 'secret_key', 'fs_backend', 'allow_bucket_creation', 'force_md5_etag', 'iam_operate_on_root_account', 'new_name', 'regenerate', 'default_connection', ...CLI_MUTUAL_OPTIONS]), 'delete': new Set(['name', ...CLI_MUTUAL_OPTIONS]), 'list': new Set(['wide', 'show_secrets', 'gid', 'uid', 'user', 'name', 'access_key', ...CLI_MUTUAL_OPTIONS]), 'status': new Set(['name', 'access_key', 'show_secrets', ...CLI_MUTUAL_OPTIONS]), @@ -123,6 +123,7 @@ const OPTION_TYPE = { gid: 'number', supplemental_groups: 'string', new_buckets_path: 'string', + custom_bucket_path_allowed_list: 'string', user: 'string', access_key: 'string', secret_key: 'string', @@ -196,6 +197,7 @@ const UNSETTABLE_OPTIONS_OBJ = Object.freeze({ 'force_md5_etag': CLI_EMPTY_STRING, 'supplemental_groups': CLI_EMPTY_STRING, 'new_buckets_path': CLI_EMPTY_STRING, + 'custom_bucket_path_allowed_list': CLI_EMPTY_STRING, 'ips': CLI_EMPTY_STRING_ARRAY, }); diff --git a/src/manage_nsfs/manage_nsfs_help_utils.js b/src/manage_nsfs/manage_nsfs_help_utils.js index 08f9d7fa09..8b6fd4dc67 100644 --- a/src/manage_nsfs/manage_nsfs_help_utils.js +++ b/src/manage_nsfs/manage_nsfs_help_utils.js @@ -143,6 +143,7 @@ Flags: --force_md5_etag (optional) Set the account to force md5 etag calculation. (unset with '') (will override default config.NSFS_NC_STORAGE_BACKEND) --iam_operate_on_root_account (optional) Set the account to create root accounts instead of IAM users in IAM API requests. --from_file (optional) Use details from the JSON file, there is no need to mention all the properties individually in the CLI + --custom_bucket_path_allowed_list (optional) Set the list of allowed custom bucket paths, separated by colons (:) example: '/gpfs/data/custom1/:/gpfs/data/custom2/' `; const ACCOUNT_FLAGS_UPDATE = ` @@ -170,6 +171,7 @@ Flags: --allow_bucket_creation (optional) Update the account to explicitly allow or block bucket creation --force_md5_etag (optional) Update the account to force md5 etag calculation (unset with '') (will override default config.NSFS_NC_STORAGE_BACKEND) --iam_operate_on_root_account (optional) Update the account to create root accounts instead of IAM users in IAM API requests. + --custom_bucket_path_allowed_list (optional) Update the list of allowed custom bucket paths, separated by colons (:) example: '/gpfs/data/custom1/:/gpfs/data/custom2/' (override;unset with '') `; const ACCOUNT_FLAGS_DELETE = ` diff --git a/src/sdk/accountspace_fs.js b/src/sdk/accountspace_fs.js index ba3d10a7b6..a2efd8fe44 100644 --- a/src/sdk/accountspace_fs.js +++ b/src/sdk/accountspace_fs.js @@ -608,6 +608,7 @@ class AccountSpaceFS { supplemental_groups: requesting_account.nsfs_account_config.supplemental_groups, new_buckets_path: requesting_account.nsfs_account_config.new_buckets_path, fs_backend: requesting_account.nsfs_account_config.fs_backend, + custom_bucket_path_allowed_list: requesting_account.nsfs_account_config.custom_bucket_path_allowed_list, } }; if (requesting_account.iam_operate_on_root_account) { diff --git a/src/sdk/bucketspace_fs.js b/src/sdk/bucketspace_fs.js index 252242d918..0808fe61d8 100644 --- a/src/sdk/bucketspace_fs.js +++ b/src/sdk/bucketspace_fs.js @@ -303,9 +303,19 @@ class BucketSpaceFS extends BucketSpaceSimpleFS { const fs_context = this.prepare_fs_context(sdk); validate_bucket_creation(params); - const { name } = params; + const { name, custom_bucket_path } = params; const bucket_config_path = this.config_fs.get_bucket_path_by_name(name); - const bucket_storage_path = path.join(sdk.requesting_account.nsfs_account_config.new_buckets_path, name); + if (custom_bucket_path) { + const allowed_list = sdk.requesting_account.nsfs_account_config.custom_bucket_path_allowed_list || + config.NSFS_CUSTOM_BUCKET_PATH_ALLOWED_LIST; + const allowed_path_prefixes = allowed_list ? allowed_list.split(':').map(p => p.trim()).filter(p => p) : []; + if (!allowed_path_prefixes.length || !allowed_path_prefixes.some(prefix => custom_bucket_path.startsWith(prefix))) { + const message = `Not allowed to create new buckets: ${custom_bucket_path} outside of the custom_bucket_path_allowed_list: ${allowed_list}`; + dbg.error(`BucketSpaceFS.create_bucket: ${message}`); + throw new RpcError('UNAUTHORIZED', message); + } + } + const bucket_storage_path = custom_bucket_path || path.join(sdk.requesting_account.nsfs_account_config.new_buckets_path, name); dbg.log0(`BucketSpaceFS.create_bucket requesting_account=${util.inspect(sdk.requesting_account)}, diff --git a/src/server/system_services/schemas/nsfs_account_schema.js b/src/server/system_services/schemas/nsfs_account_schema.js index ffca0f7ce3..c63d3d6f40 100644 --- a/src/server/system_services/schemas/nsfs_account_schema.js +++ b/src/server/system_services/schemas/nsfs_account_schema.js @@ -87,7 +87,8 @@ module.exports = { new_buckets_path: { type: 'string' }, fs_backend: { $ref: 'common_api#/definitions/fs_backend' - } + }, + custom_bucket_path_allowed_list: { type: 'string' }, } }, { type: 'object', @@ -97,7 +98,8 @@ module.exports = { new_buckets_path: { type: 'string' }, fs_backend: { $ref: 'common_api#/definitions/fs_backend' - } + }, + custom_bucket_path_allowed_list: { type: 'string' }, } }] }, diff --git a/src/test/integration_tests/nsfs/test_nsfs_integration.js b/src/test/integration_tests/nsfs/test_nsfs_integration.js index e8c866efce..9c83043b53 100644 --- a/src/test/integration_tests/nsfs/test_nsfs_integration.js +++ b/src/test/integration_tests/nsfs/test_nsfs_integration.js @@ -1,5 +1,5 @@ /* Copyright (C) 2020 NooBaa */ -/*eslint max-lines: ["error", 2500]*/ +/*eslint max-lines: ["error", 3000]*/ /*eslint max-lines-per-function: ["error", 1300]*/ /*eslint max-statements: ["error", 80, { "ignoreTopLevelFunctions": true }]*/ 'use strict'; @@ -87,6 +87,7 @@ mocha.describe('bucket operations - namespace_fs', function() { let s3_wrong_uid; let s3_correct_uid; let s3_correct_uid_default_nsr; + let s3_correct_uid_bucket_path; let account_no_perm_dn; let s3_correct_dn_default_nsr; const no_permissions_dn = 'no_permissions_dn'; @@ -389,6 +390,123 @@ mocha.describe('bucket operations - namespace_fs', function() { await fs_utils.file_must_exist(path.join(s3_new_buckets_path, bucket_name + '-s3')); }); + mocha.it('create s3 bucket with x-noobaa-custom-bucket-path no custom-bucket-path-allowed_list - should fail', async function() { + // only NC supports creating buckets on custom paths + if (!is_nc_coretest) this.skip(); // eslint-disable-line no-invalid-this + const new_buckets_path = get_new_buckets_path_by_test_env(tmp_fs_root, s3_new_buckets_dir); + const x_nsfs_bucket_path = `${new_buckets_path}${bucket_name}-custom-path`; + s3_correct_uid_bucket_path = new S3(s3_creds); + s3_correct_uid_bucket_path.middlewareStack.add( + (next, context) => args => { + args.request.headers[config.NSFS_CUSTOM_BUCKET_PATH_HTTP_HEADER] = x_nsfs_bucket_path; + return next(args); + }, + { + step: "finalizeRequest", + name: "addCustomHeader", + } + ); + try { + const res = await s3_correct_uid_bucket_path.createBucket({ Bucket: bucket_name + '-s3-custom-fail', }); + assert.fail(inspect(res)); + } catch (err) { + assert.strictEqual(err.Code, 'AccessDenied'); + } + await fs_utils.file_must_not_exist(x_nsfs_bucket_path); + }); + + mocha.it('create s3 bucket with x-noobaa-custom-bucket-path', async function() { + // only NC supports creating buckets on custom paths + if (!is_nc_coretest) this.skip(); // eslint-disable-line no-invalid-this + const new_buckets_path = get_new_buckets_path_by_test_env(tmp_fs_root, s3_new_buckets_dir); + const account_s3_bucket_path = await rpc_client.account.create_account({ + ...new_account_params, + email: 'account_s3_bucket_path@noobaa.com', + name: 'account_s3_bucket_path', + s3_access: true, + default_resource: nsr, + nsfs_account_config: { + uid: process.getuid(), + gid: process.getgid(), + new_buckets_path: new_buckets_path, + nsfs_only: false, + custom_bucket_path_allowed_list: `${tmp_fs_root}:/bla/`, + } + }); + s3_creds.credentials = { + accessKeyId: account_s3_bucket_path.access_keys[0].access_key.unwrap(), + secretAccessKey: account_s3_bucket_path.access_keys[0].secret_key.unwrap(), + }; + s3_creds.endpoint = coretest.get_http_address(); + s3_correct_uid_bucket_path = new S3(s3_creds); + const x_nsfs_bucket_path = `${new_buckets_path}/${bucket_name}-custom-path`; + s3_correct_uid_bucket_path.middlewareStack.remove("addCustomHeader"); + s3_correct_uid_bucket_path.middlewareStack.add( + (next, context) => args => { + args.request.headers[config.NSFS_CUSTOM_BUCKET_PATH_HTTP_HEADER] = x_nsfs_bucket_path; + return next(args); + }, + { + step: "finalizeRequest", + name: "addCustomHeader", + } + ); + const res = await s3_correct_uid_bucket_path.createBucket({ Bucket: bucket_name + '-s3-custom', }); + console.log(inspect(res)); + await fs_utils.file_must_exist(x_nsfs_bucket_path); + }); + + mocha.it('create s3 bucket with x-noobaa-custom-bucket-path fail as directory exists', async function() { + // only NC supports creating buckets on custom paths + if (!is_nc_coretest) this.skip(); // eslint-disable-line no-invalid-this + const new_buckets_path = get_new_buckets_path_by_test_env(tmp_fs_root, s3_new_buckets_dir); + const x_nsfs_bucket_path = `${new_buckets_path}${bucket_name}-custom-path`; // already created in previous test + // this is the path that was created if x_nsfs_bucket_path header wasn't used + const no_x_nsfs_bucket_path = `${new_buckets_path}${bucket_name}-s3-custom-fail`; + s3_correct_uid_bucket_path.middlewareStack.remove("addCustomHeader"); + s3_correct_uid_bucket_path.middlewareStack.add( + (next, context) => args => { + args.request.headers[config.NSFS_CUSTOM_BUCKET_PATH_HTTP_HEADER] = x_nsfs_bucket_path; + return next(args); + }, + { + step: "finalizeRequest", + name: "addCustomHeader", + } + ); + try { + const res = await s3_correct_uid_bucket_path.createBucket({ Bucket: bucket_name + '-s3-custom-fail', }); + assert.fail(inspect(res)); + } catch (err) { + assert.strictEqual(err.Code, 'BucketAlreadyExists'); + } + await fs_utils.file_must_not_exist(no_x_nsfs_bucket_path); + }); + + mocha.it('create s3 bucket with x-noobaa-custom-bucket-path fail as not in account custom bucket_path', async function() { + // only NC supports creating buckets on custom paths + if (!is_nc_coretest) this.skip(); // eslint-disable-line no-invalid-this + const x_nsfs_bucket_path = `/root/${bucket_name}-custom-path`; // already created in previous test + s3_correct_uid_bucket_path.middlewareStack.remove("addCustomHeader"); + s3_correct_uid_bucket_path.middlewareStack.add( + (next, context) => args => { + args.request.headers[config.NSFS_CUSTOM_BUCKET_PATH_HTTP_HEADER] = x_nsfs_bucket_path; + return next(args); + }, + { + step: "finalizeRequest", + name: "addCustomHeader", + } + ); + try { + const res = await s3_correct_uid_bucket_path.createBucket({ Bucket: bucket_name + '-s3-custom-fail', }); + assert.fail(inspect(res)); + } catch (err) { + assert.strictEqual(err.Code, 'AccessDenied'); + } + await fs_utils.file_must_not_exist(x_nsfs_bucket_path); + }); + mocha.it('get bucket acl - rpc bucket', async function() { const res = await s3_correct_uid_default_nsr.getBucketAcl({ Bucket: first_bucket }); const bucket_info = await rpc_client.bucket.read_bucket({ name: first_bucket }); diff --git a/src/test/utils/coretest/nc_coretest.js b/src/test/utils/coretest/nc_coretest.js index a2a7137e1d..01e6a23f9e 100644 --- a/src/test/utils/coretest/nc_coretest.js +++ b/src/test/utils/coretest/nc_coretest.js @@ -337,7 +337,8 @@ async function create_account_manage(options) { uid: options.nsfs_account_config.uid, gid: options.nsfs_account_config.gid, access_key: options.access_key, - secret_key: options.secret_key + secret_key: options.secret_key, + custom_bucket_path_allowed_list: options.nsfs_account_config.custom_bucket_path_allowed_list, }; const res = await exec_manage_cli(TYPES.ACCOUNT, ACTIONS.ADD, cli_options); const json_account = JSON.parse(res); @@ -434,6 +435,7 @@ async function update_account_s3_access_manage(options) { distinguished_name: options.nsfs_account_config.distinguished_name, uid: options.nsfs_account_config.uid, gid: options.nsfs_account_config.gid, + custom_bucket_path_allowed_list: options.nsfs_account_config.custom_bucket_path_allowed_list, }; await exec_manage_cli(TYPES.ACCOUNT, ACTIONS.UPDATE, cli_options); }