Copilot commented on code in PR #14160:
URL: https://github.com/apache/cloudstack/pull/14160#discussion_r4011008330


##########
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:
##########
@@ -0,0 +1,649 @@
+/*
+ * 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.
+ */
+// SPDX-License-Identifier: Apache-2.0
+package org.apache.cloudstack.storage.datastore.driver;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import javax.inject.Inject;
+
+import org.apache.cloudstack.engine.subsystem.api.storage.DataStore;
+import org.apache.cloudstack.storage.datastore.db.ObjectStoreDao;
+import org.apache.cloudstack.storage.datastore.db.ObjectStoreDetailsDao;
+import org.apache.cloudstack.storage.datastore.db.ObjectStoreVO;
+import org.apache.cloudstack.storage.datastore.util.SeaweedFSObjectStoreUtil;
+import org.apache.cloudstack.storage.object.BaseObjectStoreDriverImpl;
+import org.apache.cloudstack.storage.object.Bucket;
+import org.apache.cloudstack.storage.object.BucketObject;
+
+import com.amazonaws.AmazonClientException;
+import com.amazonaws.services.identitymanagement.AmazonIdentityManagement;
+import com.amazonaws.services.identitymanagement.model.AccessKey;
+import com.amazonaws.services.identitymanagement.model.AccessKeyMetadata;
+import com.amazonaws.services.identitymanagement.model.CreateAccessKeyRequest;
+import com.amazonaws.services.identitymanagement.model.CreateAccessKeyResult;
+import com.amazonaws.services.identitymanagement.model.CreateUserRequest;
+import com.amazonaws.services.identitymanagement.model.DeleteAccessKeyRequest;
+import 
com.amazonaws.services.identitymanagement.model.EntityAlreadyExistsException;
+import com.amazonaws.services.identitymanagement.model.ListAccessKeysRequest;
+import com.amazonaws.services.identitymanagement.model.PutUserPolicyRequest;
+import com.amazonaws.services.s3.AmazonS3;
+import com.amazonaws.services.s3.model.AccessControlList;
+import com.amazonaws.services.s3.model.BucketPolicy;
+import com.amazonaws.services.s3.model.BucketVersioningConfiguration;
+import com.amazonaws.services.s3.model.CreateBucketRequest;
+import com.amazonaws.services.s3.model.DeleteBucketPolicyRequest;
+import com.amazonaws.services.s3.model.GetBucketPolicyRequest;
+import com.amazonaws.services.s3.model.SSEAlgorithm;
+import com.amazonaws.services.s3.model.ServerSideEncryptionByDefault;
+import com.amazonaws.services.s3.model.ServerSideEncryptionConfiguration;
+import com.amazonaws.services.s3.model.ServerSideEncryptionRule;
+import com.amazonaws.services.s3.model.SetBucketEncryptionRequest;
+import com.amazonaws.services.s3.model.SetBucketVersioningConfigurationRequest;
+import com.cloud.agent.api.to.BucketTO;
+import com.cloud.agent.api.to.DataStoreTO;
+import com.cloud.storage.BucketVO;
+import com.cloud.storage.dao.BucketDao;
+import com.cloud.user.Account;
+import com.cloud.user.AccountDetailsDao;
+import com.cloud.user.dao.AccountDao;
+import com.cloud.utils.exception.CloudRuntimeException;
+
+/**
+ * SeaweedFS object store driver.
+ *
+ * Bucket operations use the AWS S3 SDK v1 (path-style access, 
endpoint-pinned).
+ * User/credential management uses the AWS IAM SDK v1, since SeaweedFS exposes 
a
+ * standard AWS IAM-compatible API. No proprietary admin client is needed.
+ *
+ * Modeled on CloudianHyperStoreObjectStoreDriverImpl, which uses the same
+ * S3 + IAM SDK pair.
+ */
+public class SeaweedFSObjectStoreDriverImpl extends BaseObjectStoreDriverImpl {
+
+    @Inject
+    AccountDao _accountDao;
+
+    @Inject
+    AccountDetailsDao _accountDetailsDao;
+
+    @Inject
+    ObjectStoreDao _storeDao;
+
+    @Inject
+    BucketDao _bucketDao;
+
+    @Inject
+    ObjectStoreDetailsDao _storeDetailsDao;
+
+    private static final String ACS_PREFIX = "acs";
+
+    @Override
+    public DataStoreTO getStoreTO(DataStore store) {
+        return null;
+    }
+
+    /**
+     * Get the SeaweedFS IAM user name for the given CloudStack account.
+     * Uses the account UUID prefixed with "acs-" for namespacing.
+     */
+    protected String getUserNameForAccount(Account account) {
+        return String.format("%s-%s", ACS_PREFIX, account.getUuid());

Review Comment:
   Although account-detail keys include `storeId`, the IAM username does not. 
If two CloudStack pools point to the same SeaweedFS IAM service, both map an 
account to `acs-<uuid>`; the second pool overwrites the shared policy and 
deletes or replaces the first pool's access key, breaking isolation and access 
for the first pool. Namespace the IAM user by store, or reject shared IAM 
endpoints.



##########
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/util/SeaweedFSObjectStoreUtil.java:
##########
@@ -0,0 +1,447 @@
+// 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.
+// SPDX-License-Identifier: Apache-2.0
+package org.apache.cloudstack.storage.datastore.util;
+
+import org.apache.commons.lang3.StringUtils;
+
+import com.amazonaws.AmazonServiceException;
+import com.amazonaws.auth.AWSStaticCredentialsProvider;
+import com.amazonaws.auth.BasicAWSCredentials;
+import com.amazonaws.client.builder.AwsClientBuilder;
+import com.amazonaws.services.identitymanagement.AmazonIdentityManagement;
+import 
com.amazonaws.services.identitymanagement.AmazonIdentityManagementClientBuilder;
+import com.amazonaws.services.s3.AmazonS3;
+import com.amazonaws.services.s3.AmazonS3ClientBuilder;
+import com.cloud.utils.exception.CloudRuntimeException;
+
+/**
+ * Utility class for the SeaweedFS object storage provider.
+ *
+ * SeaweedFS exposes both an S3-compatible API and an AWS IAM-compatible API,
+ * so this provider needs no proprietary admin client — only the AWS S3 and IAM
+ * SDKs, the same pair Cloudian HyperStore already uses in this tree.
+ */
+public class SeaweedFSObjectStoreUtil {
+
+    /** The name of our Object Store Provider */
+    public static final String OBJECT_STORE_PROVIDER_NAME = "SeaweedFS";
+
+    public static final String STORE_KEY_PROVIDER_NAME = "providerName";
+    public static final String STORE_KEY_URL           = "url";
+    public static final String STORE_KEY_NAME          = "name";
+    public static final String STORE_KEY_SIZE          = "size";
+    public static final String STORE_KEY_DETAILS       = "details";
+
+    // Store Details Map key names - managed outside of plugin
+    public static final String STORE_DETAILS_KEY_ACCESS_KEY = "accesskey";   
// admin/root access key
+    public static final String STORE_DETAILS_KEY_SECRET_KEY = "secretkey";   
// admin/root secret key
+    public static final String STORE_DETAILS_KEY_S3_URL     = "s3Url";        
// S3 endpoint URL
+    public static final String STORE_DETAILS_KEY_IAM_URL     = "iamUrl";       
// IAM endpoint URL
+
+    // Account Detail Map key names - credentials created per CloudStack 
account.
+    // Namespaced by store ID so one account can use multiple SeaweedFS pools
+    // without the second pool overwriting the first pool's credentials.
+    public static final String KEY_ACCESS_KEY_PREFIX = "swfs_AccessKey_";
+    public static final String KEY_SECRET_KEY_PREFIX = "swfs_SecretKey_";
+
+    /**
+     * Build the account-detail key for the IAM access key of a given store.
+     */
+    public static String keyAccessKey(long storeId) {
+        return KEY_ACCESS_KEY_PREFIX + storeId;
+    }
+
+    /**
+     * Build the account-detail key for the IAM secret key of a given store.
+     */
+    public static String keySecretKey(long storeId) {
+        return KEY_SECRET_KEY_PREFIX + storeId;
+    }
+
+    /**
+     * Connect timeout for the S3 extension HTTP client, in seconds.
+     */
+    public static final int S3_EXTENSION_CONNECT_TIMEOUT_SECONDS = 10;
+    /**
+     * Per-request timeout for the S3 extension HTTP request, in seconds.
+     */
+    public static final int S3_EXTENSION_REQUEST_TIMEOUT_SECONDS = 30;
+
+    /**
+     * IAM user policy name applied to each per-account IAM user.
+     */
+    public static final String IAM_USER_POLICY_NAME = "CloudStackPolicy";
+
+    /**
+     * Build an IAM user policy that grants full S3 access only to the given
+     * buckets (both the bucket and its contents), while denying bucket
+     * creation and deletion everywhere so CloudStack retains control of the
+     * bucket lifecycle. When no buckets are provided, all S3 access is denied.
+     *
+     * <p>This is the tenant boundary: each account's IAM credentials can only
+     * operate on that account's own buckets, not on every bucket in the
+     * SeaweedFS pool. The policy is refreshed whenever buckets are created or
+     * deleted (see
+     * {@code SeaweedFSObjectStoreDriverImpl.updateAccountIAMPolicy}).
+     *
+     * @param bucketNames the bucket names the account is allowed to access
+     * @return a JSON IAM policy document
+     */
+    public static String buildAccountIAMPolicy(java.util.List<String> 
bucketNames) {
+        StringBuilder sb = new StringBuilder();
+        sb.append("{\n");
+        sb.append("  \"Version\": \"2012-10-17\",\n");
+        sb.append("  \"Statement\": [\n");
+        if (bucketNames == null || bucketNames.isEmpty()) {
+            // No buckets: deny all S3 access. A Resource cannot be empty in
+            // an IAM policy, so deny everything explicitly.
+            sb.append("    {\n");
+            sb.append("      \"Sid\": \"DenyAllS3\",\n");
+            sb.append("      \"Effect\": \"Deny\",\n");
+            sb.append("      \"Action\": [\"s3:*\"],\n");
+            sb.append("      \"Resource\": [\"arn:aws:s3:::*\", 
\"arn:aws:s3:::*/*\"]\n");
+            sb.append("    }\n");
+        } else {
+            sb.append("    {\n");
+            sb.append("      \"Sid\": \"AllowAccountBuckets\",\n");
+            sb.append("      \"Effect\": \"Allow\",\n");
+            sb.append("      \"Action\": [\"s3:*\"],\n");
+            sb.append("      \"Resource\": [\n");
+            for (int i = 0; i < bucketNames.size(); i++) {
+                String name = bucketNames.get(i);
+                sb.append("        
\"arn:aws:s3:::").append(name).append("\",\n");
+                sb.append("        
\"arn:aws:s3:::").append(name).append("/*\"");
+                if (i < bucketNames.size() - 1) {
+                    sb.append(",");
+                }
+                sb.append("\n");
+            }
+            sb.append("      ]\n");
+            sb.append("    }\n");
+        }
+        // Always deny bucket creation/deletion — CloudStack controls lifecycle
+        sb.append("    ,{\n");
+        sb.append("      \"Sid\": \"DenyBucketLifecycle\",\n");
+        sb.append("      \"Effect\": \"Deny\",\n");
+        sb.append("      \"Action\": [\"s3:CreateBucket\", 
\"s3:DeleteBucket\"],\n");

Review Comment:
   The `s3:*` allow also grants the client-visible per-account credentials 
`s3:PutBucketQuota`. Bucket access and secret keys are returned in 
`BucketResponse`, while this provider uses the store admin credentials for 
`setBucketQuota`, so a tenant can call the SeaweedFS extension directly to 
disable or inflate the quota and bypass CloudStack's resource accounting. Add 
an explicit deny for `s3:PutBucketQuota` to the tenant policy.



##########
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:
##########
@@ -0,0 +1,649 @@
+/*
+ * 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.
+ */
+// SPDX-License-Identifier: Apache-2.0
+package org.apache.cloudstack.storage.datastore.driver;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import javax.inject.Inject;
+
+import org.apache.cloudstack.engine.subsystem.api.storage.DataStore;
+import org.apache.cloudstack.storage.datastore.db.ObjectStoreDao;
+import org.apache.cloudstack.storage.datastore.db.ObjectStoreDetailsDao;
+import org.apache.cloudstack.storage.datastore.db.ObjectStoreVO;
+import org.apache.cloudstack.storage.datastore.util.SeaweedFSObjectStoreUtil;
+import org.apache.cloudstack.storage.object.BaseObjectStoreDriverImpl;
+import org.apache.cloudstack.storage.object.Bucket;
+import org.apache.cloudstack.storage.object.BucketObject;
+
+import com.amazonaws.AmazonClientException;
+import com.amazonaws.services.identitymanagement.AmazonIdentityManagement;
+import com.amazonaws.services.identitymanagement.model.AccessKey;
+import com.amazonaws.services.identitymanagement.model.AccessKeyMetadata;
+import com.amazonaws.services.identitymanagement.model.CreateAccessKeyRequest;
+import com.amazonaws.services.identitymanagement.model.CreateAccessKeyResult;
+import com.amazonaws.services.identitymanagement.model.CreateUserRequest;
+import com.amazonaws.services.identitymanagement.model.DeleteAccessKeyRequest;
+import 
com.amazonaws.services.identitymanagement.model.EntityAlreadyExistsException;
+import com.amazonaws.services.identitymanagement.model.ListAccessKeysRequest;
+import com.amazonaws.services.identitymanagement.model.PutUserPolicyRequest;
+import com.amazonaws.services.s3.AmazonS3;
+import com.amazonaws.services.s3.model.AccessControlList;
+import com.amazonaws.services.s3.model.BucketPolicy;
+import com.amazonaws.services.s3.model.BucketVersioningConfiguration;
+import com.amazonaws.services.s3.model.CreateBucketRequest;
+import com.amazonaws.services.s3.model.DeleteBucketPolicyRequest;
+import com.amazonaws.services.s3.model.GetBucketPolicyRequest;
+import com.amazonaws.services.s3.model.SSEAlgorithm;
+import com.amazonaws.services.s3.model.ServerSideEncryptionByDefault;
+import com.amazonaws.services.s3.model.ServerSideEncryptionConfiguration;
+import com.amazonaws.services.s3.model.ServerSideEncryptionRule;
+import com.amazonaws.services.s3.model.SetBucketEncryptionRequest;
+import com.amazonaws.services.s3.model.SetBucketVersioningConfigurationRequest;
+import com.cloud.agent.api.to.BucketTO;
+import com.cloud.agent.api.to.DataStoreTO;
+import com.cloud.storage.BucketVO;
+import com.cloud.storage.dao.BucketDao;
+import com.cloud.user.Account;
+import com.cloud.user.AccountDetailsDao;
+import com.cloud.user.dao.AccountDao;
+import com.cloud.utils.exception.CloudRuntimeException;
+
+/**
+ * SeaweedFS object store driver.
+ *
+ * Bucket operations use the AWS S3 SDK v1 (path-style access, 
endpoint-pinned).
+ * User/credential management uses the AWS IAM SDK v1, since SeaweedFS exposes 
a
+ * standard AWS IAM-compatible API. No proprietary admin client is needed.
+ *
+ * Modeled on CloudianHyperStoreObjectStoreDriverImpl, which uses the same
+ * S3 + IAM SDK pair.
+ */
+public class SeaweedFSObjectStoreDriverImpl extends BaseObjectStoreDriverImpl {
+
+    @Inject
+    AccountDao _accountDao;
+
+    @Inject
+    AccountDetailsDao _accountDetailsDao;
+
+    @Inject
+    ObjectStoreDao _storeDao;
+
+    @Inject
+    BucketDao _bucketDao;
+
+    @Inject
+    ObjectStoreDetailsDao _storeDetailsDao;
+
+    private static final String ACS_PREFIX = "acs";
+
+    @Override
+    public DataStoreTO getStoreTO(DataStore store) {
+        return null;
+    }
+
+    /**
+     * Get the SeaweedFS IAM user name for the given CloudStack account.
+     * Uses the account UUID prefixed with "acs-" for namespacing.
+     */
+    protected String getUserNameForAccount(Account account) {
+        return String.format("%s-%s", ACS_PREFIX, account.getUuid());
+    }
+
+    /**
+     * Create the IAM user for the CloudStack account if it doesn't exist,
+     * attach the restricted S3 policy, and ensure the account has a usable
+     * IAM access key persisted in its account details.
+     *
+     * <p>If a previously stored access key is still present in IAM, it is
+     * reused rather than rotated. A new key is only created when no stored
+     * key exists or the stored key is no longer found in IAM; in the latter
+     * case any unmanaged (leftover) keys for the user are deleted first to
+     * avoid hitting IAM access-key limits. This keeps bucket records that
+     * reference the stored credentials valid across repeated calls.
+     *
+     * @return true if the user exists or was created, false on failure.
+     */
+    @Override
+    public boolean createUser(long accountId, long storeId) {
+        Account account = _accountDao.findById(accountId);
+        if (account == null) {
+            logger.error("Account {} not found", accountId);
+            return false;
+        }
+        String userName = getUserNameForAccount(account);
+        AmazonIdentityManagement iamClient = getIAMClient(storeId);
+
+        // Create the IAM user if it doesn't already exist
+        try {
+            iamClient.createUser(new CreateUserRequest(userName));
+            logger.info("Created IAM user {} for account {}", userName, 
account.getAccountName());
+        } catch (EntityAlreadyExistsException e) {
+            logger.debug("IAM user {} already exists", userName);
+        }
+
+        // Attach a scoped IAM policy that allows access only to this
+        // account's own buckets (the tenant boundary). Refreshed whenever
+        // buckets are created or deleted.
+        updateAccountIAMPolicy(iamClient, storeId, accountId, null);
+
+        // Reuse the stored access key only if both the access key id and the
+        // secret key are present and the key is still Active in IAM; otherwise
+        // create a replacement.
+        Map<String, String> details = 
_accountDetailsDao.findDetails(accountId);
+        String accessKeyDetailKey = 
SeaweedFSObjectStoreUtil.keyAccessKey(storeId);
+        String secretKeyDetailKey = 
SeaweedFSObjectStoreUtil.keySecretKey(storeId);
+        String storedAccessKeyId = details.get(accessKeyDetailKey);
+        String storedSecretKey = details.get(secretKeyDetailKey);
+        if (storedAccessKeyId != null && storedSecretKey != null
+                && iamAccessKeyExists(iamClient, userName, storedAccessKeyId)) 
{
+            logger.debug("Reusing existing IAM access key {} for user {}", 
storedAccessKeyId, userName);
+            return true;
+        }
+
+        // The stored key is missing, inactive, or no longer in IAM. Clean up
+        // ALL keys (including the inactive stored one) before creating a
+        // replacement so we do not accumulate keys and hit IAM limits.
+        deleteUnmanagedAccessKeys(iamClient, userName, null);
+
+        CreateAccessKeyResult result = iamClient.createAccessKey(
+                new CreateAccessKeyRequest().withUserName(userName));
+        AccessKey key = result.getAccessKey();
+
+        // Persist the credentials in the account details (namespaced by 
storeId)
+        details.put(accessKeyDetailKey, key.getAccessKeyId());
+        details.put(secretKeyDetailKey, key.getSecretAccessKey());
+        _accountDetailsDao.persist(accountId, details);
+
+        // Update existing bucket records for this account/store with the new
+        // credentials so previously created buckets don't keep handing out
+        // the old (now invalid) key pair.
+        updateAccountBucketCredentials(storeId, accountId, key);
+
+        logger.info("Created IAM credentials {} for user {}", 
key.getAccessKeyId(), userName);
+        return true;
+    }
+
+    /**
+     * Update the IAM credentials on all BucketVO rows for this store/account
+     * so previously created buckets reflect the new (rotated) key pair.
+     * Mirrors 
CloudianHyperStoreObjectStoreDriverImpl.updateAccountBucketCredentials.
+     */
+    private void updateAccountBucketCredentials(long storeId, long accountId, 
AccessKey iamCredential) {
+        List<BucketVO> bucketList = 
_bucketDao.listByObjectStoreIdAndAccountId(storeId, accountId);
+        for (BucketVO bucketVO : bucketList) {
+            logger.info("Updating accountId={} bucket {} with new IAM 
credentials", accountId, bucketVO.getName());
+            bucketVO.setAccessKey(iamCredential.getAccessKeyId());
+            bucketVO.setSecretKey(iamCredential.getSecretAccessKey());
+            _bucketDao.update(bucketVO.getId(), bucketVO);
+        }
+    }
+
+    /**
+     * Refresh the per-account IAM user policy so it grants S3 access only to
+     * the account's current buckets (optionally excluding one, e.g. a bucket
+     * being deleted). This is the tenant boundary: each account's IAM
+     * credentials can only operate on that account's own buckets.
+     *
+     * @param iamClient the IAM client
+     * @param storeId the object store
+     * @param accountId the CloudStack account
+     * @param excludeBucket a bucket name to omit (e.g. a bucket being 
deleted),
+     *                      or null to include all of the account's buckets
+     */
+    protected void updateAccountIAMPolicy(AmazonIdentityManagement iamClient, 
long storeId, long accountId, String excludeBucket) {
+        Account account = _accountDao.findById(accountId);
+        if (account == null) {
+            return;
+        }
+        String userName = getUserNameForAccount(account);
+        List<BucketVO> buckets = 
_bucketDao.listByObjectStoreIdAndAccountId(storeId, accountId);
+        List<String> bucketNames = new ArrayList<>();
+        for (BucketVO bvo : buckets) {
+            if (excludeBucket != null && excludeBucket.equals(bvo.getName())) {
+                continue;
+            }
+            bucketNames.add(bvo.getName());
+        }
+        String policy = 
SeaweedFSObjectStoreUtil.buildAccountIAMPolicy(bucketNames);
+        iamClient.putUserPolicy(new PutUserPolicyRequest(userName,
+                SeaweedFSObjectStoreUtil.IAM_USER_POLICY_NAME, policy));
+    }
+
+    /**
+     * Check whether the given access key id is still listed and Active in IAM
+     * for the user. Listing failures are propagated rather than swallowed so
+     * a transient IAM outage does not send createUser into the replacement
+     * path (which would overwrite stored credentials and invalidate bucket
+     * records).
+     */
+    private boolean iamAccessKeyExists(AmazonIdentityManagement iamClient, 
String userName, String accessKeyId) {
+        for (AccessKeyMetadata metadata :
+                iamClient.listAccessKeys(new ListAccessKeysRequest()
+                        .withUserName(userName)).getAccessKeyMetadata()) {
+            if (accessKeyId.equals(metadata.getAccessKeyId())) {
+                return "Active".equalsIgnoreCase(metadata.getStatus());
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Delete access keys for the user other than the (optionally) preserved
+     * key id. Used to clean up unmanaged leftover keys before creating a
+     * replacement so repeated calls do not hit IAM access-key limits.
+     */
+    private void deleteUnmanagedAccessKeys(AmazonIdentityManagement iamClient, 
String userName, String preserveAccessKeyId) {
+        try {
+            for (AccessKeyMetadata metadata :
+                    iamClient.listAccessKeys(new ListAccessKeysRequest()
+                            .withUserName(userName)).getAccessKeyMetadata()) {
+                String keyId = metadata.getAccessKeyId();
+                if (preserveAccessKeyId != null && 
preserveAccessKeyId.equals(keyId)) {
+                    continue;
+                }
+                DeleteAccessKeyRequest deleteReq =
+                        new DeleteAccessKeyRequest()
+                                .withUserName(userName)
+                                .withAccessKeyId(keyId);
+                logger.info("Deleting un-managed IAM access key {} for user 
{}", keyId, userName);
+                iamClient.deleteAccessKey(deleteReq);
+            }
+        } catch (AmazonClientException e) {
+            logger.warn("Failed to clean up IAM access keys for user {}: {}", 
userName, e.getMessage());
+        }
+    }
+
+    @Override
+    public Bucket createBucket(Bucket bucket, boolean objectLock) {
+        String bucketName = bucket.getName();
+        long storeId = bucket.getObjectStoreId();
+        long accountId = bucket.getAccountId();
+
+        // Use the store's admin credentials to create the bucket
+        AmazonS3 s3client = getS3ClientByStoreId(storeId);
+
+        // Check if the bucket already exists
+        try {
+            if (s3client.doesBucketExistV2(bucketName)) {
+                throw new CloudRuntimeException("Bucket already exists with 
name " + bucketName);
+            }
+        } catch (AmazonClientException e) {
+            throw new CloudRuntimeException(e);
+        }
+
+        // Create the bucket
+        try {
+            CreateBucketRequest request = new CreateBucketRequest(bucketName);
+            if (objectLock) {
+                request.setObjectLockEnabledForBucket(true);
+            }
+            s3client.createBucket(request);
+        } catch (AmazonClientException e) {
+            logger.error("Create bucket failed", e);
+            throw new CloudRuntimeException(e);
+        }
+
+        // Step 2: update the bucket record with the account's IAM credentials.
+        // If this fails, clean up the remote bucket so a retry does not find
+        // it already existing — mirroring the Cloudian createBucket pattern.
+        try {
+            Map<String, String> accountDetails = 
_accountDetailsDao.findDetails(accountId);
+            String accessKey = 
accountDetails.get(SeaweedFSObjectStoreUtil.keyAccessKey(storeId));
+            String secretKey = 
accountDetails.get(SeaweedFSObjectStoreUtil.keySecretKey(storeId));
+            if (accessKey == null || secretKey == null) {
+                logger.warn("No IAM credentials found for account {}. Bucket 
will be created without per-account credentials.", accountId);
+            }
+
+            String s3Url = getS3Url(storeId);
+            BucketVO bucketVO = _bucketDao.findById(bucket.getId());
+            bucketVO.setAccessKey(accessKey);
+            bucketVO.setSecretKey(secretKey);
+            bucketVO.setBucketURL(s3Url + "/" + bucketName);
+            _bucketDao.update(bucket.getId(), bucketVO);
+
+            // Refresh the account's IAM policy to include the new bucket
+            AmazonIdentityManagement iamClient = getIAMClient(storeId);
+            updateAccountIAMPolicy(iamClient, storeId, accountId, null);
+
+            return bucket;
+        } catch (Exception e) {
+            logger.error("Post-create bucket record update failed for {}; 
cleaning up remote bucket", bucketName, e);
+            try {
+                s3client.deleteBucket(bucketName);
+                logger.info("Cleanup of bucket {} succeeded", bucketName);
+            } catch (AmazonClientException cleanupEx) {
+                logger.error("Cleanup of bucket {} also failed", bucketName, 
cleanupEx);
+            }
+            throw new CloudRuntimeException(e);
+        }
+    }
+
+    @Override
+    public List<Bucket> listBuckets(long storeId) {
+        AmazonS3 s3client = getS3ClientByStoreId(storeId);
+        List<Bucket> bucketsList = new ArrayList<>();
+        try {
+            List<com.amazonaws.services.s3.model.Bucket> s3Buckets = 
s3client.listBuckets();
+            for (com.amazonaws.services.s3.model.Bucket s3Bucket : s3Buckets) {
+                Bucket bucket = new BucketObject();
+                bucket.setName(s3Bucket.getName());
+                bucketsList.add(bucket);
+            }
+        } catch (AmazonClientException e) {
+            throw new CloudRuntimeException(e);
+        }
+        return bucketsList;
+    }
+
+    @Override
+    public boolean deleteBucket(BucketTO bucket, long storeId) {
+        String bucketName = bucket.getName();
+        long accountId = bucket.getAccountId();
+        AmazonS3 s3client = getS3ClientByStoreId(storeId);
+        try {
+            if (! s3client.doesBucketExistV2(bucketName)) {
+                throw new CloudRuntimeException("Bucket doesn't exist: " + 
bucketName);
+            }
+        } catch (AmazonClientException e) {
+            throw new CloudRuntimeException(e);
+        }
+        try {
+            s3client.deleteBucket(bucketName);
+        } catch (AmazonClientException e) {
+            throw new CloudRuntimeException(e);
+        }
+        // Refresh the account's IAM policy to drop the deleted bucket
+        AmazonIdentityManagement iamClient = getIAMClient(storeId);
+        updateAccountIAMPolicy(iamClient, storeId, accountId, bucketName);

Review Comment:
   The S3 deletion has already succeeded before this IAM refresh. If 
`getIAMClient` or `putUserPolicy` fails, this method throws and 
`BucketApiServiceImpl` leaves the CloudStack `BucketVO` and resource-accounting 
state intact even though the remote bucket is gone; retries then hit the 
not-found path. Make the policy refresh best-effort after a successful deletion 
(and log or retry it separately) before returning success.



##########
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/lifecycle/SeaweedFSObjectStoreLifeCycleImpl.java:
##########
@@ -0,0 +1,162 @@
+/*
+ * 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.
+ */
+// SPDX-License-Identifier: Apache-2.0
+package org.apache.cloudstack.storage.datastore.lifecycle;
+
+import com.cloud.agent.api.StoragePoolInfo;
+import com.cloud.hypervisor.Hypervisor.HypervisorType;
+import com.cloud.utils.exception.CloudRuntimeException;
+
+import org.apache.cloudstack.engine.subsystem.api.storage.ClusterScope;
+import org.apache.cloudstack.engine.subsystem.api.storage.DataStore;
+import org.apache.cloudstack.engine.subsystem.api.storage.HostScope;
+import org.apache.cloudstack.engine.subsystem.api.storage.ZoneScope;
+import org.apache.cloudstack.storage.datastore.db.ObjectStoreVO;
+import org.apache.cloudstack.storage.datastore.util.SeaweedFSObjectStoreUtil;
+import org.apache.cloudstack.storage.object.datastore.ObjectStoreHelper;
+import 
org.apache.cloudstack.storage.object.datastore.ObjectStoreProviderManager;
+import 
org.apache.cloudstack.storage.object.store.lifecycle.ObjectStoreLifeCycle;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+import javax.inject.Inject;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public class SeaweedFSObjectStoreLifeCycleImpl implements ObjectStoreLifeCycle 
{
+
+    protected Logger logger = 
LogManager.getLogger(SeaweedFSObjectStoreLifeCycleImpl.class);
+
+    @Inject
+    ObjectStoreHelper objectStoreHelper;
+    @Inject
+    ObjectStoreProviderManager objectStoreMgr;
+
+    public SeaweedFSObjectStoreLifeCycleImpl() {
+    }
+
+    @Override
+    public DataStore initialize(Map<String, Object> dsInfos) {
+
+        String name = 
(String)dsInfos.get(SeaweedFSObjectStoreUtil.STORE_KEY_NAME);
+        String url = 
(String)dsInfos.get(SeaweedFSObjectStoreUtil.STORE_KEY_URL);
+        String providerName = 
(String)dsInfos.get(SeaweedFSObjectStoreUtil.STORE_KEY_PROVIDER_NAME);
+        Long size = (Long)dsInfos.get(SeaweedFSObjectStoreUtil.STORE_KEY_SIZE);
+
+        // Check the providerName is what we expect
+        if (! StringUtils.equalsIgnoreCase(providerName, 
SeaweedFSObjectStoreUtil.OBJECT_STORE_PROVIDER_NAME)) {
+            String msg = String.format("Unexpected providerName \"%s\". 
Expected \"%s\"", providerName, 
SeaweedFSObjectStoreUtil.OBJECT_STORE_PROVIDER_NAME);
+            logger.error(msg);
+            throw new CloudRuntimeException(msg);
+        }
+
+        Map<String, Object> objectStoreParameters = new HashMap<String, 
Object>();
+        objectStoreParameters.put(SeaweedFSObjectStoreUtil.STORE_KEY_NAME, 
name);
+        objectStoreParameters.put(SeaweedFSObjectStoreUtil.STORE_KEY_URL, url);
+        
objectStoreParameters.put(SeaweedFSObjectStoreUtil.STORE_KEY_PROVIDER_NAME, 
providerName);
+        objectStoreParameters.put(SeaweedFSObjectStoreUtil.STORE_KEY_SIZE, 
size);
+
+        // Pull out the details map
+        @SuppressWarnings("unchecked")
+        Map<String, String> details = (Map<String, String>) 
dsInfos.get(SeaweedFSObjectStoreUtil.STORE_KEY_DETAILS);
+        if (details == null) {
+            String msg = String.format("Unexpected null receiving Object Store 
initialization \"%s\"", SeaweedFSObjectStoreUtil.STORE_KEY_DETAILS);
+            logger.error(msg);
+            throw new CloudRuntimeException(msg);
+        }
+
+        // The admin/root access key and secret key are available as 
accesskey/secretkey
+        String accessKey = 
details.get(SeaweedFSObjectStoreUtil.STORE_DETAILS_KEY_ACCESS_KEY);
+        String secretKey = 
details.get(SeaweedFSObjectStoreUtil.STORE_DETAILS_KEY_SECRET_KEY);
+        String s3Url = 
details.get(SeaweedFSObjectStoreUtil.STORE_DETAILS_KEY_S3_URL);
+        String iamUrl = 
details.get(SeaweedFSObjectStoreUtil.STORE_DETAILS_KEY_IAM_URL);
+
+        // If s3Url is not provided, default it to the store url
+        if (StringUtils.isBlank(s3Url)) {
+            s3Url = url;
+        }
+        // If iamUrl is not provided, default it to the s3Url.
+        // SeaweedFS registers its embedded IAM API at POST / on the same S3
+        // endpoint (UnifiedPostHandler), so the IAM endpoint is the same as
+        // the S3 endpoint unless the deployment runs a separate weed iam 
server.
+        if (StringUtils.isBlank(iamUrl)) {
+            iamUrl = s3Url;
+        }
+
+        if (StringUtils.isAnyBlank(accessKey, secretKey, s3Url, iamUrl)) {
+            final String asteriskPassword = (secretKey == null) ? null : 
"*".repeat(secretKey.length());
+            logger.error("Required parameters are missing; accessKey={} 
secretKey={} s3Url={} iamUrl={}",
+                accessKey, asteriskPassword, s3Url, iamUrl);
+            throw new CloudRuntimeException("Required SeaweedFS configuration 
parameters are missing/empty.");
+        }
+
+        // Update the details map with the resolved URLs so the driver can 
read them later
+        details.put(SeaweedFSObjectStoreUtil.STORE_DETAILS_KEY_S3_URL, s3Url);
+        details.put(SeaweedFSObjectStoreUtil.STORE_DETAILS_KEY_IAM_URL, 
iamUrl);

Review Comment:
   These writes turn the fallback values into permanent detail overrides. 
`updateObjectStore` only changes `ObjectStoreVO.url`, while the driver prefers 
the persisted `s3Url`, so a normal SeaweedFS pool whose `s3Url` defaults to 
`url` keeps using the old endpoint after a URL update; the update's 
connectivity check also validates the old endpoint. Persist only explicitly 
supplied endpoint overrides and let the driver fallback resolve the current 
store URL.



##########
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/util/SeaweedFSObjectStoreUtil.java:
##########
@@ -0,0 +1,447 @@
+// 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.
+// SPDX-License-Identifier: Apache-2.0
+package org.apache.cloudstack.storage.datastore.util;
+
+import org.apache.commons.lang3.StringUtils;
+
+import com.amazonaws.AmazonServiceException;
+import com.amazonaws.auth.AWSStaticCredentialsProvider;
+import com.amazonaws.auth.BasicAWSCredentials;
+import com.amazonaws.client.builder.AwsClientBuilder;
+import com.amazonaws.services.identitymanagement.AmazonIdentityManagement;
+import 
com.amazonaws.services.identitymanagement.AmazonIdentityManagementClientBuilder;
+import com.amazonaws.services.s3.AmazonS3;
+import com.amazonaws.services.s3.AmazonS3ClientBuilder;
+import com.cloud.utils.exception.CloudRuntimeException;
+
+/**
+ * Utility class for the SeaweedFS object storage provider.
+ *
+ * SeaweedFS exposes both an S3-compatible API and an AWS IAM-compatible API,
+ * so this provider needs no proprietary admin client — only the AWS S3 and IAM
+ * SDKs, the same pair Cloudian HyperStore already uses in this tree.
+ */
+public class SeaweedFSObjectStoreUtil {
+
+    /** The name of our Object Store Provider */
+    public static final String OBJECT_STORE_PROVIDER_NAME = "SeaweedFS";
+
+    public static final String STORE_KEY_PROVIDER_NAME = "providerName";
+    public static final String STORE_KEY_URL           = "url";
+    public static final String STORE_KEY_NAME          = "name";
+    public static final String STORE_KEY_SIZE          = "size";
+    public static final String STORE_KEY_DETAILS       = "details";
+
+    // Store Details Map key names - managed outside of plugin
+    public static final String STORE_DETAILS_KEY_ACCESS_KEY = "accesskey";   
// admin/root access key
+    public static final String STORE_DETAILS_KEY_SECRET_KEY = "secretkey";   
// admin/root secret key
+    public static final String STORE_DETAILS_KEY_S3_URL     = "s3Url";        
// S3 endpoint URL
+    public static final String STORE_DETAILS_KEY_IAM_URL     = "iamUrl";       
// IAM endpoint URL
+
+    // Account Detail Map key names - credentials created per CloudStack 
account.
+    // Namespaced by store ID so one account can use multiple SeaweedFS pools
+    // without the second pool overwriting the first pool's credentials.
+    public static final String KEY_ACCESS_KEY_PREFIX = "swfs_AccessKey_";
+    public static final String KEY_SECRET_KEY_PREFIX = "swfs_SecretKey_";
+
+    /**
+     * Build the account-detail key for the IAM access key of a given store.
+     */
+    public static String keyAccessKey(long storeId) {
+        return KEY_ACCESS_KEY_PREFIX + storeId;
+    }
+
+    /**
+     * Build the account-detail key for the IAM secret key of a given store.
+     */
+    public static String keySecretKey(long storeId) {
+        return KEY_SECRET_KEY_PREFIX + storeId;
+    }
+
+    /**
+     * Connect timeout for the S3 extension HTTP client, in seconds.
+     */
+    public static final int S3_EXTENSION_CONNECT_TIMEOUT_SECONDS = 10;
+    /**
+     * Per-request timeout for the S3 extension HTTP request, in seconds.
+     */
+    public static final int S3_EXTENSION_REQUEST_TIMEOUT_SECONDS = 30;
+
+    /**
+     * IAM user policy name applied to each per-account IAM user.
+     */
+    public static final String IAM_USER_POLICY_NAME = "CloudStackPolicy";
+
+    /**
+     * Build an IAM user policy that grants full S3 access only to the given
+     * buckets (both the bucket and its contents), while denying bucket
+     * creation and deletion everywhere so CloudStack retains control of the
+     * bucket lifecycle. When no buckets are provided, all S3 access is denied.
+     *
+     * <p>This is the tenant boundary: each account's IAM credentials can only
+     * operate on that account's own buckets, not on every bucket in the
+     * SeaweedFS pool. The policy is refreshed whenever buckets are created or
+     * deleted (see
+     * {@code SeaweedFSObjectStoreDriverImpl.updateAccountIAMPolicy}).
+     *
+     * @param bucketNames the bucket names the account is allowed to access
+     * @return a JSON IAM policy document
+     */
+    public static String buildAccountIAMPolicy(java.util.List<String> 
bucketNames) {
+        StringBuilder sb = new StringBuilder();
+        sb.append("{\n");
+        sb.append("  \"Version\": \"2012-10-17\",\n");
+        sb.append("  \"Statement\": [\n");
+        if (bucketNames == null || bucketNames.isEmpty()) {
+            // No buckets: deny all S3 access. A Resource cannot be empty in
+            // an IAM policy, so deny everything explicitly.
+            sb.append("    {\n");
+            sb.append("      \"Sid\": \"DenyAllS3\",\n");
+            sb.append("      \"Effect\": \"Deny\",\n");
+            sb.append("      \"Action\": [\"s3:*\"],\n");
+            sb.append("      \"Resource\": [\"arn:aws:s3:::*\", 
\"arn:aws:s3:::*/*\"]\n");
+            sb.append("    }\n");
+        } else {
+            sb.append("    {\n");
+            sb.append("      \"Sid\": \"AllowAccountBuckets\",\n");
+            sb.append("      \"Effect\": \"Allow\",\n");
+            sb.append("      \"Action\": [\"s3:*\"],\n");
+            sb.append("      \"Resource\": [\n");
+            for (int i = 0; i < bucketNames.size(); i++) {
+                String name = bucketNames.get(i);
+                sb.append("        
\"arn:aws:s3:::").append(name).append("\",\n");
+                sb.append("        
\"arn:aws:s3:::").append(name).append("/*\"");
+                if (i < bucketNames.size() - 1) {
+                    sb.append(",");
+                }
+                sb.append("\n");
+            }
+            sb.append("      ]\n");
+            sb.append("    }\n");
+        }
+        // Always deny bucket creation/deletion — CloudStack controls lifecycle
+        sb.append("    ,{\n");
+        sb.append("      \"Sid\": \"DenyBucketLifecycle\",\n");
+        sb.append("      \"Effect\": \"Deny\",\n");
+        sb.append("      \"Action\": [\"s3:CreateBucket\", 
\"s3:DeleteBucket\"],\n");
+        sb.append("      \"Resource\": \"*\"\n");
+        sb.append("    }\n");
+        sb.append("  ]\n");
+        sb.append("}\n");
+        return sb.toString();
+    }
+
+    /**
+     * Returns an S3 connection for the given endpoint and credentials.
+     * Uses path-style access, which SeaweedFS requires.
+     *
+     * @param url the url of the S3 service
+     * @param accessKey the credentials to use for the S3 connection.
+     * @param secretKey the matching secret key.
+     * @return an S3 connection (never null)
+     * @throws CloudRuntimeException on failure.
+     */
+    public static AmazonS3 getS3Client(String url, String accessKey, String 
secretKey) {
+        AmazonS3 client = AmazonS3ClientBuilder.standard()
+                .enablePathStyleAccess()
+                .withCredentials(new AWSStaticCredentialsProvider(new 
BasicAWSCredentials(accessKey, secretKey)))
+                .withEndpointConfiguration(new 
AwsClientBuilder.EndpointConfiguration(url, "us-east-1"))
+                .build();
+        if (client == null) {
+            throw new CloudRuntimeException("Error while creating SeaweedFS S3 
client");
+        }
+        return client;
+    }
+
+    /**
+     * Returns an IAM connection for the given endpoint and credentials.
+     *
+     * @param url the url of the IAM service
+     * @param accessKey the credentials to use for the iam connection.
+     * @param secretKey the matching secret key.
+     * @return an IAM connection (never null)
+     * @throws CloudRuntimeException on failure.
+     */
+    public static AmazonIdentityManagement getIAMClient(String url, String 
accessKey, String secretKey) {
+        AmazonIdentityManagement iamClient = 
AmazonIdentityManagementClientBuilder.standard()
+            .withCredentials(new AWSStaticCredentialsProvider(new 
BasicAWSCredentials(accessKey, secretKey)))
+            .withEndpointConfiguration(new 
AwsClientBuilder.EndpointConfiguration(url, "us-east-1"))
+            .build();
+        if (iamClient == null) {
+            throw new CloudRuntimeException("Error while creating SeaweedFS 
IAM client");
+        }
+        return iamClient;
+    }
+
+    /**
+     * Test the S3Url to confirm it behaves like an S3 Service.
+     *
+     * Uses bad credentials and looks for the particular error from S3 that 
says
+     * InvalidAccessKeyId was used. Quietly returns if we connect and get the
+     * expected error back.
+     *
+     * @param s3Url the url to check
+     * @throws CloudRuntimeException if there is any unexpected issue.
+     */
+    public static void validateS3Url(String s3Url) {
+        try {
+            AmazonS3 s3Client = SeaweedFSObjectStoreUtil.getS3Client(s3Url, 
"unknown", "unknown");
+            s3Client.listBuckets();
+        } catch (AmazonServiceException e) {
+            if (StringUtils.compareIgnoreCase(e.getErrorCode(), 
"InvalidAccessKeyId") != 0
+                    && StringUtils.compareIgnoreCase(e.getErrorCode(), 
"SignatureDoesNotMatch") != 0) {
+                throw new CloudRuntimeException("Unexpected response from S3 
Endpoint.", e);
+            }
+        }
+    }
+
+    /**
+     * Test the IAMUrl to confirm it behaves like an IAM Service.
+     *
+     * Uses bad credentials and looks for the particular error from IAM that 
says
+     * InvalidAccessKeyId or InvalidClientTokenId was used. Quietly returns if 
we
+     * connect and get the expected error back.
+     *
+     * @param iamUrl the url to check
+     * @throws CloudRuntimeException if there is any unexpected issue.
+     */
+    public static void validateIAMUrl(String iamUrl) {
+        try {
+            AmazonIdentityManagement iamClient = 
SeaweedFSObjectStoreUtil.getIAMClient(iamUrl, "unknown", "unknown");
+            iamClient.listAccessKeys();
+        } catch (AmazonServiceException e) {
+            if (! StringUtils.equalsAnyIgnoreCase(e.getErrorCode(), 
"InvalidAccessKeyId", "InvalidClientTokenId", "SignatureDoesNotMatch")) {
+                throw new CloudRuntimeException("Unexpected response from IAM 
Endpoint.", e);
+            }
+        }
+    }
+
+    /**
+     * Set bucket quota via the SeaweedFS S3 extension endpoint.
+     *
+     * SeaweedFS exposes a custom S3 subresource at
+     *   PUT /{bucket}?seaweedfs-quota
+     * authenticated via standard S3 SigV4 and authorized via the
+     * s3:PutBucketQuota IAM permission. This avoids the need for a
+     * separate admin API credential.
+     *
+     * The request body is JSON:
+     *   {"quota_size": <n>, "quota_unit": "GB", "quota_enabled": true}
+     *
+     * @param s3Url     the S3 endpoint URL (e.g. http://host:8333)
+     * @param accessKey the S3 access key (must have s3:PutBucketQuota 
permission)
+     * @param secretKey the S3 secret key
+     * @param bucketName the bucket name
+     * @param sizeGiB    the quota size in GiB (0 to disable quota)
+     * @throws CloudRuntimeException on any failure
+     */
+    public static void setBucketQuotaViaS3Extension(String s3Url, String 
accessKey, String secretKey, String bucketName, long sizeGiB) {
+        setBucketQuotaViaS3Extension(s3Url, accessKey, secretKey, bucketName, 
sizeGiB, newS3ExtensionHttpClient());
+    }
+
+    /**
+     * Build a bounded HTTP client for SeaweedFS S3 extension requests with a
+     * connect timeout so a stalled endpoint cannot block the management-server
+     * API thread indefinitely.
+     */
+    public static java.net.http.HttpClient newS3ExtensionHttpClient() {
+        return java.net.http.HttpClient.newBuilder()
+                
.connectTimeout(java.time.Duration.ofSeconds(S3_EXTENSION_CONNECT_TIMEOUT_SECONDS))
+                .build();
+    }
+
+    /**
+     * Set bucket quota via the SeaweedFS S3 extension endpoint using the
+     * supplied HTTP client. The client is injected so tests can assert the
+     * signed request without hitting the network.
+     */
+    public static void setBucketQuotaViaS3Extension(String s3Url, String 
accessKey, String secretKey,
+                                                     String bucketName, long 
sizeGiB, java.net.http.HttpClient httpClient) {
+        if (sizeGiB < 0) {
+            // Only zero disables a quota; a negative value would corrupt
+            // resource accounting (BucketApiServiceImpl persists the requested
+            // value and computes deltas from it), so reject it outright.
+            throw new CloudRuntimeException("Bucket quota cannot be negative: 
" + sizeGiB);
+        }
+        String body;
+        if (sizeGiB == 0) {
+            body = 
"{\"quota_size\":0,\"quota_unit\":\"B\",\"quota_enabled\":false}";
+        } else {
+            body = 
String.format("{\"quota_size\":%d,\"quota_unit\":\"GB\",\"quota_enabled\":true}",
 sizeGiB);
+        }
+        executeSignedS3Request("PUT", s3Url, "/" + bucketName + 
"?seaweedfs-quota", accessKey, secretKey, body, httpClient);
+    }
+
+    /**
+     * Execute a custom S3 request with SigV4 signing.
+     *
+     * Uses the AWS SDK v1 Aws4Signer to sign the request, then sends it via
+     * java.net.http.HttpClient. This allows calling SeaweedFS-specific S3
+     * extensions (like ?seaweedfs-quota) that the AWS SDK doesn't natively
+     * support.
+     *
+     * The query string portion of {@code resourcePath} (e.g.
+     * {@code /bucket?seaweedfs-quota}) is split off and added to the request
+     * via {@code addParameter(...)} before signing, so the signer includes it
+     * in the canonical query string. {@code DefaultRequest.setResourcePath}
+     * does not parse an embedded query string, so passing it verbatim would
+     * leave the subresource unsigned while the outgoing URI would still carry
+     * it, causing a signature mismatch on the server.
+     *
+     * @param method     HTTP method (PUT, GET, etc.)
+     * @param s3Url      the S3 endpoint base URL
+     * @param resourcePath the path + optional query string (e.g. 
/bucket?seaweedfs-quota)
+     * @param accessKey  S3 access key
+     * @param secretKey  S3 secret key
+     * @param body       the request body (null for GET)
+     * @param httpClient the HTTP client used to send the request
+     * @return the response body as a string
+     * @throws CloudRuntimeException on any failure
+     */
+    protected static String executeSignedS3Request(String method, String 
s3Url, String resourcePath,
+                                                   String accessKey, String 
secretKey, String body,
+                                                   java.net.http.HttpClient 
httpClient) {
+        try {
+            java.net.URI endpointUri = java.net.URI.create(s3Url);
+
+            // Split the resource path into a path and a query string so the
+            // query parameters are signed as canonical query parameters.
+            String path = resourcePath;
+            String queryString = "";
+            int q = resourcePath.indexOf('?');
+            if (q >= 0) {
+                path = resourcePath.substring(0, q);
+                queryString = resourcePath.substring(q + 1);
+            }
+
+            // Build AWS SDK v1 Request for SigV4 signing
+            com.amazonaws.DefaultRequest<?> request = new 
com.amazonaws.DefaultRequest<>("s3");
+            request.setEndpoint(endpointUri);
+            
request.setHttpMethod(com.amazonaws.http.HttpMethodName.valueOf(method));
+            request.setResourcePath(path);

Review Comment:
   The outgoing URI below includes `endpointPath`, but the AWS SigV4 signer 
canonicalizes the request resource path set above. For an endpoint such as 
`https://host/object-s3`, the signature is generated for `/bucket` while the 
request is sent to `/object-s3/bucket`, which causes `SignatureDoesNotMatch`. 
Include the endpoint path in the signed resource path or reject path-prefixed 
endpoints.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to