diff --git a/hadoop-tools/hadoop-azure/src/main/java/org/apache/hadoop/fs/azurebfs/AbfsConfiguration.java b/hadoop-tools/hadoop-azure/src/main/java/org/apache/hadoop/fs/azurebfs/AbfsConfiguration.java index e5ba80b77c0929..592b1b1e49da6b 100644 --- a/hadoop-tools/hadoop-azure/src/main/java/org/apache/hadoop/fs/azurebfs/AbfsConfiguration.java +++ b/hadoop-tools/hadoop-azure/src/main/java/org/apache/hadoop/fs/azurebfs/AbfsConfiguration.java @@ -648,6 +648,11 @@ public class AbfsConfiguration{ DefaultValue = DEFAULT_FS_AZURE_RESTRICT_GPS_ON_OPENFILE) private boolean restrictGpsOnOpenFile; + + @BooleanConfigurationValidatorAnnotation(ConfigurationKey = FS_AZURE_RBAC_ONLY_MODE, + DefaultValue = DEFAULT_FS_AZURE_RBAC_ONLY_MODE) + private boolean rbacOnlyMode; + private String clientProvidedEncryptionKey; private String clientProvidedEncryptionKeySHA; @@ -2250,4 +2255,14 @@ public int getMaxReadSizeForVectoredReads() { public int getMaxReadSizeForVectoredReadsThroughput() { return maxReadSizeForVectoredReadsThroughput; } + + /** + * Returns whether the filesystem is configured to operate in RBAC-only mode. + * When true, setPermission call will be a no-op on HNS-enabled accounts. + * + * @return true if RBAC-only mode is enabled; false otherwise. + */ + public boolean isRbacOnlyMode() { + return this.rbacOnlyMode; + } } diff --git a/hadoop-tools/hadoop-azure/src/main/java/org/apache/hadoop/fs/azurebfs/AzureBlobFileSystem.java b/hadoop-tools/hadoop-azure/src/main/java/org/apache/hadoop/fs/azurebfs/AzureBlobFileSystem.java index a24f3303270f15..08bc89c0614a63 100644 --- a/hadoop-tools/hadoop-azure/src/main/java/org/apache/hadoop/fs/azurebfs/AzureBlobFileSystem.java +++ b/hadoop-tools/hadoop-azure/src/main/java/org/apache/hadoop/fs/azurebfs/AzureBlobFileSystem.java @@ -1218,6 +1218,16 @@ public void setPermission(final Path path, final FsPermission permission) throw new IllegalArgumentException("The permission can't be null"); } + // RBAC-only short-circuit: when fs.azure.rbac.only=true on an HNS-enabled + // account, skip the backend SetAccessControl call. Framework-generated + // setPermission() invocations (Spark/Hadoop commit protocols, distcp, etc.) + // succeed as no-ops so RBAC-only deployments are not blocked by lack of + // ACL-management permissions. Explicit ACL APIs are unaffected. + if (getAbfsStore().getAbfsConfiguration().isRbacOnlyMode()) { + LOG.debug("RBAC-only mode enabled; skipping setPermission for path: {}", path); + return; + } + Path qualifiedPath = makeQualified(path); try { diff --git a/hadoop-tools/hadoop-azure/src/main/java/org/apache/hadoop/fs/azurebfs/constants/ConfigurationKeys.java b/hadoop-tools/hadoop-azure/src/main/java/org/apache/hadoop/fs/azurebfs/constants/ConfigurationKeys.java index 5b98ea7901ca3a..debe7089302eeb 100644 --- a/hadoop-tools/hadoop-azure/src/main/java/org/apache/hadoop/fs/azurebfs/constants/ConfigurationKeys.java +++ b/hadoop-tools/hadoop-azure/src/main/java/org/apache/hadoop/fs/azurebfs/constants/ConfigurationKeys.java @@ -659,5 +659,13 @@ public static String containerProperty(String property, String fsName, String ac */ public static final String FS_AZURE_RESTRICT_GPS_ON_OPENFILE = "fs.azure.restrict.gps.on.openfile"; + /** + * If true, skips the backend SetAccessControl call in setPermission() + * on HNS-enabled accounts. Intended for RBAC-only deployments. + * Explicit ACL APIs (setAcl, modifyAclEntries, etc.) are unaffected. + * Default: false + */ + public static final String FS_AZURE_RBAC_ONLY_MODE = "fs.azure.rbac.only"; + private ConfigurationKeys() {} } diff --git a/hadoop-tools/hadoop-azure/src/main/java/org/apache/hadoop/fs/azurebfs/constants/FileSystemConfigurations.java b/hadoop-tools/hadoop-azure/src/main/java/org/apache/hadoop/fs/azurebfs/constants/FileSystemConfigurations.java index 875dfb55768c65..c95c8bae7c7e94 100644 --- a/hadoop-tools/hadoop-azure/src/main/java/org/apache/hadoop/fs/azurebfs/constants/FileSystemConfigurations.java +++ b/hadoop-tools/hadoop-azure/src/main/java/org/apache/hadoop/fs/azurebfs/constants/FileSystemConfigurations.java @@ -311,6 +311,11 @@ public final class FileSystemConfigurations { public static final int DEFAULT_FS_AZURE_MAX_MERGED_READ_SIZE_FOR_VECTORED_READS = 4 * ONE_MB; public static final int DEFAULT_FS_AZURE_MAX_MERGED_READ_SIZE_FOR_VECTORED_READS_THROUGHPUT = 8 * ONE_MB; public static final boolean DEFAULT_FS_AZURE_RESTRICT_GPS_ON_OPENFILE = false; + /** + * Default value for {@link ConfigurationKeys#FS_AZURE_RBAC_ONLY_MODE}. + * Disabled by default to preserve existing behavior. + */ + public static final boolean DEFAULT_FS_AZURE_RBAC_ONLY_MODE = false; private FileSystemConfigurations() {} } diff --git a/hadoop-tools/hadoop-azure/src/site/markdown/index.md b/hadoop-tools/hadoop-azure/src/site/markdown/index.md index 8b8c8c51ff6e03..0c2a2900601bfd 100644 --- a/hadoop-tools/hadoop-azure/src/site/markdown/index.md +++ b/hadoop-tools/hadoop-azure/src/site/markdown/index.md @@ -1102,6 +1102,36 @@ Config `fs.azure.account.hns.enabled` provides an option to specify whether Config `fs.azure.enable.check.access` needs to be set true to enable the AzureBlobFileSystem.access(). +### RBAC-Only Mode + +Config `fs.azure.rbac.only`, when set to `true`, treats +`AzureBlobFileSystem#setPermission()` as a **pure no-op** on HNS-enabled +accounts — no backend `SetAccessControl` request is issued, regardless of +whether the path exists. Default: `false`. + +This unblocks Spark/Hadoop workloads running under Azure RBAC roles like +`Storage Blob Data Contributor`, which grant full data-plane access but +lack ACL-management permissions and would otherwise fail +framework-generated `setPermission()` calls with +`AuthorizationPermissionMismatch` (HTTP 403) on HNS accounts. + +**Not gated by this flag** (continue to require ACL-management permissions): +`setAcl`, `modifyAclEntries`, `removeAclEntries`, `removeDefaultAcl`, +`removeAcl`. The flag is a workload-compatibility switch, not a permission +bypass. `setPermission(path, null)` still throws `IllegalArgumentException`. +On non-HNS accounts the flag has no effect. + +```xml + + fs.azure.rbac.only + true + + Treats setPermission() as a no-op on HNS-enabled accounts for + RBAC-only deployments. Explicit ACL APIs are unaffected. Default: false. + + +``` + ### Operation Idempotency Requests failing due to server timeouts and network failures will be retried. diff --git a/hadoop-tools/hadoop-azure/src/test/java/org/apache/hadoop/fs/azurebfs/ITestAzureBlobFileSystemOauth.java b/hadoop-tools/hadoop-azure/src/test/java/org/apache/hadoop/fs/azurebfs/ITestAzureBlobFileSystemOauth.java index c719fb37d71c51..6a20b8d345b0dc 100644 --- a/hadoop-tools/hadoop-azure/src/test/java/org/apache/hadoop/fs/azurebfs/ITestAzureBlobFileSystemOauth.java +++ b/hadoop-tools/hadoop-azure/src/test/java/org/apache/hadoop/fs/azurebfs/ITestAzureBlobFileSystemOauth.java @@ -20,7 +20,10 @@ import java.io.IOException; import java.io.InputStream; +import java.nio.file.AccessDeniedException; +import java.util.List; import java.util.Map; +import java.util.UUID; import org.junit.jupiter.api.Test; import org.mockito.Mockito; @@ -39,10 +42,12 @@ import org.apache.hadoop.fs.azurebfs.services.AbfsBlobClient; import org.apache.hadoop.fs.azurebfs.services.AuthType; import org.apache.hadoop.fs.azurebfs.utils.TracingContext; +import org.apache.hadoop.fs.permission.FsPermission; import org.apache.hadoop.io.IOUtils; import static org.apache.hadoop.fs.azurebfs.constants.ConfigurationKeys.FS_AZURE_ACCOUNT_OAUTH_CLIENT_ID; import static org.apache.hadoop.fs.azurebfs.constants.ConfigurationKeys.FS_AZURE_ACCOUNT_OAUTH_CLIENT_SECRET; +import static org.apache.hadoop.fs.azurebfs.constants.ConfigurationKeys.FS_AZURE_RBAC_ONLY_MODE; import static org.apache.hadoop.fs.azurebfs.constants.TestConfigurationKeys.FS_AZURE_BLOB_DATA_CONTRIBUTOR_CLIENT_ID; import static org.apache.hadoop.fs.azurebfs.constants.TestConfigurationKeys.FS_AZURE_BLOB_DATA_CONTRIBUTOR_CLIENT_SECRET; import static org.apache.hadoop.fs.azurebfs.constants.TestConfigurationKeys.FS_AZURE_BLOB_DATA_READER_CLIENT_ID; @@ -64,6 +69,7 @@ public class ITestAzureBlobFileSystemOauth extends AbstractAbfsIntegrationTest{ private static final String EXISTED_FOLDER_PATH = "/existedFolder"; private static final Logger LOG = LoggerFactory.getLogger(ITestAbfsStreamStatistics.class); + private static final FsPermission RBAC_ONLY_TEST_PERMISSION = new FsPermission((short) 0755); public ITestAzureBlobFileSystemOauth() throws Exception { assumeThat(this.getAuthType()).isEqualTo(AuthType.OAuth); @@ -226,4 +232,209 @@ private AzureBlobFileSystem getBlobReader() throws Exception { Configuration rawConfig = abfsConfig.getRawConfiguration(); return getFileSystem(rawConfig); } + + // ========================================================================= + // Tests for fs.azure.rbac.only mode under OAuth (RBAC) authentication. + // + // Storage Blob Data Contributor has full data-plane access but does NOT + // include ACL-management permissions on HNS-enabled ADLS Gen2 accounts. + // Therefore setPermission(), which calls SetAccessControl on HNS, fails + // with AUTHORIZATION_PERMISSION_MISS_MATCH (403) unless the caller also + // holds Storage Blob Data Owner (or an equivalent ACL-management role). + // + // fs.azure.rbac.only=true short-circuits setPermission() to a pure no-op + // on HNS, unblocking Spark/Hadoop workloads that use RBAC-only auth. + // + // These tests prove: + // 1. Without the flag, Contributor cannot call setPermission (baseline). + // 2. With the flag, Contributor's setPermission succeeds as a no-op. + // 3. The flag does NOT grant any additional backend permission - explicit + // ACL APIs (setAcl) continue to fail for Contributor even with the + // flag on. + // ========================================================================= + + /** + * Blob Data Contributor + fs.azure.rbac.only=false (default): + * setPermission must fail with AUTHORIZATION_PERMISSION_MISS_MATCH on an + * HNS-enabled account, because Contributor lacks ACL-management permissions. + * This documents the exact failure mode this feature is designed to unblock. + */ + @Test + public void testSetPermissionFailsForContributorWithoutRbacOnly() + throws Exception { + String clientId = this.getConfiguration() + .get(FS_AZURE_BLOB_DATA_CONTRIBUTOR_CLIENT_ID); + assumeThat(clientId).as("Contributor client id not provided").isNotNull(); + String secret = this.getConfiguration() + .get(FS_AZURE_BLOB_DATA_CONTRIBUTOR_CLIENT_SECRET); + assumeThat(secret).as("Contributor client secret not provided").isNotNull(); + + final AzureBlobFileSystem fs = getBlobContributorWithRbacOnly(false); + assumeThat(getIsNamespaceEnabled(fs)) + .as("This test requires an HNS-enabled account") + .isTrue(); + + Path filePath = path("/rbac-only-contributor-off"); + try (FSDataOutputStream stream = fs.create(filePath)) { + stream.write(0); + } + + // AzureBlobFileSystem.checkException() wraps AbfsRestOperationException + // into java.nio.file.AccessDeniedException for 403 responses, so we + // must assert on the wrapper and then unwrap to check the service + // error code. + AccessDeniedException ex = assertThrows( + AccessDeniedException.class, + () -> fs.setPermission(filePath, RBAC_ONLY_TEST_PERMISSION), + "setPermission is expected to fail for Contributor when " + + "fs.azure.rbac.only=false on an HNS-enabled account"); + + Throwable cause = ex.getCause(); + assertTrue(cause instanceof AbfsRestOperationException, + "AccessDeniedException must wrap an AbfsRestOperationException, " + + "but was: " + (cause == null ? "null" : cause.getClass())); + assertEquals(AzureServiceErrorCode.AUTHORIZATION_PERMISSION_MISS_MATCH, + ((AbfsRestOperationException) cause).getErrorCode(), + "Underlying failure must be AUTHORIZATION_PERMISSION_MISS_MATCH " + + "(403), confirming the ACL-management permission gap that " + + "fs.azure.rbac.only is designed to bypass"); + } + + /** + * Blob Data Contributor + fs.azure.rbac.only=true: + * setPermission must succeed as a pure no-op. No backend request is made, + * so the Contributor's lack of ACL-management permissions is irrelevant. + * This is the primary success path for RBAC-only deployments. + */ + @Test + public void testSetPermissionSucceedsForContributorWithRbacOnly() + throws Exception { + String clientId = this.getConfiguration() + .get(FS_AZURE_BLOB_DATA_CONTRIBUTOR_CLIENT_ID); + assumeThat(clientId).as("Contributor client id not provided").isNotNull(); + String secret = this.getConfiguration() + .get(FS_AZURE_BLOB_DATA_CONTRIBUTOR_CLIENT_SECRET); + assumeThat(secret).as("Contributor client secret not provided").isNotNull(); + + final AzureBlobFileSystem fs = getBlobContributorWithRbacOnly(true); + assumeThat(getIsNamespaceEnabled(fs)) + .as("This test requires an HNS-enabled account") + .isTrue(); + + Path filePath = path("/rbac-only-contributor-on"); + try (FSDataOutputStream stream = fs.create(filePath)) { + stream.write(0); + } + + // Must not throw. This is the pure no-op path. + assertThatCode(() -> + fs.setPermission(filePath, RBAC_ONLY_TEST_PERMISSION)) + .as("setPermission must succeed as a no-op for Contributor when " + + "fs.azure.rbac.only=true on an HNS-enabled account") + .doesNotThrowAnyException(); + + // Data-plane operations must still work normally under Contributor. + try (FSDataOutputStream stream = fs.append(filePath)) { + stream.write(0); + } + assertEquals(2, fs.getFileStatus(filePath).getLen(), + "Data-plane operations must remain functional when " + + "fs.azure.rbac.only=true"); + } + + /** + * Pure no-op semantics under RBAC: setPermission on a non-existent path + * must NOT contact the backend and must return successfully. + */ + @Test + public void testSetPermissionOnNonExistentPathIsNoOpForContributor() + throws Exception { + String clientId = this.getConfiguration() + .get(FS_AZURE_BLOB_DATA_CONTRIBUTOR_CLIENT_ID); + assumeThat(clientId).as("Contributor client id not provided").isNotNull(); + String secret = this.getConfiguration() + .get(FS_AZURE_BLOB_DATA_CONTRIBUTOR_CLIENT_SECRET); + assumeThat(secret).as("Contributor client secret not provided").isNotNull(); + + final AzureBlobFileSystem fs = getBlobContributorWithRbacOnly(true); + assumeThat(getIsNamespaceEnabled(fs)) + .as("This test requires an HNS-enabled account") + .isTrue(); + + Path missing = path("/rbac-only-missing-" + UUID.randomUUID()); + // Path is intentionally NOT created; pure no-op must not throw. + assertThatCode(() -> + fs.setPermission(missing, RBAC_ONLY_TEST_PERMISSION)) + .as("setPermission on a non-existent path must be a pure no-op " + + "when fs.azure.rbac.only=true (documented in abfs.md)") + .doesNotThrowAnyException(); + } + + /** + * fs.azure.rbac.only must NOT grant any additional backend permission. + * Explicit ACL APIs (setAcl) are not gated by the flag and must continue + * to fail with AUTHORIZATION_PERMISSION_MISS_MATCH for Contributor, + * proving the flag is not a security bypass. + */ + @Test + public void testExplicitSetAclStillFailsForContributorWithRbacOnly() + throws Exception { + String clientId = this.getConfiguration() + .get(FS_AZURE_BLOB_DATA_CONTRIBUTOR_CLIENT_ID); + assumeThat(clientId).as("Contributor client id not provided").isNotNull(); + String secret = this.getConfiguration() + .get(FS_AZURE_BLOB_DATA_CONTRIBUTOR_CLIENT_SECRET); + assumeThat(secret).as("Contributor client secret not provided").isNotNull(); + + final AzureBlobFileSystem fs = getBlobContributorWithRbacOnly(true); + assumeThat(getIsNamespaceEnabled(fs)) + .as("This test requires an HNS-enabled account") + .isTrue(); + + Path filePath = path("/rbac-only-setacl-still-fails"); + try (FSDataOutputStream stream = fs.create(filePath)) { + stream.write(0); + } + + List aclSpec = + org.apache.hadoop.util.Lists.newArrayList( + org.apache.hadoop.fs.azurebfs.utils.AclTestHelpers.aclEntry( + org.apache.hadoop.fs.permission.AclEntryScope.ACCESS, + org.apache.hadoop.fs.permission.AclEntryType.USER, + org.apache.hadoop.fs.permission.FsAction.ALL)); + + AccessDeniedException ex = assertThrows( + AccessDeniedException.class, + () -> fs.setAcl(filePath, aclSpec), + "setAcl must NOT be gated by fs.azure.rbac.only and must still " + + "require ACL-management permissions"); + + Throwable cause = ex.getCause(); + assertTrue(cause instanceof AbfsRestOperationException, + "AccessDeniedException must wrap an AbfsRestOperationException, " + + "but was: " + (cause == null ? "null" : cause.getClass())); + assertEquals(AzureServiceErrorCode.AUTHORIZATION_PERMISSION_MISS_MATCH, + ((AbfsRestOperationException) cause).getErrorCode(), + "Explicit ACL APIs must continue to enforce ACL-management " + + "permissions even when fs.azure.rbac.only=true. The flag " + + "is not a security bypass."); + } + + /** + * Helper: builds a Blob Data Contributor filesystem with the requested + * fs.azure.rbac.only setting applied at initialization time. + */ + private AzureBlobFileSystem getBlobContributorWithRbacOnly(boolean rbacOnly) + throws Exception { + AbfsConfiguration abfsConfig = this.getConfiguration(); + abfsConfig.set( + FS_AZURE_ACCOUNT_OAUTH_CLIENT_ID + "." + this.getAccountName(), + abfsConfig.get(FS_AZURE_BLOB_DATA_CONTRIBUTOR_CLIENT_ID)); + abfsConfig.set( + FS_AZURE_ACCOUNT_OAUTH_CLIENT_SECRET + "." + this.getAccountName(), + abfsConfig.get(FS_AZURE_BLOB_DATA_CONTRIBUTOR_CLIENT_SECRET)); + Configuration rawConfig = abfsConfig.getRawConfiguration(); + rawConfig.setBoolean(FS_AZURE_RBAC_ONLY_MODE, rbacOnly); + return getFileSystem(rawConfig); + } } diff --git a/hadoop-tools/hadoop-azure/src/test/java/org/apache/hadoop/fs/azurebfs/ITestAzureBlobFilesystemAcl.java b/hadoop-tools/hadoop-azure/src/test/java/org/apache/hadoop/fs/azurebfs/ITestAzureBlobFilesystemAcl.java index 688e6c4a4c75c7..565c2e00439882 100644 --- a/hadoop-tools/hadoop-azure/src/test/java/org/apache/hadoop/fs/azurebfs/ITestAzureBlobFilesystemAcl.java +++ b/hadoop-tools/hadoop-azure/src/test/java/org/apache/hadoop/fs/azurebfs/ITestAzureBlobFilesystemAcl.java @@ -18,7 +18,9 @@ package org.apache.hadoop.fs.azurebfs; +import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.azurebfs.constants.AbfsServiceType; +import org.apache.hadoop.fs.azurebfs.utils.TracingContext; import org.apache.hadoop.util.Lists; import java.io.FileNotFoundException; @@ -40,6 +42,7 @@ import org.apache.hadoop.fs.permission.FsAction; import org.apache.hadoop.fs.permission.FsPermission; +import static org.apache.hadoop.fs.azurebfs.constants.ConfigurationKeys.FS_AZURE_RBAC_ONLY_MODE; import static org.apache.hadoop.fs.contract.ContractTestUtils.assertPathExists; import static org.junit.jupiter.api.Assumptions.assumeTrue; @@ -50,7 +53,12 @@ import static org.apache.hadoop.fs.permission.AclEntryType.OTHER; import static org.apache.hadoop.fs.permission.AclEntryType.MASK; import static org.apache.hadoop.fs.azurebfs.utils.AclTestHelpers.aclEntry; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.clearInvocations; + import org.junit.jupiter.api.Assertions; +import org.mockito.Mockito; /** * Test acl operations. @@ -105,6 +113,8 @@ public class ITestAzureBlobFilesystemAcl extends AbstractAbfsIntegrationTest { private static Path testRoot = new Path("/test"); + private static final FsPermission RBAC_ONLY_TEST_PERMISSION = new FsPermission((short) 0557); + private Path path; public ITestAzureBlobFilesystemAcl() throws Exception { @@ -1529,4 +1539,339 @@ private void assertPermission(FileSystem fs, Path pathToCheck, short perm) throws Exception { AclTestHelpers.assertPermission(fs, pathToCheck, perm); } + + // ========================================================================= + // Tests for fs.azure.rbac.only mode. + // + // When fs.azure.rbac.only=true on an HNS-enabled account: + // - setPermission() must be a no-op (permission is not updated on the + // backend) so RBAC-only workloads are not blocked by lack of + // ACL-management permissions. + // - Explicit ACL management APIs (setAcl, modifyAclEntries, + // removeAclEntries, removeDefaultAcl, removeAcl) must be unaffected. + // + // On non-HNS accounts the flag must have no effect (existing behavior). + // + // Every test below creates a dedicated FileSystem instance via + // FileSystem.newInstance() so the config is picked up at init time; those + // instances are owned by the test and are closed with try-with-resources. + // ========================================================================= + + /** + * When RBAC-only mode is enabled on an HNS-enabled account, setPermission + * must be treated as a no-op: the on-storage permission stays at whatever + * it was before the setPermission() call. + */ + @Test + public void testSetPermissionNoOpWhenRbacOnlyEnabledOnHns() throws Exception { + try (AzureBlobFileSystem rawFs = getRbacOnlyFileSystem(true)) { + assumeTrue(getIsNamespaceEnabled(rawFs)); + final AzureBlobFileSystem fs = spyWithSpiedStore(rawFs); + final AzureBlobFileSystemStore store = fs.getAbfsStore(); + + path = new Path(testRoot, UUID.randomUUID().toString()); + FileSystem.mkdirs(fs, path, FsPermission.createImmutable((short) RWX_RX)); + + // Capture the current permission before the (should-be no-op) call. + FsPermission before = fs.getFileStatus(path).getPermission(); + + // This must NOT touch the backend ACL/permission. + fs.setPermission(path, FsPermission.createImmutable((short) RWX)); + + // The no-op must short-circuit in the FS layer: store is never hit. + Mockito.verify(store, Mockito.never()).setPermission( + any(Path.class), any(FsPermission.class), + nullable(TracingContext.class)); + + FsPermission after = fs.getFileStatus(path).getPermission(); + assertEquals(before, after, + "setPermission() must be a no-op when fs.azure.rbac.only=true " + + "on an HNS-enabled account"); + } + } + + @Test + public void testSetPermissionAppliedWhenRbacOnlyDisabledOnHns() + throws Exception { + try (AzureBlobFileSystem rawFs = getRbacOnlyFileSystem(false)) { + assumeTrue(getIsNamespaceEnabled(rawFs)); + final AzureBlobFileSystem fs = spyWithSpiedStore(rawFs); + final AzureBlobFileSystemStore store = fs.getAbfsStore(); + + path = new Path(testRoot, UUID.randomUUID().toString()); + FileSystem.mkdirs(fs, path, FsPermission.createImmutable((short) RWX_RX)); + + // FileSystem.mkdirs(fs, dir, perm) internally calls setPermission(); + // discard that setup invocation so we verify only the call under test. + clearInvocations(store); + + fs.setPermission(path, FsPermission.createImmutable((short) RWX)); + + Mockito.verify(store, Mockito.times(1)).setPermission( + any(Path.class), any(FsPermission.class), + nullable(TracingContext.class)); + + assertPermission(fs, (short) RWX); + } + } + + /** + * On non-HNS accounts the RBAC-only flag has no effect: existing driver + * behavior for setPermission (which delegates to super.setPermission and + * is effectively a no-op at the store level) must continue to work + * without throwing. + */ + @Test + public void testSetPermissionRbacOnlyHasNoEffectOnNonHns() throws Exception { + try (AzureBlobFileSystem fs = getRbacOnlyFileSystem(true)) { + assumeTrue(!getIsNamespaceEnabled(fs)); + + path = new Path(testRoot, UUID.randomUUID().toString()); + fs.create(path).close(); + assertPathExists(fs, "This path should exist", path); + + // Should not throw. Behavior identical to today on non-HNS accounts. + fs.setPermission(path, FsPermission.createImmutable(RWX_RX_RX)); + } + } + + /** + * Explicit ACL APIs must NOT be gated by fs.azure.rbac.only. setAcl, + * modifyAclEntries, removeAclEntries, removeDefaultAcl, and removeAcl + * must continue to update the backend ACL. + */ + @Test + public void testExplicitAclApisNotAffectedByRbacOnlyMode() throws Exception { + try (AzureBlobFileSystem fs = getRbacOnlyFileSystem(true)) { + assumeTrue(getIsNamespaceEnabled(fs)); + + path = new Path(testRoot, UUID.randomUUID().toString()); + FileSystem.mkdirs(fs, path, FsPermission.createImmutable((short) RWX_RX)); + + // setAcl must apply. + List aclSpec = Lists.newArrayList( + aclEntry(ACCESS, USER, ALL), + aclEntry(ACCESS, USER, FOO, ALL), + aclEntry(ACCESS, GROUP, READ_EXECUTE), + aclEntry(ACCESS, OTHER, NONE), + aclEntry(DEFAULT, USER, FOO, ALL)); + fs.setAcl(path, aclSpec); + + AclEntry[] afterSetAcl = + fs.getAclStatus(path).getEntries().toArray(new AclEntry[0]); + assertArrayEquals(new AclEntry[]{ + aclEntry(ACCESS, USER, FOO, ALL), + aclEntry(ACCESS, GROUP, READ_EXECUTE), + aclEntry(DEFAULT, USER, ALL), + aclEntry(DEFAULT, USER, FOO, ALL), + aclEntry(DEFAULT, GROUP, READ_EXECUTE), + aclEntry(DEFAULT, MASK, ALL), + aclEntry(DEFAULT, OTHER, NONE) + }, + afterSetAcl); + + // modifyAclEntries must apply. + fs.modifyAclEntries(path, Lists.newArrayList( + aclEntry(ACCESS, USER, FOO, READ_EXECUTE), + aclEntry(DEFAULT, USER, FOO, READ_EXECUTE))); + AclEntry[] afterModify = + fs.getAclStatus(path).getEntries().toArray(new AclEntry[0]); + assertArrayEquals(new AclEntry[]{ + aclEntry(ACCESS, USER, FOO, READ_EXECUTE), + aclEntry(ACCESS, GROUP, READ_EXECUTE), + aclEntry(DEFAULT, USER, ALL), + aclEntry(DEFAULT, USER, FOO, READ_EXECUTE), + aclEntry(DEFAULT, GROUP, READ_EXECUTE), + aclEntry(DEFAULT, MASK, READ_EXECUTE), + aclEntry(DEFAULT, OTHER, NONE) + }, + afterModify); + + // removeAclEntries must apply. + fs.removeAclEntries(path, Lists.newArrayList( + aclEntry(ACCESS, USER, FOO), + aclEntry(DEFAULT, USER, FOO))); + AclEntry[] afterRemoveEntries = + fs.getAclStatus(path).getEntries().toArray(new AclEntry[0]); + assertArrayEquals(new AclEntry[]{ + aclEntry(ACCESS, GROUP, READ_EXECUTE), + aclEntry(DEFAULT, USER, ALL), + aclEntry(DEFAULT, GROUP, READ_EXECUTE), + aclEntry(DEFAULT, MASK, READ_EXECUTE), + aclEntry(DEFAULT, OTHER, NONE) + }, + afterRemoveEntries); + + // removeDefaultAcl must apply. + fs.removeDefaultAcl(path); + AclEntry[] afterRemoveDefault = + fs.getAclStatus(path).getEntries().toArray(new AclEntry[0]); + assertArrayEquals(new AclEntry[]{ + aclEntry(ACCESS, GROUP, READ_EXECUTE) + }, afterRemoveDefault); + + // removeAcl must apply. + fs.removeAcl(path); + AclEntry[] afterRemoveAcl = + fs.getAclStatus(path).getEntries().toArray(new AclEntry[0]); + assertArrayEquals(new AclEntry[]{}, afterRemoveAcl); + } + } + + /** + * Interaction test: a prior setAcl sets a specific mode; a subsequent + * setPermission() while in RBAC-only mode must not reach the store and + * must NOT alter that mode. + */ + @Test + public void testSetPermissionNoOpPreservesPriorAclState() throws Exception { + try (AzureBlobFileSystem rawFs = getRbacOnlyFileSystem(true)) { + assumeTrue(getIsNamespaceEnabled(rawFs)); + final AzureBlobFileSystem fs = spyWithSpiedStore(rawFs); + final AzureBlobFileSystemStore store = fs.getAbfsStore(); + + path = new Path(testRoot, UUID.randomUUID().toString()); + FileSystem.mkdirs(fs, path, FsPermission.createImmutable((short) RWX_RX)); + + // setAcl is NOT gated by the flag - this still applies. + List aclSpec = Lists.newArrayList( + aclEntry(ACCESS, USER, ALL), + aclEntry(ACCESS, USER, FOO, ALL), + aclEntry(ACCESS, GROUP, READ_EXECUTE), + aclEntry(ACCESS, OTHER, NONE), + aclEntry(DEFAULT, USER, FOO, ALL)); + fs.setAcl(path, aclSpec); + + AclStatus before = fs.getAclStatus(path); + + // Attempt to change mode via setPermission while RBAC-only is on. + // This must be a no-op; ACL entries and mode must remain unchanged. + fs.setPermission(path, FsPermission.createImmutable((short) RWX)); + + Mockito.verify(store, Mockito.never()).setPermission( + any(Path.class), any(FsPermission.class), + nullable(TracingContext.class)); + + AclStatus after = fs.getAclStatus(path); + assertArrayEquals(before.getEntries().toArray(new AclEntry[0]), + after.getEntries().toArray(new AclEntry[0]), + "ACL entries must be unchanged by a no-op setPermission()"); + assertEquals(before.getPermission(), after.getPermission(), + "Permission bits must be unchanged by a no-op setPermission()"); + } + } + + /** + * Default value of fs.azure.rbac.only must be false: an FS created without + * setting the key must behave exactly as today (setPermission applies). + * + * Note: this deliberately uses the shared test FileSystem, so it must NOT + * be closed here. + */ + @Test + public void testRbacOnlyDefaultIsFalse() throws Exception { + final AzureBlobFileSystem fs = this.getFileSystem(); + assumeTrue(getIsNamespaceEnabled(fs)); + + path = new Path(testRoot, UUID.randomUUID().toString()); + FileSystem.mkdirs(fs, path, FsPermission.createImmutable((short) RWX_RX)); + + fs.setPermission(path, FsPermission.createImmutable((short) RWX)); + assertPermission(fs, (short) RWX); + } + + /** + * The null-permission contract must be preserved even when RBAC-only mode + * is enabled. The IllegalArgumentException must trigger before the no-op + * short-circuit. + */ + @Test + public void testSetPermissionNullPermissionStillThrowsInRbacOnly() + throws Exception { + try (AzureBlobFileSystem fs = getRbacOnlyFileSystem(true)) { + assumeTrue(getIsNamespaceEnabled(fs)); + + path = new Path(testRoot, UUID.randomUUID().toString()); + FileSystem.mkdirs(fs, path, FsPermission.createImmutable((short) RWX_RX)); + + Assertions.assertThrows(IllegalArgumentException.class, + () -> fs.setPermission(path, null)); + } + } + + /** + * Strongest form of the no-op assertion: setPermission on a non-existent + * path in RBAC-only mode returns successfully without contacting the + * store, so there is no backend path that could have absorbed the call. + */ + @Test + public void testSetPermissionNonExistentPathIsNoOpInRbacOnly() + throws Exception { + try (AzureBlobFileSystem rawFs = getRbacOnlyFileSystem(true)) { + assumeTrue(getIsNamespaceEnabled(rawFs)); + final AzureBlobFileSystem fs = spyWithSpiedStore(rawFs); + final AzureBlobFileSystemStore store = fs.getAbfsStore(); + + Path missing = new Path(testRoot, UUID.randomUUID().toString()); + // Path is intentionally NOT created; pure no-op must not throw. + fs.setPermission(missing, FsPermission.createImmutable((short) RWX)); + + Mockito.verify(store, Mockito.never()).setPermission( + any(Path.class), any(FsPermission.class), + nullable(TracingContext.class)); + } + } + + + /** + * Read-side ACL APIs and setOwner must NOT be gated by fs.azure.rbac.only. + */ + @Test + public void testReadApisAndSetOwnerUnaffectedByRbacOnly() throws Exception { + try (AzureBlobFileSystem fs = getRbacOnlyFileSystem(true)) { + assumeTrue(getIsNamespaceEnabled(fs)); + + path = new Path(testRoot, UUID.randomUUID().toString()); + fs.create(path).close(); + + // getAclStatus must work. + fs.getAclStatus(path); + + // setOwner must apply and NOT be gated. + fs.setOwner(path, TEST_OWNER, TEST_GROUP); + FileStatus st = fs.getFileStatus(path); + assertEquals(TEST_OWNER, st.getOwner()); + assertEquals(TEST_GROUP, st.getGroup()); + } + } + + /** + * Creates a new AzureBlobFileSystem instance with fs.azure.rbac.only set + * to the provided value. A fresh instance is required so the config is + * picked up during initialization. + * + * The caller owns the returned instance and must close it - use + * try-with-resources. + */ + private AzureBlobFileSystem getRbacOnlyFileSystem(boolean rbacOnly) + throws Exception { + Configuration conf = new Configuration(getRawConfiguration()); + conf.setBoolean(FS_AZURE_RBAC_ONLY_MODE, rbacOnly); + return (AzureBlobFileSystem) FileSystem.newInstance( + getFileSystem().getUri(), conf); + } + + /** + * Wraps the given filesystem in a Mockito spy whose getAbfsStore() returns + * a spied store, so tests can verify whether a call reached the store. + * + * The returned spy must NOT be closed; close the underlying instance + * passed in via try-with-resources instead. + */ + private AzureBlobFileSystem spyWithSpiedStore(AzureBlobFileSystem fs) { + AzureBlobFileSystemStore store = Mockito.spy(fs.getAbfsStore()); + AzureBlobFileSystem spiedFs = Mockito.spy(fs); + Mockito.doReturn(store).when(spiedFs).getAbfsStore(); + return spiedFs; + } }