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;
+ }
}