This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new 7a675c9565 [filesystems][oss] support customer CMK (BYOK) SSE-KMS via 
fs.oss.server-side-encryption-key-id (#8331)
7a675c9565 is described below

commit 7a675c9565897e5a12bc879aa8ca8100e7f8c42e
Author: Jiajia Li <[email protected]>
AuthorDate: Tue Jun 23 21:48:34 2026 +0800

    [filesystems][oss] support customer CMK (BYOK) SSE-KMS via 
fs.oss.server-side-encryption-key-id (#8331)
    
    hadoop-aliyun exposes only the SSE algorithm
    (fs.oss.server-side-encryption-algorithm),
    never a custom CMK key-id, even though the OSS SDK supports it.
    Following the pattern of
    PR #6413 (reflect into the underlying OSSClient), when
    fs.oss.server-side-encryption-key-id
    is set we swap the client's object/multipart operations for subclasses
    that stamp the
      OSS-native SSE-KMS headers (x-oss-server-side-encryption=KMS +
      x-oss-server-side-encryption-key-id=<CMK>) onto the write paths.
    
    PutObject, InitiateMultipartUpload, and CopyObject (server-side copy
    used by rename/commit)
    are overridden so every write Paimon performs carries the CMK; multipart
    parts inherit it
    from the initiation. UploadPart/HeadObject/GetObject are left untouched
    (stamping SSE headers
      on those makes OSS reject the request with 400).
    
    For PutObject/InitiateMultipartUpload the CMK is set on the request's
    ObjectMetadata; for
    CopyObject it is set on the CopyObjectRequest's own SSE fields (not
    newObjectMetadata), so the
    copy is encrypted without forcing a metadata-directive REPLACE that
    would drop the source
      object's Content-Type / user metadata.
    
    The key-id is validated at filesystem init (rejecting blank or
    whitespace-containing values so
    config errors fail fast), and the operation swap is fail-closed: if a
    future SDK change makes
    the swap a no-op, init throws rather than silently writing unencrypted
    objects. Adds unit tests
    pinning the reflection contract (field names + getters) against the
    bundled aliyun-oss-sdk and
      covering the new ReflectionUtils.setPrivateFieldValue.
---
 docs/docs/maintenance/filesystems.mdx              |  10 ++
 .../org/apache/paimon/utils/ReflectionUtils.java   |  16 +++
 .../apache/paimon/utils/ReflectionUtilsTest.java   |  60 ++++++++
 .../main/java/org/apache/paimon/oss/OSSFileIO.java | 151 ++++++++++++++++++++-
 .../java/org/apache/paimon/oss/OSSFileIOTest.java  |  46 +++++++
 5 files changed, 279 insertions(+), 4 deletions(-)

diff --git a/docs/docs/maintenance/filesystems.mdx 
b/docs/docs/maintenance/filesystems.mdx
index 98a90ec45e..8f1cc844a5 100644
--- a/docs/docs/maintenance/filesystems.mdx
+++ b/docs/docs/maintenance/filesystems.mdx
@@ -332,6 +332,16 @@ Download 
[paimon-jindo-@@VERSION@@.jar](https://repository.apache.org/snapshots/
 
 </Unstable>
 
+### Server-Side Encryption with Customer CMK (SSE-KMS)
+
+To encrypt OSS writes with a customer-managed CMK (BYOK) via SSE-KMS, 
configure the CMK key id:
+
+```yaml
+fs.oss.server-side-encryption-key-id: your-cmk-key-id
+```
+
+When set, Paimon stamps the given CMK on the OSS write paths it performs: 
`PutObject`, server-side `CopyObject` (used by rename/commit), and 
multipart-upload initiation (parts inherit the CMK).
+
 ## S3
 
 <Stable>
diff --git 
a/paimon-common/src/main/java/org/apache/paimon/utils/ReflectionUtils.java 
b/paimon-common/src/main/java/org/apache/paimon/utils/ReflectionUtils.java
index 3d02271959..8d2da7da71 100644
--- a/paimon-common/src/main/java/org/apache/paimon/utils/ReflectionUtils.java
+++ b/paimon-common/src/main/java/org/apache/paimon/utils/ReflectionUtils.java
@@ -81,4 +81,20 @@ public class ReflectionUtils {
         }
         throw new NoSuchFieldException(fieldName);
     }
+
+    public static void setPrivateFieldValue(Object obj, String fieldName, 
Object value)
+            throws NoSuchFieldException, IllegalAccessException {
+        Class<?> clazz = obj.getClass();
+        while (clazz != null) {
+            try {
+                Field field = clazz.getDeclaredField(fieldName);
+                field.setAccessible(true);
+                field.set(obj, value);
+                return;
+            } catch (NoSuchFieldException e) {
+                clazz = clazz.getSuperclass();
+            }
+        }
+        throw new NoSuchFieldException(fieldName);
+    }
 }
diff --git 
a/paimon-common/src/test/java/org/apache/paimon/utils/ReflectionUtilsTest.java 
b/paimon-common/src/test/java/org/apache/paimon/utils/ReflectionUtilsTest.java
new file mode 100644
index 0000000000..32030502af
--- /dev/null
+++ 
b/paimon-common/src/test/java/org/apache/paimon/utils/ReflectionUtilsTest.java
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.utils;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link ReflectionUtils}. */
+public class ReflectionUtilsTest {
+
+    @Test
+    public void testSetPrivateFieldValueOnDeclaringClass() throws Exception {
+        Child child = new Child();
+        ReflectionUtils.setPrivateFieldValue(child, "ownField", "new-value");
+        assertThat((String) ReflectionUtils.getPrivateFieldValue(child, 
"ownField"))
+                .isEqualTo("new-value");
+    }
+
+    @Test
+    public void testSetPrivateFieldValueWalksSuperclass() throws Exception {
+        Child child = new Child();
+        ReflectionUtils.setPrivateFieldValue(child, "parentField", 
"from-parent");
+        assertThat((String) ReflectionUtils.getPrivateFieldValue(child, 
"parentField"))
+                .isEqualTo("from-parent");
+    }
+
+    @Test
+    public void testSetPrivateFieldValueUnknownFieldThrows() {
+        assertThatThrownBy(() -> ReflectionUtils.setPrivateFieldValue(new 
Child(), "missing", "x"))
+                .isInstanceOf(NoSuchFieldException.class);
+    }
+
+    private static class Parent {
+        @SuppressWarnings("unused")
+        private String parentField = "parent";
+    }
+
+    private static class Child extends Parent {
+        @SuppressWarnings("unused")
+        private String ownField = "child";
+    }
+}
diff --git 
a/paimon-filesystems/paimon-oss-impl/src/main/java/org/apache/paimon/oss/OSSFileIO.java
 
b/paimon-filesystems/paimon-oss-impl/src/main/java/org/apache/paimon/oss/OSSFileIO.java
index b9c18bf994..e126277288 100644
--- 
a/paimon-filesystems/paimon-oss-impl/src/main/java/org/apache/paimon/oss/OSSFileIO.java
+++ 
b/paimon-filesystems/paimon-oss-impl/src/main/java/org/apache/paimon/oss/OSSFileIO.java
@@ -30,8 +30,17 @@ import org.apache.paimon.utils.StringUtils;
 
 import com.aliyun.oss.OSSClient;
 import com.aliyun.oss.OSSException;
+import com.aliyun.oss.common.auth.CredentialsProvider;
 import com.aliyun.oss.common.comm.ServiceClient;
+import com.aliyun.oss.internal.OSSMultipartOperation;
+import com.aliyun.oss.internal.OSSObjectOperation;
+import com.aliyun.oss.model.CopyObjectRequest;
+import com.aliyun.oss.model.CopyObjectResult;
+import com.aliyun.oss.model.InitiateMultipartUploadRequest;
+import com.aliyun.oss.model.InitiateMultipartUploadResult;
 import com.aliyun.oss.model.ObjectMetadata;
+import com.aliyun.oss.model.PutObjectRequest;
+import com.aliyun.oss.model.PutObjectResult;
 import org.apache.hadoop.conf.Configuration;
 import org.apache.hadoop.fs.FileSystem;
 import org.apache.hadoop.fs.aliyun.oss.AliyunOSSFileSystem;
@@ -69,6 +78,10 @@ public class OSSFileIO extends HadoopCompliantFileIO 
implements HadoopOptionsPro
     private static final String OSS_ACCESS_KEY_SECRET = 
"fs.oss.accessKeySecret";
     private static final String OSS_SECURITY_TOKEN = "fs.oss.securityToken";
     private static final String OSS_SECOND_LEVEL_DOMAIN_ENABLED = 
"fs.oss.sld.enabled";
+    private static final String OSS_SSE_KMS_KEY_ID = 
"fs.oss.server-side-encryption-key-id";
+
+    /** OSS-native value for the {@code x-oss-server-side-encryption} header 
when using a CMK. */
+    private static final String SSE_KMS_ALGORITHM = "KMS";
 
     private static final Map<String, String> CASE_SENSITIVE_KEYS =
             new HashMap<String, String>() {
@@ -174,6 +187,22 @@ public class OSSFileIO extends HadoopCompliantFileIO 
implements HadoopOptionsPro
                         enableSecondLevelDomain(fs);
                     }
 
+                    String sseKmsKeyId = hadoopOptions.get(OSS_SSE_KMS_KEY_ID);
+                    if (sseKmsKeyId != null) {
+                        String trimmed = sseKmsKeyId.trim();
+                        // Reject what can never be a key id, so config errors 
fail fast at
+                        // init instead of mid-job.
+                        if (trimmed.isEmpty()
+                                || 
trimmed.chars().anyMatch(Character::isWhitespace)) {
+                            throw new IllegalArgumentException(
+                                    "Invalid value for '"
+                                            + OSS_SSE_KMS_KEY_ID
+                                            + "': the CMK key id must not be 
blank or contain"
+                                            + " whitespace/newlines.");
+                        }
+                        enableSseKms(fs, trimmed);
+                    }
+
                     return fs;
                 };
 
@@ -203,9 +232,8 @@ public class OSSFileIO extends HadoopCompliantFileIO 
implements HadoopOptionsPro
         }
 
         AliyunOSSFileSystem fs = (AliyunOSSFileSystem) 
getFileSystem(path(path));
-        AliyunOSSFileSystemStore store = fs.getStore();
         try {
-            OSSClient ossClient = ReflectionUtils.getPrivateFieldValue(store, 
"ossClient");
+            OSSClient ossClient = getOssClient(fs);
             ossClient.putObject(bucket, objectKey, new 
ByteArrayInputStream(bytes), metadata);
             return true;
         } catch (OSSException e) {
@@ -228,9 +256,8 @@ public class OSSFileIO extends HadoopCompliantFileIO 
implements HadoopOptionsPro
     }
 
     public void enableSecondLevelDomain(AliyunOSSFileSystem fs) {
-        AliyunOSSFileSystemStore store = fs.getStore();
         try {
-            OSSClient ossClient = ReflectionUtils.getPrivateFieldValue(store, 
"ossClient");
+            OSSClient ossClient = getOssClient(fs);
             ServiceClient serviceClient =
                     ReflectionUtils.getPrivateFieldValue(ossClient, 
"serviceClient");
             serviceClient.getClientConfiguration().setSLDEnabled(true);
@@ -240,6 +267,122 @@ public class OSSFileIO extends HadoopCompliantFileIO 
implements HadoopOptionsPro
         }
     }
 
+    /** Reflectively extract the underlying {@link OSSClient} that 
hadoop-aliyun keeps private. */
+    private static OSSClient getOssClient(AliyunOSSFileSystem fs) throws 
Exception {
+        AliyunOSSFileSystemStore store = fs.getStore();
+        return ReflectionUtils.getPrivateFieldValue(store, "ossClient");
+    }
+
+    /**
+     * Enable SSE-KMS with a customer CMK. hadoop-aliyun exposes no CMK key-id 
option, so swap the
+     * OSS client's object/multipart operations for subclasses that stamp the 
OSS-native SSE-KMS
+     * headers on the write paths: PutObject, server-side CopyObject 
(rename/commit) and
+     * InitiateMultipartUpload. UploadPart/HeadObject/GetObject are left 
untouched (stamping SSE
+     * headers on those makes OSS reject the request with 400).
+     */
+    private void enableSseKms(AliyunOSSFileSystem fs, String kmsKeyId) {
+        try {
+            swapSseKmsOperations(getOssClient(fs), kmsKeyId);
+        } catch (Exception e) {
+            LOG.error("Failed to enable SSE-KMS BYOK.", e);
+            throw new RuntimeException("Failed to enable SSE-KMS BYOK.", e);
+        }
+    }
+
+    /**
+     * Swap the OSS client's object/multipart operations for the CMK-stamping 
subclasses. Package
+     * private so a unit test can pin the reflected field names and getters 
against the bundled
+     * aliyun-oss-sdk version.
+     */
+    static void swapSseKmsOperations(OSSClient ossClient, String kmsKeyId) 
throws Exception {
+        ServiceClient serviceClient =
+                ReflectionUtils.getPrivateFieldValue(ossClient, 
"serviceClient");
+        CredentialsProvider credsProvider =
+                ReflectionUtils.getPrivateFieldValue(ossClient, 
"credsProvider");
+        // Replacement operations must inherit the endpoint or requests NPE.
+        URI endpoint = ossClient.getEndpoint();
+        SseKmsObjectOperation objectOperation =
+                new SseKmsObjectOperation(serviceClient, credsProvider, 
kmsKeyId);
+        objectOperation.setEndpoint(endpoint);
+        SseKmsMultipartOperation multipartOperation =
+                new SseKmsMultipartOperation(serviceClient, credsProvider, 
kmsKeyId);
+        multipartOperation.setEndpoint(endpoint);
+        ReflectionUtils.setPrivateFieldValue(ossClient, "objectOperation", 
objectOperation);
+        ReflectionUtils.setPrivateFieldValue(ossClient, "multipartOperation", 
multipartOperation);
+        // Fail closed: if a future SDK change makes the swap a no-op, fail 
loudly
+        // rather than silently writing objects without the customer CMK.
+        if (ossClient.getObjectOperation() != objectOperation
+                || ossClient.getMultipartOperation() != multipartOperation) {
+            throw new IllegalStateException(
+                    "SSE-KMS BYOK operation swap did not take effect; refusing 
to write "
+                            + "without server-side encryption. The 
aliyun-oss-sdk internals "
+                            + "may have changed.");
+        }
+    }
+
+    /** Stamps the customer CMK on the given metadata, allocating one if 
absent. */
+    private static ObjectMetadata applySseKms(ObjectMetadata metadata, String 
kmsKeyId) {
+        if (metadata == null) {
+            metadata = new ObjectMetadata();
+        }
+        String existing = metadata.getServerSideEncryption();
+        if (existing != null && !SSE_KMS_ALGORITHM.equals(existing)) {
+            LOG.warn(
+                    "Customer CMK SSE-KMS is overriding the previously-set 
server-side "
+                            + "encryption algorithm '{}' with 'KMS'.",
+                    existing);
+        }
+        metadata.setServerSideEncryption(SSE_KMS_ALGORITHM);
+        metadata.setServerSideEncryptionKeyId(kmsKeyId);
+        return metadata;
+    }
+
+    /** Stamps the customer CMK on every simple PutObject. */
+    static class SseKmsObjectOperation extends OSSObjectOperation {
+        private final String kmsKeyId;
+
+        SseKmsObjectOperation(
+                ServiceClient serviceClient, CredentialsProvider 
credsProvider, String kmsKeyId) {
+            super(serviceClient, credsProvider);
+            this.kmsKeyId = kmsKeyId;
+        }
+
+        @Override
+        public PutObjectResult putObject(PutObjectRequest request) {
+            request.setMetadata(applySseKms(request.getMetadata(), kmsKeyId));
+            return super.putObject(request);
+        }
+
+        @Override
+        public CopyObjectResult copyObject(CopyObjectRequest request) {
+            // Rename/commit uses server-side copy. populateCopyObjectHeaders 
reads the SSE
+            // headers straight from these request fields, so setting them 
here stamps the CMK
+            // without touching newObjectMetadata. Setting newObjectMetadata 
would force
+            // metadata-directive REPLACE and drop the source object's 
Content-Type/user metadata.
+            request.setServerSideEncryption(SSE_KMS_ALGORITHM);
+            request.setServerSideEncryptionKeyId(kmsKeyId);
+            return super.copyObject(request);
+        }
+    }
+
+    /** Stamps the customer CMK on the multipart-upload init; parts inherit 
it. */
+    static class SseKmsMultipartOperation extends OSSMultipartOperation {
+        private final String kmsKeyId;
+
+        SseKmsMultipartOperation(
+                ServiceClient serviceClient, CredentialsProvider 
credsProvider, String kmsKeyId) {
+            super(serviceClient, credsProvider);
+            this.kmsKeyId = kmsKeyId;
+        }
+
+        @Override
+        public InitiateMultipartUploadResult initiateMultipartUpload(
+                InitiateMultipartUploadRequest request) {
+            request.setObjectMetadata(applySseKms(request.getObjectMetadata(), 
kmsKeyId));
+            return super.initiateMultipartUpload(request);
+        }
+    }
+
     private static class CacheKey {
 
         private final Options options;
diff --git 
a/paimon-filesystems/paimon-oss-impl/src/test/java/org/apache/paimon/oss/OSSFileIOTest.java
 
b/paimon-filesystems/paimon-oss-impl/src/test/java/org/apache/paimon/oss/OSSFileIOTest.java
new file mode 100644
index 0000000000..e5100ce3ef
--- /dev/null
+++ 
b/paimon-filesystems/paimon-oss-impl/src/test/java/org/apache/paimon/oss/OSSFileIOTest.java
@@ -0,0 +1,46 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.oss;
+
+import com.aliyun.oss.OSSClient;
+import com.aliyun.oss.OSSClientBuilder;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link OSSFileIO}. */
+public class OSSFileIOTest {
+
+    /** The swap must replace both operations with the CMK-stamping 
subclasses. */
+    @Test
+    public void testSseKmsOperationSwapTakesEffect() throws Exception {
+        OSSClient ossClient =
+                (OSSClient) new 
OSSClientBuilder().build("http://oss.example.com";, "ak", "sk");
+        try {
+            OSSFileIO.swapSseKmsOperations(ossClient, "my-cmk-key-id");
+
+            assertThat(ossClient.getObjectOperation())
+                    .isInstanceOf(OSSFileIO.SseKmsObjectOperation.class);
+            assertThat(ossClient.getMultipartOperation())
+                    .isInstanceOf(OSSFileIO.SseKmsMultipartOperation.class);
+        } finally {
+            ossClient.shutdown();
+        }
+    }
+}

Reply via email to