diff --git a/PendingReleaseNotes b/PendingReleaseNotes index 44923495b23f..021bb159c039 100644 --- a/PendingReleaseNotes +++ b/PendingReleaseNotes @@ -62,3 +62,34 @@ example.ver.1 > example.ver.2: additional key store is required. Note: attaching an encrypted RBD volume to a running Instance requires libvirt >= 10.1.0; booting an Instance from an encrypted RBD root disk works on older libvirt. + + * Per-bucket credentials for object storage. Buckets on Ceph RGW object storage + now each get their own credential, and each credential has two key slots that + rotate independently, so one key can be replaced while consumers keep working + on the other. Every bucket in an account previously shared one credential, so + a leaked key exposed all of them and there was no way to rotate it. + + - Requires Ceph Squid (v19) or later, because it is built on RGW accounts. + Object storage that cannot provide per-bucket credentials keeps the existing + per-account behaviour, and says so in the UI. + + - Existing accounts keep the shared credential until an administrator migrates + them, from the Object Storage tab on the account. Migration is per account + per object store, and cannot be undone: it adopts the account's existing RGW + user as the root of a new RGW account at the gateway. Buckets created before + the migration keep working on the shared key until each one is given its own + credential. + + - Once every bucket on an object store has its own credential, an administrator + can rotate the account's own key, so that the key that was shared with users + no longer works. + + - The global setting object.storage.per.bucket.credentials decides how an + account is set up the first time it uses an object store. Turning it off + leaves already-migrated accounts as they are. + + * listBuckets now applies its objectstorageid parameter. The parameter has been + accepted since 4.19.0 but never filtered, so any caller passing it received + every bucket. Those callers will now receive only the buckets on that object + store. + diff --git a/api/src/main/java/com/cloud/agent/api/to/BucketCredentialTO.java b/api/src/main/java/com/cloud/agent/api/to/BucketCredentialTO.java new file mode 100644 index 000000000000..2c46ea7da140 --- /dev/null +++ b/api/src/main/java/com/cloud/agent/api/to/BucketCredentialTO.java @@ -0,0 +1,43 @@ +// 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 com.cloud.agent.api.to; + +import java.util.List; + +/** + * A dedicated backend identity provisioned for a single bucket by an object + * store provider, together with the key pairs it currently holds. + */ +public final class BucketCredentialTO { + + private final String providerCredentialId; + + private final List keys; + + public BucketCredentialTO(String providerCredentialId, List keys) { + this.providerCredentialId = providerCredentialId; + this.keys = keys; + } + + public String getProviderCredentialId() { + return providerCredentialId; + } + + public List getKeys() { + return keys; + } +} diff --git a/api/src/main/java/com/cloud/agent/api/to/BucketKeyTO.java b/api/src/main/java/com/cloud/agent/api/to/BucketKeyTO.java new file mode 100644 index 000000000000..d1c145f8400f --- /dev/null +++ b/api/src/main/java/com/cloud/agent/api/to/BucketKeyTO.java @@ -0,0 +1,41 @@ +// 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 com.cloud.agent.api.to; + +/** + * A single access/secret key pair of a bucket credential, as returned by an + * object store provider. + */ +public final class BucketKeyTO { + + private final String accessKey; + + private final String secretKey; + + public BucketKeyTO(String accessKey, String secretKey) { + this.accessKey = accessKey; + this.secretKey = secretKey; + } + + public String getAccessKey() { + return accessKey; + } + + public String getSecretKey() { + return secretKey; + } +} diff --git a/api/src/main/java/com/cloud/agent/api/to/BucketTO.java b/api/src/main/java/com/cloud/agent/api/to/BucketTO.java index fd8237998a74..61222ba9a564 100644 --- a/api/src/main/java/com/cloud/agent/api/to/BucketTO.java +++ b/api/src/main/java/com/cloud/agent/api/to/BucketTO.java @@ -22,14 +22,19 @@ public final class BucketTO { private String name; + private String uuid; + private String accessKey; private String secretKey; private long accountId; + private String providerCredentialId; + public BucketTO(Bucket bucket) { this.name = bucket.getName(); + this.uuid = bucket.getUuid(); this.accessKey = bucket.getAccessKey(); this.secretKey = bucket.getSecretKey(); this.accountId = bucket.getAccountId(); @@ -43,6 +48,10 @@ public String getName() { return this.name; } + public String getUuid() { + return this.uuid; + } + public String getAccessKey() { return this.accessKey; } @@ -54,4 +63,12 @@ public String getSecretKey() { public long getAccountId() { return this.accountId; } + + public String getProviderCredentialId() { + return this.providerCredentialId; + } + + public void setProviderCredentialId(String providerCredentialId) { + this.providerCredentialId = providerCredentialId; + } } diff --git a/api/src/main/java/com/cloud/event/EventTypes.java b/api/src/main/java/com/cloud/event/EventTypes.java index f7d13343d469..fc93597d4b2d 100644 --- a/api/src/main/java/com/cloud/event/EventTypes.java +++ b/api/src/main/java/com/cloud/event/EventTypes.java @@ -816,6 +816,11 @@ public class EventTypes { public static final String EVENT_BUCKET_CREATE = "BUCKET.CREATE"; public static final String EVENT_BUCKET_DELETE = "BUCKET.DELETE"; public static final String EVENT_BUCKET_UPDATE = "BUCKET.UPDATE"; + public static final String EVENT_BUCKET_KEY_ROTATE = "BUCKET.KEY.ROTATE"; + public static final String EVENT_BUCKET_KEY_REVOKE = "BUCKET.KEY.REVOKE"; + public static final String EVENT_BUCKET_CREDENTIAL_MIGRATE = "BUCKET.CREDENTIAL.MIGRATE"; + public static final String EVENT_OBJECT_STORE_ACCOUNT_MIGRATE = "OBJECTSTORE.ACCOUNT.MIGRATE"; + public static final String EVENT_OBJECT_STORE_ACCOUNT_KEY_ROTATE = "OBJECTSTORE.ACCOUNT.KEY.ROTATE"; // Quota public static final String EVENT_QUOTA_TARIFF_CREATE = "QUOTA.TARIFF.CREATE"; @@ -1395,6 +1400,11 @@ public class EventTypes { entityEventDetails.put(EVENT_BUCKET_CREATE, Bucket.class); entityEventDetails.put(EVENT_BUCKET_UPDATE, Bucket.class); entityEventDetails.put(EVENT_BUCKET_DELETE, Bucket.class); + entityEventDetails.put(EVENT_BUCKET_KEY_ROTATE, Bucket.class); + entityEventDetails.put(EVENT_BUCKET_KEY_REVOKE, Bucket.class); + entityEventDetails.put(EVENT_BUCKET_CREDENTIAL_MIGRATE, Bucket.class); + entityEventDetails.put(EVENT_OBJECT_STORE_ACCOUNT_MIGRATE, Account.class); + entityEventDetails.put(EVENT_OBJECT_STORE_ACCOUNT_KEY_ROTATE, Account.class); // Quota entityEventDetails.put(EVENT_QUOTA_TARIFF_CREATE, QuotaTariff.class); diff --git a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java index f74c46161180..c6f917cd8cb4 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java +++ b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java @@ -1382,6 +1382,10 @@ public class ApiConstants { public static final String BUCKET_LIMIT = "bucketlimit"; public static final String BUCKET_TOTAL = "buckettotal"; public static final String OBJECT_STORAGE_ID = "objectstorageid"; + public static final String KEY_SLOT = "keyslot"; + public static final String LAST_USED = "lastused"; + public static final String CREDENTIAL_SCOPE = "credentialscope"; + public static final String CREDENTIAL_KEYS = "keys"; public static final String OBJECT_STORAGE = "objectstore"; public static final String OBJECT_STORAGE_AVAILABLE = "objectstorageavailable"; public static final String OBJECT_STORAGE_LIMIT = "objectstoragelimit"; diff --git a/api/src/main/java/org/apache/cloudstack/api/ResponseGenerator.java b/api/src/main/java/org/apache/cloudstack/api/ResponseGenerator.java index 6e880c89432f..4deb94f765d1 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ResponseGenerator.java +++ b/api/src/main/java/org/apache/cloudstack/api/ResponseGenerator.java @@ -44,6 +44,7 @@ import org.apache.cloudstack.api.response.BackupRepositoryResponse; import org.apache.cloudstack.api.response.BackupScheduleResponse; import org.apache.cloudstack.api.response.BaseRolePermissionResponse; +import org.apache.cloudstack.api.response.BucketKeyResponse; import org.apache.cloudstack.api.response.BucketResponse; import org.apache.cloudstack.api.response.CapacityResponse; import org.apache.cloudstack.api.response.ClusterResponse; @@ -163,6 +164,7 @@ import org.apache.cloudstack.region.Region; import org.apache.cloudstack.secstorage.heuristics.Heuristic; import org.apache.cloudstack.storage.object.Bucket; +import org.apache.cloudstack.storage.object.BucketCredentialKey; import org.apache.cloudstack.storage.object.ObjectStore; import org.apache.cloudstack.storage.sharedfs.SharedFS; import org.apache.cloudstack.usage.Usage; @@ -592,6 +594,8 @@ DirectDownloadCertificateHostStatusResponse createDirectDownloadCertificateProvi BucketResponse createBucketResponse(Bucket bucket); + BucketKeyResponse createBucketKeyResponse(BucketCredentialKey key); + ASNRangeResponse createASNumberRangeResponse(ASNumberRange asnRange); ASNumberResponse createASNumberResponse(ASNumber asn); diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/storage/ListObjectStoragePoolsCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/storage/ListObjectStoragePoolsCmd.java index 005a1a54444d..809dc345f9f9 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/storage/ListObjectStoragePoolsCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/storage/ListObjectStoragePoolsCmd.java @@ -22,6 +22,7 @@ import org.apache.cloudstack.api.BaseListCmd; import org.apache.cloudstack.api.Parameter; import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.api.response.AccountResponse; import org.apache.cloudstack.api.response.ObjectStoreResponse; @APICommand(name = "listObjectStoragePools", description = "Lists object storage pools.", responseObject = ObjectStoreResponse.class, since = "4.19.0", @@ -43,6 +44,10 @@ public class ListObjectStoragePoolsCmd extends BaseListCmd { @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = ObjectStoreResponse.class, description = "the ID of the storage pool") private Long id; + @Parameter(name = ApiConstants.ACCOUNT_ID, type = CommandType.UUID, entityType = AccountResponse.class, + description = "when given, each store also reports the credential state of this account on it: accountcredentialscope and legacybuckets", since = "24.0.0") + private Long accountId; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// ///////////////////////////////////////////////////// @@ -56,6 +61,10 @@ public Long getId() { return id; } + public Long getAccountId() { + return accountId; + } + public String getProvider() { return provider; } diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/storage/MigrateObjectStoreAccountCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/storage/MigrateObjectStoreAccountCmd.java new file mode 100644 index 000000000000..6501099e6afc --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/storage/MigrateObjectStoreAccountCmd.java @@ -0,0 +1,104 @@ +// 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.cloudstack.api.command.admin.storage; + +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.user.Account; +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.AccountResponse; +import org.apache.cloudstack.api.response.ObjectStoreResponse; +import org.apache.cloudstack.api.response.SuccessResponse; +import org.apache.cloudstack.context.CallContext; + +@APICommand(name = "migrateObjectStoreAccount", description = "Migrates an account's identity on an object store into the state its provider requires for per-bucket credentials. On Ceph RGW this creates an RGW account and adopts the account's existing RGW user into it as the account root, which transfers ownership of all its buckets to the RGW account and is permanent. Existing buckets keep working with their current keys; they gain dedicated credentials individually via migrateBucketCredential.", + responseObject = SuccessResponse.class, entityType = {Account.class}, + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, since = "24.0.0", + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin}) +public class MigrateObjectStoreAccountCmd extends BaseCmd { + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + + @Parameter(name = ApiConstants.ACCOUNT_ID, type = CommandType.UUID, entityType = AccountResponse.class, + required = true, description = "The ID of the account to migrate") + private Long accountId; + + @Parameter(name = ApiConstants.OBJECT_STORAGE_ID, type = CommandType.UUID, entityType = ObjectStoreResponse.class, + required = true, description = "The ID of the object store on which to migrate the account") + private Long objectStoreId; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public Long getAccountId() { + return accountId; + } + + public Long getObjectStoreId() { + return objectStoreId; + } + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + + @Override + public long getEntityOwnerId() { + Account account = _entityMgr.findById(Account.class, getAccountId()); + if (account != null) { + return account.getId(); + } + return Account.ACCOUNT_ID_SYSTEM; + } + + @Override + public Long getApiResourceId() { + return accountId; + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.Account; + } + + @Override + public void execute() { + CallContext.current().setEventDetails("Account ID: " + getResourceUuid(ApiConstants.ACCOUNT_ID) + " object store ID: " + getResourceUuid(ApiConstants.OBJECT_STORAGE_ID)); + boolean result; + try { + result = _bucketService.migrateObjectStoreAccount(this, CallContext.current().getCallingAccount()); + } catch (InvalidParameterValueException e) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, e.getMessage()); + } catch (Exception e) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Error while migrating account on object store. " + e.getMessage()); + } + if (result) { + setResponseObject(new SuccessResponse(getCommandName())); + } else { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to migrate account on object store"); + } + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/storage/RotateObjectStoreAccountKeyCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/storage/RotateObjectStoreAccountKeyCmd.java new file mode 100644 index 000000000000..4042e9e591f6 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/storage/RotateObjectStoreAccountKeyCmd.java @@ -0,0 +1,104 @@ +// 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.cloudstack.api.command.admin.storage; + +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.user.Account; +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.AccountResponse; +import org.apache.cloudstack.api.response.ObjectStoreResponse; +import org.apache.cloudstack.api.response.SuccessResponse; +import org.apache.cloudstack.context.CallContext; + +@APICommand(name = "rotateObjectStoreAccountKey", description = "Replaces the account-level key CloudStack holds for an account on an object store with a fresh one and revokes the old key. Completes the migration to per-bucket credentials: only allowed once no bucket of the account on that store still uses the account key. Any consumer still configured with the old account key loses access immediately.", + responseObject = SuccessResponse.class, entityType = {Account.class}, + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, since = "24.0.0", + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin}) +public class RotateObjectStoreAccountKeyCmd extends BaseCmd { + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + + @Parameter(name = ApiConstants.ACCOUNT_ID, type = CommandType.UUID, entityType = AccountResponse.class, + required = true, description = "The ID of the account whose key to rotate") + private Long accountId; + + @Parameter(name = ApiConstants.OBJECT_STORAGE_ID, type = CommandType.UUID, entityType = ObjectStoreResponse.class, + required = true, description = "The ID of the object store") + private Long objectStoreId; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public Long getAccountId() { + return accountId; + } + + public Long getObjectStoreId() { + return objectStoreId; + } + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + + @Override + public long getEntityOwnerId() { + Account account = _entityMgr.findById(Account.class, getAccountId()); + if (account != null) { + return account.getId(); + } + return Account.ACCOUNT_ID_SYSTEM; + } + + @Override + public Long getApiResourceId() { + return accountId; + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.Account; + } + + @Override + public void execute() { + CallContext.current().setEventDetails("Account ID: " + getResourceUuid(ApiConstants.ACCOUNT_ID) + " object store ID: " + getResourceUuid(ApiConstants.OBJECT_STORAGE_ID)); + boolean result; + try { + result = _bucketService.rotateObjectStoreAccountKey(this, CallContext.current().getCallingAccount()); + } catch (InvalidParameterValueException e) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, e.getMessage()); + } catch (Exception e) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Error while rotating the account key on the object store. " + e.getMessage()); + } + if (result) { + setResponseObject(new SuccessResponse(getCommandName())); + } else { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to rotate the account key on the object store"); + } + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/bucket/ListBucketsCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/bucket/ListBucketsCmd.java index bda0c7ed381e..f1f441c3f8a3 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/bucket/ListBucketsCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/bucket/ListBucketsCmd.java @@ -17,6 +17,7 @@ package org.apache.cloudstack.api.command.user.bucket; import org.apache.cloudstack.storage.object.Bucket; +import org.apache.cloudstack.storage.object.BucketCredential; import org.apache.cloudstack.acl.RoleType; import org.apache.cloudstack.api.APICommand; import org.apache.cloudstack.api.ApiCommandResourceType; @@ -27,7 +28,7 @@ import org.apache.cloudstack.api.command.user.UserCmd; import org.apache.cloudstack.api.response.BucketResponse; import org.apache.cloudstack.api.response.ListResponse; -import org.apache.cloudstack.api.response.StoragePoolResponse; +import org.apache.cloudstack.api.response.ObjectStoreResponse; import java.util.List; @@ -51,10 +52,15 @@ public class ListBucketsCmd extends BaseListTaggedResourcesCmd implements UserCm @Parameter(name = ApiConstants.NAME, type = CommandType.STRING, description = "the name of the bucket") private String bucketName; - @Parameter(name = ApiConstants.OBJECT_STORAGE_ID, type = CommandType.UUID, entityType = StoragePoolResponse.class, description = "the ID of the object storage pool, available to ROOT admin only", authorized = { + @Parameter(name = ApiConstants.OBJECT_STORAGE_ID, type = CommandType.UUID, entityType = ObjectStoreResponse.class, description = "the ID of the object storage pool, available to ROOT admin only", authorized = { RoleType.Admin}) private Long objectStorageId; + @Parameter(name = ApiConstants.CREDENTIAL_SCOPE, type = CommandType.STRING, description = "filter by credential scope: '" + + BucketCredential.SCOPE_BUCKET + "' lists buckets that have their own credential, '" + + BucketCredential.SCOPE_ACCOUNT + "' lists buckets still using the account credential", since = "24.0.0") + private String credentialScope; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// ///////////////////////////////////////////////////// @@ -67,6 +73,10 @@ public String getBucketName() { return bucketName; } + public String getCredentialScope() { + return credentialScope; + } + public Long getObjectStorageId() { return objectStorageId; } diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/bucket/MigrateBucketCredentialCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/bucket/MigrateBucketCredentialCmd.java new file mode 100644 index 000000000000..c07aac551c2e --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/bucket/MigrateBucketCredentialCmd.java @@ -0,0 +1,96 @@ +// 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.cloudstack.api.command.user.bucket; + +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.user.Account; +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.acl.SecurityChecker.AccessType; +import org.apache.cloudstack.api.ACL; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.BucketResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.storage.object.Bucket; + +@APICommand(name = "migrateBucketCredential", description = "Gives an existing bucket that still uses its account's credential a dedicated credential with its own rotatable keys. The account's credential keeps working on the bucket until it is revoked by the operator. Requires the account to have been migrated on the object store with migrateObjectStoreAccount where the provider needs it.", + responseObject = BucketResponse.class, entityType = {Bucket.class}, + requestHasSensitiveInfo = false, responseHasSensitiveInfo = true, since = "24.0.0", + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class MigrateBucketCredentialCmd extends BaseCmd { + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + + @ACL(accessType = AccessType.OperateEntry) + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = BucketResponse.class, + required = true, description = "The ID of the Bucket") + private Long id; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public Long getId() { + return id; + } + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + + @Override + public long getEntityOwnerId() { + Bucket bucket = _entityMgr.findById(Bucket.class, getId()); + if (bucket != null) { + return bucket.getAccountId(); + } + return Account.ACCOUNT_ID_SYSTEM; + } + + @Override + public Long getApiResourceId() { + return id; + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.Bucket; + } + + @Override + public void execute() { + CallContext.current().setEventDetails("Bucket ID: " + getResourceUuid(ApiConstants.ID)); + Bucket bucket; + try { + bucket = _bucketService.migrateBucketCredential(this, CallContext.current().getCallingAccount()); + } catch (InvalidParameterValueException e) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, e.getMessage()); + } catch (Exception e) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Error while migrating bucket credential. " + e.getMessage()); + } + BucketResponse response = _responseGenerator.createBucketResponse(bucket); + response.setResponseName(getCommandName()); + setResponseObject(response); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/bucket/RevokeBucketKeyCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/bucket/RevokeBucketKeyCmd.java new file mode 100644 index 000000000000..eb6afada8d90 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/bucket/RevokeBucketKeyCmd.java @@ -0,0 +1,107 @@ +// 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.cloudstack.api.command.user.bucket; + +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.user.Account; +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.acl.SecurityChecker.AccessType; +import org.apache.cloudstack.api.ACL; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.BucketResponse; +import org.apache.cloudstack.api.response.SuccessResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.storage.object.Bucket; + +@APICommand(name = "revokeBucketKey", description = "Revokes the key pair in one slot of a bucket's dedicated credential. A bucket always keeps one active key, so the last active key cannot be revoked: create a key in the other slot first.", + responseObject = SuccessResponse.class, entityType = {Bucket.class}, + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, since = "24.0.0", + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class RevokeBucketKeyCmd extends BaseCmd { + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + + @ACL(accessType = AccessType.OperateEntry) + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = BucketResponse.class, + required = true, description = "The ID of the Bucket") + private Long id; + + @Parameter(name = ApiConstants.KEY_SLOT, type = CommandType.INTEGER, required = true, + description = "The key slot (1 or 2) to revoke") + private Integer keySlot; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public Long getId() { + return id; + } + + public Integer getKeySlot() { + return keySlot; + } + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + + @Override + public long getEntityOwnerId() { + Bucket bucket = _entityMgr.findById(Bucket.class, getId()); + if (bucket != null) { + return bucket.getAccountId(); + } + return Account.ACCOUNT_ID_SYSTEM; + } + + @Override + public Long getApiResourceId() { + return id; + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.Bucket; + } + + @Override + public void execute() { + CallContext.current().setEventDetails("Bucket ID: " + getResourceUuid(ApiConstants.ID) + " key slot: " + keySlot); + boolean result; + try { + result = _bucketService.revokeBucketKey(this, CallContext.current().getCallingAccount()); + } catch (InvalidParameterValueException e) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, e.getMessage()); + } catch (Exception e) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Error while revoking bucket key. " + e.getMessage()); + } + if (result) { + setResponseObject(new SuccessResponse(getCommandName())); + } else { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to revoke bucket key"); + } + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/bucket/RotateBucketKeyCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/bucket/RotateBucketKeyCmd.java new file mode 100644 index 000000000000..4b1d5347004f --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/bucket/RotateBucketKeyCmd.java @@ -0,0 +1,106 @@ +// 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.cloudstack.api.command.user.bucket; + +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.user.Account; +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.acl.SecurityChecker.AccessType; +import org.apache.cloudstack.api.ACL; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.BucketKeyResponse; +import org.apache.cloudstack.api.response.BucketResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.storage.object.Bucket; +import org.apache.cloudstack.storage.object.BucketCredentialKey; + +@APICommand(name = "rotateBucketKey", description = "Creates a new key pair in one slot of a bucket's dedicated credential, replacing any key that slot held. The other slot is untouched.", + responseObject = BucketKeyResponse.class, entityType = {Bucket.class}, + requestHasSensitiveInfo = false, responseHasSensitiveInfo = true, since = "24.0.0", + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class RotateBucketKeyCmd extends BaseCmd { + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + + @ACL(accessType = AccessType.OperateEntry) + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = BucketResponse.class, + required = true, description = "The ID of the Bucket") + private Long id; + + @Parameter(name = ApiConstants.KEY_SLOT, type = CommandType.INTEGER, + description = "The key slot (1 or 2) to rotate. Defaults to the slot that holds no active key; required when both slots are active.") + private Integer keySlot; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public Long getId() { + return id; + } + + public Integer getKeySlot() { + return keySlot; + } + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + + @Override + public long getEntityOwnerId() { + Bucket bucket = _entityMgr.findById(Bucket.class, getId()); + if (bucket != null) { + return bucket.getAccountId(); + } + return Account.ACCOUNT_ID_SYSTEM; + } + + @Override + public Long getApiResourceId() { + return id; + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.Bucket; + } + + @Override + public void execute() { + CallContext.current().setEventDetails("Bucket ID: " + getResourceUuid(ApiConstants.ID)); + BucketCredentialKey key; + try { + key = _bucketService.rotateBucketKey(this, CallContext.current().getCallingAccount()); + } catch (InvalidParameterValueException e) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, e.getMessage()); + } catch (Exception e) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Error while rotating bucket key. " + e.getMessage()); + } + BucketKeyResponse response = _responseGenerator.createBucketKeyResponse(key); + response.setResponseName(getCommandName()); + setResponseObject(response); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/BucketKeyResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/BucketKeyResponse.java new file mode 100644 index 000000000000..edd23b16fc64 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/BucketKeyResponse.java @@ -0,0 +1,109 @@ +// 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.cloudstack.api.response; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; +import org.apache.cloudstack.api.EntityReference; +import org.apache.cloudstack.storage.object.BucketCredentialKey; + +import java.util.Date; + +@EntityReference(value = BucketCredentialKey.class) +public class BucketKeyResponse extends BaseResponse { + @SerializedName(ApiConstants.ID) + @Param(description = "ID of the bucket key") + private String id; + + @SerializedName(ApiConstants.KEY_SLOT) + @Param(description = "the key slot (1 or 2) this key occupies") + private Integer keySlot; + + @SerializedName(ApiConstants.ACCESS_KEY) + @Param(description = "the access key") + private String accessKey; + + @SerializedName(ApiConstants.SECRET_KEY) + @Param(description = "the secret key; absent once the key is revoked", isSensitive = true) + private String secretKey; + + @SerializedName(ApiConstants.STATE) + @Param(description = "state of the key: Active or Revoked") + private String state; + + @SerializedName(ApiConstants.CREATED) + @Param(description = "the date the key was created") + private Date created; + + @SerializedName(ApiConstants.LAST_USED) + @Param(description = "the date the key was last used, if known") + private Date lastUsed; + + public BucketKeyResponse() { + setObjectName("bucketkey"); + } + + public void setId(String id) { + this.id = id; + } + + public void setKeySlot(Integer keySlot) { + this.keySlot = keySlot; + } + + public void setAccessKey(String accessKey) { + this.accessKey = accessKey; + } + + public void setSecretKey(String secretKey) { + this.secretKey = secretKey; + } + + public void setState(String state) { + this.state = state; + } + + public void setCreated(Date created) { + this.created = created; + } + + public void setLastUsed(Date lastUsed) { + this.lastUsed = lastUsed; + } + + public String getId() { + return id; + } + + public Integer getKeySlot() { + return keySlot; + } + + public String getAccessKey() { + return accessKey; + } + + public String getSecretKey() { + return secretKey; + } + + public String getState() { + return state; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/BucketResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/BucketResponse.java index cde140839ec0..633ad1bfbd7e 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/BucketResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/BucketResponse.java @@ -25,6 +25,7 @@ import java.util.Date; import java.util.LinkedHashSet; +import java.util.List; import java.util.Set; @EntityReference(value = Bucket.class) @@ -103,13 +104,25 @@ public class BucketResponse extends BaseResponseWithTagInformation implements Co private String accessKey; @SerializedName(ApiConstants.USER_SECRET_KEY) - @Param(description = "Bucket Secret Key") + @Param(description = "Bucket Secret Key", isSensitive = true) private String secretKey; @SerializedName(ApiConstants.PROVIDER) @Param(description = "Object storage provider") private String provider; + @SerializedName("accountcredentialscope") + @Param(description = "whether the owning account is set up for per-bucket credentials on this object store: 'bucket' when it is, 'account' when its buckets still share one credential. Only a bucket whose account is set up for them can be given its own credential.", since = "24.0.0") + private String accountCredentialScope; + + @SerializedName(ApiConstants.CREDENTIAL_SCOPE) + @Param(description = "scope of the bucket's credential: 'bucket' when the bucket has a dedicated credential, 'account' when it shares the account's credential", since = "24.0.0") + private String credentialScope; + + @SerializedName(ApiConstants.CREDENTIAL_KEYS) + @Param(description = "the key slots of the bucket's dedicated credential", responseObject = BucketKeyResponse.class, since = "24.0.0") + private List keys; + public BucketResponse() { tags = new LinkedHashSet(); } @@ -299,4 +312,24 @@ public String getProvider() { public void setProvider(String provider) { this.provider = provider; } + + public String getCredentialScope() { + return credentialScope; + } + + public void setAccountCredentialScope(String accountCredentialScope) { + this.accountCredentialScope = accountCredentialScope; + } + + public void setCredentialScope(String credentialScope) { + this.credentialScope = credentialScope; + } + + public List getKeys() { + return keys; + } + + public void setKeys(List keys) { + this.keys = keys; + } } diff --git a/api/src/main/java/org/apache/cloudstack/api/response/ObjectStoreResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/ObjectStoreResponse.java index dcb93aaaf1d2..9f682ba5952a 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/ObjectStoreResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/ObjectStoreResponse.java @@ -50,6 +50,30 @@ public class ObjectStoreResponse extends BaseResponseWithAnnotations { @Param(description = "the allocated size of the object store") private Long storageAllocated; + @SerializedName("perbucketcredentialsready") + @Param(description = "whether this store can provide per-bucket credentials right now; root admin only", since = "24.0.0") + private Boolean perBucketCredentialsReady; + + @SerializedName("perbucketcredentialsissue") + @Param(description = "what has to be resolved before this store can provide per-bucket credentials, absent when it can; root admin only", since = "24.0.0") + private String perBucketCredentialsIssue; + + @SerializedName("perbucketcredentialssupported") + @Param(description = "only with accountid: whether this store supports per-bucket credentials at all; when false the account cannot be migrated on it", since = "24.0.0") + private Boolean perBucketCredentialsSupported; + + @SerializedName("accountcredentialscope") + @Param(description = "only with accountid: 'bucket' when the account has been migrated for per-bucket credentials on this store, 'account' otherwise", since = "24.0.0") + private String accountCredentialScope; + + @SerializedName("accountkeyrotationpending") + @Param(description = "only with accountid: true when the account was migrated from a pre-existing identity and its original key has not been rotated yet", since = "24.0.0") + private Boolean accountKeyRotationPending; + + @SerializedName("legacybuckets") + @Param(description = "only with accountid: number of the account's buckets on this store that still use the account-level key", since = "24.0.0") + private Long legacyBuckets; + @SerializedName("storageused") @Param(description = "the object store currently used size") private Long storageUsed; @@ -117,4 +141,28 @@ public Long getStorageUsed() { public void setStorageUsed(Long storageUsed) { this.storageUsed = storageUsed; } + + public void setPerBucketCredentialsReady(Boolean perBucketCredentialsReady) { + this.perBucketCredentialsReady = perBucketCredentialsReady; + } + + public void setPerBucketCredentialsIssue(String perBucketCredentialsIssue) { + this.perBucketCredentialsIssue = perBucketCredentialsIssue; + } + + public void setPerBucketCredentialsSupported(Boolean perBucketCredentialsSupported) { + this.perBucketCredentialsSupported = perBucketCredentialsSupported; + } + + public void setAccountCredentialScope(String accountCredentialScope) { + this.accountCredentialScope = accountCredentialScope; + } + + public void setLegacyBuckets(Long legacyBuckets) { + this.legacyBuckets = legacyBuckets; + } + + public void setAccountKeyRotationPending(Boolean accountKeyRotationPending) { + this.accountKeyRotationPending = accountKeyRotationPending; + } } diff --git a/api/src/main/java/org/apache/cloudstack/storage/object/BucketApiService.java b/api/src/main/java/org/apache/cloudstack/storage/object/BucketApiService.java index 8c164133db88..b26f223927a3 100644 --- a/api/src/main/java/org/apache/cloudstack/storage/object/BucketApiService.java +++ b/api/src/main/java/org/apache/cloudstack/storage/object/BucketApiService.java @@ -20,12 +20,26 @@ import com.cloud.exception.ResourceAllocationException; import com.cloud.user.Account; +import org.apache.cloudstack.api.command.admin.storage.MigrateObjectStoreAccountCmd; +import org.apache.cloudstack.api.command.admin.storage.RotateObjectStoreAccountKeyCmd; import org.apache.cloudstack.api.command.user.bucket.CreateBucketCmd; +import org.apache.cloudstack.api.command.user.bucket.MigrateBucketCredentialCmd; +import org.apache.cloudstack.api.command.user.bucket.RevokeBucketKeyCmd; +import org.apache.cloudstack.api.command.user.bucket.RotateBucketKeyCmd; import org.apache.cloudstack.api.command.user.bucket.UpdateBucketCmd; import org.apache.cloudstack.framework.config.ConfigKey; +import java.util.List; + public interface BucketApiService { + ConfigKey PerBucketCredentials = new ConfigKey("Advanced", Boolean.class, + "object.storage.per.bucket.credentials", + "true", + "How an account is set up the first time it uses an object store that supports per-bucket credentials. When true, it is set up so that each of its buckets gets its own credential with rotatable keys. When false, it is set up to share one credential across its buckets, as in earlier releases, and an administrator can migrate it later. Accounts that have already been migrated always get per-bucket credentials, whatever this is set to.", + true, + ConfigKey.Scope.Global, + null); ConfigKey DefaultMaxAccountBuckets = new ConfigKey("Account Defaults", Long.class, "max.account.buckets", @@ -99,5 +113,44 @@ public interface BucketApiService { boolean updateBucket(UpdateBucketCmd cmd, Account caller) throws ResourceAllocationException; + /** + * Create a new key pair in one slot of the bucket's dedicated credential, replacing + * whatever the slot held. The other slot is untouched. + */ + BucketCredentialKey rotateBucketKey(RotateBucketKeyCmd cmd, Account caller); + + /** + * Revoke the key pair in one slot. The last active key of a credential cannot be revoked. + */ + boolean revokeBucketKey(RevokeBucketKeyCmd cmd, Account caller); + + /** + * Give an existing bucket that still uses the account credential a dedicated credential. + */ + Bucket migrateBucketCredential(MigrateBucketCredentialCmd cmd, Account caller); + + /** + * Explicitly migrate an account's identity on an object store into the state its provider + * requires for per-bucket credentials. Irreversible on some providers. + */ + boolean migrateObjectStoreAccount(MigrateObjectStoreAccountCmd cmd, Account caller); + + /** + * Rotate the account-level key CloudStack holds for the account on the object store, once + * no bucket of that account on the store still uses it. Revokes the old key. + */ + boolean rotateObjectStoreAccountKey(RotateObjectStoreAccountKeyCmd cmd, Account caller); + + /** + * Number of buckets of the account on the store that still use the account-level key. + */ + long countAccountScopedBuckets(long accountId, long objectStoreId); + + /** + * The key slots of the bucket's dedicated credential, or null if the bucket uses the + * account credential. + */ + List listBucketKeys(long bucketId); + void getBucketUsage(); } diff --git a/api/src/main/java/org/apache/cloudstack/storage/object/BucketCredential.java b/api/src/main/java/org/apache/cloudstack/storage/object/BucketCredential.java new file mode 100644 index 000000000000..54d5165dc927 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/storage/object/BucketCredential.java @@ -0,0 +1,50 @@ +// 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.cloudstack.storage.object; + +import org.apache.cloudstack.api.Identity; +import org.apache.cloudstack.api.InternalIdentity; + +import java.util.Date; + +/** + * A dedicated backend identity owned by a single bucket. A bucket without a + * BucketCredential uses the legacy account-scoped credential of its owner. + */ +public interface BucketCredential extends Identity, InternalIdentity { + + /** Credential scope of a bucket that has its own credential. */ + String SCOPE_BUCKET = "bucket"; + /** Credential scope of a bucket still served by the account's credential. */ + String SCOPE_ACCOUNT = "account"; + + long getBucketId(); + + String getProviderCredentialId(); + + State getState(); + + Date getCreated(); + + enum State { + Active, Removed; + @Override + public String toString() { + return this.name(); + } + } +} diff --git a/api/src/main/java/org/apache/cloudstack/storage/object/BucketCredentialKey.java b/api/src/main/java/org/apache/cloudstack/storage/object/BucketCredentialKey.java new file mode 100644 index 000000000000..e5bb96470acf --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/storage/object/BucketCredentialKey.java @@ -0,0 +1,55 @@ +// 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.cloudstack.storage.object; + +import org.apache.cloudstack.api.Identity; +import org.apache.cloudstack.api.InternalIdentity; + +import java.util.Date; + +/** + * One key slot of a {@link BucketCredential}. A credential holds up to two + * slots that rotate independently, so consumers can migrate to a fresh key + * in one slot while the other keeps serving. + */ +public interface BucketCredentialKey extends Identity, InternalIdentity { + + int KEY_SLOT_ONE = 1; + int KEY_SLOT_TWO = 2; + + long getBucketCredentialId(); + + int getKeySlot(); + + String getAccessKey(); + + String getSecretKey(); + + State getState(); + + Date getCreated(); + + Date getLastUsed(); + + enum State { + Active, Revoked; + @Override + public String toString() { + return this.name(); + } + } +} diff --git a/api/src/main/java/org/apache/cloudstack/storage/object/ObjectStore.java b/api/src/main/java/org/apache/cloudstack/storage/object/ObjectStore.java index 47741fc67f4a..c64e1d56ec1e 100644 --- a/api/src/main/java/org/apache/cloudstack/storage/object/ObjectStore.java +++ b/api/src/main/java/org/apache/cloudstack/storage/object/ObjectStore.java @@ -21,6 +21,28 @@ public interface ObjectStore extends Identity, InternalIdentity { + /** + * Prefix of the account details that hold the credentials CloudStack keeps for an account on + * an object store, one set per store. They are CloudStack's own credentials for the backend + * rather than the account's, so they are never returned in API responses. + */ + String ACCOUNT_DETAIL_PREFIX = "objectstore-"; + + /** Prefix shared by the account details of one object store. */ + static String accountDetailPrefix(long storeId) { + return ACCOUNT_DETAIL_PREFIX + storeId + "-"; + } + + /** Key of an account detail holding an object store credential for one store. */ + static String accountDetailKey(long storeId, String name) { + return accountDetailPrefix(storeId) + name; + } + + /** Whether an account detail key holds an object store credential CloudStack keeps internally. */ + static boolean isInternalAccountDetail(String key) { + return key != null && key.startsWith(ACCOUNT_DETAIL_PREFIX); + } + /** * @return name of the object store. */ diff --git a/api/src/test/java/org/apache/cloudstack/storage/object/ObjectStoreAccountDetailTest.java b/api/src/test/java/org/apache/cloudstack/storage/object/ObjectStoreAccountDetailTest.java new file mode 100644 index 000000000000..066b8d7f2024 --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/storage/object/ObjectStoreAccountDetailTest.java @@ -0,0 +1,48 @@ +// 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.cloudstack.storage.object; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class ObjectStoreAccountDetailTest { + + @Test + public void testAccountDetailKeysAreScopedPerStore() { + assertEquals("objectstore-7-root-accesskey", ObjectStore.accountDetailKey(7L, "root-accesskey")); + assertEquals("objectstore-7-", ObjectStore.accountDetailPrefix(7L)); + assertFalse(ObjectStore.accountDetailKey(7L, "root-accesskey").equals(ObjectStore.accountDetailKey(8L, "root-accesskey"))); + } + + @Test + public void testInternalDetailsAreRecognised() { + assertTrue(ObjectStore.isInternalAccountDetail(ObjectStore.accountDetailKey(1L, "root-secretkey"))); + assertTrue(ObjectStore.isInternalAccountDetail(ObjectStore.accountDetailKey(1L, "account-id"))); + } + + @Test + public void testUnrelatedDetailsAreLeftAlone() { + // pre-existing account details, including the provider's own legacy rows, are not ours to hide + assertFalse(ObjectStore.isInternalAccountDetail("ceph-rgw-accesskey")); + assertFalse(ObjectStore.isInternalAccountDetail("ceph-rgw-secretkey")); + assertFalse(ObjectStore.isInternalAccountDetail("some.account.setting")); + assertFalse(ObjectStore.isInternalAccountDetail(null)); + } +} diff --git a/engine/api/src/main/java/org/apache/cloudstack/storage/object/ObjectStoreEntity.java b/engine/api/src/main/java/org/apache/cloudstack/storage/object/ObjectStoreEntity.java index 7efb72d23b27..94ab624c5bea 100644 --- a/engine/api/src/main/java/org/apache/cloudstack/storage/object/ObjectStoreEntity.java +++ b/engine/api/src/main/java/org/apache/cloudstack/storage/object/ObjectStoreEntity.java @@ -18,11 +18,14 @@ */ package org.apache.cloudstack.storage.object; +import com.cloud.agent.api.to.BucketCredentialTO; +import com.cloud.agent.api.to.BucketKeyTO; import com.cloud.agent.api.to.BucketTO; import org.apache.cloudstack.engine.subsystem.api.storage.DataStore; import java.util.List; import java.util.Map; +import java.util.Set; public interface ObjectStoreEntity extends DataStore, ObjectStore { Bucket createBucket(Bucket bucket, boolean objectLock); @@ -46,4 +49,25 @@ public interface ObjectStoreEntity extends DataStore, ObjectStore { void setQuota(BucketTO bucket, int quota); Map getAllBucketsUsage(); + + boolean supportsBucketCredentials(); + + /** Why this store cannot provide per-bucket credentials; {@code null} when it can. */ + String bucketCredentialsUnsupportedReason(); + + boolean accountSupportsBucketCredentials(long accountId); + + boolean migrateAccountForBucketCredentials(long accountId); + + BucketCredentialTO createBucketCredential(BucketTO bucket); + + BucketKeyTO createBucketCredentialKey(BucketTO bucket, Set knownAccessKeys); + + boolean removeBucketCredentialKey(BucketTO bucket, String accessKey); + + boolean deleteBucketCredential(BucketTO bucket); + + BucketKeyTO rotateAccountKey(long accountId); + + boolean isAccountKeyRotationPending(long accountId); } diff --git a/engine/schema/src/main/java/com/cloud/storage/BucketCredentialKeyVO.java b/engine/schema/src/main/java/com/cloud/storage/BucketCredentialKeyVO.java new file mode 100644 index 000000000000..52d339136517 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/storage/BucketCredentialKeyVO.java @@ -0,0 +1,156 @@ +// 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 com.cloud.storage; + +import com.cloud.utils.db.Encrypt; +import com.cloud.utils.db.GenericDao; +import org.apache.cloudstack.storage.object.BucketCredentialKey; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; +import javax.persistence.Temporal; +import javax.persistence.TemporalType; +import java.util.Date; +import java.util.UUID; + +@Entity +@Table(name = "bucket_credential_key") +public class BucketCredentialKeyVO implements BucketCredentialKey { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private long id; + + @Column(name = "uuid") + private String uuid; + + @Column(name = "bucket_credential_id") + private long bucketCredentialId; + + @Column(name = "key_slot") + private int keySlot; + + @Column(name = "access_key") + private String accessKey; + + @Encrypt + @Column(name = "secret_key") + private String secretKey; + + @Column(name = "state", updatable = true, nullable = false) + @Enumerated(value = EnumType.STRING) + private State state; + + @Column(name = GenericDao.CREATED_COLUMN) + private Date created; + + @Column(name = "last_used") + @Temporal(value = TemporalType.TIMESTAMP) + private Date lastUsed; + + @Column(name = GenericDao.REMOVED_COLUMN) + private Date removed; + + public BucketCredentialKeyVO() { + this.uuid = UUID.randomUUID().toString(); + } + + public BucketCredentialKeyVO(long bucketCredentialId, int keySlot, String accessKey, String secretKey) { + this.uuid = UUID.randomUUID().toString(); + this.bucketCredentialId = bucketCredentialId; + this.keySlot = keySlot; + this.accessKey = accessKey; + this.secretKey = secretKey; + this.state = State.Active; + } + + @Override + public long getId() { + return id; + } + + @Override + public String getUuid() { + return uuid; + } + + @Override + public long getBucketCredentialId() { + return bucketCredentialId; + } + + @Override + public int getKeySlot() { + return keySlot; + } + + @Override + public String getAccessKey() { + return accessKey; + } + + public void setAccessKey(String accessKey) { + this.accessKey = accessKey; + } + + @Override + public String getSecretKey() { + return secretKey; + } + + public void setSecretKey(String secretKey) { + this.secretKey = secretKey; + } + + @Override + public State getState() { + return state; + } + + public void setState(State state) { + this.state = state; + } + + @Override + public Date getCreated() { + return created; + } + + public void setCreated(Date created) { + this.created = created; + } + + @Override + public Date getLastUsed() { + return lastUsed; + } + + public void setLastUsed(Date lastUsed) { + this.lastUsed = lastUsed; + } + + public Date getRemoved() { + return removed; + } +} diff --git a/engine/schema/src/main/java/com/cloud/storage/BucketCredentialVO.java b/engine/schema/src/main/java/com/cloud/storage/BucketCredentialVO.java new file mode 100644 index 000000000000..2430029e132e --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/storage/BucketCredentialVO.java @@ -0,0 +1,113 @@ +// 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 com.cloud.storage; + +import com.cloud.utils.db.GenericDao; +import org.apache.cloudstack.storage.object.BucketCredential; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; +import java.util.Date; +import java.util.UUID; + +@Entity +@Table(name = "bucket_credential") +public class BucketCredentialVO implements BucketCredential { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private long id; + + @Column(name = "uuid") + private String uuid; + + @Column(name = "bucket_id") + private long bucketId; + + @Column(name = "provider_credential_id") + private String providerCredentialId; + + @Column(name = "state", updatable = true, nullable = false) + @Enumerated(value = EnumType.STRING) + private State state; + + @Column(name = GenericDao.CREATED_COLUMN) + private Date created; + + @Column(name = GenericDao.REMOVED_COLUMN) + private Date removed; + + public BucketCredentialVO() { + this.uuid = UUID.randomUUID().toString(); + } + + public BucketCredentialVO(long bucketId, String providerCredentialId) { + this.uuid = UUID.randomUUID().toString(); + this.bucketId = bucketId; + this.providerCredentialId = providerCredentialId; + this.state = State.Active; + } + + @Override + public long getId() { + return id; + } + + @Override + public String getUuid() { + return uuid; + } + + @Override + public long getBucketId() { + return bucketId; + } + + @Override + public String getProviderCredentialId() { + return providerCredentialId; + } + + public void setProviderCredentialId(String providerCredentialId) { + this.providerCredentialId = providerCredentialId; + } + + @Override + public State getState() { + return state; + } + + public void setState(State state) { + this.state = state; + } + + @Override + public Date getCreated() { + return created; + } + + public Date getRemoved() { + return removed; + } +} diff --git a/engine/schema/src/main/java/com/cloud/storage/dao/BucketCredentialDao.java b/engine/schema/src/main/java/com/cloud/storage/dao/BucketCredentialDao.java new file mode 100644 index 000000000000..fd1fbc3ebc45 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/storage/dao/BucketCredentialDao.java @@ -0,0 +1,29 @@ +// 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 com.cloud.storage.dao; + +import com.cloud.storage.BucketCredentialVO; +import com.cloud.utils.db.GenericDao; + +import java.util.List; + +public interface BucketCredentialDao extends GenericDao { + BucketCredentialVO findByBucketId(long bucketId); + + /** Ids of the buckets that have a credential of their own, for credential-scope filtering. */ + List listBucketIdsWithCredential(); +} diff --git a/engine/schema/src/main/java/com/cloud/storage/dao/BucketCredentialDaoImpl.java b/engine/schema/src/main/java/com/cloud/storage/dao/BucketCredentialDaoImpl.java new file mode 100644 index 000000000000..3d79565090b2 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/storage/dao/BucketCredentialDaoImpl.java @@ -0,0 +1,67 @@ +// 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 com.cloud.storage.dao; + +import com.cloud.storage.BucketCredentialVO; +import com.cloud.utils.db.GenericDaoBase; +import com.cloud.utils.db.GenericSearchBuilder; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; +import org.springframework.stereotype.Component; + +import javax.naming.ConfigurationException; +import java.util.List; +import java.util.Map; + +@Component +public class BucketCredentialDaoImpl extends GenericDaoBase implements BucketCredentialDao { + + private SearchBuilder bucketIdSearch; + private GenericSearchBuilder allBucketIdsSearch; + + private static final String BUCKET_ID = "bucket_id"; + + protected BucketCredentialDaoImpl() { + } + + @Override + public boolean configure(String name, Map params) throws ConfigurationException { + super.configure(name, params); + + bucketIdSearch = createSearchBuilder(); + bucketIdSearch.and(BUCKET_ID, bucketIdSearch.entity().getBucketId(), SearchCriteria.Op.EQ); + bucketIdSearch.done(); + + allBucketIdsSearch = createSearchBuilder(Long.class); + allBucketIdsSearch.selectFields(allBucketIdsSearch.entity().getBucketId()); + allBucketIdsSearch.done(); + + return true; + } + + @Override + public List listBucketIdsWithCredential() { + return customSearch(allBucketIdsSearch.create(), null); + } + + @Override + public BucketCredentialVO findByBucketId(long bucketId) { + SearchCriteria sc = bucketIdSearch.create(); + sc.setParameters(BUCKET_ID, bucketId); + return findOneBy(sc); + } +} diff --git a/engine/schema/src/main/java/com/cloud/storage/dao/BucketCredentialKeyDao.java b/engine/schema/src/main/java/com/cloud/storage/dao/BucketCredentialKeyDao.java new file mode 100644 index 000000000000..085faeb39df7 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/storage/dao/BucketCredentialKeyDao.java @@ -0,0 +1,28 @@ +// 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 com.cloud.storage.dao; + +import com.cloud.storage.BucketCredentialKeyVO; +import com.cloud.utils.db.GenericDao; + +import java.util.List; + +public interface BucketCredentialKeyDao extends GenericDao { + List listByCredentialId(long bucketCredentialId); + + BucketCredentialKeyVO findByCredentialIdAndSlot(long bucketCredentialId, int keySlot); +} diff --git a/engine/schema/src/main/java/com/cloud/storage/dao/BucketCredentialKeyDaoImpl.java b/engine/schema/src/main/java/com/cloud/storage/dao/BucketCredentialKeyDaoImpl.java new file mode 100644 index 000000000000..be2744c71fee --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/storage/dao/BucketCredentialKeyDaoImpl.java @@ -0,0 +1,71 @@ +// 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 com.cloud.storage.dao; + +import com.cloud.storage.BucketCredentialKeyVO; +import com.cloud.utils.db.GenericDaoBase; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; +import org.springframework.stereotype.Component; + +import javax.naming.ConfigurationException; +import java.util.List; +import java.util.Map; + +@Component +public class BucketCredentialKeyDaoImpl extends GenericDaoBase implements BucketCredentialKeyDao { + + private SearchBuilder credentialIdSearch; + private SearchBuilder credentialIdSlotSearch; + + private static final String CREDENTIAL_ID = "bucket_credential_id"; + private static final String KEY_SLOT = "key_slot"; + + protected BucketCredentialKeyDaoImpl() { + } + + @Override + public boolean configure(String name, Map params) throws ConfigurationException { + super.configure(name, params); + + credentialIdSearch = createSearchBuilder(); + credentialIdSearch.and(CREDENTIAL_ID, credentialIdSearch.entity().getBucketCredentialId(), SearchCriteria.Op.EQ); + credentialIdSearch.done(); + + credentialIdSlotSearch = createSearchBuilder(); + credentialIdSlotSearch.and(CREDENTIAL_ID, credentialIdSlotSearch.entity().getBucketCredentialId(), SearchCriteria.Op.EQ); + credentialIdSlotSearch.and(KEY_SLOT, credentialIdSlotSearch.entity().getKeySlot(), SearchCriteria.Op.EQ); + credentialIdSlotSearch.done(); + + return true; + } + + @Override + public List listByCredentialId(long bucketCredentialId) { + SearchCriteria sc = credentialIdSearch.create(); + sc.setParameters(CREDENTIAL_ID, bucketCredentialId); + return listBy(sc); + } + + @Override + public BucketCredentialKeyVO findByCredentialIdAndSlot(long bucketCredentialId, int keySlot) { + SearchCriteria sc = credentialIdSlotSearch.create(); + sc.setParameters(CREDENTIAL_ID, bucketCredentialId); + sc.setParameters(KEY_SLOT, keySlot); + return findOneBy(sc); + } +} diff --git a/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql b/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql index 417d69a162eb..50b01c70669e 100644 --- a/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql +++ b/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql @@ -651,3 +651,35 @@ WHERE `name`='user.vm.readonly.details' AND `value` IS NOT NULL; -- usage records introduced in 4.22.1 (cumulative and per-VM) can coexist. See #13399. CALL `cloud_usage`.`IDEMPOTENT_DROP_INDEX`('id', 'cloud_usage.usage_volume'); CALL `cloud_usage`.`IDEMPOTENT_ADD_UNIQUE_INDEX`('cloud_usage.usage_volume', 'id', '(volume_id ASC, created ASC, vm_id ASC)'); + +-- Per-bucket object storage credentials. A bucket without a bucket_credential row uses the +-- legacy account-scoped credential; a bucket with one owns a dedicated backend identity +-- holding up to two independently rotatable key slots. +CREATE TABLE IF NOT EXISTS `cloud`.`bucket_credential` ( + `id` bigint(20) unsigned NOT NULL auto_increment, + `uuid` varchar(40) UNIQUE NOT NULL, + `bucket_id` bigint(20) unsigned NOT NULL, + `provider_credential_id` varchar(255) NOT NULL COMMENT 'backend identity reference, e.g. the Ceph RGW user id', + `state` varchar(32) NOT NULL, + `created` datetime NOT NULL, + `removed` datetime, + PRIMARY KEY (`id`), + UNIQUE KEY `uc_bucket_credential__bucket_id` (`bucket_id`), + CONSTRAINT `fk_bucket_credential__bucket_id` FOREIGN KEY (`bucket_id`) REFERENCES `cloud`.`bucket`(`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `cloud`.`bucket_credential_key` ( + `id` bigint(20) unsigned NOT NULL auto_increment, + `uuid` varchar(40) UNIQUE NOT NULL, + `bucket_credential_id` bigint(20) unsigned NOT NULL, + `key_slot` int unsigned NOT NULL COMMENT '1 or 2', + `access_key` varchar(255), + `secret_key` varchar(255) COMMENT 'encrypted bucket secret key', + `state` varchar(32) NOT NULL, + `created` datetime NOT NULL, + `last_used` datetime, + `removed` datetime, + PRIMARY KEY (`id`), + UNIQUE KEY `uc_bucket_credential_key__cred_slot` (`bucket_credential_id`, `key_slot`), + CONSTRAINT `fk_bucket_credential_key__credential_id` FOREIGN KEY (`bucket_credential_id`) REFERENCES `cloud`.`bucket_credential`(`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/engine/storage/object/src/main/java/org/apache/cloudstack/storage/object/store/ObjectStoreImpl.java b/engine/storage/object/src/main/java/org/apache/cloudstack/storage/object/store/ObjectStoreImpl.java index a96d87ada045..012071bb1019 100644 --- a/engine/storage/object/src/main/java/org/apache/cloudstack/storage/object/store/ObjectStoreImpl.java +++ b/engine/storage/object/src/main/java/org/apache/cloudstack/storage/object/store/ObjectStoreImpl.java @@ -18,6 +18,8 @@ */ package org.apache.cloudstack.storage.object.store; +import com.cloud.agent.api.to.BucketCredentialTO; +import com.cloud.agent.api.to.BucketKeyTO; import com.cloud.agent.api.to.BucketTO; import com.cloud.agent.api.to.DataStoreTO; import org.apache.cloudstack.storage.object.Bucket; @@ -35,6 +37,7 @@ import java.util.Date; import java.util.List; import java.util.Map; +import java.util.Set; public class ObjectStoreImpl implements ObjectStoreEntity { @@ -168,6 +171,56 @@ public boolean createUser(long accountId) { return driver.createUser(accountId, objectStoreVO.getId()); } + @Override + public boolean supportsBucketCredentials() { + return driver.supportsBucketCredentials(objectStoreVO.getId()); + } + + @Override + public String bucketCredentialsUnsupportedReason() { + return driver.bucketCredentialsUnsupportedReason(objectStoreVO.getId()); + } + + @Override + public boolean accountSupportsBucketCredentials(long accountId) { + return driver.accountSupportsBucketCredentials(accountId, objectStoreVO.getId()); + } + + @Override + public boolean migrateAccountForBucketCredentials(long accountId) { + return driver.migrateAccountForBucketCredentials(accountId, objectStoreVO.getId()); + } + + @Override + public BucketCredentialTO createBucketCredential(BucketTO bucket) { + return driver.createBucketCredential(bucket, objectStoreVO.getId()); + } + + @Override + public BucketKeyTO createBucketCredentialKey(BucketTO bucket, Set knownAccessKeys) { + return driver.createBucketCredentialKey(bucket, objectStoreVO.getId(), knownAccessKeys); + } + + @Override + public boolean removeBucketCredentialKey(BucketTO bucket, String accessKey) { + return driver.removeBucketCredentialKey(bucket, objectStoreVO.getId(), accessKey); + } + + @Override + public boolean deleteBucketCredential(BucketTO bucket) { + return driver.deleteBucketCredential(bucket, objectStoreVO.getId()); + } + + @Override + public BucketKeyTO rotateAccountKey(long accountId) { + return driver.rotateAccountKey(accountId, objectStoreVO.getId()); + } + + @Override + public boolean isAccountKeyRotationPending(long accountId) { + return driver.isAccountKeyRotationPending(accountId, objectStoreVO.getId()); + } + @Override public boolean delete(DataObject obj) { return false; diff --git a/engine/storage/object/src/main/resources/META-INF/cloudstack/core/spring-engine-storage-object-core-context.xml b/engine/storage/object/src/main/resources/META-INF/cloudstack/core/spring-engine-storage-object-core-context.xml index 57bd9f877498..5dbf77b84c33 100644 --- a/engine/storage/object/src/main/resources/META-INF/cloudstack/core/spring-engine-storage-object-core-context.xml +++ b/engine/storage/object/src/main/resources/META-INF/cloudstack/core/spring-engine-storage-object-core-context.xml @@ -32,5 +32,7 @@ + + diff --git a/engine/storage/src/main/java/org/apache/cloudstack/storage/object/BaseObjectStoreDriverImpl.java b/engine/storage/src/main/java/org/apache/cloudstack/storage/object/BaseObjectStoreDriverImpl.java index 8d45c959b59b..bea89fac08c9 100644 --- a/engine/storage/src/main/java/org/apache/cloudstack/storage/object/BaseObjectStoreDriverImpl.java +++ b/engine/storage/src/main/java/org/apache/cloudstack/storage/object/BaseObjectStoreDriverImpl.java @@ -18,8 +18,12 @@ */ package org.apache.cloudstack.storage.object; +import com.cloud.agent.api.to.BucketCredentialTO; +import com.cloud.agent.api.to.BucketKeyTO; +import com.cloud.agent.api.to.BucketTO; import com.cloud.agent.api.to.DataTO; import com.cloud.host.Host; +import com.cloud.utils.exception.CloudRuntimeException; import org.apache.cloudstack.engine.subsystem.api.storage.CopyCommandResult; import org.apache.cloudstack.engine.subsystem.api.storage.CreateCmdResult; import org.apache.cloudstack.engine.subsystem.api.storage.DataObject; @@ -31,10 +35,65 @@ import org.apache.logging.log4j.Logger; import java.util.Map; +import java.util.Set; public abstract class BaseObjectStoreDriverImpl implements ObjectStoreDriver { protected Logger logger = LogManager.getLogger(getClass()); + protected static final String BUCKET_CREDENTIALS_UNSUPPORTED = "Per-bucket credentials are not supported by this object store provider"; + + @Override + public boolean supportsBucketCredentials(long storeId) { + return false; + } + + @Override + public String bucketCredentialsUnsupportedReason(long storeId) { + // a provider that implements per-bucket credentials overrides this with its own diagnosis; + // for the rest, the reason is simply that they do not offer them + return "This object storage provider does not offer per-bucket credentials"; + } + + @Override + public boolean accountSupportsBucketCredentials(long accountId, long storeId) { + return false; + } + + @Override + public boolean migrateAccountForBucketCredentials(long accountId, long storeId) { + throw new CloudRuntimeException(BUCKET_CREDENTIALS_UNSUPPORTED); + } + + @Override + public BucketCredentialTO createBucketCredential(BucketTO bucket, long storeId) { + throw new CloudRuntimeException(BUCKET_CREDENTIALS_UNSUPPORTED); + } + + @Override + public BucketKeyTO createBucketCredentialKey(BucketTO bucket, long storeId, Set knownAccessKeys) { + throw new CloudRuntimeException(BUCKET_CREDENTIALS_UNSUPPORTED); + } + + @Override + public boolean removeBucketCredentialKey(BucketTO bucket, long storeId, String accessKey) { + throw new CloudRuntimeException(BUCKET_CREDENTIALS_UNSUPPORTED); + } + + @Override + public boolean deleteBucketCredential(BucketTO bucket, long storeId) { + throw new CloudRuntimeException(BUCKET_CREDENTIALS_UNSUPPORTED); + } + + @Override + public BucketKeyTO rotateAccountKey(long accountId, long storeId) { + throw new CloudRuntimeException(BUCKET_CREDENTIALS_UNSUPPORTED); + } + + @Override + public boolean isAccountKeyRotationPending(long accountId, long storeId) { + return false; + } + @Override public Map getCapabilities() { return null; diff --git a/engine/storage/src/main/java/org/apache/cloudstack/storage/object/ObjectStoreDriver.java b/engine/storage/src/main/java/org/apache/cloudstack/storage/object/ObjectStoreDriver.java index 13aaf7c002ef..b0556b7f0864 100644 --- a/engine/storage/src/main/java/org/apache/cloudstack/storage/object/ObjectStoreDriver.java +++ b/engine/storage/src/main/java/org/apache/cloudstack/storage/object/ObjectStoreDriver.java @@ -20,11 +20,14 @@ import com.amazonaws.services.s3.model.AccessControlList; import com.amazonaws.services.s3.model.BucketPolicy; +import com.cloud.agent.api.to.BucketCredentialTO; +import com.cloud.agent.api.to.BucketKeyTO; import com.cloud.agent.api.to.BucketTO; import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreDriver; import java.util.List; import java.util.Map; +import java.util.Set; public interface ObjectStoreDriver extends DataStoreDriver { Bucket createBucket(Bucket bucket, boolean objectLock); @@ -57,4 +60,66 @@ public interface ObjectStoreDriver extends DataStoreDriver { void setBucketQuota(BucketTO bucket, long storeId, long size); Map getAllBucketsUsage(long storeId); + + /** + * Whether this store can provision a dedicated credential per bucket at all + * (backend capability, independent of any account). + */ + boolean supportsBucketCredentials(long storeId); + + /** + * Why this store cannot provide per-bucket credentials, for an administrator who has just + * been refused; {@code null} when it can, or when the provider has nothing to add beyond + * "this provider does not offer them". + */ + default String bucketCredentialsUnsupportedReason(long storeId) { + return null; + } + + /** + * Whether buckets of the given account can be provisioned with dedicated + * credentials right now: the store supports them and the account's backend + * identity is in the required state (for example, already migrated to a + * backend account container where the provider needs one). + */ + boolean accountSupportsBucketCredentials(long accountId, long storeId); + + /** + * Explicit, administrator-triggered migration of an account's backend + * identity into the state required for per-bucket credentials. May be + * irreversible on the backend; never invoked implicitly. Idempotent. + */ + boolean migrateAccountForBucketCredentials(long accountId, long storeId); + + /** + * Provision a dedicated backend identity for the bucket, granted access to + * that bucket only, with its initial key pair. Idempotent on the backend. + */ + BucketCredentialTO createBucketCredential(BucketTO bucket, long storeId); + + /** + * Create an additional key pair on the bucket's identity and return exactly + * the new pair. {@code knownAccessKeys} lists the access keys CloudStack + * already tracks, for providers whose key-create call returns the full set. + */ + BucketKeyTO createBucketCredentialKey(BucketTO bucket, long storeId, Set knownAccessKeys); + + /** Remove one key pair from the bucket's identity. Key not found counts as success. */ + boolean removeBucketCredentialKey(BucketTO bucket, long storeId, String accessKey); + + /** Remove the bucket's identity and everything attached to it. Identity not found counts as success. */ + boolean deleteBucketCredential(BucketTO bucket, long storeId); + + /** + * Replace the account-level key CloudStack uses on this store with a fresh one and revoke + * the old one on the backend. Only valid once no bucket of the account still relies on the + * account key. Returns the new key pair. + */ + BucketKeyTO rotateAccountKey(long accountId, long storeId); + + /** + * Whether the account was migrated by adopting a pre-existing identity whose key may be + * held outside CloudStack, and that key has not been rotated away yet. + */ + boolean isAccountKeyRotationPending(long accountId, long storeId); } diff --git a/plugins/storage/object/ceph/pom.xml b/plugins/storage/object/ceph/pom.xml index dce9207843f6..9df777d183ef 100644 --- a/plugins/storage/object/ceph/pom.xml +++ b/plugins/storage/object/ceph/pom.xml @@ -48,5 +48,10 @@ radosgw-admin4j 2.0.9 + + com.amazonaws + aws-java-sdk-iam + ${cs.aws.sdk.version} + diff --git a/plugins/storage/object/ceph/src/main/java/org/apache/cloudstack/storage/datastore/driver/CephObjectStoreDriverImpl.java b/plugins/storage/object/ceph/src/main/java/org/apache/cloudstack/storage/datastore/driver/CephObjectStoreDriverImpl.java index 2a7b6e1dda6c..37abd27ee2a1 100644 --- a/plugins/storage/object/ceph/src/main/java/org/apache/cloudstack/storage/datastore/driver/CephObjectStoreDriverImpl.java +++ b/plugins/storage/object/ceph/src/main/java/org/apache/cloudstack/storage/datastore/driver/CephObjectStoreDriverImpl.java @@ -18,9 +18,24 @@ */ package org.apache.cloudstack.storage.datastore.driver; +import com.amazonaws.ClientConfiguration; 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.identitymanagement.model.AccessKey; +import com.amazonaws.services.identitymanagement.model.AccessKeyMetadata; +import com.amazonaws.services.identitymanagement.model.CreateAccessKeyRequest; +import com.amazonaws.services.identitymanagement.model.CreateUserRequest; +import com.amazonaws.services.identitymanagement.model.DeleteAccessKeyRequest; +import com.amazonaws.services.identitymanagement.model.DeleteUserPolicyRequest; +import com.amazonaws.services.identitymanagement.model.DeleteUserRequest; +import com.amazonaws.services.identitymanagement.model.EntityAlreadyExistsException; +import com.amazonaws.services.identitymanagement.model.ListAccessKeysRequest; +import com.amazonaws.services.identitymanagement.model.ListUserPoliciesRequest; +import com.amazonaws.services.identitymanagement.model.NoSuchEntityException; +import com.amazonaws.services.identitymanagement.model.PutUserPolicyRequest; import com.amazonaws.services.s3.AmazonS3; import com.amazonaws.services.s3.AmazonS3ClientBuilder; import com.amazonaws.services.s3.model.AmazonS3Exception; @@ -31,12 +46,19 @@ import com.amazonaws.services.s3.model.SetBucketPolicyRequest; import com.amazonaws.services.s3.model.GetBucketPolicyRequest; import com.amazonaws.services.s3.model.SetBucketVersioningConfigurationRequest; +import com.cloud.agent.api.to.BucketCredentialTO; +import com.cloud.agent.api.to.BucketKeyTO; import com.cloud.agent.api.to.BucketTO; import com.cloud.agent.api.to.DataStoreTO; +import com.cloud.utils.crypt.DBEncryptionUtil; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; import org.apache.cloudstack.storage.object.Bucket; +import org.apache.cloudstack.storage.object.ObjectStore; import com.cloud.storage.BucketVO; import com.cloud.storage.dao.BucketDao; import com.cloud.user.Account; +import com.cloud.user.AccountDetailVO; import com.cloud.user.AccountDetailsDao; import com.cloud.user.dao.AccountDao; import com.cloud.utils.exception.CloudRuntimeException; @@ -44,20 +66,27 @@ 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.RgwAccountClient; +import org.apache.cloudstack.storage.datastore.util.RgwIamSigner; import org.apache.cloudstack.storage.object.BaseObjectStoreDriverImpl; +import org.apache.cloudstack.storage.object.BucketApiService; import org.apache.cloudstack.storage.object.BucketObject; import org.twonote.rgwadmin4j.RgwAdmin; import org.twonote.rgwadmin4j.RgwAdminBuilder; +import org.twonote.rgwadmin4j.impl.RgwAdminException; import org.twonote.rgwadmin4j.model.BucketInfo; import org.twonote.rgwadmin4j.model.S3Credential; import org.twonote.rgwadmin4j.model.User; import javax.inject.Inject; +import java.util.Collections; import java.util.List; import java.util.ArrayList; import java.util.Optional; import java.util.Map; import java.util.HashMap; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; public class CephObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { @@ -79,9 +108,63 @@ public class CephObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { private static final String ACCESS_KEY = "accesskey"; private static final String SECRET_KEY = "secretkey"; + // Legacy account-scoped RGW user: uid = CloudStack account UUID, keys stored in clear. private static final String CEPH_ACCESS_KEY = "ceph-rgw-accesskey"; private static final String CEPH_SECRET_KEY = "ceph-rgw-secretkey"; + // RGW account mode (Squid+): the CloudStack account maps to an RGW account whose root user + // is the same uid as the legacy user. Presence of the account id is the never-fall-back marker. + // The rows are keyed per object store ("objectstore--account-id" and so on): an account + // can be migrated on one Ceph store and still be legacy on another. That namespace is + // CloudStack's own (see ObjectStore.ACCOUNT_DETAIL_PREFIX) and is kept out of API responses, + // unlike the legacy rows above, which existing behaviour still exposes. + protected static final String CEPH_ACCOUNT_ID = "account-id"; + protected static final String CEPH_ROOT_ACCESS_KEY = "root-accesskey"; + protected static final String CEPH_ROOT_SECRET_KEY = "root-secretkey"; + // set once the pre-migration account key has been rotated away; a brand-new account is + // created with a key nobody else ever held, so it is marked rotated from the start + protected static final String CEPH_ROOT_KEY_ROTATED = "root-key-rotated"; + + // Bounds on the IAM calls, which run under the per-account-per-store lock. The SDK's own + // defaults are a 50s socket timeout with three retries and no ceiling on the call as a whole. + private static final int IAM_SOCKET_TIMEOUT_MILLIS = 20000; + private static final int IAM_MAX_ERROR_RETRY = 1; + private static final int IAM_CALL_TIMEOUT_MILLIS = 30000; + + protected static String detailKey(long storeId, String name) { + return ObjectStore.accountDetailKey(storeId, name); + } + + /** The account-mode rows for this store, with the store prefix stripped from the keys. */ + protected Map accountModeDetails(long accountId, long storeId) { + String prefix = ObjectStore.accountDetailPrefix(storeId); + Map scoped = new HashMap<>(); + for (Map.Entry entry : _accountDetailsDao.findDetails(accountId).entrySet()) { + if (entry.getKey().startsWith(prefix)) { + scoped.put(entry.getKey().substring(prefix.length()), entry.getValue()); + } + } + return scoped; + } + + /** + * Merge the store's account-mode rows into the account's details. {@code update} keeps every + * other row (other stores, unrelated details); {@code persist} would replace the whole map. + */ + protected void persistAccountModeDetails(long accountId, long storeId, Map details) { + Map prefixed = new HashMap<>(); + for (Map.Entry entry : details.entrySet()) { + prefixed.put(detailKey(storeId, entry.getKey()), entry.getValue()); + } + _accountDetailsDao.update(accountId, prefixed); + } + + protected static final String BUCKET_POLICY_NAME = "cloudstack-bucket-access"; + private static final long ACCOUNT_SUPPORT_CACHE_MILLIS = 5 * 60 * 1000L; + + private final Map accountSupportCache = new ConcurrentHashMap<>(); + private final Map unsupportedReasonCache = new ConcurrentHashMap<>(); + @Override public DataStoreTO getStoreTO(DataStore store) { return null; @@ -107,12 +190,18 @@ public Bucket createBucket(Bucket bucket, boolean objectLock) { } try { s3client.createBucket(bucketName); - String accessKey = _accountDetailsDao.findDetail(accountId, CEPH_ACCESS_KEY).getValue(); - String secretKey = _accountDetailsDao.findDetail(accountId, CEPH_SECRET_KEY).getValue(); ObjectStoreVO store = _storeDao.findById(storeId); BucketVO bucketVO = _bucketDao.findById(bucket.getId()); - bucketVO.setAccessKey(accessKey); - bucketVO.setSecretKey(secretKey); + if (accountSupportsBucketCredentials(accountId, storeId)) { + // The bucket is about to get a credential of its own, which the service mirrors + // onto the row. The account's root key must never touch the row even briefly: + // every user of the account can list it there, and that key opens every bucket. + logger.debug("Bucket {} of a migrated account is created without the account key; its own credential follows", bucketName); + } else { + BucketKeyTO accountKey = getAccountKey(accountId, storeId); + bucketVO.setAccessKey(accountKey.getAccessKey()); + bucketVO.setSecretKey(accountKey.getSecretKey()); + } bucketVO.setBucketURL(store.getUrl() + "/" + bucketName); _bucketDao.update(bucket.getId(), bucketVO); return bucketVO; @@ -144,6 +233,12 @@ public boolean deleteBucket(BucketTO bucket, long storeId) { try { rgwAdmin.removeBucket(bucket.getName()); + } catch (RgwAdminException e) { + if (e.status() == 404) { + logger.info("Bucket {} no longer exists in Ceph RGW; treating removal as done", bucket.getName()); + return true; + } + throw new CloudRuntimeException(e); } catch (Exception e) { throw new CloudRuntimeException(e); } @@ -162,39 +257,60 @@ public void setBucketAcl(BucketTO bucket, AccessControlList acl, long storeId) { @Override public void setBucketPolicy(BucketTO bucket, String policy, long storeId) { - String policyConfig; + writeBucketPolicy(bucket, storeId, policy, bucket.getProviderCredentialId()); + } - if (policy.equalsIgnoreCase("public")) { + /** + * The bucket policy is shared between two features: CloudStack's public/private access + * setting and the grant that lets a bucket's dedicated IAM user reach objects the account + * root wrote before the account migration (an identity policy alone does not cover those, + * because they are still owned by the legacy user id). Always regenerate the whole document + * from both inputs so neither feature overwrites the other. + */ + protected void writeBucketPolicy(BucketTO bucket, long storeId, String policy, String credentialUserName) { + JsonArray statements = new JsonArray(); + String bucketArn = "arn:aws:s3:::" + bucket.getName(); + if (policy != null && policy.equalsIgnoreCase("public")) { logger.debug("Setting public policy on bucket " + bucket.getName()); - StringBuilder builder = new StringBuilder(); - builder.append("{\n"); - builder.append(" \"Statement\": [\n"); - builder.append(" {\n"); - builder.append(" \"Action\": [\n"); - builder.append(" \"s3:GetBucketLocation\",\n"); - builder.append(" \"s3:ListBucket\"\n"); - builder.append(" ],\n"); - builder.append(" \"Effect\": \"Allow\",\n"); - builder.append(" \"Principal\": \"*\",\n"); - builder.append(" \"Resource\": \"arn:aws:s3:::" + bucket.getName() + "\"\n"); - builder.append(" },\n"); - builder.append(" {\n"); - builder.append(" \"Action\": \"s3:GetObject\",\n"); - builder.append(" \"Effect\": \"Allow\",\n"); - builder.append(" \"Principal\": \"*\",\n"); - builder.append(" \"Resource\": \"arn:aws:s3:::" + bucket.getName() + "/*\"\n"); - builder.append(" }\n"); - builder.append(" ],\n"); - builder.append(" \"Version\": \"2012-10-17\"\n"); - builder.append("}\n"); - policyConfig = builder.toString(); + statements.add(statement("*", new String[] {"s3:GetBucketLocation", "s3:ListBucket"}, new String[] {bucketArn})); + statements.add(statement("*", new String[] {"s3:GetObject"}, new String[] {bucketArn + "/*"})); } else { logger.debug("Setting private policy on bucket " + bucket.getName()); - policyConfig = "{\"Version\":\"2012-10-17\",\"Statement\":[]}"; } + if (credentialUserName != null) { + String accountId = accountModeDetails(bucket.getAccountId(), storeId).get(CEPH_ACCOUNT_ID); + String principal = "arn:aws:iam::" + accountId + ":user/" + credentialUserName; + statements.add(statement(principal, new String[] {"s3:*"}, new String[] {bucketArn, bucketArn + "/*"})); + } + JsonObject document = new JsonObject(); + document.addProperty("Version", "2012-10-17"); + document.add("Statement", statements); - AmazonS3 client = getS3Client(getStoreURL(storeId), bucket.getAccessKey(), bucket.getSecretKey()); - client.setBucketPolicy(new SetBucketPolicyRequest(bucket.getName(), policyConfig)); + AmazonS3 client = getS3Client(storeId, bucket.getAccountId()); + client.setBucketPolicy(new SetBucketPolicyRequest(bucket.getName(), document.toString())); + } + + private static JsonObject statement(String principal, String[] actions, String[] resources) { + JsonObject statement = new JsonObject(); + statement.addProperty("Effect", "Allow"); + if ("*".equals(principal)) { + statement.addProperty("Principal", "*"); + } else { + JsonObject aws = new JsonObject(); + aws.addProperty("AWS", principal); + statement.add("Principal", aws); + } + JsonArray actionArray = new JsonArray(); + for (String action : actions) { + actionArray.add(action); + } + statement.add("Action", actionArray); + JsonArray resourceArray = new JsonArray(); + for (String resource : resources) { + resourceArray.add(resource); + } + statement.add("Resource", resourceArray); + return statement; } @Override @@ -209,8 +325,28 @@ public void deleteBucketPolicy(BucketTO bucket, long storeId) { client.deleteBucketPolicy(new DeleteBucketPolicyRequest(bucket.getName())); } + /** + * Ensure the account's backend identity exists. An account already in RGW account mode, or + * one that already has a legacy user, keeps what it has. A brand-new account is created in + * account mode when the store and the global setting allow it, otherwise as a legacy user. + * Existing legacy accounts are never moved into account mode here; that is the explicit, + * irreversible {@link #migrateAccountForBucketCredentials} operation. + */ @Override public boolean createUser(long accountId, long storeId) { + if (accountModeDetails(accountId, storeId).containsKey(CEPH_ACCOUNT_ID)) { + return true; + } + if (_accountDetailsDao.findDetails(accountId).containsKey(CEPH_ACCESS_KEY)) { + return ensureLegacyUser(accountId, storeId); + } + if (BucketApiService.PerBucketCredentials.value() && supportsBucketCredentials(storeId)) { + return migrateAccountForBucketCredentials(accountId, storeId); + } + return ensureLegacyUser(accountId, storeId); + } + + private boolean ensureLegacyUser(long accountId, long storeId) { Account account = _accountDao.findById(accountId); RgwAdmin rgwAdmin = getRgwAdminClient(storeId); String username = account.getUuid(); @@ -243,6 +379,288 @@ public boolean createUser(long accountId, long storeId) { } } + @Override + public boolean supportsBucketCredentials(long storeId) { + long[] cached = accountSupportCache.get(storeId); + long now = System.currentTimeMillis(); + if (cached != null && cached[0] == 1 && now - cached[1] < ACCOUNT_SUPPORT_CACHE_MILLIS) { + return true; + } + if (cached != null && cached[0] == 0 && now - cached[1] < ACCOUNT_SUPPORT_CACHE_MILLIS) { + return false; + } + boolean supported; + try { + RgwAccountClient accountClient = getRgwAccountClient(storeId); + supported = accountClient.isAvailable() && accountClient.hasAccountsWriteCapability(); + } catch (Exception e) { + logger.debug("Ceph RGW account API probe failed for store {}: {}", storeId, e.getMessage()); + supported = false; + } + logger.debug("Ceph RGW store {} {} the account API needed for per-bucket credentials", storeId, supported ? "supports" : "does not support"); + accountSupportCache.put(storeId, new long[] {supported ? 1 : 0, now}); + return supported; + } + + @Override + public String bucketCredentialsUnsupportedReason(long storeId) { + String[] cached = unsupportedReasonCache.get(storeId); + long now = System.currentTimeMillis(); + if (cached != null && now - Long.parseLong(cached[1]) < ACCOUNT_SUPPORT_CACHE_MILLIS) { + return cached[0].isEmpty() ? null : cached[0]; + } + String reason; + try { + reason = getRgwAccountClient(storeId).unsupportedReason(); + } catch (Exception e) { + reason = "the object store's admin API could not be reached: " + e.getMessage(); + } + // listing the stores asks this of every one of them, so keep it as briefly as the probe + unsupportedReasonCache.put(storeId, new String[] {reason == null ? "" : reason, Long.toString(now)}); + return reason; + } + + @Override + public boolean isAccountKeyRotationPending(long accountId, long storeId) { + Map details = accountModeDetails(accountId, storeId); + return details.containsKey(CEPH_ACCOUNT_ID) && !details.containsKey(CEPH_ROOT_KEY_ROTATED); + } + + /** + * Whether this account has been migrated to an RGW account on this store. Migration is + * permanent on the backend, so the recorded marker wins over the live support probe: a probe + * that fails or flaps during a gateway upgrade must never downgrade a migrated account, which + * would put its new buckets back on the shared account key. The gateway still overrules a + * stale record, but only when it answers; when it cannot be asked, the record stands. + */ + @Override + public boolean accountSupportsBucketCredentials(long accountId, long storeId) { + AccountDetailVO accountDetail = _accountDetailsDao.findDetail(accountId, detailKey(storeId, CEPH_ACCOUNT_ID)); + if (accountDetail == null) { + return false; + } + Account account = _accountDao.findById(accountId); + if (account == null) { + return false; + } + try { + boolean rootOnGateway = getRgwAccountClient(storeId).isAccountRootUser(account.getUuid()); + if (!rootOnGateway) { + logger.warn("Account {} is recorded as migrated on Ceph RGW store {} but its user is not an account root on the gateway; treating it as not migrated", account, storeId); + } + return rootOnGateway; + } catch (Exception e) { + logger.warn("Unable to confirm with Ceph RGW store {} whether account {} is an account root; keeping its recorded migrated state", storeId, account, e); + return true; + } + } + + /** + * Create the RGW account for the CloudStack account and make its RGW user the account root. + * An existing legacy user is adopted (its buckets move to the account, its keys keep working); + * a missing one is created directly in the account. Idempotent; permanent on the backend. + */ + @Override + public boolean migrateAccountForBucketCredentials(long accountId, long storeId) { + if (accountModeDetails(accountId, storeId).containsKey(CEPH_ACCOUNT_ID)) { + logger.debug("Account {} is already in Ceph RGW account mode on store {}", accountId, storeId); + return true; + } + if (!supportsBucketCredentials(storeId)) { + throw new CloudRuntimeException("The Ceph RGW object store does not support accounts (requires Ceph Squid or later and the accounts=write admin capability)"); + } + Account account = _accountDao.findById(accountId); + String uid = account.getUuid(); + RgwAdmin rgwAdmin = getRgwAdminClient(storeId); + RgwAccountClient accountClient = getRgwAccountClient(storeId); + + logger.info("Ensuring Ceph RGW account for CloudStack account {}", account); + RgwAccountClient.RgwAccount rgwAccount = accountClient.createAccount(RgwAccountClient.accountIdFor(uid), uid); + + Map userParams = new HashMap<>(); + userParams.put("account-id", rgwAccount.getId()); + userParams.put("account-root", "true"); + User rootUser; + boolean adopted = false; + try { + Optional existing = rgwAdmin.getUserInfo(uid); + if (existing.isPresent()) { + logger.info("Adopting Ceph RGW user {} into account {} as its root user", uid, rgwAccount.getId()); + rootUser = rgwAdmin.modifyUser(uid, userParams); + adopted = true; + } else { + logger.info("Creating Ceph RGW root user {} in account {}", uid, rgwAccount.getId()); + userParams.put("display-name", account.getAccountName()); + rootUser = rgwAdmin.createUser(uid, userParams); + } + if (rootUser == null || rootUser.getS3Credentials() == null || rootUser.getS3Credentials().isEmpty()) { + rootUser = rgwAdmin.getUserInfo(uid).orElseThrow(() -> new CloudRuntimeException("Ceph RGW root user " + uid + " not found after creation")); + } + } catch (RgwAdminException e) { + throw new CloudRuntimeException("Unable to set up the Ceph RGW root user for account " + account.getAccountName() + ": " + e.getMessage(), e); + } + + S3Credential rootKey = rootUser.getS3Credentials().get(0); + Map newDetails = new HashMap<>(); + newDetails.put(CEPH_ACCOUNT_ID, rgwAccount.getId()); + newDetails.put(CEPH_ROOT_ACCESS_KEY, rootKey.getAccessKey()); + newDetails.put(CEPH_ROOT_SECRET_KEY, DBEncryptionUtil.encrypt(rootKey.getSecretKey())); + if (!adopted) { + newDetails.put(CEPH_ROOT_KEY_ROTATED, "true"); + } + persistAccountModeDetails(accountId, storeId, newDetails); + return true; + } + + @Override + public BucketCredentialTO createBucketCredential(BucketTO bucket, long storeId) { + String userName = bucket.getUuid(); + AmazonIdentityManagement iam = getIamClient(storeId, bucket.getAccountId()); + boolean created = false; + try { + iam.createUser(new CreateUserRequest(userName)); + created = true; + logger.info("Created IAM user {} for bucket {}", userName, bucket.getName()); + } catch (EntityAlreadyExistsException e) { + logger.debug("IAM user {} for bucket {} already exists", userName, bucket.getName()); + } + iam.putUserPolicy(new PutUserPolicyRequest(userName, BUCKET_POLICY_NAME, bucketPolicy(bucket.getName()))); + if (!created) { + // CloudStack tracks no keys for this identity, so anything on it is an orphan from an + // earlier failed attempt; clear it so the new key is the only one. + for (AccessKeyMetadata orphan : iam.listAccessKeys(new ListAccessKeysRequest().withUserName(userName)).getAccessKeyMetadata()) { + logger.info("Removing untracked access key {} from IAM user {}", orphan.getAccessKeyId(), userName); + iam.deleteAccessKey(new DeleteAccessKeyRequest(userName, orphan.getAccessKeyId())); + } + } + AccessKey key = iam.createAccessKey(new CreateAccessKeyRequest(userName)).getAccessKey(); + + BucketVO bucketVO = _bucketDao.findByUuid(bucket.getUuid()); + writeBucketPolicy(bucket, storeId, bucketVO != null ? bucketVO.getPolicy() : null, userName); + return new BucketCredentialTO(userName, Collections.singletonList(new BucketKeyTO(key.getAccessKeyId(), key.getSecretAccessKey()))); + } + + @Override + public BucketKeyTO createBucketCredentialKey(BucketTO bucket, long storeId, Set knownAccessKeys) { + String userName = requireCredentialId(bucket); + AmazonIdentityManagement iam = getIamClient(storeId, bucket.getAccountId()); + AccessKey key = iam.createAccessKey(new CreateAccessKeyRequest(userName)).getAccessKey(); + return new BucketKeyTO(key.getAccessKeyId(), key.getSecretAccessKey()); + } + + @Override + public boolean removeBucketCredentialKey(BucketTO bucket, long storeId, String accessKey) { + String userName = requireCredentialId(bucket); + AmazonIdentityManagement iam = getIamClient(storeId, bucket.getAccountId()); + try { + iam.deleteAccessKey(new DeleteAccessKeyRequest(userName, accessKey)); + } catch (NoSuchEntityException e) { + logger.info("Access key {} of IAM user {} no longer exists; treating removal as done", accessKey, userName); + } + return true; + } + + @Override + public boolean deleteBucketCredential(BucketTO bucket, long storeId) { + String userName = requireCredentialId(bucket); + AmazonIdentityManagement iam = getIamClient(storeId, bucket.getAccountId()); + try { + for (AccessKeyMetadata key : iam.listAccessKeys(new ListAccessKeysRequest().withUserName(userName)).getAccessKeyMetadata()) { + iam.deleteAccessKey(new DeleteAccessKeyRequest(userName, key.getAccessKeyId())); + } + for (String policyName : iam.listUserPolicies(new ListUserPoliciesRequest().withUserName(userName)).getPolicyNames()) { + iam.deleteUserPolicy(new DeleteUserPolicyRequest(userName, policyName)); + } + iam.deleteUser(new DeleteUserRequest(userName)); + logger.info("Deleted IAM user {} of bucket {}", userName, bucket.getName()); + } catch (NoSuchEntityException e) { + logger.info("IAM user {} of bucket {} no longer exists; treating removal as done", userName, bucket.getName()); + } + return true; + } + + /** + * Issue a fresh key pair for the account's RGW root user, persist it and revoke the old one. + * Create-before-remove, so CloudStack never loses its own management access: if persisting + * the new key fails the new key is removed again and the old one stays in force. + */ + @Override + public BucketKeyTO rotateAccountKey(long accountId, long storeId) { + Map details = accountModeDetails(accountId, storeId); + if (!details.containsKey(CEPH_ACCOUNT_ID)) { + throw new CloudRuntimeException("Account " + accountId + " has not been migrated to a Ceph RGW account on this store yet"); + } + Account account = _accountDao.findById(accountId); + // trust the gateway over our own records: the user must really be an RGW account root here + if (!getRgwAccountClient(storeId).isAccountRootUser(account.getUuid())) { + throw new CloudRuntimeException("Account " + account.getAccountName() + " is not the root of a Ceph RGW account on this store; migrate the account on this store first"); + } + String oldAccessKey = details.get(CEPH_ROOT_ACCESS_KEY); + AmazonIdentityManagement iam = getIamClient(storeId, accountId); + + // An RGW account root is not a listed IAM user (it does not appear in ListUsers and IAM + // operations addressed to it by UserName - uid or display name alike - fail with + // NoSuchEntity), so its own keys can only be managed via the self-referential, no-UserName + // form of these calls, authenticated as the root itself. + AccessKey newKey = iam.createAccessKey(new CreateAccessKeyRequest()).getAccessKey(); + try { + Map newDetails = new HashMap<>(); + newDetails.put(CEPH_ROOT_ACCESS_KEY, newKey.getAccessKeyId()); + newDetails.put(CEPH_ROOT_SECRET_KEY, DBEncryptionUtil.encrypt(newKey.getSecretAccessKey())); + newDetails.put(CEPH_ROOT_KEY_ROTATED, "true"); + persistAccountModeDetails(accountId, storeId, newDetails); + } catch (RuntimeException e) { + logger.warn("Failed to record the new root key for account {}; removing it from Ceph RGW", account, e); + try { + AmazonIdentityManagement iamWithNewKey = getIamClient(storeId, accountId); + iamWithNewKey.deleteAccessKey(new DeleteAccessKeyRequest().withAccessKeyId(newKey.getAccessKeyId())); + } catch (Exception cleanup) { + logger.warn("Failed to remove access key {} of RGW root user of account {}; it needs manual cleanup", newKey.getAccessKeyId(), account, cleanup); + } + throw e; + } + + // the new key is recorded: from here on CloudStack authenticates with it + AmazonIdentityManagement iamWithNewKey = getIamClient(storeId, accountId); + if (oldAccessKey != null && !oldAccessKey.equals(newKey.getAccessKeyId())) { + try { + iamWithNewKey.deleteAccessKey(new DeleteAccessKeyRequest().withAccessKeyId(oldAccessKey)); + } catch (NoSuchEntityException e) { + logger.info("Old root access key {} of account {} no longer exists", oldAccessKey, account); + } + } + // Only the key CloudStack issued is removed. Anything else on the root user was put there + // by someone else, or left by a rotation that was interrupted before it recorded its key, + // and either way it still opens every bucket of the account. Report it rather than delete + // it: removing a credential CloudStack did not create could break whatever depends on it. + try { + List untracked = new ArrayList<>(); + for (AccessKeyMetadata existing : iamWithNewKey.listAccessKeys(new ListAccessKeysRequest()).getAccessKeyMetadata()) { + if (!existing.getAccessKeyId().equals(newKey.getAccessKeyId())) { + untracked.add(existing.getAccessKeyId()); + } + } + if (!untracked.isEmpty()) { + logger.warn("Account {} still has {} access key(s) on its object store root user that CloudStack did not issue ({}) on store {}. " + + "They keep full access to every bucket of the account and have to be reviewed and removed on the gateway.", + account, untracked.size(), String.join(", ", untracked), storeId); + } + } catch (Exception e) { + logger.debug("Unable to check for other access keys on the root user of account {}", account, e); + } + // the legacy rows held the pre-migration key material, which is now revoked: drop them + // (persist replaces the whole detail map; update would only merge) + Map allDetails = _accountDetailsDao.findDetails(accountId); + if (allDetails.containsKey(CEPH_ACCESS_KEY) || allDetails.containsKey(CEPH_SECRET_KEY)) { + Map remaining = new HashMap<>(allDetails); + remaining.remove(CEPH_ACCESS_KEY); + remaining.remove(CEPH_SECRET_KEY); + _accountDetailsDao.persist(accountId, remaining); + } + logger.info("Rotated the Ceph RGW root key of account {} on store {}", account, storeId); + return new BucketKeyTO(newKey.getAccessKeyId(), newKey.getSecretAccessKey()); + } + @Override public boolean setBucketEncryption(BucketTO bucket, long storeId) { return false; @@ -314,6 +732,34 @@ public Map getAllBucketsUsage(long storeId) { } } + protected static String bucketPolicy(String bucketName) { + return "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":\"s3:*\"," + + "\"Resource\":[\"arn:aws:s3:::" + bucketName + "\",\"arn:aws:s3:::" + bucketName + "/*\"]}]}"; + } + + private static String requireCredentialId(BucketTO bucket) { + if (bucket.getProviderCredentialId() == null) { + throw new CloudRuntimeException("Bucket " + bucket.getName() + " has no dedicated credential"); + } + return bucket.getProviderCredentialId(); + } + + /** + * The key pair CloudStack itself uses on behalf of the account: the RGW account root's in + * account mode, the legacy user's otherwise. + */ + protected BucketKeyTO getAccountKey(long accountId, long storeId) { + Map accountMode = accountModeDetails(accountId, storeId); + if (accountMode.containsKey(CEPH_ACCOUNT_ID)) { + return new BucketKeyTO(accountMode.get(CEPH_ROOT_ACCESS_KEY), DBEncryptionUtil.decrypt(accountMode.get(CEPH_ROOT_SECRET_KEY))); + } + Map details = _accountDetailsDao.findDetails(accountId); + if (!details.containsKey(CEPH_ACCESS_KEY)) { + throw new CloudRuntimeException("No Ceph RGW credential is recorded for account " + accountId); + } + return new BucketKeyTO(details.get(CEPH_ACCESS_KEY), details.get(CEPH_SECRET_KEY)); + } + protected RgwAdmin getRgwAdminClient(long storeId) { ObjectStoreVO store = _storeDao.findById(storeId); Map storeDetails = _storeDetailsDao.getDetails(storeId); @@ -331,6 +777,38 @@ protected RgwAdmin getRgwAdminClient(long storeId) { return admin; } + protected RgwAccountClient getRgwAccountClient(long storeId) { + ObjectStoreVO store = _storeDao.findById(storeId); + Map storeDetails = _storeDetailsDao.getDetails(storeId); + return new RgwAccountClient(store.getUrl() + "/admin", storeDetails.get(ACCESS_KEY), storeDetails.get(SECRET_KEY)); + } + + /** + * IAM client authenticated as the account's RGW root user. RGW serves the IAM API on the + * same endpoint as S3. + * + * Account migration and account key rotation run under a lock for that account on that store, + * and each makes several of these calls, so the SDK defaults (50s socket timeout, three + * retries, no ceiling on the call as a whole) would let one unresponsive gateway hold that + * lock for minutes. These are sub-second calls in normal operation. + */ + protected AmazonIdentityManagement getIamClient(long storeId, long accountId) { + if (!accountModeDetails(accountId, storeId).containsKey(CEPH_ACCOUNT_ID)) { + throw new CloudRuntimeException("Account " + accountId + " has not been migrated to a Ceph RGW account on this store yet"); + } + BucketKeyTO rootKey = getAccountKey(accountId, storeId); + ClientConfiguration clientConfig = new ClientConfiguration() + .withSignerOverride(RgwIamSigner.register()) + .withSocketTimeout(IAM_SOCKET_TIMEOUT_MILLIS) + .withMaxErrorRetry(IAM_MAX_ERROR_RETRY) + .withClientExecutionTimeout(IAM_CALL_TIMEOUT_MILLIS); + return AmazonIdentityManagementClientBuilder.standard() + .withClientConfiguration(clientConfig) + .withCredentials(new AWSStaticCredentialsProvider(new BasicAWSCredentials(rootKey.getAccessKey(), rootKey.getSecretKey()))) + .withEndpointConfiguration(new AwsClientBuilder.EndpointConfiguration(getStoreURL(storeId), "us-east-1")) + .build(); + } + private String getStoreURL(long storeId) { ObjectStoreVO store = _storeDao.findById(storeId); String url = store.getUrl(); @@ -339,9 +817,8 @@ private String getStoreURL(long storeId) { protected AmazonS3 getS3Client(long storeId, long accountId) { String url = getStoreURL(storeId); - String accessKey = _accountDetailsDao.findDetail(accountId, CEPH_ACCESS_KEY).getValue(); - String secretKey = _accountDetailsDao.findDetail(accountId, CEPH_SECRET_KEY).getValue(); - return this.getS3Client(url, accessKey, secretKey); + BucketKeyTO accountKey = getAccountKey(accountId, storeId); + return this.getS3Client(url, accountKey.getAccessKey(), accountKey.getSecretKey()); } protected AmazonS3 getS3Client(String url, String accessKey, String secretKey) { AmazonS3 client = AmazonS3ClientBuilder.standard() diff --git a/plugins/storage/object/ceph/src/main/java/org/apache/cloudstack/storage/datastore/util/RgwAccountClient.java b/plugins/storage/object/ceph/src/main/java/org/apache/cloudstack/storage/datastore/util/RgwAccountClient.java new file mode 100644 index 000000000000..7215e59dbb99 --- /dev/null +++ b/plugins/storage/object/ceph/src/main/java/org/apache/cloudstack/storage/datastore/util/RgwAccountClient.java @@ -0,0 +1,296 @@ +/* + * 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.cloudstack.storage.datastore.util; + +import com.cloud.utils.exception.CloudRuntimeException; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.io.IOException; +import java.io.InputStream; +import java.math.BigInteger; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Minimal client for the parts of the RGW admin ops API that radosgw-admin4j does not cover: + * the account endpoint ({@code /admin/account}, Ceph Squid v19+), the {@code /admin/info} + * probe and the caller's own capabilities. Requests are signed the same way the library signs + * its admin calls (AWS signature version 2 over the canonical resource path), so the same + * admin credentials work for both. + * + * Version notes, verified against 18.2.7 (Reef), 19.2.3 (Squid) and 20.2.4 (Tentacle): + * Reef answers 405 on the account endpoint and does not serve {@code /admin/info}; Squid serves + * both but its account GET (and DELETE) answer 403 regardless of capabilities, while POST works + * and a duplicate POST answers 409 {@code AccountAlreadyExists}. Everything here therefore avoids + * account GETs: the account id is chosen by the caller and creation is idempotent on 409. + */ +public class RgwAccountClient { + + private static final Logger LOGGER = LogManager.getLogger(RgwAccountClient.class); + private static final DateTimeFormatter RFC_1123 = DateTimeFormatter.RFC_1123_DATE_TIME; + private static final int TIMEOUT_MILLIS = 30000; + private static final BigInteger ACCOUNT_ID_SPACE = BigInteger.TEN.pow(17); + + private final String adminEndpoint; + private final String accessKey; + private final String secretKey; + + public static class RgwAccount { + private final String id; + private final String name; + + public RgwAccount(String id, String name) { + this.id = id; + this.name = name; + } + + public String getId() { + return id; + } + + public String getName() { + return name; + } + } + + /** + * @param adminEndpoint the admin base URL, for example {@code http://rgw:8000/admin} + */ + public RgwAccountClient(String adminEndpoint, String accessKey, String secretKey) { + this.adminEndpoint = adminEndpoint.endsWith("/") ? adminEndpoint.substring(0, adminEndpoint.length() - 1) : adminEndpoint; + this.accessKey = accessKey; + this.secretKey = secretKey; + } + + /** + * RGW account ids must be {@code RGW} followed by 17 digits. Deriving them from the + * CloudStack account UUID makes account creation idempotent without any lookup. + */ + public static String accountIdFor(String cloudStackAccountUuid) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(cloudStackAccountUuid.getBytes(StandardCharsets.UTF_8)); + BigInteger number = new BigInteger(1, digest).mod(ACCOUNT_ID_SPACE); + return String.format("RGW%017d", number); + } catch (GeneralSecurityException e) { + throw new CloudRuntimeException("Unable to derive an RGW account id", e); + } + } + + /** + * Whether this gateway serves the account API: {@code /admin/info} exists from Squid on, + * and a gateway without accounts answers 405 on the account endpoint. + */ + public boolean isAvailable() { + if (call("GET", "info", new LinkedHashMap<>()).status != 200) { + return false; + } + Map params = new LinkedHashMap<>(); + params.put("name", "cloudstack-probe"); + return call("GET", "account", params).status != 405; + } + + /** + * Why this gateway and credential cannot drive per-bucket credentials, or {@code null} when + * they can. The three causes look identical from the outside, and an operator who reads + * "unsupported" tends to blame the Ceph version when the real cause is a missing capability + * on the store's admin credential, so they are told apart here. + */ + public String unsupportedReason() { + Response info = call("GET", "info", new LinkedHashMap<>()); + if (info.status == 403) { + return "the object store's admin credential is missing the 'info' capability"; + } + if (info.status == 404 || info.status == 405) { + return "this gateway does not serve /admin/info, so it predates RGW accounts (Ceph Squid or later is required)"; + } + if (info.status != 200) { + return "the gateway answered HTTP " + info.status + " on /admin/info"; + } + Map params = new LinkedHashMap<>(); + params.put("name", "cloudstack-probe"); + if (call("GET", "account", params).status == 405) { + return "this gateway does not implement RGW accounts (Ceph Squid or later is required)"; + } + if (!hasAccountsWriteCapability()) { + return "the object store's admin credential is missing the 'accounts' capability"; + } + return null; + } + + /** + * Whether the admin credential holds write permission on the {@code accounts} capability, + * which the account endpoint requires. + */ + public boolean hasAccountsWriteCapability() { + Map params = new LinkedHashMap<>(); + params.put("access-key", accessKey); + Response response = call("GET", "user", params); + if (response.status != 200) { + LOGGER.debug("Unable to read the admin user's capabilities: HTTP {}", response.status); + return false; + } + JsonElement caps = JsonParser.parseString(response.body).getAsJsonObject().get("caps"); + if (caps == null || !caps.isJsonArray()) { + return false; + } + for (JsonElement cap : caps.getAsJsonArray()) { + JsonObject entry = cap.getAsJsonObject(); + String type = entry.has("type") ? entry.get("type").getAsString() : ""; + String perm = entry.has("perm") ? entry.get("perm").getAsString() : ""; + if ("accounts".equals(type) && (perm.contains("write") || perm.contains("*"))) { + return true; + } + } + return false; + } + + /** + * Whether the RGW user is the root user of an RGW account (rather than a plain, legacy user). + * Read from the gateway itself, so it stays correct even if CloudStack's records are stale. + * Answers false only when the gateway is certain (200 for a plain user, 404 for no such user); + * when it cannot be asked this throws rather than reporting a migrated account as legacy. + */ + public boolean isAccountRootUser(String uid) { + Map params = new LinkedHashMap<>(); + params.put("uid", uid); + Response response = call("GET", "user", params); + if (response.status == 404) { + // the gateway is certain: there is no such user, so it is certainly not an account root + return false; + } + if (response.status != 200) { + // anything else (403 on a capability problem, a gateway error) means we cannot tell. + // Saying "not a root" here would downgrade a migrated account, so refuse to answer. + throw new CloudRuntimeException("Unable to read RGW user " + uid + ": HTTP " + response.status + " " + response.body); + } + JsonObject user = JsonParser.parseString(response.body).getAsJsonObject(); + JsonElement accountId = user.get("account_id"); + JsonElement type = user.get("type"); + return accountId != null && !accountId.isJsonNull() && !accountId.getAsString().isEmpty() + && type != null && !type.isJsonNull() && "root".equals(type.getAsString()); + } + + /** + * Create the account, or return it unchanged if an account with this id or name exists. + */ + public RgwAccount createAccount(String id, String name) { + Map params = new LinkedHashMap<>(); + params.put("id", id); + params.put("name", name); + Response response = call("POST", "account", params); + if (response.status == 409) { + // Either our id already exists (a retry) or the name is taken by an account with + // another id. Resolve the id by name where the gateway allows it (Squid answers 403 + // to account GETs, in which case the id we chose is the only sensible answer). + Map lookup = new LinkedHashMap<>(); + lookup.put("name", name); + Response existing = call("GET", "account", lookup); + if (existing.status == 200) { + JsonObject json = JsonParser.parseString(existing.body).getAsJsonObject(); + String existingId = json.get("id").getAsString(); + if (!existingId.equals(id)) { + LOGGER.info("RGW account named {} already exists with id {}; using it", name, existingId); + } + return new RgwAccount(existingId, name); + } + LOGGER.debug("RGW account {} ({}) already exists", id, name); + return new RgwAccount(id, name); + } + response.ensureOk("create RGW account " + name); + JsonObject json = JsonParser.parseString(response.body).getAsJsonObject(); + return new RgwAccount(json.get("id").getAsString(), json.has("name") ? json.get("name").getAsString() : name); + } + + private Response call(String method, String resource, Map params) { + String path = adminEndpoint.replaceFirst("^https?://[^/]+", "") + "/" + resource; + StringBuilder query = new StringBuilder("?format=json"); + for (Map.Entry param : params.entrySet()) { + query.append('&').append(param.getKey()).append('=').append(URLEncoder.encode(param.getValue(), StandardCharsets.UTF_8)); + } + String date = RFC_1123.format(ZonedDateTime.now(ZoneId.of("GMT"))); + // HttpURLConnection adds a form content type to any request with a body; the content + // type is part of the SigV2 string to sign, so set it explicitly and sign with it. + String contentType = "POST".equals(method) ? "application/x-www-form-urlencoded" : ""; + try { + HttpURLConnection connection = (HttpURLConnection) new URL(adminEndpoint + "/" + resource + query).openConnection(); + connection.setRequestMethod(method); + connection.setConnectTimeout(TIMEOUT_MILLIS); + connection.setReadTimeout(TIMEOUT_MILLIS); + connection.setRequestProperty("Date", date); + connection.setRequestProperty("Authorization", "AWS " + accessKey + ":" + sign(method + "\n\n" + contentType + "\n" + date + "\n" + path)); + if ("POST".equals(method)) { + connection.setRequestProperty("Content-Type", contentType); + connection.setDoOutput(true); + connection.setFixedLengthStreamingMode(0); + connection.getOutputStream().close(); + } + int status = connection.getResponseCode(); + InputStream stream = status >= 400 ? connection.getErrorStream() : connection.getInputStream(); + String body = stream == null ? "" : new String(stream.readAllBytes(), StandardCharsets.UTF_8); + // a user record carries the user's S3 secret keys: never let it reach a log + LOGGER.trace("RGW admin API {} {} -> {} {}", method, path + query, status, + "user".equals(resource) && status == 200 ? "(body withheld: contains credentials)" : body); + return new Response(status, body); + } catch (IOException e) { + throw new CloudRuntimeException("RGW admin API request failed: " + method + " " + path, e); + } + } + + private String sign(String stringToSign) { + try { + Mac mac = Mac.getInstance("HmacSHA1"); + mac.init(new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA1")); + return Base64.getEncoder().encodeToString(mac.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8))); + } catch (GeneralSecurityException e) { + throw new CloudRuntimeException("Unable to sign RGW admin request", e); + } + } + + private static class Response { + final int status; + final String body; + + Response(int status, String body) { + this.status = status; + this.body = body; + } + + void ensureOk(String operation) { + if (status < 200 || status >= 300) { + throw new CloudRuntimeException("Unable to " + operation + ": HTTP " + status + " " + body); + } + } + } +} diff --git a/plugins/storage/object/ceph/src/main/java/org/apache/cloudstack/storage/datastore/util/RgwIamSigner.java b/plugins/storage/object/ceph/src/main/java/org/apache/cloudstack/storage/datastore/util/RgwIamSigner.java new file mode 100644 index 000000000000..e01af2960b86 --- /dev/null +++ b/plugins/storage/object/ceph/src/main/java/org/apache/cloudstack/storage/datastore/util/RgwIamSigner.java @@ -0,0 +1,62 @@ +/* + * 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.cloudstack.storage.datastore.util; + +import com.amazonaws.SignableRequest; +import com.amazonaws.auth.AWS4Signer; +import com.amazonaws.auth.AWSCredentials; +import com.amazonaws.auth.SignerFactory; +import com.amazonaws.http.HttpMethodName; + +/** + * SigV4 signer for the IAM API of Ceph RGW. + * + * The AWS SDK signs IAM (query protocol) requests before it attaches the form body and its + * {@code Content-Type} header, so that header is sent unsigned. RGW from Tentacle (v20) on + * rejects a request whose supplied {@code Content-Type} is not among the signed headers + * ("'content-type' supplied but not in CanonicalHeaders"); Squid tolerated it. Adding the + * header the SDK will send anyway, before signing, makes it part of the canonical request. + */ +public class RgwIamSigner extends AWS4Signer { + + public static final String NAME = "RgwIamSigner"; + static final String FORM_CONTENT_TYPE = "application/x-www-form-urlencoded; charset=utf-8"; + + private static volatile boolean registered; + + public static String register() { + if (!registered) { + synchronized (RgwIamSigner.class) { + if (!registered) { + SignerFactory.registerSigner(NAME, RgwIamSigner.class); + registered = true; + } + } + } + return NAME; + } + + @Override + public void sign(SignableRequest request, AWSCredentials credentials) { + if (request.getHttpMethod() == HttpMethodName.POST && !request.getHeaders().containsKey("Content-Type")) { + request.addHeader("Content-Type", FORM_CONTENT_TYPE); + } + super.sign(request, credentials); + } +} diff --git a/plugins/storage/object/ceph/src/test/java/org/apache/cloudstack/storage/datastore/driver/CephObjectStoreDriverImplTest.java b/plugins/storage/object/ceph/src/test/java/org/apache/cloudstack/storage/datastore/driver/CephObjectStoreDriverImplTest.java index 33919107b5ae..8488c39290ca 100644 --- a/plugins/storage/object/ceph/src/test/java/org/apache/cloudstack/storage/datastore/driver/CephObjectStoreDriverImplTest.java +++ b/plugins/storage/object/ceph/src/test/java/org/apache/cloudstack/storage/datastore/driver/CephObjectStoreDriverImplTest.java @@ -16,7 +16,26 @@ // under the License. package org.apache.cloudstack.storage.datastore.driver; +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.DeleteUserPolicyRequest; +import com.amazonaws.services.identitymanagement.model.DeleteUserRequest; +import com.amazonaws.services.identitymanagement.model.EntityAlreadyExistsException; +import com.amazonaws.services.identitymanagement.model.ListAccessKeysRequest; +import com.amazonaws.services.identitymanagement.model.ListAccessKeysResult; +import com.amazonaws.services.identitymanagement.model.ListUserPoliciesRequest; +import com.amazonaws.services.identitymanagement.model.ListUserPoliciesResult; +import com.amazonaws.services.identitymanagement.model.NoSuchEntityException; +import com.amazonaws.services.identitymanagement.model.PutUserPolicyRequest; import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.SetBucketPolicyRequest; +import com.cloud.agent.api.to.BucketCredentialTO; +import com.cloud.agent.api.to.BucketKeyTO; import com.cloud.agent.api.to.BucketTO; import com.cloud.storage.BucketVO; import com.cloud.storage.dao.BucketDao; @@ -24,25 +43,45 @@ import com.cloud.user.AccountDetailsDao; import com.cloud.user.AccountVO; import com.cloud.user.dao.AccountDao; +import com.cloud.utils.exception.CloudRuntimeException; +import org.apache.cloudstack.storage.object.ObjectStore; 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.RgwAccountClient; import org.apache.cloudstack.storage.object.Bucket; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.MockitoAnnotations; import org.mockito.Spy; import org.mockito.junit.MockitoJUnitRunner; import org.twonote.rgwadmin4j.RgwAdmin; +import org.twonote.rgwadmin4j.impl.RgwAdminException; +import org.twonote.rgwadmin4j.model.S3Credential; +import org.twonote.rgwadmin4j.model.User; + +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.any; import static org.mockito.Mockito.anyLong; import static org.mockito.Mockito.anyString; import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -50,6 +89,12 @@ @RunWith(MockitoJUnitRunner.class) public class CephObjectStoreDriverImplTest { + private static final long STORE_ID = 1L; + private static final long ACCOUNT_ID = 7L; + private static final String ACCOUNT_UUID = "7c0e3d2a-account-uuid"; + private static final String BUCKET_UUID = "b0b0b0b0-bucket-uuid"; + private static final String RGW_ACCOUNT_ID = "RGW12345678901234567"; + @Spy CephObjectStoreDriverImpl cephObjectStoreDriverImpl = new CephObjectStoreDriverImpl(); @@ -58,6 +103,10 @@ public class CephObjectStoreDriverImplTest { @Mock RgwAdmin rgwAdmin; @Mock + RgwAccountClient rgwAccountClient; + @Mock + AmazonIdentityManagement iam; + @Mock ObjectStoreDao objectStoreDao; @Mock ObjectStoreVO objectStoreVO; @@ -73,6 +122,7 @@ public class CephObjectStoreDriverImplTest { AccountDetailsDao accountDetailsDao; Bucket bucket; + BucketTO bucketTO; @Before public void setUp() { @@ -84,18 +134,55 @@ public void setUp() { cephObjectStoreDriverImpl._accountDetailsDao = accountDetailsDao; bucket = new BucketVO(); bucket.setName("test-bucket"); + BucketVO bucketVO = new BucketVO(); + bucketVO.setName("test-bucket"); + bucketVO.setUuid(BUCKET_UUID); + bucketTO = new BucketTO(bucketVO); + bucketTO.setProviderCredentialId(BUCKET_UUID); when(objectStoreVO.getUrl()).thenReturn("http://localhost:8000"); when(objectStoreDao.findById(any())).thenReturn(objectStoreVO); + when(account.getUuid()).thenReturn(ACCOUNT_UUID); + when(account.getAccountName()).thenReturn("tenant"); + when(accountDao.findById(anyLong())).thenReturn(account); + } + + private Map legacyDetails() { + Map details = new HashMap<>(); + details.put("ceph-rgw-accesskey", "abc"); + details.put("ceph-rgw-secretkey", "def"); + return details; + } + + private static String key(String name) { + return CephObjectStoreDriverImpl.detailKey(STORE_ID, name); + } + + private Map accountModeDetails() { + Map details = new HashMap<>(); + details.put(key(CephObjectStoreDriverImpl.CEPH_ACCOUNT_ID), RGW_ACCOUNT_ID); + details.put(key(CephObjectStoreDriverImpl.CEPH_ROOT_ACCESS_KEY), "root-ak"); + details.put(key(CephObjectStoreDriverImpl.CEPH_ROOT_SECRET_KEY), "root-sk"); + return details; + } + + private User userWithKey(String accessKey, String secretKey) { + // the library's model classes are populated by Gson and expose getters only + S3Credential credential = mock(S3Credential.class); + when(credential.getAccessKey()).thenReturn(accessKey); + when(credential.getSecretKey()).thenReturn(secretKey); + User user = mock(User.class); + when(user.getS3Credentials()).thenReturn(Collections.singletonList(credential)); + return user; } @Test public void testCreateBucket() throws Exception { doReturn(rgwClient).when(cephObjectStoreDriverImpl).getS3Client(anyLong(), anyLong()); - when(accountDetailsDao.findDetail(anyLong(),anyString())). - thenReturn(new AccountDetailVO(1L, "abc","def")); + when(accountDetailsDao.findDetails(anyLong())).thenReturn(legacyDetails()); when(bucketDao.findById(anyLong())).thenReturn(new BucketVO(bucket.getName())); Bucket bucketRet = cephObjectStoreDriverImpl.createBucket(bucket, false); assertEquals(bucketRet.getName(), bucket.getName()); + assertEquals("abc", bucketRet.getAccessKey()); verify(rgwClient, times(1)).doesBucketExistV2(anyString()); verify(rgwClient, times(1)).createBucket(anyString()); } @@ -109,4 +196,494 @@ public void testDeleteBucket() throws Exception { assertTrue(success); verify(rgwAdmin, times(1)).removeBucket(anyString()); } + + @Test + public void testDeleteBucketTreatsMissingBucketAsRemoved() { + BucketTO bucket = new BucketTO("test-bucket"); + doReturn(rgwAdmin).when(cephObjectStoreDriverImpl).getRgwAdminClient(anyLong()); + doThrow(new RgwAdminException(404, "NoSuchBucket")).when(rgwAdmin).removeBucket(anyString()); + assertTrue(cephObjectStoreDriverImpl.deleteBucket(bucket, 1L)); + } + + @Test + public void testDeleteBucketPropagatesOtherErrors() { + BucketTO bucket = new BucketTO("test-bucket"); + doReturn(rgwAdmin).when(cephObjectStoreDriverImpl).getRgwAdminClient(anyLong()); + doThrow(new RgwAdminException(403, "AccessDenied")).when(rgwAdmin).removeBucket(anyString()); + try { + cephObjectStoreDriverImpl.deleteBucket(bucket, 1L); + fail("expected a non-404 error to propagate"); + } catch (CloudRuntimeException expected) { + // propagated + } + } + + @Test + public void testCreateUserKeepsAccountModeAccount() { + when(accountDetailsDao.findDetails(ACCOUNT_ID)).thenReturn(accountModeDetails()); + assertTrue(cephObjectStoreDriverImpl.createUser(ACCOUNT_ID, STORE_ID)); + verify(cephObjectStoreDriverImpl, never()).getRgwAdminClient(anyLong()); + } + + @Test + public void testCreateUserKeepsLegacyAccountLegacy() { + when(accountDetailsDao.findDetails(ACCOUNT_ID)).thenReturn(legacyDetails()); + doReturn(rgwAdmin).when(cephObjectStoreDriverImpl).getRgwAdminClient(anyLong()); + User legacyUser = userWithKey("abc", "def"); + when(rgwAdmin.getUserInfo(ACCOUNT_UUID)).thenReturn(Optional.of(legacyUser)); + assertTrue(cephObjectStoreDriverImpl.createUser(ACCOUNT_ID, STORE_ID)); + verify(rgwAdmin, never()).modifyUser(anyString(), any()); + verify(cephObjectStoreDriverImpl, never()).migrateAccountForBucketCredentials(anyLong(), anyLong()); + } + + @Test + public void testCreateUserNewAccountOnUnsupportedStoreCreatesLegacyUser() { + when(accountDetailsDao.findDetails(ACCOUNT_ID)).thenReturn(new HashMap<>()); + doReturn(false).when(cephObjectStoreDriverImpl).supportsBucketCredentials(STORE_ID); + doReturn(rgwAdmin).when(cephObjectStoreDriverImpl).getRgwAdminClient(anyLong()); + User createdUser = userWithKey("abc", "def"); + when(rgwAdmin.getUserInfo(ACCOUNT_UUID)).thenReturn(Optional.empty()).thenReturn(Optional.of(createdUser)); + assertTrue(cephObjectStoreDriverImpl.createUser(ACCOUNT_ID, STORE_ID)); + verify(rgwAdmin, times(1)).createUser(ACCOUNT_UUID); + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + verify(accountDetailsDao).persist(eq(ACCOUNT_ID), captor.capture()); + assertEquals("abc", captor.getValue().get("ceph-rgw-accesskey")); + assertFalse(captor.getValue().containsKey(key(CephObjectStoreDriverImpl.CEPH_ACCOUNT_ID))); + } + + @Test + public void testCreateUserNewAccountOnSupportedStoreCreatesAccountRoot() { + when(accountDetailsDao.findDetails(ACCOUNT_ID)).thenReturn(new HashMap<>()); + doReturn(true).when(cephObjectStoreDriverImpl).supportsBucketCredentials(STORE_ID); + doReturn(rgwAdmin).when(cephObjectStoreDriverImpl).getRgwAdminClient(anyLong()); + doReturn(rgwAccountClient).when(cephObjectStoreDriverImpl).getRgwAccountClient(anyLong()); + when(rgwAccountClient.createAccount(anyString(), eq(ACCOUNT_UUID))).thenReturn(new RgwAccountClient.RgwAccount(RGW_ACCOUNT_ID, ACCOUNT_UUID)); + when(rgwAdmin.getUserInfo(ACCOUNT_UUID)).thenReturn(Optional.empty()); + User rootUser = userWithKey("root-ak", "root-sk"); + when(rgwAdmin.createUser(eq(ACCOUNT_UUID), any())).thenReturn(rootUser); + + assertTrue(cephObjectStoreDriverImpl.createUser(ACCOUNT_ID, STORE_ID)); + + ArgumentCaptor> userParams = ArgumentCaptor.forClass(Map.class); + verify(rgwAdmin).createUser(eq(ACCOUNT_UUID), userParams.capture()); + assertEquals(RGW_ACCOUNT_ID, userParams.getValue().get("account-id")); + assertEquals("true", userParams.getValue().get("account-root")); + verify(rgwAdmin, never()).modifyUser(anyString(), any()); + ArgumentCaptor> details = ArgumentCaptor.forClass(Map.class); + verify(accountDetailsDao).update(eq(ACCOUNT_ID), details.capture()); + assertEquals(RGW_ACCOUNT_ID, details.getValue().get(key(CephObjectStoreDriverImpl.CEPH_ACCOUNT_ID))); + assertEquals("root-ak", details.getValue().get(key(CephObjectStoreDriverImpl.CEPH_ROOT_ACCESS_KEY))); + assertNotNull(details.getValue().get(key(CephObjectStoreDriverImpl.CEPH_ROOT_SECRET_KEY))); + } + + @Test + public void testMigrateAccountAdoptsExistingLegacyUser() { + when(accountDetailsDao.findDetails(ACCOUNT_ID)).thenReturn(legacyDetails()); + doReturn(true).when(cephObjectStoreDriverImpl).supportsBucketCredentials(STORE_ID); + doReturn(rgwAdmin).when(cephObjectStoreDriverImpl).getRgwAdminClient(anyLong()); + doReturn(rgwAccountClient).when(cephObjectStoreDriverImpl).getRgwAccountClient(anyLong()); + when(rgwAccountClient.createAccount(eq(RgwAccountClient.accountIdFor(ACCOUNT_UUID)), eq(ACCOUNT_UUID))).thenReturn(new RgwAccountClient.RgwAccount(RGW_ACCOUNT_ID, ACCOUNT_UUID)); + User legacyUser = userWithKey("abc", "def"); + User adoptedUser = userWithKey("abc", "def"); + when(rgwAdmin.getUserInfo(ACCOUNT_UUID)).thenReturn(Optional.of(legacyUser)); + when(rgwAdmin.modifyUser(eq(ACCOUNT_UUID), any())).thenReturn(adoptedUser); + + assertTrue(cephObjectStoreDriverImpl.migrateAccountForBucketCredentials(ACCOUNT_ID, STORE_ID)); + + verify(rgwAccountClient).createAccount(eq(RgwAccountClient.accountIdFor(ACCOUNT_UUID)), eq(ACCOUNT_UUID)); + ArgumentCaptor> userParams = ArgumentCaptor.forClass(Map.class); + verify(rgwAdmin).modifyUser(eq(ACCOUNT_UUID), userParams.capture()); + assertEquals(RGW_ACCOUNT_ID, userParams.getValue().get("account-id")); + assertEquals("true", userParams.getValue().get("account-root")); + verify(rgwAdmin, never()).createUser(anyString(), any()); + ArgumentCaptor> details = ArgumentCaptor.forClass(Map.class); + verify(accountDetailsDao).update(eq(ACCOUNT_ID), details.capture()); + assertEquals(RGW_ACCOUNT_ID, details.getValue().get(key(CephObjectStoreDriverImpl.CEPH_ACCOUNT_ID))); + assertEquals("abc", details.getValue().get(key(CephObjectStoreDriverImpl.CEPH_ROOT_ACCESS_KEY))); + } + + @Test + public void testMigrateAccountIsIdempotent() { + when(accountDetailsDao.findDetails(ACCOUNT_ID)).thenReturn(accountModeDetails()); + assertTrue(cephObjectStoreDriverImpl.migrateAccountForBucketCredentials(ACCOUNT_ID, STORE_ID)); + verify(cephObjectStoreDriverImpl, never()).getRgwAccountClient(anyLong()); + verify(accountDetailsDao, never()).update(anyLong(), any()); + } + + @Test + public void testMigrateAccountRefusesUnsupportedStore() { + when(accountDetailsDao.findDetails(ACCOUNT_ID)).thenReturn(legacyDetails()); + doReturn(false).when(cephObjectStoreDriverImpl).supportsBucketCredentials(STORE_ID); + try { + cephObjectStoreDriverImpl.migrateAccountForBucketCredentials(ACCOUNT_ID, STORE_ID); + fail("expected failure on a store without account support"); + } catch (CloudRuntimeException expected) { + assertTrue(expected.getMessage().contains("Squid")); + } + verify(accountDetailsDao, never()).update(anyLong(), any()); + } + + @Test + public void testAccountSupportsBucketCredentialsRequiresGatewayAgreement() { + when(accountDetailsDao.findDetail(ACCOUNT_ID, key(CephObjectStoreDriverImpl.CEPH_ACCOUNT_ID))).thenReturn(new AccountDetailVO(ACCOUNT_ID, key(CephObjectStoreDriverImpl.CEPH_ACCOUNT_ID), RGW_ACCOUNT_ID)); + stubRootUserOnGateway(false); + assertFalse("stale rows without a root user on the gateway are not migrated", cephObjectStoreDriverImpl.accountSupportsBucketCredentials(ACCOUNT_ID, STORE_ID)); + } + + @Test + public void testAccountSupportsBucketCredentialsRequiresMarker() { + stubRootUserOnGateway(true); + when(accountDetailsDao.findDetail(ACCOUNT_ID, key(CephObjectStoreDriverImpl.CEPH_ACCOUNT_ID))).thenReturn(null); + assertFalse(cephObjectStoreDriverImpl.accountSupportsBucketCredentials(ACCOUNT_ID, STORE_ID)); + when(accountDetailsDao.findDetail(ACCOUNT_ID, key(CephObjectStoreDriverImpl.CEPH_ACCOUNT_ID))).thenReturn(new AccountDetailVO(ACCOUNT_ID, key(CephObjectStoreDriverImpl.CEPH_ACCOUNT_ID), RGW_ACCOUNT_ID)); + assertTrue(cephObjectStoreDriverImpl.accountSupportsBucketCredentials(ACCOUNT_ID, STORE_ID)); + } + + @Test + public void testMigratedAccountSurvivesAnUnreachableGateway() { + doReturn(rgwAccountClient).when(cephObjectStoreDriverImpl).getRgwAccountClient(anyLong()); + when(rgwAccountClient.isAccountRootUser(ACCOUNT_UUID)).thenThrow(new CloudRuntimeException("gateway down")); + when(accountDetailsDao.findDetail(ACCOUNT_ID, key(CephObjectStoreDriverImpl.CEPH_ACCOUNT_ID))) + .thenReturn(new AccountDetailVO(ACCOUNT_ID, key(CephObjectStoreDriverImpl.CEPH_ACCOUNT_ID), RGW_ACCOUNT_ID)); + // a momentary failure must not downgrade the account into sharing its account key again + assertTrue(cephObjectStoreDriverImpl.accountSupportsBucketCredentials(ACCOUNT_ID, STORE_ID)); + } + + @Test + public void testMigratedAccountDoesNotNeedTheSupportProbe() { + stubRootUserOnGateway(true); + when(accountDetailsDao.findDetail(ACCOUNT_ID, key(CephObjectStoreDriverImpl.CEPH_ACCOUNT_ID))) + .thenReturn(new AccountDetailVO(ACCOUNT_ID, key(CephObjectStoreDriverImpl.CEPH_ACCOUNT_ID), RGW_ACCOUNT_ID)); + assertTrue(cephObjectStoreDriverImpl.accountSupportsBucketCredentials(ACCOUNT_ID, STORE_ID)); + verify(cephObjectStoreDriverImpl, never()).supportsBucketCredentials(anyLong()); + } + + @Test + public void testUnsupportedReasonIsPassedThroughForTheAdministrator() { + doReturn(rgwAccountClient).when(cephObjectStoreDriverImpl).getRgwAccountClient(anyLong()); + when(rgwAccountClient.unsupportedReason()).thenReturn("the object store's admin credential is missing the 'accounts' capability"); + assertEquals("the object store's admin credential is missing the 'accounts' capability", + cephObjectStoreDriverImpl.bucketCredentialsUnsupportedReason(STORE_ID)); + } + + @Test + public void testUnsupportedReasonSurvivesAnUnreachableGateway() { + doReturn(rgwAccountClient).when(cephObjectStoreDriverImpl).getRgwAccountClient(anyLong()); + when(rgwAccountClient.unsupportedReason()).thenThrow(new CloudRuntimeException("connection refused")); + assertTrue(cephObjectStoreDriverImpl.bucketCredentialsUnsupportedReason(STORE_ID).contains("could not be reached")); + } + + @Test + public void testSupportsBucketCredentialsProbesAndCaches() { + doReturn(rgwAccountClient).when(cephObjectStoreDriverImpl).getRgwAccountClient(anyLong()); + when(rgwAccountClient.isAvailable()).thenReturn(true); + when(rgwAccountClient.hasAccountsWriteCapability()).thenReturn(true); + assertTrue(cephObjectStoreDriverImpl.supportsBucketCredentials(STORE_ID)); + assertTrue(cephObjectStoreDriverImpl.supportsBucketCredentials(STORE_ID)); + verify(rgwAccountClient, times(1)).isAvailable(); + } + + @Test + public void testSupportsBucketCredentialsRequiresAccountsCapability() { + doReturn(rgwAccountClient).when(cephObjectStoreDriverImpl).getRgwAccountClient(anyLong()); + when(rgwAccountClient.isAvailable()).thenReturn(true); + when(rgwAccountClient.hasAccountsWriteCapability()).thenReturn(false); + assertFalse(cephObjectStoreDriverImpl.supportsBucketCredentials(STORE_ID)); + } + + @Test + public void testAccountIdDerivationIsStableAndWellFormed() { + String id = RgwAccountClient.accountIdFor(ACCOUNT_UUID); + assertEquals(id, RgwAccountClient.accountIdFor(ACCOUNT_UUID)); + assertTrue(id, id.matches("RGW[0-9]{17}")); + assertFalse(id.equals(RgwAccountClient.accountIdFor("another-uuid"))); + } + + @Test + public void testSupportsBucketCredentialsFalseWhenProbeFails() { + doReturn(rgwAccountClient).when(cephObjectStoreDriverImpl).getRgwAccountClient(anyLong()); + when(rgwAccountClient.isAvailable()).thenThrow(new CloudRuntimeException("connection refused")); + assertFalse(cephObjectStoreDriverImpl.supportsBucketCredentials(STORE_ID)); + } + + private void stubBucketPolicyWrite() { + doReturn(rgwClient).when(cephObjectStoreDriverImpl).getS3Client(anyLong(), anyLong()); + when(accountDetailsDao.findDetails(anyLong())).thenReturn(accountModeDetails()); + } + + @Test + public void testCreateBucketCredentialCreatesIamUserPolicyKeyAndBucketGrant() { + doReturn(iam).when(cephObjectStoreDriverImpl).getIamClient(anyLong(), anyLong()); + stubBucketPolicyWrite(); + BucketVO publicBucket = new BucketVO("test-bucket"); + publicBucket.setPolicy("public"); + when(bucketDao.findByUuid(BUCKET_UUID)).thenReturn(publicBucket); + when(iam.createAccessKey(any(CreateAccessKeyRequest.class))).thenReturn(new CreateAccessKeyResult().withAccessKey(new AccessKey().withAccessKeyId("AK1").withSecretAccessKey("SK1"))); + + BucketCredentialTO credential = cephObjectStoreDriverImpl.createBucketCredential(bucketTO, STORE_ID); + + assertEquals(BUCKET_UUID, credential.getProviderCredentialId()); + assertEquals(1, credential.getKeys().size()); + assertEquals("AK1", credential.getKeys().get(0).getAccessKey()); + assertEquals("SK1", credential.getKeys().get(0).getSecretKey()); + verify(iam).createUser(new CreateUserRequest(BUCKET_UUID)); + ArgumentCaptor policy = ArgumentCaptor.forClass(PutUserPolicyRequest.class); + verify(iam).putUserPolicy(policy.capture()); + assertEquals(BUCKET_UUID, policy.getValue().getUserName()); + assertTrue(policy.getValue().getPolicyDocument().contains("arn:aws:s3:::test-bucket/*")); + verify(iam, never()).listAccessKeys(any(ListAccessKeysRequest.class)); + ArgumentCaptor bucketPolicy = ArgumentCaptor.forClass(SetBucketPolicyRequest.class); + verify(rgwClient).setBucketPolicy(bucketPolicy.capture()); + String document = bucketPolicy.getValue().getPolicyText(); + assertTrue("grant for the IAM user", document.contains("arn:aws:iam::" + RGW_ACCOUNT_ID + ":user/" + BUCKET_UUID)); + assertTrue("public statements preserved", document.contains("s3:GetObject") && document.contains("\"Principal\":\"*\"")); + } + + @Test + public void testSetBucketPolicyPrivateKeepsCredentialGrant() { + stubBucketPolicyWrite(); + cephObjectStoreDriverImpl.setBucketPolicy(bucketTO, "private", STORE_ID); + ArgumentCaptor bucketPolicy = ArgumentCaptor.forClass(SetBucketPolicyRequest.class); + verify(rgwClient).setBucketPolicy(bucketPolicy.capture()); + String document = bucketPolicy.getValue().getPolicyText(); + assertTrue(document.contains(":user/" + BUCKET_UUID)); + assertFalse(document.contains("\"Principal\":\"*\"")); + } + + @Test + public void testSetBucketPolicyWithoutCredentialMatchesLegacyShape() { + doReturn(rgwClient).when(cephObjectStoreDriverImpl).getS3Client(anyLong(), anyLong()); + cephObjectStoreDriverImpl.setBucketPolicy(new BucketTO("legacy-bucket"), "private", STORE_ID); + ArgumentCaptor bucketPolicy = ArgumentCaptor.forClass(SetBucketPolicyRequest.class); + verify(rgwClient).setBucketPolicy(bucketPolicy.capture()); + assertEquals("{\"Version\":\"2012-10-17\",\"Statement\":[]}", bucketPolicy.getValue().getPolicyText()); + verify(accountDetailsDao, never()).findDetails(anyLong()); + } + + @Test + public void testCreateBucketCredentialClearsOrphanKeysOnRetry() { + doReturn(iam).when(cephObjectStoreDriverImpl).getIamClient(anyLong(), anyLong()); + stubBucketPolicyWrite(); + when(iam.createUser(any(CreateUserRequest.class))).thenThrow(new EntityAlreadyExistsException("exists")); + when(iam.listAccessKeys(any(ListAccessKeysRequest.class))).thenReturn(new ListAccessKeysResult().withAccessKeyMetadata(new AccessKeyMetadata().withAccessKeyId("ORPHAN"))); + when(iam.createAccessKey(any(CreateAccessKeyRequest.class))).thenReturn(new CreateAccessKeyResult().withAccessKey(new AccessKey().withAccessKeyId("AK2").withSecretAccessKey("SK2"))); + + BucketCredentialTO credential = cephObjectStoreDriverImpl.createBucketCredential(bucketTO, STORE_ID); + + assertEquals("AK2", credential.getKeys().get(0).getAccessKey()); + verify(iam).deleteAccessKey(new DeleteAccessKeyRequest(BUCKET_UUID, "ORPHAN")); + verify(iam).putUserPolicy(any(PutUserPolicyRequest.class)); + } + + @Test + public void testCreateBucketCredentialKeyReturnsNewPair() { + doReturn(iam).when(cephObjectStoreDriverImpl).getIamClient(anyLong(), anyLong()); + when(iam.createAccessKey(new CreateAccessKeyRequest(BUCKET_UUID))).thenReturn(new CreateAccessKeyResult().withAccessKey(new AccessKey().withAccessKeyId("AK3").withSecretAccessKey("SK3"))); + BucketKeyTO key = cephObjectStoreDriverImpl.createBucketCredentialKey(bucketTO, STORE_ID, new HashSet<>(Collections.singletonList("AK1"))); + assertEquals("AK3", key.getAccessKey()); + assertEquals("SK3", key.getSecretKey()); + } + + @Test + public void testRemoveBucketCredentialKeyTreatsMissingAsRemoved() { + doReturn(iam).when(cephObjectStoreDriverImpl).getIamClient(anyLong(), anyLong()); + when(iam.deleteAccessKey(any(DeleteAccessKeyRequest.class))).thenThrow(new NoSuchEntityException("gone")); + assertTrue(cephObjectStoreDriverImpl.removeBucketCredentialKey(bucketTO, STORE_ID, "AK1")); + } + + @Test + public void testDeleteBucketCredentialRemovesKeysPolicyAndUser() { + doReturn(iam).when(cephObjectStoreDriverImpl).getIamClient(anyLong(), anyLong()); + when(iam.listAccessKeys(any(ListAccessKeysRequest.class))).thenReturn(new ListAccessKeysResult().withAccessKeyMetadata(new AccessKeyMetadata().withAccessKeyId("AK1"), new AccessKeyMetadata().withAccessKeyId("AK2"))); + when(iam.listUserPolicies(any(ListUserPoliciesRequest.class))).thenReturn(new ListUserPoliciesResult().withPolicyNames(CephObjectStoreDriverImpl.BUCKET_POLICY_NAME)); + + assertTrue(cephObjectStoreDriverImpl.deleteBucketCredential(bucketTO, STORE_ID)); + + verify(iam).deleteAccessKey(new DeleteAccessKeyRequest(BUCKET_UUID, "AK1")); + verify(iam).deleteAccessKey(new DeleteAccessKeyRequest(BUCKET_UUID, "AK2")); + verify(iam).deleteUserPolicy(new DeleteUserPolicyRequest(BUCKET_UUID, CephObjectStoreDriverImpl.BUCKET_POLICY_NAME)); + verify(iam).deleteUser(new DeleteUserRequest(BUCKET_UUID)); + } + + @Test + public void testDeleteBucketCredentialTreatsMissingUserAsRemoved() { + doReturn(iam).when(cephObjectStoreDriverImpl).getIamClient(anyLong(), anyLong()); + when(iam.listAccessKeys(any(ListAccessKeysRequest.class))).thenThrow(new NoSuchEntityException("gone")); + assertTrue(cephObjectStoreDriverImpl.deleteBucketCredential(bucketTO, STORE_ID)); + verify(iam, never()).deleteUser(any(DeleteUserRequest.class)); + } + + private void stubRootUserOnGateway(boolean isRoot) { + doReturn(rgwAccountClient).when(cephObjectStoreDriverImpl).getRgwAccountClient(anyLong()); + when(rgwAccountClient.isAccountRootUser(ACCOUNT_UUID)).thenReturn(isRoot); + } + + @Test + public void testRotateAccountKeyRefusedWhenGatewayUserIsNotAccountRoot() { + when(accountDetailsDao.findDetails(ACCOUNT_ID)).thenReturn(accountModeDetails()); + stubRootUserOnGateway(false); + try { + cephObjectStoreDriverImpl.rotateAccountKey(ACCOUNT_ID, STORE_ID); + fail("expected refusal when RGW does not know the user as an account root"); + } catch (CloudRuntimeException expected) { + assertTrue(expected.getMessage().contains("migrate the account on this store first")); + } + verify(iam, never()).createAccessKey(any(CreateAccessKeyRequest.class)); + } + + @Test + public void testRotateAccountKeyCreatesPersistsThenRemovesOld() { + stubRootUserOnGateway(true); + Map details = accountModeDetails(); + details.put("ceph-rgw-accesskey", "legacy-ak"); + details.put("ceph-rgw-secretkey", "legacy-sk"); + when(accountDetailsDao.findDetails(ACCOUNT_ID)).thenReturn(details); + doReturn(iam).when(cephObjectStoreDriverImpl).getIamClient(anyLong(), anyLong()); + when(iam.createAccessKey(new CreateAccessKeyRequest())).thenReturn(new CreateAccessKeyResult().withAccessKey(new AccessKey().withAccessKeyId("root-ak-2").withSecretAccessKey("root-sk-2"))); + // the root holds the key we recorded plus one left behind by an interrupted rotation + when(iam.listAccessKeys(new ListAccessKeysRequest())).thenReturn(new ListAccessKeysResult() + .withAccessKeyMetadata(new AccessKeyMetadata().withAccessKeyId("root-ak"), + new AccessKeyMetadata().withAccessKeyId("untracked-ak"), + new AccessKeyMetadata().withAccessKeyId("root-ak-2"))); + + BucketKeyTO key = cephObjectStoreDriverImpl.rotateAccountKey(ACCOUNT_ID, STORE_ID); + + assertEquals("root-ak-2", key.getAccessKey()); + assertEquals("root-sk-2", key.getSecretKey()); + org.mockito.InOrder order = org.mockito.Mockito.inOrder(iam, accountDetailsDao); + order.verify(iam).createAccessKey(new CreateAccessKeyRequest()); + order.verify(accountDetailsDao).update(eq(ACCOUNT_ID), any()); + order.verify(iam).deleteAccessKey(new DeleteAccessKeyRequest().withAccessKeyId("root-ak")); + // a key CloudStack did not issue is reported, never deleted: it may be someone's own + verify(iam, never()).deleteAccessKey(new DeleteAccessKeyRequest().withAccessKeyId("untracked-ak")); + verify(iam, never()).deleteAccessKey(new DeleteAccessKeyRequest().withAccessKeyId("root-ak-2")); + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + verify(accountDetailsDao, times(1)).persist(eq(ACCOUNT_ID), captor.capture()); + Map remaining = captor.getValue(); + assertFalse("legacy access key row removed", remaining.containsKey("ceph-rgw-accesskey")); + assertFalse("legacy secret key row removed", remaining.containsKey("ceph-rgw-secretkey")); + } + + @Test + public void testRotateAccountKeyCompensatesWhenPersistFails() { + stubRootUserOnGateway(true); + when(accountDetailsDao.findDetails(ACCOUNT_ID)).thenReturn(accountModeDetails()); + doReturn(iam).when(cephObjectStoreDriverImpl).getIamClient(anyLong(), anyLong()); + when(iam.createAccessKey(new CreateAccessKeyRequest())).thenReturn(new CreateAccessKeyResult().withAccessKey(new AccessKey().withAccessKeyId("root-ak-2").withSecretAccessKey("root-sk-2"))); + doThrow(new RuntimeException("db down")).when(accountDetailsDao).update(eq(ACCOUNT_ID), any()); + try { + cephObjectStoreDriverImpl.rotateAccountKey(ACCOUNT_ID, STORE_ID); + fail("expected the persist failure to propagate"); + } catch (RuntimeException expected) { + assertEquals("db down", expected.getMessage()); + } + verify(iam).deleteAccessKey(new DeleteAccessKeyRequest().withAccessKeyId("root-ak-2")); + verify(iam, never()).deleteAccessKey(new DeleteAccessKeyRequest().withAccessKeyId("root-ak")); + } + + @Test + public void testRotateAccountKeyRequiresAccountMode() { + when(accountDetailsDao.findDetails(ACCOUNT_ID)).thenReturn(legacyDetails()); + try { + cephObjectStoreDriverImpl.rotateAccountKey(ACCOUNT_ID, STORE_ID); + fail("expected refusal for a legacy account"); + } catch (CloudRuntimeException expected) { + assertTrue(expected.getMessage().contains("has not been migrated")); + } + } + + @Test + public void testAdoptedAccountHasKeyRotationPendingUntilRotated() { + when(accountDetailsDao.findDetails(ACCOUNT_ID)).thenReturn(accountModeDetails()); + assertTrue("adopted account (no marker) is pending", cephObjectStoreDriverImpl.isAccountKeyRotationPending(ACCOUNT_ID, STORE_ID)); + Map rotated = accountModeDetails(); + rotated.put(key(CephObjectStoreDriverImpl.CEPH_ROOT_KEY_ROTATED), "true"); + when(accountDetailsDao.findDetails(ACCOUNT_ID)).thenReturn(rotated); + assertFalse("rotated account is not pending", cephObjectStoreDriverImpl.isAccountKeyRotationPending(ACCOUNT_ID, STORE_ID)); + when(accountDetailsDao.findDetails(ACCOUNT_ID)).thenReturn(legacyDetails()); + assertFalse("legacy account is not pending", cephObjectStoreDriverImpl.isAccountKeyRotationPending(ACCOUNT_ID, STORE_ID)); + } + + @Test + public void testFreshAccountIsCreatedWithRotatedMarker() { + when(accountDetailsDao.findDetails(ACCOUNT_ID)).thenReturn(new HashMap<>()); + doReturn(true).when(cephObjectStoreDriverImpl).supportsBucketCredentials(STORE_ID); + doReturn(rgwAdmin).when(cephObjectStoreDriverImpl).getRgwAdminClient(anyLong()); + doReturn(rgwAccountClient).when(cephObjectStoreDriverImpl).getRgwAccountClient(anyLong()); + when(rgwAccountClient.createAccount(anyString(), eq(ACCOUNT_UUID))).thenReturn(new RgwAccountClient.RgwAccount(RGW_ACCOUNT_ID, ACCOUNT_UUID)); + when(rgwAdmin.getUserInfo(ACCOUNT_UUID)).thenReturn(Optional.empty()); + User rootUser = userWithKey("root-ak", "root-sk"); + when(rgwAdmin.createUser(eq(ACCOUNT_UUID), any())).thenReturn(rootUser); + cephObjectStoreDriverImpl.migrateAccountForBucketCredentials(ACCOUNT_ID, STORE_ID); + ArgumentCaptor> details = ArgumentCaptor.forClass(Map.class); + verify(accountDetailsDao).update(eq(ACCOUNT_ID), details.capture()); + assertEquals("true", details.getValue().get(key(CephObjectStoreDriverImpl.CEPH_ROOT_KEY_ROTATED))); + } + + @Test + public void testAdoptedAccountIsNotMarkedRotated() { + when(accountDetailsDao.findDetails(ACCOUNT_ID)).thenReturn(legacyDetails()); + doReturn(true).when(cephObjectStoreDriverImpl).supportsBucketCredentials(STORE_ID); + doReturn(rgwAdmin).when(cephObjectStoreDriverImpl).getRgwAdminClient(anyLong()); + doReturn(rgwAccountClient).when(cephObjectStoreDriverImpl).getRgwAccountClient(anyLong()); + when(rgwAccountClient.createAccount(anyString(), eq(ACCOUNT_UUID))).thenReturn(new RgwAccountClient.RgwAccount(RGW_ACCOUNT_ID, ACCOUNT_UUID)); + User legacyUser = userWithKey("abc", "def"); + User adoptedUser = userWithKey("abc", "def"); + when(rgwAdmin.getUserInfo(ACCOUNT_UUID)).thenReturn(Optional.of(legacyUser)); + when(rgwAdmin.modifyUser(eq(ACCOUNT_UUID), any())).thenReturn(adoptedUser); + cephObjectStoreDriverImpl.migrateAccountForBucketCredentials(ACCOUNT_ID, STORE_ID); + ArgumentCaptor> details = ArgumentCaptor.forClass(Map.class); + verify(accountDetailsDao).update(eq(ACCOUNT_ID), details.capture()); + assertFalse(details.getValue().containsKey(key(CephObjectStoreDriverImpl.CEPH_ROOT_KEY_ROTATED))); + } + + @Test + public void testAccountModeIsScopedPerStore() { + stubRootUserOnGateway(true); + when(accountDetailsDao.findDetails(ACCOUNT_ID)).thenReturn(accountModeDetails()); + when(accountDetailsDao.findDetail(ACCOUNT_ID, key(CephObjectStoreDriverImpl.CEPH_ACCOUNT_ID))).thenReturn(new AccountDetailVO(ACCOUNT_ID, key(CephObjectStoreDriverImpl.CEPH_ACCOUNT_ID), RGW_ACCOUNT_ID)); + assertTrue("migrated on store 1", cephObjectStoreDriverImpl.accountSupportsBucketCredentials(ACCOUNT_ID, STORE_ID)); + assertFalse("legacy on store 2", cephObjectStoreDriverImpl.accountSupportsBucketCredentials(ACCOUNT_ID, 2L)); + assertFalse("nothing pending on store 2", cephObjectStoreDriverImpl.isAccountKeyRotationPending(ACCOUNT_ID, 2L)); + try { + cephObjectStoreDriverImpl.rotateAccountKey(ACCOUNT_ID, 2L); + fail("rotation on a store the account is not migrated on must be refused"); + } catch (CloudRuntimeException expected) { + assertTrue(expected.getMessage().contains("on this store")); + } + } + + @Test + public void testMigratingOnOneStoreKeepsOtherStoresRows() { + Map existing = new HashMap<>(); + existing.put(CephObjectStoreDriverImpl.detailKey(9L, CephObjectStoreDriverImpl.CEPH_ACCOUNT_ID), "RGW00000000000000009"); + existing.put("unrelated-detail", "keep-me"); + when(accountDetailsDao.findDetails(ACCOUNT_ID)).thenReturn(existing); + doReturn(true).when(cephObjectStoreDriverImpl).supportsBucketCredentials(STORE_ID); + doReturn(rgwAdmin).when(cephObjectStoreDriverImpl).getRgwAdminClient(anyLong()); + doReturn(rgwAccountClient).when(cephObjectStoreDriverImpl).getRgwAccountClient(anyLong()); + when(rgwAccountClient.createAccount(anyString(), eq(ACCOUNT_UUID))).thenReturn(new RgwAccountClient.RgwAccount(RGW_ACCOUNT_ID, ACCOUNT_UUID)); + when(rgwAdmin.getUserInfo(ACCOUNT_UUID)).thenReturn(Optional.empty()); + User rootUser = userWithKey("root-ak", "root-sk"); + when(rgwAdmin.createUser(eq(ACCOUNT_UUID), any())).thenReturn(rootUser); + + cephObjectStoreDriverImpl.migrateAccountForBucketCredentials(ACCOUNT_ID, STORE_ID); + + // rows are merged, never replaced: the DAO's update keeps everything not in the map + verify(accountDetailsDao, never()).persist(anyLong(), any()); + ArgumentCaptor> written = ArgumentCaptor.forClass(Map.class); + verify(accountDetailsDao).update(eq(ACCOUNT_ID), written.capture()); + assertTrue(written.getValue().keySet().stream().allMatch(k -> k.startsWith(ObjectStore.accountDetailPrefix(STORE_ID)))); + } + + @Test + public void testBucketCredentialOperationsRequireCredentialId() { + BucketTO withoutCredential = new BucketTO("orphan"); + try { + cephObjectStoreDriverImpl.createBucketCredentialKey(withoutCredential, STORE_ID, new HashSet<>()); + fail("expected failure without a provider credential id"); + } catch (CloudRuntimeException expected) { + assertTrue(expected.getMessage().contains("no dedicated credential")); + } + } } diff --git a/plugins/storage/object/simulator/src/main/java/org/apache/cloudstack/storage/datastore/driver/SimulatorObjectStoreDriverImpl.java b/plugins/storage/object/simulator/src/main/java/org/apache/cloudstack/storage/datastore/driver/SimulatorObjectStoreDriverImpl.java index 7b9ac59d5b1e..85f24ac1a080 100644 --- a/plugins/storage/object/simulator/src/main/java/org/apache/cloudstack/storage/datastore/driver/SimulatorObjectStoreDriverImpl.java +++ b/plugins/storage/object/simulator/src/main/java/org/apache/cloudstack/storage/datastore/driver/SimulatorObjectStoreDriverImpl.java @@ -20,6 +20,8 @@ import com.amazonaws.services.s3.model.AccessControlList; import com.amazonaws.services.s3.model.BucketPolicy; +import com.cloud.agent.api.to.BucketCredentialTO; +import com.cloud.agent.api.to.BucketKeyTO; import com.cloud.agent.api.to.BucketTO; import com.cloud.agent.api.to.DataStoreTO; import org.apache.cloudstack.storage.object.Bucket; @@ -32,9 +34,12 @@ import javax.inject.Inject; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.UUID; public class SimulatorObjectStoreDriverImpl extends BaseObjectStoreDriverImpl { @@ -135,4 +140,52 @@ public void setBucketQuota(BucketTO bucket, long storeId, long size) { public Map getAllBucketsUsage(long storeId) { return new HashMap(); } + + // Per-bucket credentials: stateless fakes that hand out fresh key material so the + // service layer's slot bookkeeping can be exercised without a real backend. + + @Override + public boolean supportsBucketCredentials(long storeId) { + return true; + } + + @Override + public boolean accountSupportsBucketCredentials(long accountId, long storeId) { + return true; + } + + @Override + public boolean migrateAccountForBucketCredentials(long accountId, long storeId) { + return true; + } + + @Override + public BucketCredentialTO createBucketCredential(BucketTO bucket, long storeId) { + return new BucketCredentialTO(bucket.getUuid(), Collections.singletonList(generateKey())); + } + + @Override + public BucketKeyTO createBucketCredentialKey(BucketTO bucket, long storeId, Set knownAccessKeys) { + return generateKey(); + } + + @Override + public boolean removeBucketCredentialKey(BucketTO bucket, long storeId, String accessKey) { + return true; + } + + @Override + public boolean deleteBucketCredential(BucketTO bucket, long storeId) { + return true; + } + + @Override + public BucketKeyTO rotateAccountKey(long accountId, long storeId) { + return generateKey(); + } + + private static BucketKeyTO generateKey() { + return new BucketKeyTO("AK" + UUID.randomUUID().toString().replace("-", "").substring(0, 18).toUpperCase(), + UUID.randomUUID().toString().replace("-", "") + UUID.randomUUID().toString().replace("-", "").substring(0, 8)); + } } diff --git a/server/src/main/java/com/cloud/api/ApiDBUtils.java b/server/src/main/java/com/cloud/api/ApiDBUtils.java index 934600eb2b61..59400b2f45ad 100644 --- a/server/src/main/java/com/cloud/api/ApiDBUtils.java +++ b/server/src/main/java/com/cloud/api/ApiDBUtils.java @@ -32,6 +32,7 @@ import com.cloud.cpu.CPU; import com.cloud.storage.GuestOSVO; +import org.apache.cloudstack.storage.object.ObjectStore; import org.apache.cloudstack.acl.Role; import org.apache.cloudstack.acl.RoleService; import org.apache.cloudstack.affinity.AffinityGroup; @@ -1554,6 +1555,9 @@ public static Map getDomainDetails(long domainId) { public static Map getAccountDetails(long accountId) { Map details = s_accountDetailsDao.findDetails(accountId); + // Credentials CloudStack holds for the account on an object store live in account details + // but are CloudStack's own, not the account's: they must never reach an API response. + details.keySet().removeIf(ObjectStore::isInternalAccountDetail); return details.isEmpty() ? null : details; } diff --git a/server/src/main/java/com/cloud/api/ApiResponseHelper.java b/server/src/main/java/com/cloud/api/ApiResponseHelper.java index f56cda6e557a..c9e8471885a4 100644 --- a/server/src/main/java/com/cloud/api/ApiResponseHelper.java +++ b/server/src/main/java/com/cloud/api/ApiResponseHelper.java @@ -74,6 +74,7 @@ import org.apache.cloudstack.api.response.BackupScheduleResponse; import org.apache.cloudstack.api.response.BaseRolePermissionResponse; import org.apache.cloudstack.api.response.BgpPeerResponse; +import org.apache.cloudstack.api.response.BucketKeyResponse; import org.apache.cloudstack.api.response.BucketResponse; import org.apache.cloudstack.api.response.CapabilityResponse; import org.apache.cloudstack.api.response.CapacityResponse; @@ -233,6 +234,10 @@ import org.apache.cloudstack.storage.datastore.db.SnapshotDataStoreVO; import org.apache.cloudstack.storage.datastore.db.StoragePoolVO; import org.apache.cloudstack.storage.object.Bucket; +import org.apache.cloudstack.storage.object.BucketCredential; +import org.apache.cloudstack.storage.object.ObjectStoreEntity; +import org.apache.cloudstack.storage.object.BucketApiService; +import org.apache.cloudstack.storage.object.BucketCredentialKey; import org.apache.cloudstack.storage.object.ObjectStore; import org.apache.cloudstack.storage.sharedfs.SharedFS; import org.apache.cloudstack.storage.sharedfs.query.vo.SharedFSJoinVO; @@ -537,6 +542,8 @@ public class ApiResponseHelper implements ResponseGenerator, ResourceIdSupport { @Inject ObjectStoreDao _objectStoreDao; @Inject + BucketApiService _bucketApiService; + @Inject VpcOfferingDao vpcOfferingDao; @Inject BgpPeerDao bgpPeerDao; @@ -5612,17 +5619,53 @@ public BucketResponse createBucketResponse(Bucket bucket) { bucketResponse.setObjectLock(bucket.isObjectLock()); bucketResponse.setPolicy(bucket.getPolicy()); bucketResponse.setBucketURL(bucket.getBucketURL()); - bucketResponse.setAccessKey(bucket.getAccessKey()); - bucketResponse.setSecretKey(bucket.getSecretKey()); + // keys are only final once the bucket is Created: until then the row may hold interim + // material from a creation still in progress, and a bucket in any other state is unusable + if (bucket.getState() == Bucket.State.Created) { + bucketResponse.setAccessKey(bucket.getAccessKey()); + bucketResponse.setSecretKey(bucket.getSecretKey()); + } ObjectStoreVO objectStoreVO = _objectStoreDao.findById(bucket.getObjectStoreId()); bucketResponse.setObjectStoragePoolId(objectStoreVO.getUuid()); bucketResponse.setObjectStoragePool(objectStoreVO.getName()); bucketResponse.setObjectName("bucket"); bucketResponse.setProvider(objectStoreVO.getProviderName()); + List keys = _bucketApiService.listBucketKeys(bucket.getId()); + if (keys == null) { + bucketResponse.setCredentialScope(BucketCredential.SCOPE_ACCOUNT); + // whether this bucket could be given its own credential, so callers and the UI can + // tell apart "not migrated yet" from "the account is ready and this bucket is not". + // Only asked for buckets still on the account credential, and an account with no + // record of being set up answers from the database without calling the gateway. + ObjectStoreEntity objectStore = (ObjectStoreEntity)_dataStoreMgr.getDataStore(objectStoreVO.getId(), DataStoreRole.Object); + boolean accountReady = objectStore.accountSupportsBucketCredentials(bucket.getAccountId()); + bucketResponse.setAccountCredentialScope(accountReady ? BucketCredential.SCOPE_BUCKET : BucketCredential.SCOPE_ACCOUNT); + } else { + bucketResponse.setCredentialScope(BucketCredential.SCOPE_BUCKET); + List keyResponses = new ArrayList<>(); + for (BucketCredentialKey key : keys) { + keyResponses.add(createBucketKeyResponse(key)); + } + bucketResponse.setKeys(keyResponses); + bucketResponse.setAccountCredentialScope(BucketCredential.SCOPE_BUCKET); + } populateAccount(bucketResponse, bucket.getAccountId()); return bucketResponse; } + @Override + public BucketKeyResponse createBucketKeyResponse(BucketCredentialKey key) { + BucketKeyResponse response = new BucketKeyResponse(); + response.setId(key.getUuid()); + response.setKeySlot(key.getKeySlot()); + response.setAccessKey(key.getAccessKey()); + response.setSecretKey(key.getSecretKey()); + response.setState(key.getState().toString()); + response.setCreated(key.getCreated()); + response.setLastUsed(key.getLastUsed()); + return response; + } + @Override public ASNRangeResponse createASNumberRangeResponse(ASNumberRange asnRange) { ASNRangeResponse response = new ASNRangeResponse(); diff --git a/server/src/main/java/com/cloud/api/query/QueryManagerImpl.java b/server/src/main/java/com/cloud/api/query/QueryManagerImpl.java index d700fba5a787..b945d0b264c4 100644 --- a/server/src/main/java/com/cloud/api/query/QueryManagerImpl.java +++ b/server/src/main/java/com/cloud/api/query/QueryManagerImpl.java @@ -169,6 +169,9 @@ import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreCapabilities; import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreDriver; import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreManager; +import org.apache.cloudstack.storage.object.BucketApiService; +import org.apache.cloudstack.storage.object.BucketCredential; +import org.apache.cloudstack.storage.object.ObjectStoreEntity; import org.apache.cloudstack.engine.subsystem.api.storage.TemplateState; import org.apache.cloudstack.extension.Extension; import org.apache.cloudstack.extension.ExtensionHelper; @@ -330,6 +333,7 @@ import com.cloud.storage.Volume; import com.cloud.storage.VolumeApiServiceImpl; import com.cloud.storage.VolumeVO; +import com.cloud.storage.dao.BucketCredentialDao; import com.cloud.storage.dao.BucketDao; import com.cloud.storage.dao.DiskOfferingDao; import com.cloud.storage.dao.GuestOSDao; @@ -537,6 +541,8 @@ public class QueryManagerImpl extends MutualExclusiveIdsManagerBase implements Q @Inject DataStoreManager dataStoreManager; + @Inject + BucketApiService bucketApiService; @Inject ManagementServerJoinDao managementServerJoinDao; @@ -625,6 +631,9 @@ public class QueryManagerImpl extends MutualExclusiveIdsManagerBase implements Q @Inject BucketDao bucketDao; + @Inject + BucketCredentialDao bucketCredentialDao; + @Inject EntityManager entityManager; @@ -6285,6 +6294,58 @@ public ListResponse searchForObjectStores(ListObjectStorage ListResponse response = new ListResponse<>(); List poolResponses = ViewResponseHelper.createObjectStoreResponse(result.first().toArray(new ObjectStoreVO[0])); + + // Whether a store is ready for per-bucket credentials, and what is stopping it, is + // operator information: it names the gateway's own admin credential and its capabilities. + if (accountMgr.isRootAdmin(CallContext.current().getCallingAccount().getId())) { + Map byUuid = new HashMap<>(); + for (ObjectStoreVO store : result.first()) { + byUuid.put(store.getUuid(), store); + } + for (ObjectStoreResponse storeResponse : poolResponses) { + ObjectStoreVO store = byUuid.get(storeResponse.getId()); + if (store == null) { + continue; + } + try { + ObjectStoreEntity objectStore = (ObjectStoreEntity) dataStoreManager.getDataStore(store.getId(), DataStoreRole.Object); + boolean ready = objectStore.supportsBucketCredentials(); + storeResponse.setPerBucketCredentialsReady(ready); + if (!ready) { + storeResponse.setPerBucketCredentialsIssue(objectStore.bucketCredentialsUnsupportedReason()); + } + } catch (Exception e) { + logger.debug("Unable to read the per-bucket credential readiness of object store {}", store.getName(), e); + } + } + } + + if (cmd.getAccountId() != null) { + Account account = accountMgr.getAccount(cmd.getAccountId()); + if (account == null) { + throw new InvalidParameterValueException("Unable to find account with ID: " + cmd.getAccountId()); + } + accountMgr.checkAccess(CallContext.current().getCallingAccount(), null, true, account); + Map storesByUuid = new HashMap<>(); + for (ObjectStoreVO store : result.first()) { + storesByUuid.put(store.getUuid(), store); + } + for (ObjectStoreResponse storeResponse : poolResponses) { + ObjectStoreVO store = storesByUuid.get(storeResponse.getId()); + if (store == null) { + continue; + } + ObjectStoreEntity objectStore = (ObjectStoreEntity) dataStoreManager.getDataStore(store.getId(), DataStoreRole.Object); + // a store this account has already been migrated on supports the feature by + // definition, whatever a momentarily failing probe says + boolean migrated = objectStore.accountSupportsBucketCredentials(account.getId()); + boolean supported = migrated || objectStore.supportsBucketCredentials(); + storeResponse.setPerBucketCredentialsSupported(supported); + storeResponse.setAccountCredentialScope(migrated ? BucketCredential.SCOPE_BUCKET : BucketCredential.SCOPE_ACCOUNT); + storeResponse.setLegacyBuckets(bucketApiService.countAccountScopedBuckets(account.getId(), store.getId())); + storeResponse.setAccountKeyRotationPending(migrated && objectStore.isAccountKeyRotationPending(account.getId())); + } + } response.setResponses(poolResponses, result.second()); return response; } @@ -6362,6 +6423,8 @@ private List searchForBucketsInternal(ListBucketsCmd cmd) { Long id = cmd.getId(); String name = cmd.getBucketName(); + Long objectStorageId = cmd.getObjectStorageId(); + String credentialScope = cmd.getCredentialScope(); String keyword = cmd.getKeyword(); Long startIndex = cmd.getStartIndex(); Long pageSize = cmd.getPageSizeVal(); @@ -6369,6 +6432,12 @@ private List searchForBucketsInternal(ListBucketsCmd cmd) { List permittedAccounts = new ArrayList<>(); // Verify parameters + if (credentialScope != null && !BucketCredential.SCOPE_BUCKET.equalsIgnoreCase(credentialScope) + && !BucketCredential.SCOPE_ACCOUNT.equalsIgnoreCase(credentialScope)) { + throw new InvalidParameterValueException(String.format("Invalid credential scope %s, expected %s or %s", + credentialScope, BucketCredential.SCOPE_BUCKET, BucketCredential.SCOPE_ACCOUNT)); + } + if (id != null) { BucketVO bucket = bucketDao.findById(id); if (bucket != null) { @@ -6393,6 +6462,9 @@ private List searchForBucketsInternal(ListBucketsCmd cmd) { // ids sb.and("id", sb.entity().getId(), SearchCriteria.Op.EQ); sb.and("name", sb.entity().getName(), SearchCriteria.Op.EQ); + sb.and("objectStoreId", sb.entity().getObjectStoreId(), SearchCriteria.Op.EQ); + sb.and("withCredential", sb.entity().getId(), SearchCriteria.Op.IN); + sb.and("withoutCredential", sb.entity().getId(), SearchCriteria.Op.NIN); SearchCriteria sc = sb.create(); accountMgr.buildACLSearchCriteria(sc, domainId, isRecursive, permittedAccounts, listProjectResourcesCriteria); @@ -6412,6 +6484,23 @@ private List searchForBucketsInternal(ListBucketsCmd cmd) { sc.setParameters("name", name); } + if (objectStorageId != null) { + sc.setParameters("objectStoreId", objectStorageId); + } + + if (credentialScope != null) { + // the scope is not a column on the bucket: it is whether the bucket has a credential row + List bucketIdsWithCredential = bucketCredentialDao.listBucketIdsWithCredential(); + if (BucketCredential.SCOPE_BUCKET.equalsIgnoreCase(credentialScope)) { + if (bucketIdsWithCredential.isEmpty()) { + return new ArrayList<>(); + } + sc.setParameters("withCredential", bucketIdsWithCredential.toArray()); + } else if (!bucketIdsWithCredential.isEmpty()) { + sc.setParameters("withoutCredential", bucketIdsWithCredential.toArray()); + } + } + setIdsListToSearchCriteria(sc, ids); // search Volume details by ids diff --git a/server/src/main/java/com/cloud/server/ManagementServerImpl.java b/server/src/main/java/com/cloud/server/ManagementServerImpl.java index f32857d7cf04..8f5bf471c457 100644 --- a/server/src/main/java/com/cloud/server/ManagementServerImpl.java +++ b/server/src/main/java/com/cloud/server/ManagementServerImpl.java @@ -386,8 +386,13 @@ import org.apache.cloudstack.api.command.user.autoscale.UpdateAutoScaleVmGroupCmd; import org.apache.cloudstack.api.command.user.autoscale.UpdateAutoScaleVmProfileCmd; import org.apache.cloudstack.api.command.user.autoscale.UpdateConditionCmd; +import org.apache.cloudstack.api.command.admin.storage.MigrateObjectStoreAccountCmd; +import org.apache.cloudstack.api.command.admin.storage.RotateObjectStoreAccountKeyCmd; import org.apache.cloudstack.api.command.user.bucket.CreateBucketCmd; import org.apache.cloudstack.api.command.user.bucket.DeleteBucketCmd; +import org.apache.cloudstack.api.command.user.bucket.MigrateBucketCredentialCmd; +import org.apache.cloudstack.api.command.user.bucket.RevokeBucketKeyCmd; +import org.apache.cloudstack.api.command.user.bucket.RotateBucketKeyCmd; import org.apache.cloudstack.api.command.user.bucket.ListBucketsCmd; import org.apache.cloudstack.api.command.user.bucket.UpdateBucketCmd; import org.apache.cloudstack.api.command.user.config.ListCapabilitiesCmd; @@ -4436,6 +4441,11 @@ public List> getCommands() { cmdList.add(CreateBucketCmd.class); cmdList.add(UpdateBucketCmd.class); cmdList.add(DeleteBucketCmd.class); + cmdList.add(RotateBucketKeyCmd.class); + cmdList.add(RevokeBucketKeyCmd.class); + cmdList.add(MigrateBucketCredentialCmd.class); + cmdList.add(MigrateObjectStoreAccountCmd.class); + cmdList.add(RotateObjectStoreAccountKeyCmd.class); cmdList.add(ListBucketsCmd.class); return cmdList; diff --git a/server/src/main/java/org/apache/cloudstack/storage/object/BucketApiServiceImpl.java b/server/src/main/java/org/apache/cloudstack/storage/object/BucketApiServiceImpl.java index 900cbdfac0db..b81fd522fa91 100644 --- a/server/src/main/java/org/apache/cloudstack/storage/object/BucketApiServiceImpl.java +++ b/server/src/main/java/org/apache/cloudstack/storage/object/BucketApiServiceImpl.java @@ -16,8 +16,14 @@ // under the License. package org.apache.cloudstack.storage.object; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Date; +import java.util.HashSet; import java.util.List; +import java.util.function.Supplier; import java.util.Map; +import java.util.Set; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @@ -25,7 +31,12 @@ import javax.inject.Inject; import javax.naming.ConfigurationException; +import org.apache.cloudstack.api.command.admin.storage.MigrateObjectStoreAccountCmd; +import org.apache.cloudstack.api.command.admin.storage.RotateObjectStoreAccountKeyCmd; import org.apache.cloudstack.api.command.user.bucket.CreateBucketCmd; +import org.apache.cloudstack.api.command.user.bucket.MigrateBucketCredentialCmd; +import org.apache.cloudstack.api.command.user.bucket.RevokeBucketKeyCmd; +import org.apache.cloudstack.api.command.user.bucket.RotateBucketKeyCmd; import org.apache.cloudstack.api.command.user.bucket.UpdateBucketCmd; import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreManager; import org.apache.cloudstack.framework.config.ConfigKey; @@ -39,6 +50,8 @@ import com.amazonaws.services.s3.internal.BucketNameUtils; import com.amazonaws.services.s3.model.IllegalBucketNameException; +import com.cloud.agent.api.to.BucketCredentialTO; +import com.cloud.agent.api.to.BucketKeyTO; import com.cloud.agent.api.to.BucketTO; import com.cloud.configuration.Resource; import com.cloud.event.ActionEvent; @@ -47,8 +60,12 @@ import com.cloud.exception.ResourceAllocationException; import com.cloud.resourcelimit.CheckedReservation; import com.cloud.resourcelimit.ResourceLimitManagerImpl; +import com.cloud.storage.BucketCredentialKeyVO; +import com.cloud.storage.BucketCredentialVO; import com.cloud.storage.BucketVO; import com.cloud.storage.DataStoreRole; +import com.cloud.storage.dao.BucketCredentialDao; +import com.cloud.storage.dao.BucketCredentialKeyDao; import com.cloud.storage.dao.BucketDao; import com.cloud.usage.BucketStatisticsVO; import com.cloud.usage.dao.BucketStatisticsDao; @@ -57,6 +74,9 @@ import com.cloud.utils.component.ManagerBase; import com.cloud.utils.concurrency.NamedThreadFactory; import com.cloud.utils.db.GlobalLock; +import com.cloud.utils.db.Transaction; +import com.cloud.utils.db.TransactionCallbackNoReturn; +import com.cloud.utils.db.TransactionStatus; import com.cloud.utils.exception.CloudRuntimeException; public class BucketApiServiceImpl extends ManagerBase implements BucketApiService, Configurable { @@ -76,6 +96,10 @@ public class BucketApiServiceImpl extends ManagerBase implements BucketApiServic private BucketStatisticsDao _bucketStatisticsDao; @Inject ReservationDao reservationDao; + @Inject + private BucketCredentialDao _bucketCredentialDao; + @Inject + private BucketCredentialKeyDao _bucketCredentialKeyDao; private ScheduledExecutorService _executor = null; @@ -116,7 +140,8 @@ public ConfigKey[] getConfigKeys() { DefaultMaxProjectBuckets, DefaultMaxProjectObjectStorage, DefaultMaxDomainBuckets, - DefaultMaxDomainObjectStorage + DefaultMaxDomainObjectStorage, + PerBucketCredentials }; } @@ -175,6 +200,7 @@ public Bucket createBucket(CreateBucketCmd cmd) { BucketTO bucketTO = new BucketTO(bucket); boolean objectLock = false; boolean bucketCreated = false; + boolean credentialCreated = false; if(cmd.isObjectLocking()) { objectLock = true; } @@ -182,6 +208,13 @@ public Bucket createBucket(CreateBucketCmd cmd) { bucketTO = new BucketTO(objectStore.createBucket(bucket, objectLock)); bucketCreated = true; + if (isPerBucketCredentialsEnabled(objectStore, bucket.getAccountId())) { + provisionBucketCredential(objectStore, bucket); + credentialCreated = true; + bucket = _bucketDao.findById(bucket.getId()); + bucketTO = toBucketTO(bucket); + } + if (cmd.isVersioning()) { objectStore.setBucketVersioning(bucketTO); } @@ -213,6 +246,9 @@ public Bucket createBucket(CreateBucketCmd cmd) { } } catch (Exception e) { logger.debug("Failed to create bucket with name: {}", bucket.getName(), e); + if (credentialCreated) { + removeBucketCredential(objectStore, bucket, bucketTO, false); + } if(bucketCreated) { objectStore.deleteBucket(bucketTO); } @@ -247,8 +283,9 @@ private boolean deleteCheckedBucket(ObjectStoreEntity objectStore, Bucket bucket CheckedReservation objectStorageReservation = new CheckedReservation(owner, Resource.ResourceType.object_storage, bucket.getId(), null, -1*(ObjectUtils.defaultIfNull(bucket.getQuota(), 0) * Resource.ResourceType.bytesToGiB), reservationDao, resourceLimitManager)) { - BucketTO bucketTO = new BucketTO(bucket); + BucketTO bucketTO = toBucketTO(bucket); if (objectStore.deleteBucket(bucketTO)) { + removeBucketCredential(objectStore, bucket, bucketTO, true); resourceLimitManager.decrementResourceCount(bucket.getAccountId(), Resource.ResourceType.bucket); if (bucket.getQuota() != null) { resourceLimitManager.decrementResourceCount(bucket.getAccountId(), Resource.ResourceType.object_storage, (bucket.getQuota() * Resource.ResourceType.bytesToGiB)); @@ -265,10 +302,10 @@ private boolean deleteCheckedBucket(ObjectStoreEntity objectStore, Bucket bucket @ActionEvent(eventType = EventTypes.EVENT_BUCKET_UPDATE, eventDescription = "updating bucket") public boolean updateBucket(UpdateBucketCmd cmd, Account caller) throws ResourceAllocationException { BucketVO bucket = _bucketDao.findById(cmd.getId()); - BucketTO bucketTO = new BucketTO(bucket); if (bucket == null) { throw new InvalidParameterValueException("Unable to find bucket with ID: " + cmd.getId()); } + BucketTO bucketTO = toBucketTO(bucket); _accountMgr.checkAccess(caller, null, true, bucket); ObjectStoreVO objectStoreVO = _objectStoreDao.findById(bucket.getObjectStoreId()); ObjectStoreEntity objectStore = (ObjectStoreEntity)_dataStoreMgr.getDataStore(objectStoreVO.getId(), DataStoreRole.Object); @@ -332,6 +369,385 @@ private void updateBucketQuota(UpdateBucketCmd cmd, BucketVO bucket, ObjectStore } } + @Override + @ActionEvent(eventType = EventTypes.EVENT_BUCKET_KEY_ROTATE, eventDescription = "rotating bucket key") + public BucketCredentialKey rotateBucketKey(RotateBucketKeyCmd cmd, Account caller) { + BucketVO bucket = getCheckedBucket(cmd.getId(), caller); + BucketCredentialVO credential = getRequiredCredential(bucket); + ObjectStoreEntity objectStore = getObjectStore(bucket); + BucketTO bucketTO = getBucketTO(bucket, credential); + + List keys = _bucketCredentialKeyDao.listByCredentialId(credential.getId()); + int slot = resolveRotationSlot(cmd.getKeySlot(), keys); + BucketCredentialKeyVO target = findKeyInSlot(keys, slot); + BucketCredentialKeyVO other = findKeyInSlot(keys, slot == BucketCredentialKey.KEY_SLOT_ONE ? BucketCredentialKey.KEY_SLOT_TWO : BucketCredentialKey.KEY_SLOT_ONE); + + // Identities are commonly limited to two keys, so a slot can only be replaced in place + // when the other slot is also active. Everywhere else the new key is created first so the + // credential never has zero valid keys and the mirror never points at a dead key. + boolean targetActive = target != null && target.getState() == BucketCredentialKey.State.Active; + boolean otherActive = other != null && other.getState() == BucketCredentialKey.State.Active; + String oldAccessKey = targetActive ? target.getAccessKey() : null; + if (targetActive && otherActive) { + objectStore.removeBucketCredentialKey(bucketTO, oldAccessKey); + revokeKeyRow(target); + updateBucketKeyMirror(bucket, credential); + oldAccessKey = null; + } + + Set knownAccessKeys = new HashSet<>(); + for (BucketCredentialKeyVO key : keys) { + if (key.getAccessKey() != null && key.getState() == BucketCredentialKey.State.Active) { + knownAccessKeys.add(key.getAccessKey()); + } + } + BucketKeyTO newKey = objectStore.createBucketCredentialKey(bucketTO, knownAccessKeys); + BucketCredentialKeyVO keyVO; + try { + keyVO = persistKeyInSlot(credential, slot, target, newKey); + updateBucketKeyMirror(bucket, credential); + } catch (RuntimeException e) { + logger.warn("Failed to record new key {} for bucket {}; removing it from the backend", newKey.getAccessKey(), bucket.getName(), e); + objectStore.removeBucketCredentialKey(bucketTO, newKey.getAccessKey()); + throw e; + } + + if (oldAccessKey != null) { + objectStore.removeBucketCredentialKey(bucketTO, oldAccessKey); + } + return keyVO; + } + + @Override + @ActionEvent(eventType = EventTypes.EVENT_BUCKET_KEY_REVOKE, eventDescription = "revoking bucket key") + public boolean revokeBucketKey(RevokeBucketKeyCmd cmd, Account caller) { + BucketVO bucket = getCheckedBucket(cmd.getId(), caller); + BucketCredentialVO credential = getRequiredCredential(bucket); + ObjectStoreEntity objectStore = getObjectStore(bucket); + BucketTO bucketTO = getBucketTO(bucket, credential); + + List keys = _bucketCredentialKeyDao.listByCredentialId(credential.getId()); + BucketCredentialKeyVO target = findKeyInSlot(keys, cmd.getKeySlot()); + if (target == null || target.getState() != BucketCredentialKey.State.Active) { + throw new InvalidParameterValueException("Key slot " + cmd.getKeySlot() + " of bucket " + bucket.getName() + " holds no active key"); + } + if (countActiveKeys(keys) <= 1) { + throw new InvalidParameterValueException("Key slot " + cmd.getKeySlot() + " holds the only active key of bucket " + bucket.getName() + ". A bucket always keeps one active key: create a key in the other slot before revoking this one"); + } + + objectStore.removeBucketCredentialKey(bucketTO, target.getAccessKey()); + revokeKeyRow(target); + updateBucketKeyMirror(bucket, credential); + return true; + } + + @Override + @ActionEvent(eventType = EventTypes.EVENT_BUCKET_CREDENTIAL_MIGRATE, eventDescription = "migrating bucket to a dedicated credential") + public Bucket migrateBucketCredential(MigrateBucketCredentialCmd cmd, Account caller) { + BucketVO bucket = getCheckedBucket(cmd.getId(), caller); + if (bucket.getState() != Bucket.State.Created) { + throw new InvalidParameterValueException("Bucket " + bucket.getName() + " is not in the Created state"); + } + if (_bucketCredentialDao.findByBucketId(bucket.getId()) != null) { + throw new InvalidParameterValueException("Bucket " + bucket.getName() + " already has a dedicated credential"); + } + ObjectStoreEntity objectStore = getObjectStore(bucket); + if (!objectStore.supportsBucketCredentials()) { + throw new InvalidParameterValueException("The object store hosting bucket " + bucket.getName() + " does not support per-bucket credentials"); + } + if (!objectStore.accountSupportsBucketCredentials(bucket.getAccountId())) { + throw new InvalidParameterValueException("The account owning bucket " + bucket.getName() + " has not been migrated to per-bucket credentials on this object store yet. An administrator can migrate it from the Object Storage tab under the Account"); + } + provisionBucketCredential(objectStore, bucket); + return _bucketDao.findById(bucket.getId()); + } + + @Override + @ActionEvent(eventType = EventTypes.EVENT_OBJECT_STORE_ACCOUNT_MIGRATE, eventDescription = "migrating account on object store for per-bucket credentials") + public boolean migrateObjectStoreAccount(MigrateObjectStoreAccountCmd cmd, Account caller) { + Account account = _accountMgr.getActiveAccountById(cmd.getAccountId()); + if (account == null) { + throw new InvalidParameterValueException("Unable to find account with ID: " + cmd.getAccountId()); + } + _accountMgr.checkAccess(caller, null, true, account); + ObjectStoreVO objectStoreVO = _objectStoreDao.findById(cmd.getObjectStoreId()); + if (objectStoreVO == null) { + throw new InvalidParameterValueException("Unable to find object store with ID: " + cmd.getObjectStoreId()); + } + ObjectStoreEntity objectStore = (ObjectStoreEntity)_dataStoreMgr.getDataStore(objectStoreVO.getId(), DataStoreRole.Object); + if (!objectStore.supportsBucketCredentials()) { + // the cause names the gateway's own admin credential and its capabilities, which belong + // to whoever runs the platform; a domain admin is told to ask them rather than shown it + String reason = _accountMgr.isRootAdmin(caller.getId()) ? objectStore.bucketCredentialsUnsupportedReason() : null; + throw new InvalidParameterValueException("Object store " + objectStoreVO.getName() + + " does not support per-bucket credentials" + + (reason != null ? ": " + reason : ". Contact your platform administrator")); + } + return withAccountStoreLock(account, objectStoreVO, "migrate the account", () -> objectStore.migrateAccountForBucketCredentials(account.getId())); + } + + @Override + @ActionEvent(eventType = EventTypes.EVENT_OBJECT_STORE_ACCOUNT_KEY_ROTATE, eventDescription = "rotating account key on object store") + public boolean rotateObjectStoreAccountKey(RotateObjectStoreAccountKeyCmd cmd, Account caller) { + Account account = _accountMgr.getActiveAccountById(cmd.getAccountId()); + if (account == null) { + throw new InvalidParameterValueException("Unable to find account with ID: " + cmd.getAccountId()); + } + _accountMgr.checkAccess(caller, null, true, account); + ObjectStoreVO objectStoreVO = _objectStoreDao.findById(cmd.getObjectStoreId()); + if (objectStoreVO == null) { + throw new InvalidParameterValueException("Unable to find object store with ID: " + cmd.getObjectStoreId()); + } + ObjectStoreEntity objectStore = (ObjectStoreEntity)_dataStoreMgr.getDataStore(objectStoreVO.getId(), DataStoreRole.Object); + if (!objectStore.accountSupportsBucketCredentials(account.getId())) { + throw new InvalidParameterValueException("Account " + account.getAccountName() + " has not been migrated to per-bucket credentials on object store " + objectStoreVO.getName() + " yet. Migrate the account on this store first"); + } + List legacyBuckets = new ArrayList<>(); + for (BucketVO bucket : _bucketDao.listByObjectStoreIdAndAccountId(objectStoreVO.getId(), account.getId())) { + if (_bucketCredentialDao.findByBucketId(bucket.getId()) == null) { + legacyBuckets.add(bucket.getName()); + } + } + if (!legacyBuckets.isEmpty()) { + throw new InvalidParameterValueException("Cannot rotate the account key yet: " + legacyBuckets.size() + " bucket(s) still use it (" + String.join(", ", legacyBuckets) + "). Move each of them to a per-bucket credential first"); + } + return withAccountStoreLock(account, objectStoreVO, "rotate the account key", () -> { + objectStore.rotateAccountKey(account.getId()); + return true; + }); + } + + /** + * Run an account-level object store operation under a lock covering that account on that + * store. Two of these running at once, whether from two API calls or two management servers, + * would each issue a key and then revoke the other's, leaving the account with a key nobody + * holds. Refusing the second is better than repairing that afterwards. + */ + protected boolean withAccountStoreLock(Account account, ObjectStoreVO objectStore, String operation, Supplier work) { + GlobalLock lock = GlobalLock.getInternLock("ObjectStoreAccount-" + account.getId() + "-" + objectStore.getId()); + if (!lock.lock(ACQUIRE_GLOBAL_LOCK_TIMEOUT_FOR_COOPERATION)) { + throw new CloudRuntimeException("Another object storage operation is already running for account " + + account.getAccountName() + " on " + objectStore.getName() + "; unable to " + operation + " right now"); + } + try { + return work.get(); + } finally { + lock.unlock(); + } + } + + @Override + public long countAccountScopedBuckets(long accountId, long objectStoreId) { + long count = 0; + for (BucketVO bucket : _bucketDao.listByObjectStoreIdAndAccountId(objectStoreId, accountId)) { + if (_bucketCredentialDao.findByBucketId(bucket.getId()) == null) { + count++; + } + } + return count; + } + + @Override + public List listBucketKeys(long bucketId) { + BucketCredentialVO credential = _bucketCredentialDao.findByBucketId(bucketId); + if (credential == null) { + return null; + } + List keys = _bucketCredentialKeyDao.listByCredentialId(credential.getId()); + keys.sort(Comparator.comparingInt(BucketCredentialKeyVO::getKeySlot)); + return keys; + } + + /** + * Whether a new bucket of this account gets its own credential. An account that has been + * migrated always does, whatever the global setting says: a shared-key bucket carries the + * account's root key on the bucket row, where every user of the account can read it, which + * would undo the migration. The setting therefore only decides how an account is set up to + * begin with. If the backend cannot issue a per-bucket credential, creation fails the way any + * backend failure does, rather than falling back to the shared credential. + */ + protected boolean isPerBucketCredentialsEnabled(ObjectStoreEntity objectStore, long accountId) { + return objectStore.accountSupportsBucketCredentials(accountId); + } + + /** + * Provision a dedicated backend identity for the bucket, record it with its first key in + * slot one, and mirror that key onto the bucket row. The backend call is idempotent, so a + * failure after it can be retried safely; a failure to record it removes the identity again. + */ + protected void provisionBucketCredential(ObjectStoreEntity objectStore, BucketVO bucket) { + BucketTO bucketTO = new BucketTO(bucket); + BucketCredentialTO credentialTO = objectStore.createBucketCredential(bucketTO); + if (credentialTO == null || credentialTO.getKeys() == null || credentialTO.getKeys().isEmpty()) { + throw new CloudRuntimeException("Object store returned no credential for bucket " + bucket.getName()); + } + BucketKeyTO firstKey = credentialTO.getKeys().get(0); + try { + Transaction.execute(new TransactionCallbackNoReturn() { + @Override + public void doInTransactionWithoutResult(TransactionStatus status) { + BucketCredentialVO credential = _bucketCredentialDao.persist(new BucketCredentialVO(bucket.getId(), credentialTO.getProviderCredentialId())); + _bucketCredentialKeyDao.persist(new BucketCredentialKeyVO(credential.getId(), BucketCredentialKey.KEY_SLOT_ONE, firstKey.getAccessKey(), firstKey.getSecretKey())); + bucket.setAccessKey(firstKey.getAccessKey()); + bucket.setSecretKey(firstKey.getSecretKey()); + _bucketDao.update(bucket.getId(), bucket); + } + }); + } catch (RuntimeException e) { + logger.warn("Failed to record dedicated credential for bucket {}; removing it from the backend", bucket.getName(), e); + bucketTO.setProviderCredentialId(credentialTO.getProviderCredentialId()); + try { + objectStore.deleteBucketCredential(bucketTO); + } catch (Exception cleanup) { + logger.warn("Failed to remove backend credential {} for bucket {}; it needs manual cleanup", credentialTO.getProviderCredentialId(), bucket.getName(), cleanup); + } + throw e; + } + } + + /** + * Remove the bucket's dedicated backend identity (if any) and its rows. When + * {@code propagateFailure} is false the backend failure is logged and the rows are removed + * anyway, which is what a rollback wants; otherwise the failure is raised so the operation + * can be retried with the rows still in place. + */ + protected void removeBucketCredential(ObjectStoreEntity objectStore, Bucket bucket, BucketTO bucketTO, boolean propagateFailure) { + BucketCredentialVO credential = _bucketCredentialDao.findByBucketId(bucket.getId()); + if (credential == null) { + return; + } + bucketTO.setProviderCredentialId(credential.getProviderCredentialId()); + try { + objectStore.deleteBucketCredential(bucketTO); + } catch (RuntimeException e) { + if (propagateFailure) { + throw e; + } + logger.warn("Failed to remove backend credential {} for bucket {}; it needs manual cleanup", credential.getProviderCredentialId(), bucket.getName(), e); + } + for (BucketCredentialKeyVO key : _bucketCredentialKeyDao.listByCredentialId(credential.getId())) { + _bucketCredentialKeyDao.expunge(key.getId()); + } + _bucketCredentialDao.expunge(credential.getId()); + } + + /** + * Keep the bucket row's access/secret key columns pointing at the most recently created + * active key, so every existing consumer of those columns keeps working across rotations. + */ + protected void updateBucketKeyMirror(BucketVO bucket, BucketCredentialVO credential) { + BucketCredentialKeyVO newest = null; + for (BucketCredentialKeyVO key : _bucketCredentialKeyDao.listByCredentialId(credential.getId())) { + if (key.getState() != BucketCredentialKey.State.Active) { + continue; + } + if (newest == null || (key.getCreated() != null && newest.getCreated() != null && key.getCreated().after(newest.getCreated()))) { + newest = key; + } + } + if (newest == null) { + throw new CloudRuntimeException("Bucket " + bucket.getName() + " has no active key left"); + } + bucket.setAccessKey(newest.getAccessKey()); + bucket.setSecretKey(newest.getSecretKey()); + _bucketDao.update(bucket.getId(), bucket); + } + + private BucketCredentialKeyVO persistKeyInSlot(BucketCredentialVO credential, int slot, BucketCredentialKeyVO existing, BucketKeyTO newKey) { + if (existing == null) { + return _bucketCredentialKeyDao.persist(new BucketCredentialKeyVO(credential.getId(), slot, newKey.getAccessKey(), newKey.getSecretKey())); + } + existing.setAccessKey(newKey.getAccessKey()); + existing.setSecretKey(newKey.getSecretKey()); + existing.setState(BucketCredentialKey.State.Active); + existing.setCreated(new Date()); + existing.setLastUsed(null); + _bucketCredentialKeyDao.update(existing.getId(), existing); + return existing; + } + + private void revokeKeyRow(BucketCredentialKeyVO key) { + key.setState(BucketCredentialKey.State.Revoked); + key.setSecretKey(null); + _bucketCredentialKeyDao.update(key.getId(), key); + } + + private int resolveRotationSlot(Integer requested, List keys) { + if (requested != null) { + if (requested != BucketCredentialKey.KEY_SLOT_ONE && requested != BucketCredentialKey.KEY_SLOT_TWO) { + throw new InvalidParameterValueException("Key slot must be " + BucketCredentialKey.KEY_SLOT_ONE + " or " + BucketCredentialKey.KEY_SLOT_TWO); + } + return requested; + } + for (int slot : new int[] {BucketCredentialKey.KEY_SLOT_ONE, BucketCredentialKey.KEY_SLOT_TWO}) { + BucketCredentialKeyVO key = findKeyInSlot(keys, slot); + if (key == null || key.getState() != BucketCredentialKey.State.Active) { + return slot; + } + } + throw new InvalidParameterValueException("Both key slots hold active keys; specify the slot to rotate"); + } + + private static BucketCredentialKeyVO findKeyInSlot(List keys, int slot) { + for (BucketCredentialKeyVO key : keys) { + if (key.getKeySlot() == slot) { + return key; + } + } + return null; + } + + private static int countActiveKeys(List keys) { + int active = 0; + for (BucketCredentialKeyVO key : keys) { + if (key.getState() == BucketCredentialKey.State.Active) { + active++; + } + } + return active; + } + + private BucketVO getCheckedBucket(long bucketId, Account caller) { + BucketVO bucket = _bucketDao.findById(bucketId); + if (bucket == null) { + throw new InvalidParameterValueException("Unable to find bucket with ID: " + bucketId); + } + _accountMgr.checkAccess(caller, null, true, bucket); + return bucket; + } + + private BucketCredentialVO getRequiredCredential(BucketVO bucket) { + BucketCredentialVO credential = _bucketCredentialDao.findByBucketId(bucket.getId()); + if (credential == null) { + throw new InvalidParameterValueException("Bucket " + bucket.getName() + " still uses the account key. Move it to a per-bucket credential first (the Migrate to Per-Bucket Credential action on the bucket)"); + } + return credential; + } + + private ObjectStoreEntity getObjectStore(BucketVO bucket) { + ObjectStoreVO objectStoreVO = _objectStoreDao.findById(bucket.getObjectStoreId()); + return (ObjectStoreEntity)_dataStoreMgr.getDataStore(objectStoreVO.getId(), DataStoreRole.Object); + } + + private static BucketTO getBucketTO(BucketVO bucket, BucketCredentialVO credential) { + BucketTO bucketTO = new BucketTO(bucket); + bucketTO.setProviderCredentialId(credential.getProviderCredentialId()); + return bucketTO; + } + + /** + * A BucketTO that carries the bucket's dedicated credential reference when it has one, so + * drivers can keep their per-credential grants intact on bucket-level operations. + */ + protected BucketTO toBucketTO(Bucket bucket) { + BucketTO bucketTO = new BucketTO(bucket); + BucketCredentialVO credential = _bucketCredentialDao.findByBucketId(bucket.getId()); + if (credential != null) { + bucketTO.setProviderCredentialId(credential.getProviderCredentialId()); + } + return bucketTO; + } + public void getBucketUsage() { //ToDo track usage one last time when object store or bucket is removed List objectStores = _objectStoreDao.listObjectStores(); diff --git a/server/src/test/java/com/cloud/api/query/QueryManagerImplTest.java b/server/src/test/java/com/cloud/api/query/QueryManagerImplTest.java index f4ccfc3b1994..5103c29230ac 100644 --- a/server/src/test/java/com/cloud/api/query/QueryManagerImplTest.java +++ b/server/src/test/java/com/cloud/api/query/QueryManagerImplTest.java @@ -20,7 +20,9 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -104,6 +106,7 @@ import com.cloud.storage.BucketVO; import com.cloud.storage.ScopeType; import com.cloud.storage.VMTemplateVO; +import com.cloud.storage.dao.BucketCredentialDao; import com.cloud.storage.dao.BucketDao; import com.cloud.storage.dao.VMTemplateDao; import com.cloud.user.Account; @@ -172,6 +175,9 @@ public class QueryManagerImplTest { @Mock BucketDao bucketDao; + + @Mock + BucketCredentialDao bucketCredentialDao; @Mock VMTemplateDao templateDao; @@ -434,6 +440,67 @@ public void testSearchForBuckets() { queryManagerImplSpy.searchForBuckets(listBucketsCmd); } + @Test + public void testSearchForBucketsFiltersByObjectStore() { + ListBucketsCmd listBucketsCmd = new ListBucketsCmd(); + ReflectionTestUtils.setField(listBucketsCmd, "objectStorageId", 42L); + SearchBuilder sb = mock(SearchBuilder.class); + BucketVO bucketVO = mock(BucketVO.class); + when(sb.entity()).thenReturn(bucketVO); + when(bucketDao.createSearchBuilder()).thenReturn(sb); + SearchCriteria sc = mock(SearchCriteria.class); + when(sb.create()).thenReturn(sc); + when(bucketDao.searchAndCount(any(), any())).thenReturn(new Pair<>(new ArrayList<>(), 0)); + + queryManagerImplSpy.searchForBuckets(listBucketsCmd); + + verify(sb).and(eq("objectStoreId"), any(), eq(SearchCriteria.Op.EQ)); + verify(sc).setParameters("objectStoreId", 42L); + } + + @Test + public void testSearchForBucketsFiltersByCredentialScopeBucket() { + ListBucketsCmd cmd = new ListBucketsCmd(); + ReflectionTestUtils.setField(cmd, "credentialScope", "bucket"); + SearchBuilder sb = mock(SearchBuilder.class); + when(sb.entity()).thenReturn(mock(BucketVO.class)); + when(bucketDao.createSearchBuilder()).thenReturn(sb); + SearchCriteria sc = mock(SearchCriteria.class); + when(sb.create()).thenReturn(sc); + when(bucketCredentialDao.listBucketIdsWithCredential()).thenReturn(Arrays.asList(7L, 9L)); + when(bucketDao.searchAndCount(any(), any())).thenReturn(new Pair<>(new ArrayList<>(), 0)); + + queryManagerImplSpy.searchForBuckets(cmd); + + verify(sc).setParameters("withCredential", 7L, 9L); + verify(sc, never()).setParameters(eq("withoutCredential"), any()); + } + + @Test + public void testSearchForBucketsFiltersByCredentialScopeAccount() { + ListBucketsCmd cmd = new ListBucketsCmd(); + ReflectionTestUtils.setField(cmd, "credentialScope", "account"); + SearchBuilder sb = mock(SearchBuilder.class); + when(sb.entity()).thenReturn(mock(BucketVO.class)); + when(bucketDao.createSearchBuilder()).thenReturn(sb); + SearchCriteria sc = mock(SearchCriteria.class); + when(sb.create()).thenReturn(sc); + when(bucketCredentialDao.listBucketIdsWithCredential()).thenReturn(Arrays.asList(7L)); + when(bucketDao.searchAndCount(any(), any())).thenReturn(new Pair<>(new ArrayList<>(), 0)); + + queryManagerImplSpy.searchForBuckets(cmd); + + verify(sc).setParameters("withoutCredential", 7L); + verify(sc, never()).setParameters(eq("withCredential"), any()); + } + + @Test(expected = InvalidParameterValueException.class) + public void testSearchForBucketsRejectsUnknownCredentialScope() { + ListBucketsCmd cmd = new ListBucketsCmd(); + ReflectionTestUtils.setField(cmd, "credentialScope", "nonsense"); + queryManagerImplSpy.searchForBuckets(cmd); + } + @Test public void testGetHostTagsFromTemplateForServiceOfferingsListingNoTemplateId() { Assert.assertTrue(CollectionUtils.isEmpty(queryManager.getHostTagsFromTemplateForServiceOfferingsListing(mock(AccountVO.class), null))); diff --git a/server/src/test/java/org/apache/cloudstack/storage/object/BucketApiServiceImplTest.java b/server/src/test/java/org/apache/cloudstack/storage/object/BucketApiServiceImplTest.java index a4429befc44b..dec30eb8a8ff 100644 --- a/server/src/test/java/org/apache/cloudstack/storage/object/BucketApiServiceImplTest.java +++ b/server/src/test/java/org/apache/cloudstack/storage/object/BucketApiServiceImplTest.java @@ -25,7 +25,12 @@ import java.util.ArrayList; import java.util.List; +import org.apache.cloudstack.api.command.admin.storage.MigrateObjectStoreAccountCmd; +import org.apache.cloudstack.api.command.admin.storage.RotateObjectStoreAccountKeyCmd; import org.apache.cloudstack.api.command.user.bucket.CreateBucketCmd; +import org.apache.cloudstack.api.command.user.bucket.MigrateBucketCredentialCmd; +import org.apache.cloudstack.api.command.user.bucket.RevokeBucketKeyCmd; +import org.apache.cloudstack.api.command.user.bucket.RotateBucketKeyCmd; import org.apache.cloudstack.api.command.user.bucket.UpdateBucketCmd; import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreManager; @@ -48,12 +53,19 @@ import org.mockito.stubbing.Answer; import org.springframework.test.util.ReflectionTestUtils; +import com.cloud.agent.api.to.BucketCredentialTO; +import com.cloud.agent.api.to.BucketKeyTO; import com.cloud.agent.api.to.BucketTO; import com.cloud.configuration.Resource; +import com.cloud.exception.InvalidParameterValueException; import com.cloud.exception.ResourceAllocationException; import com.cloud.resourcelimit.ResourceLimitManagerImpl; +import com.cloud.storage.BucketCredentialKeyVO; +import com.cloud.storage.BucketCredentialVO; import com.cloud.storage.BucketVO; import com.cloud.storage.DataStoreRole; +import com.cloud.storage.dao.BucketCredentialDao; +import com.cloud.storage.dao.BucketCredentialKeyDao; import com.cloud.storage.dao.BucketDao; import com.cloud.user.Account; import com.cloud.user.AccountManager; @@ -88,6 +100,12 @@ public class BucketApiServiceImplTest { @Mock private AccountVO mockAccountVO; + @Mock + private BucketCredentialDao bucketCredentialDao; + + @Mock + private BucketCredentialKeyDao bucketCredentialKeyDao; + private MockedStatic dbUtilMockedStatic; private final List mockedGlobalLocks = new ArrayList<>(); private static final long ACCOUNT_ID = 1001L; @@ -259,4 +277,485 @@ public void testUpdateBucket() throws ResourceAllocationException { .decrementResourceCount(ACCOUNT_ID, Resource.ResourceType.object_storage, (bucketQuota - cmdQuota) * Resource.ResourceType.bytesToGiB); } + + // ---- per-bucket credentials ---- + + private static final long BUCKET_ID = 42L; + private static final long CREDENTIAL_ID = 77L; + private static final long OBJECT_STORE_ID = 5L; + + private BucketVO dedicatedBucket() { + BucketVO bucket = new BucketVO("bucket1"); + ReflectionTestUtils.setField(bucket, "id", BUCKET_ID); + ReflectionTestUtils.setField(bucket, "accountId", ACCOUNT_ID); + ReflectionTestUtils.setField(bucket, "objectStoreId", OBJECT_STORE_ID); + ReflectionTestUtils.setField(bucket, "state", Bucket.State.Created); + bucket.setAccessKey("AK1"); + bucket.setSecretKey("SK1"); + Mockito.when(bucketDao.findById(BUCKET_ID)).thenReturn(bucket); + return bucket; + } + + private ObjectStoreEntity objectStoreFor(BucketVO bucket) { + ObjectStoreVO objectStoreVO = Mockito.mock(ObjectStoreVO.class); + Mockito.when(objectStoreVO.getId()).thenReturn(OBJECT_STORE_ID); + Mockito.when(objectStoreDao.findById(OBJECT_STORE_ID)).thenReturn(objectStoreVO); + ObjectStoreEntity objectStore = Mockito.mock(ObjectStoreEntity.class); + Mockito.when(dataStoreMgr.getDataStore(OBJECT_STORE_ID, DataStoreRole.Object)).thenReturn(objectStore); + return objectStore; + } + + private BucketCredentialVO credentialFor(BucketVO bucket, BucketCredentialKeyVO... keys) { + BucketCredentialVO credential = new BucketCredentialVO(bucket.getId(), "iam-user"); + ReflectionTestUtils.setField(credential, "id", CREDENTIAL_ID); + Mockito.when(bucketCredentialDao.findByBucketId(bucket.getId())).thenReturn(credential); + List keyList = new ArrayList<>(); + for (BucketCredentialKeyVO key : keys) { + keyList.add(key); + } + Mockito.when(bucketCredentialKeyDao.listByCredentialId(CREDENTIAL_ID)).thenReturn(keyList); + return credential; + } + + private BucketCredentialKeyVO key(int slot, String accessKey, BucketCredentialKey.State state, long createdOffsetMillis) { + BucketCredentialKeyVO key = new BucketCredentialKeyVO(CREDENTIAL_ID, slot, accessKey, "secret-" + accessKey); + ReflectionTestUtils.setField(key, "id", (long) slot); + key.setState(state); + key.setCreated(new java.util.Date(1_000_000L + createdOffsetMillis)); + return key; + } + + private RotateBucketKeyCmd rotateCmd(Integer slot) { + RotateBucketKeyCmd cmd = Mockito.mock(RotateBucketKeyCmd.class); + Mockito.when(cmd.getId()).thenReturn(BUCKET_ID); + Mockito.when(cmd.getKeySlot()).thenReturn(slot); + return cmd; + } + + @Test + public void testCreateBucketProvisionsDedicatedCredentialAndMirrorsKey() { + CreateBucketCmd cmd = Mockito.mock(CreateBucketCmd.class); + Mockito.when(cmd.getObjectStoragePoolId()).thenReturn(OBJECT_STORE_ID); + Mockito.when(cmd.getEntityId()).thenReturn(BUCKET_ID); + + BucketVO bucket = dedicatedBucket(); + bucket.setAccessKey("account-ak"); + bucket.setSecretKey("account-sk"); + ObjectStoreEntity objectStore = objectStoreFor(bucket); + Mockito.when(objectStore.createBucket(bucket, false)).thenReturn(bucket); + Mockito.when(objectStore.accountSupportsBucketCredentials(ACCOUNT_ID)).thenReturn(true); + Mockito.when(objectStore.createBucketCredential(any(BucketTO.class))) + .thenReturn(new BucketCredentialTO("iam-user", List.of(new BucketKeyTO("AK1", "SK1")))); + Mockito.when(bucketCredentialDao.persist(any(BucketCredentialVO.class))).thenAnswer(invocation -> { + BucketCredentialVO credential = invocation.getArgument(0); + ReflectionTestUtils.setField(credential, "id", CREDENTIAL_ID); + return credential; + }); + + bucketApiService.createBucket(cmd); + + Assert.assertEquals(Bucket.State.Created, bucket.getState()); + Assert.assertEquals("AK1", bucket.getAccessKey()); + Assert.assertEquals("SK1", bucket.getSecretKey()); + Mockito.verify(bucketCredentialDao).persist(any(BucketCredentialVO.class)); + Mockito.verify(bucketCredentialKeyDao).persist(any(BucketCredentialKeyVO.class)); + } + + @Test + public void testCreateBucketSkipsCredentialWhenAccountNotSupported() { + CreateBucketCmd cmd = Mockito.mock(CreateBucketCmd.class); + Mockito.when(cmd.getObjectStoragePoolId()).thenReturn(OBJECT_STORE_ID); + Mockito.when(cmd.getEntityId()).thenReturn(BUCKET_ID); + + BucketVO bucket = dedicatedBucket(); + ObjectStoreEntity objectStore = objectStoreFor(bucket); + Mockito.when(objectStore.createBucket(bucket, false)).thenReturn(bucket); + Mockito.when(objectStore.accountSupportsBucketCredentials(ACCOUNT_ID)).thenReturn(false); + + bucketApiService.createBucket(cmd); + + Mockito.verify(objectStore, Mockito.never()).createBucketCredential(any(BucketTO.class)); + Mockito.verify(bucketCredentialDao, Mockito.never()).persist(any(BucketCredentialVO.class)); + } + + @Test + public void testCreateBucketRollbackRemovesCredential() { + CreateBucketCmd cmd = Mockito.mock(CreateBucketCmd.class); + Mockito.when(cmd.getObjectStoragePoolId()).thenReturn(OBJECT_STORE_ID); + Mockito.when(cmd.getEntityId()).thenReturn(BUCKET_ID); + Mockito.when(cmd.isVersioning()).thenReturn(true); + + BucketVO bucket = dedicatedBucket(); + ObjectStoreEntity objectStore = objectStoreFor(bucket); + Mockito.when(objectStore.createBucket(bucket, false)).thenReturn(bucket); + Mockito.when(objectStore.accountSupportsBucketCredentials(ACCOUNT_ID)).thenReturn(true); + Mockito.when(objectStore.createBucketCredential(any(BucketTO.class))) + .thenReturn(new BucketCredentialTO("iam-user", List.of(new BucketKeyTO("AK1", "SK1")))); + BucketCredentialVO credential = credentialFor(bucket, key(1, "AK1", BucketCredentialKey.State.Active, 0)); + Mockito.when(bucketCredentialDao.persist(any(BucketCredentialVO.class))).thenReturn(credential); + Mockito.when(objectStore.setBucketVersioning(any(BucketTO.class))).thenThrow(new RuntimeException("versioning failed")); + + try { + bucketApiService.createBucket(cmd); + Assert.fail("expected the bucket creation to fail"); + } catch (Exception expected) { + // rollback path under test + } + + Mockito.verify(objectStore).deleteBucketCredential(any(BucketTO.class)); + Mockito.verify(bucketCredentialDao).expunge(CREDENTIAL_ID); + Mockito.verify(objectStore).deleteBucket(any(BucketTO.class)); + Mockito.verify(bucketDao).remove(BUCKET_ID); + } + + @Test + public void testDeleteBucketRemovesDedicatedCredential() throws ResourceAllocationException { + BucketVO bucket = dedicatedBucket(); + Mockito.when(accountManager.getAccount(ACCOUNT_ID)).thenReturn(mock(AccountVO.class)); + ObjectStoreEntity objectStore = objectStoreFor(bucket); + Mockito.when(objectStore.deleteBucket(any(BucketTO.class))).thenReturn(true); + credentialFor(bucket, key(1, "AK1", BucketCredentialKey.State.Active, 0)); + + bucketApiService.deleteBucket(BUCKET_ID, null); + + Mockito.verify(objectStore).deleteBucketCredential(any(BucketTO.class)); + Mockito.verify(bucketCredentialKeyDao).expunge(1L); + Mockito.verify(bucketCredentialDao).expunge(CREDENTIAL_ID); + Mockito.verify(bucketDao).remove(BUCKET_ID); + } + + @Test + public void testRotateIntoFreeSlotCreatesBeforeRemovingNothing() { + BucketVO bucket = dedicatedBucket(); + ObjectStoreEntity objectStore = objectStoreFor(bucket); + credentialFor(bucket, key(1, "AK1", BucketCredentialKey.State.Active, 0)); + Mockito.when(objectStore.createBucketCredentialKey(any(BucketTO.class), any())).thenReturn(new BucketKeyTO("AK2", "SK2")); + Mockito.when(bucketCredentialKeyDao.persist(any(BucketCredentialKeyVO.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + BucketCredentialKey newKey = bucketApiService.rotateBucketKey(rotateCmd(null), null); + + Assert.assertEquals(2, newKey.getKeySlot()); + Assert.assertEquals("AK2", newKey.getAccessKey()); + Mockito.verify(objectStore, Mockito.never()).removeBucketCredentialKey(any(BucketTO.class), anyString()); + Mockito.verify(objectStore).createBucketCredentialKey(any(BucketTO.class), any()); + } + + @Test + public void testRotateOccupiedSlotWithOtherActiveRemovesFirst() { + BucketVO bucket = dedicatedBucket(); + ObjectStoreEntity objectStore = objectStoreFor(bucket); + BucketCredentialKeyVO slot1 = key(1, "AK1", BucketCredentialKey.State.Active, 0); + BucketCredentialKeyVO slot2 = key(2, "AK2", BucketCredentialKey.State.Active, 10); + credentialFor(bucket, slot1, slot2); + Mockito.when(objectStore.createBucketCredentialKey(any(BucketTO.class), any())).thenReturn(new BucketKeyTO("AK3", "SK3")); + + BucketCredentialKey newKey = bucketApiService.rotateBucketKey(rotateCmd(1), null); + + Assert.assertEquals(1, newKey.getKeySlot()); + Assert.assertEquals("AK3", newKey.getAccessKey()); + Mockito.verify(objectStore).removeBucketCredentialKey(any(BucketTO.class), Mockito.eq("AK1")); + Mockito.verify(objectStore).createBucketCredentialKey(any(BucketTO.class), any()); + Assert.assertEquals("AK3", bucket.getAccessKey()); + } + + @Test + public void testRotateOnlyActiveKeyCreatesFirstThenRemovesOld() { + BucketVO bucket = dedicatedBucket(); + ObjectStoreEntity objectStore = objectStoreFor(bucket); + BucketCredentialKeyVO slot1 = key(1, "AK1", BucketCredentialKey.State.Active, 0); + BucketCredentialKeyVO slot2 = key(2, "AK2", BucketCredentialKey.State.Revoked, 10); + credentialFor(bucket, slot1, slot2); + Mockito.when(objectStore.createBucketCredentialKey(any(BucketTO.class), any())).thenReturn(new BucketKeyTO("AK3", "SK3")); + + bucketApiService.rotateBucketKey(rotateCmd(1), null); + + org.mockito.InOrder order = Mockito.inOrder(objectStore); + order.verify(objectStore).createBucketCredentialKey(any(BucketTO.class), any()); + order.verify(objectStore).removeBucketCredentialKey(any(BucketTO.class), Mockito.eq("AK1")); + Assert.assertEquals("AK3", bucket.getAccessKey()); + } + + @Test + public void testRotateRejectsAmbiguousSlot() { + BucketVO bucket = dedicatedBucket(); + objectStoreFor(bucket); + credentialFor(bucket, key(1, "AK1", BucketCredentialKey.State.Active, 0), key(2, "AK2", BucketCredentialKey.State.Active, 10)); + try { + bucketApiService.rotateBucketKey(rotateCmd(null), null); + Assert.fail("expected rejection when both slots are active and none is named"); + } catch (InvalidParameterValueException expected) { + Assert.assertTrue(expected.getMessage().contains("specify the slot")); + } + } + + @Test + public void testRotateCompensatesWhenRecordingFails() { + BucketVO bucket = dedicatedBucket(); + ObjectStoreEntity objectStore = objectStoreFor(bucket); + credentialFor(bucket, key(1, "AK1", BucketCredentialKey.State.Active, 0)); + Mockito.when(objectStore.createBucketCredentialKey(any(BucketTO.class), any())).thenReturn(new BucketKeyTO("AK2", "SK2")); + Mockito.when(bucketCredentialKeyDao.persist(any(BucketCredentialKeyVO.class))).thenThrow(new RuntimeException("db down")); + + try { + bucketApiService.rotateBucketKey(rotateCmd(null), null); + Assert.fail("expected the DB failure to propagate"); + } catch (RuntimeException expected) { + Assert.assertEquals("db down", expected.getMessage()); + } + Mockito.verify(objectStore).removeBucketCredentialKey(any(BucketTO.class), Mockito.eq("AK2")); + } + + @Test + public void testRotateRequiresDedicatedCredential() { + BucketVO bucket = dedicatedBucket(); + Mockito.when(bucketCredentialDao.findByBucketId(BUCKET_ID)).thenReturn(null); + try { + bucketApiService.rotateBucketKey(rotateCmd(null), null); + Assert.fail("expected rejection for an account-scoped bucket"); + } catch (InvalidParameterValueException expected) { + Assert.assertTrue(expected.getMessage().contains("per-bucket credential first")); + } + } + + @Test + public void testRevokeKey() { + BucketVO bucket = dedicatedBucket(); + ObjectStoreEntity objectStore = objectStoreFor(bucket); + BucketCredentialKeyVO slot1 = key(1, "AK1", BucketCredentialKey.State.Active, 0); + BucketCredentialKeyVO slot2 = key(2, "AK2", BucketCredentialKey.State.Active, 10); + credentialFor(bucket, slot1, slot2); + RevokeBucketKeyCmd cmd = Mockito.mock(RevokeBucketKeyCmd.class); + Mockito.when(cmd.getId()).thenReturn(BUCKET_ID); + Mockito.when(cmd.getKeySlot()).thenReturn(1); + + Assert.assertTrue(bucketApiService.revokeBucketKey(cmd, null)); + + Mockito.verify(objectStore).removeBucketCredentialKey(any(BucketTO.class), Mockito.eq("AK1")); + Assert.assertEquals(BucketCredentialKey.State.Revoked, slot1.getState()); + Assert.assertNull(slot1.getSecretKey()); + Assert.assertEquals("AK2", bucket.getAccessKey()); + } + + @Test + public void testRevokeLastActiveKeyIsRejected() { + BucketVO bucket = dedicatedBucket(); + ObjectStoreEntity objectStore = objectStoreFor(bucket); + credentialFor(bucket, key(1, "AK1", BucketCredentialKey.State.Active, 0), key(2, "AK2", BucketCredentialKey.State.Revoked, 10)); + RevokeBucketKeyCmd cmd = Mockito.mock(RevokeBucketKeyCmd.class); + Mockito.when(cmd.getId()).thenReturn(BUCKET_ID); + Mockito.when(cmd.getKeySlot()).thenReturn(1); + try { + bucketApiService.revokeBucketKey(cmd, null); + Assert.fail("expected rejection of revoking the only active key"); + } catch (InvalidParameterValueException expected) { + Assert.assertTrue(expected.getMessage().contains("only active key")); + } + Mockito.verify(objectStore, Mockito.never()).removeBucketCredentialKey(any(BucketTO.class), anyString()); + } + + @Test + public void testMigrateBucketCredentialRequiresMigratedAccount() { + BucketVO bucket = dedicatedBucket(); + ObjectStoreEntity objectStore = objectStoreFor(bucket); + Mockito.when(bucketCredentialDao.findByBucketId(BUCKET_ID)).thenReturn(null); + Mockito.when(objectStore.supportsBucketCredentials()).thenReturn(true); + Mockito.when(objectStore.accountSupportsBucketCredentials(ACCOUNT_ID)).thenReturn(false); + MigrateBucketCredentialCmd cmd = Mockito.mock(MigrateBucketCredentialCmd.class); + Mockito.when(cmd.getId()).thenReturn(BUCKET_ID); + try { + bucketApiService.migrateBucketCredential(cmd, null); + Assert.fail("expected rejection while the account is not migrated"); + } catch (InvalidParameterValueException expected) { + Assert.assertTrue(expected.getMessage().contains("Object Storage tab")); + } + Mockito.verify(objectStore, Mockito.never()).createBucketCredential(any(BucketTO.class)); + } + + @Test + public void testMigrateBucketCredentialProvisionsCredential() { + BucketVO bucket = dedicatedBucket(); + ObjectStoreEntity objectStore = objectStoreFor(bucket); + Mockito.when(bucketCredentialDao.findByBucketId(BUCKET_ID)).thenReturn(null); + Mockito.when(objectStore.supportsBucketCredentials()).thenReturn(true); + Mockito.when(objectStore.accountSupportsBucketCredentials(ACCOUNT_ID)).thenReturn(true); + Mockito.when(objectStore.createBucketCredential(any(BucketTO.class))) + .thenReturn(new BucketCredentialTO("iam-user", List.of(new BucketKeyTO("AK9", "SK9")))); + Mockito.when(bucketCredentialDao.persist(any(BucketCredentialVO.class))).thenAnswer(invocation -> { + BucketCredentialVO credential = invocation.getArgument(0); + ReflectionTestUtils.setField(credential, "id", CREDENTIAL_ID); + return credential; + }); + MigrateBucketCredentialCmd cmd = Mockito.mock(MigrateBucketCredentialCmd.class); + Mockito.when(cmd.getId()).thenReturn(BUCKET_ID); + + bucketApiService.migrateBucketCredential(cmd, null); + + Assert.assertEquals("AK9", bucket.getAccessKey()); + Mockito.verify(bucketCredentialKeyDao).persist(any(BucketCredentialKeyVO.class)); + } + + private RotateObjectStoreAccountKeyCmd rotateAccountCmd() { + RotateObjectStoreAccountKeyCmd cmd = Mockito.mock(RotateObjectStoreAccountKeyCmd.class); + Mockito.when(cmd.getAccountId()).thenReturn(ACCOUNT_ID); + Mockito.when(cmd.getObjectStoreId()).thenReturn(OBJECT_STORE_ID); + return cmd; + } + + private ObjectStoreEntity accountModeStore() { + ObjectStoreVO objectStoreVO = Mockito.mock(ObjectStoreVO.class); + Mockito.when(objectStoreVO.getId()).thenReturn(OBJECT_STORE_ID); + Mockito.when(objectStoreVO.getName()).thenReturn("ceph"); + Mockito.when(objectStoreDao.findById(OBJECT_STORE_ID)).thenReturn(objectStoreVO); + ObjectStoreEntity objectStore = Mockito.mock(ObjectStoreEntity.class); + Mockito.when(dataStoreMgr.getDataStore(OBJECT_STORE_ID, DataStoreRole.Object)).thenReturn(objectStore); + Mockito.when(objectStore.accountSupportsBucketCredentials(ACCOUNT_ID)).thenReturn(true); + Mockito.when(mockAccountVO.getId()).thenReturn(ACCOUNT_ID); + return objectStore; + } + + @Test + public void testRotateObjectStoreAccountKeyRefusedWhileLegacyBucketsRemain() { + ObjectStoreEntity objectStore = accountModeStore(); + BucketVO legacy = new BucketVO("backups"); + ReflectionTestUtils.setField(legacy, "id", 9L); + Mockito.when(bucketDao.listByObjectStoreIdAndAccountId(OBJECT_STORE_ID, ACCOUNT_ID)).thenReturn(List.of(legacy)); + Mockito.when(bucketCredentialDao.findByBucketId(9L)).thenReturn(null); + try { + bucketApiService.rotateObjectStoreAccountKey(rotateAccountCmd(), mock(Account.class)); + Assert.fail("expected refusal while a legacy bucket remains"); + } catch (InvalidParameterValueException expected) { + Assert.assertTrue(expected.getMessage().contains("backups")); + } + Mockito.verify(objectStore, Mockito.never()).rotateAccountKey(ACCOUNT_ID); + } + + @Test + public void testRotateObjectStoreAccountKeyRotatesWhenAllBucketsMigrated() { + ObjectStoreEntity objectStore = accountModeStore(); + BucketVO migrated = new BucketVO("data"); + ReflectionTestUtils.setField(migrated, "id", 9L); + Mockito.when(bucketDao.listByObjectStoreIdAndAccountId(OBJECT_STORE_ID, ACCOUNT_ID)).thenReturn(List.of(migrated)); + Mockito.when(bucketCredentialDao.findByBucketId(9L)).thenReturn(new BucketCredentialVO(9L, "iam-user")); + Mockito.when(objectStore.rotateAccountKey(ACCOUNT_ID)).thenReturn(new BucketKeyTO("AK", "SK")); + Account caller = mock(Account.class); + + Assert.assertTrue(bucketApiService.rotateObjectStoreAccountKey(rotateAccountCmd(), caller)); + + Mockito.verify(accountManager).checkAccess(caller, null, true, mockAccountVO); + Mockito.verify(objectStore).rotateAccountKey(ACCOUNT_ID); + } + + @Test + public void testRotateObjectStoreAccountKeyRequiresMigratedAccount() { + ObjectStoreEntity objectStore = accountModeStore(); + Mockito.when(objectStore.accountSupportsBucketCredentials(ACCOUNT_ID)).thenReturn(false); + try { + bucketApiService.rotateObjectStoreAccountKey(rotateAccountCmd(), mock(Account.class)); + Assert.fail("expected refusal for an unmigrated account"); + } catch (InvalidParameterValueException expected) { + Assert.assertTrue(expected.getMessage().contains("Migrate the account on this store first")); + } + } + + @Test + public void testCountAccountScopedBuckets() { + BucketVO a = new BucketVO("a"); ReflectionTestUtils.setField(a, "id", 1L); + BucketVO b = new BucketVO("b"); ReflectionTestUtils.setField(b, "id", 2L); + BucketVO c = new BucketVO("c"); ReflectionTestUtils.setField(c, "id", 3L); + Mockito.when(bucketDao.listByObjectStoreIdAndAccountId(OBJECT_STORE_ID, ACCOUNT_ID)).thenReturn(List.of(a, b, c)); + Mockito.when(bucketCredentialDao.findByBucketId(2L)).thenReturn(new BucketCredentialVO(2L, "iam-user")); + Assert.assertEquals(2, bucketApiService.countAccountScopedBuckets(ACCOUNT_ID, OBJECT_STORE_ID)); + } + + @Test + public void testMigrateObjectStoreAccountChecksAccessAndDelegates() { + ObjectStoreVO objectStoreVO = Mockito.mock(ObjectStoreVO.class); + Mockito.when(objectStoreVO.getId()).thenReturn(OBJECT_STORE_ID); + Mockito.when(objectStoreDao.findById(OBJECT_STORE_ID)).thenReturn(objectStoreVO); + ObjectStoreEntity objectStore = Mockito.mock(ObjectStoreEntity.class); + Mockito.when(dataStoreMgr.getDataStore(OBJECT_STORE_ID, DataStoreRole.Object)).thenReturn(objectStore); + Mockito.when(objectStore.supportsBucketCredentials()).thenReturn(true); + Mockito.when(objectStore.migrateAccountForBucketCredentials(ACCOUNT_ID)).thenReturn(true); + Mockito.when(mockAccountVO.getId()).thenReturn(ACCOUNT_ID); + MigrateObjectStoreAccountCmd cmd = Mockito.mock(MigrateObjectStoreAccountCmd.class); + Mockito.when(cmd.getAccountId()).thenReturn(ACCOUNT_ID); + Mockito.when(cmd.getObjectStoreId()).thenReturn(OBJECT_STORE_ID); + Account caller = mock(Account.class); + + Assert.assertTrue(bucketApiService.migrateObjectStoreAccount(cmd, caller)); + + Mockito.verify(accountManager).checkAccess(caller, null, true, mockAccountVO); + Mockito.verify(objectStore).migrateAccountForBucketCredentials(ACCOUNT_ID); + } + + @Test + public void testMigratedAccountNeverFallsBackToTheSharedKey() { + ObjectStoreEntity objectStore = Mockito.mock(ObjectStoreEntity.class); + Mockito.when(objectStore.accountSupportsBucketCredentials(ACCOUNT_ID)).thenReturn(true); + // the global setting does not re-share a migrated account's key, whatever it is set to + Assert.assertTrue(bucketApiService.isPerBucketCredentialsEnabled(objectStore, ACCOUNT_ID)); + } + + @Test + public void testUnmigratedAccountKeepsTheSharedKey() { + ObjectStoreEntity objectStore = Mockito.mock(ObjectStoreEntity.class); + Mockito.when(objectStore.accountSupportsBucketCredentials(ACCOUNT_ID)).thenReturn(false); + Assert.assertFalse(bucketApiService.isPerBucketCredentialsEnabled(objectStore, ACCOUNT_ID)); + } + + @Test + public void testMigrateObjectStoreAccountRefusedOnUnsupportedStore() { + ObjectStoreVO objectStoreVO = Mockito.mock(ObjectStoreVO.class); + Mockito.when(objectStoreVO.getId()).thenReturn(OBJECT_STORE_ID); + Mockito.when(objectStoreVO.getName()).thenReturn("reef-store"); + Mockito.when(objectStoreDao.findById(OBJECT_STORE_ID)).thenReturn(objectStoreVO); + ObjectStoreEntity objectStore = Mockito.mock(ObjectStoreEntity.class); + Mockito.when(dataStoreMgr.getDataStore(OBJECT_STORE_ID, DataStoreRole.Object)).thenReturn(objectStore); + Mockito.when(objectStore.supportsBucketCredentials()).thenReturn(false); + Mockito.when(objectStore.bucketCredentialsUnsupportedReason()) + .thenReturn("the object store's admin credential is missing the 'accounts' capability"); + MigrateObjectStoreAccountCmd cmd = Mockito.mock(MigrateObjectStoreAccountCmd.class); + Mockito.when(cmd.getAccountId()).thenReturn(ACCOUNT_ID); + Mockito.when(cmd.getObjectStoreId()).thenReturn(OBJECT_STORE_ID); + Account caller = mock(Account.class); + Mockito.when(caller.getId()).thenReturn(1L); + Mockito.when(accountManager.isRootAdmin(1L)).thenReturn(true); + + try { + bucketApiService.migrateObjectStoreAccount(cmd, caller); + Assert.fail("expected refusal on a store without per-bucket credential support"); + } catch (InvalidParameterValueException expected) { + Assert.assertTrue(expected.getMessage().contains("does not support per-bucket credentials")); + // an operator is told which of the three possible causes applies + Assert.assertTrue(expected.getMessage().contains("missing the 'accounts' capability")); + } + Mockito.verify(objectStore, Mockito.never()).migrateAccountForBucketCredentials(Mockito.anyLong()); + } + + @Test + public void testMigrateRefusalHidesTheGatewayDetailFromADomainAdmin() { + ObjectStoreVO objectStoreVO = Mockito.mock(ObjectStoreVO.class); + Mockito.when(objectStoreVO.getId()).thenReturn(OBJECT_STORE_ID); + Mockito.when(objectStoreVO.getName()).thenReturn("reef-store"); + Mockito.when(objectStoreDao.findById(OBJECT_STORE_ID)).thenReturn(objectStoreVO); + ObjectStoreEntity objectStore = Mockito.mock(ObjectStoreEntity.class); + Mockito.when(dataStoreMgr.getDataStore(OBJECT_STORE_ID, DataStoreRole.Object)).thenReturn(objectStore); + Mockito.when(objectStore.supportsBucketCredentials()).thenReturn(false); + MigrateObjectStoreAccountCmd cmd = Mockito.mock(MigrateObjectStoreAccountCmd.class); + Mockito.when(cmd.getAccountId()).thenReturn(ACCOUNT_ID); + Mockito.when(cmd.getObjectStoreId()).thenReturn(OBJECT_STORE_ID); + Account caller = mock(Account.class); + Mockito.when(caller.getId()).thenReturn(2L); + Mockito.when(accountManager.isRootAdmin(2L)).thenReturn(false); + + try { + bucketApiService.migrateObjectStoreAccount(cmd, caller); + Assert.fail("expected refusal"); + } catch (InvalidParameterValueException expected) { + // the gateway's own credential and capabilities are the platform's business + Assert.assertTrue(expected.getMessage().contains("Contact your platform administrator")); + Assert.assertFalse(expected.getMessage().contains("capability")); + } + Mockito.verify(objectStore, Mockito.never()).bucketCredentialsUnsupportedReason(); + } } diff --git a/test/integration/smoke/test_bucket.py b/test/integration/smoke/test_bucket.py index 7d92ea98b073..b833a89a1c96 100644 --- a/test/integration/smoke/test_bucket.py +++ b/test/integration/smoke/test_bucket.py @@ -61,7 +61,8 @@ def test_01_create_bucket(self): bucket = Bucket.create( self.apiclient, "mybucket", - object_store.id + object_store.id, + quota=1 ) list_buckets_response = Bucket.list( @@ -108,4 +109,60 @@ def test_01_create_bucket(self): self.cleanup.append(bucket) self.cleanup.append(object_store) + @attr(tags=["smoke"], required_hardware="false") + def test_02_bucket_key_rotation(self): + """Test per-bucket credentials and two-slot key rotation on the simulator object store + + The simulator provider supports dedicated bucket credentials, so a new bucket gets + its own credential with a key in slot 1. Rotating fills slot 2 with a different key, + revoking slot 1 leaves slot 2 active, and the last active key cannot be revoked. + """ + + object_store = ObjectStoragePool.create( + self.apiclient, + "testOS-keys", + "http://192.168.0.2", + "Simulator", + None + ) + bucket = Bucket.create( + self.apiclient, + "rotatebucket", + object_store.id, + quota=1 + ) + self.cleanup.append(bucket) + self.cleanup.append(object_store) + + bucket_response = Bucket.list(self.apiclient, id=bucket.id)[0] + self.assertEqual("bucket", bucket_response.credentialscope, "New bucket should have a dedicated credential") + self.assertEqual(1, len(bucket_response.keys), "New bucket should have one key slot") + slot1 = bucket_response.keys[0] + self.assertEqual(1, slot1.keyslot) + self.assertEqual("Active", slot1.state) + self.assertEqual(slot1.accesskey, bucket_response.accesskey, "Bucket access key should mirror the active key") + + rotated = bucket.rotate_key(self.apiclient) + self.assertEqual(2, rotated.keyslot, "Default rotation should fill the free slot") + self.assertNotEqual(slot1.accesskey, rotated.accesskey, "Rotated key must differ from the existing key") + + bucket_response = Bucket.list(self.apiclient, id=bucket.id)[0] + self.assertEqual(2, len(bucket_response.keys), "Both slots should be populated after rotation") + self.assertEqual(rotated.accesskey, bucket_response.accesskey, "Bucket access key should follow the newest active key") + + bucket.revoke_key(self.apiclient, keyslot=1) + bucket_response = Bucket.list(self.apiclient, id=bucket.id)[0] + states = {key.keyslot: key.state for key in bucket_response.keys} + self.assertEqual("Revoked", states[1], "Slot 1 should be revoked") + self.assertEqual("Active", states[2], "Slot 2 should stay active") + + with self.assertRaises(Exception): + bucket.revoke_key(self.apiclient, keyslot=2) + + rotated_again = bucket.rotate_key(self.apiclient) + self.assertEqual(1, rotated_again.keyslot, "Rotation should reuse the revoked slot") + bucket_response = Bucket.list(self.apiclient, id=bucket.id)[0] + states = {key.keyslot: key.state for key in bucket_response.keys} + self.assertEqual({1: "Active", 2: "Active"}, states, "Both slots should be active again") + return diff --git a/tools/apidoc/gen_toc.py b/tools/apidoc/gen_toc.py index c99328fff9ff..63b7886cee7f 100644 --- a/tools/apidoc/gen_toc.py +++ b/tools/apidoc/gen_toc.py @@ -253,6 +253,11 @@ 'updateBucket': 'Object Store', 'deleteBucket': 'Object Store', 'listBuckets': 'Object Store', + 'rotateBucketKey': 'Object Store', + 'revokeBucketKey': 'Object Store', + 'migrateBucketCredential': 'Object Store', + 'migrateObjectStoreAccount': 'Object Store', + 'rotateObjectStoreAccountKey': 'Object Store', 'listVmsForImport': 'Virtual Machine', 'SharedFS': 'Shared FileSystem', 'SharedFileSystem': 'Shared FileSystem', diff --git a/tools/marvin/marvin/lib/base.py b/tools/marvin/marvin/lib/base.py index e7fa2f763db5..8391f0140d36 100755 --- a/tools/marvin/marvin/lib/base.py +++ b/tools/marvin/marvin/lib/base.py @@ -7523,6 +7523,48 @@ def update(self, apiclient, **kwargs): [setattr(cmd, k, v) for k, v in list(kwargs.items())] return apiclient.updateBucket(cmd) + def rotate_key(self, apiclient, keyslot=None): + """Create a new key pair in one slot of the bucket's dedicated credential""" + + cmd = rotateBucketKey.rotateBucketKeyCmd() + cmd.id = self.id + if keyslot is not None: + cmd.keyslot = keyslot + return apiclient.rotateBucketKey(cmd) + + def revoke_key(self, apiclient, keyslot): + """Revoke the key pair in one slot of the bucket's dedicated credential""" + + cmd = revokeBucketKey.revokeBucketKeyCmd() + cmd.id = self.id + cmd.keyslot = keyslot + return apiclient.revokeBucketKey(cmd) + + def migrate_credential(self, apiclient): + """Give a bucket that uses the account credential a dedicated credential""" + + cmd = migrateBucketCredential.migrateBucketCredentialCmd() + cmd.id = self.id + return apiclient.migrateBucketCredential(cmd) + + @classmethod + def migrate_account(cls, apiclient, accountid, objectstorageid): + """Migrate an account's identity on an object store for per-bucket credentials (admin)""" + + cmd = migrateObjectStoreAccount.migrateObjectStoreAccountCmd() + cmd.accountid = accountid + cmd.objectstorageid = objectstorageid + return apiclient.migrateObjectStoreAccount(cmd) + + @classmethod + def rotate_account_key(cls, apiclient, accountid, objectstorageid): + """Rotate the account-level key on an object store once no bucket uses it (admin)""" + + cmd = rotateObjectStoreAccountKey.rotateObjectStoreAccountKeyCmd() + cmd.accountid = accountid + cmd.objectstorageid = objectstorageid + return apiclient.rotateObjectStoreAccountKey(cmd) + class Webhook: """Manage Webhook Life cycle""" diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index 99bf2cf7aef9..e1e01ae0c4ab 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -3064,6 +3064,59 @@ "label.objectlocking": "Object Lock", "label.bucket.policy": "Bucket Policy", "label.usersecretkey": "API Secret Key", +"label.perbucketcredentialsready": "Per-Bucket Credentials", +"label.ready": "Ready", +"label.not.ready": "Not ready", +"label.perbucketcredentialsissue": "To Resolve Before Migration", +"label.credentialscope": "Credential Scope", +"label.credentialscope.bucket": "Per-Bucket", +"label.credentialscope.account": "Account", +"label.keys": "Keys", +"label.keyslot": "Key Slot", +"label.bucket.key.rotate": "Rotate Key", +"label.bucket.key.create": "Create Key", +"label.bucket.key.revoke": "Revoke Key", +"label.bucket.key.rotated": "Key slot {slot} rotated. Update consumers to the new key before revoking the other slot.", +"label.bucket.key.revoked": "Key slot {slot} revoked.", +"label.bucket.credential.migrate": "Migrate to Per-Bucket Credential", +"label.legacy.buckets": "Buckets on Account Key", +"label.account.object.storage.migrate": "Migrate Account to Per-Bucket Credentials", +"label.account.key.rotate": "Rotate Account Key", +"label.account.key.rotation.pending": "Account key rotation pending", +"label.account.object.storage.title": "Per-Bucket Credentials", +"message.account.object.storage.intro": "Object storage buckets used to share one key per account. Per-bucket credentials give every bucket its own rotatable keys, so a leaked key exposes one bucket instead of all of them.\nThe change is made per object store in the stages below. Nothing happens automatically, and existing consumers keep working until the final stage.\nOnce an account has been migrated, every new bucket created on that object store is created using per-bucket credentials.", +"label.account.object.storage.complete": "Per-Bucket Credentials In Use", +"label.account.object.storage.unsupported": "Per-Bucket Credentials Not Supported", +"message.account.object.storage.unsupported.all": "None of this account's object storage supports per-bucket credentials, so its buckets share one credential per object store. Contact your platform administrator if per-bucket credentials are required. If an object store gains support later, it is detected automatically and the migration steps appear here.", +"message.account.object.storage.unsupported": "This object storage backend does not support per-bucket credentials, so the account's buckets on it share one credential. Contact your platform administrator if per-bucket credentials are required here. If the backend gains support later, it is detected automatically.", +"message.account.object.storage.none": "No object storage is available for this account. Contact your platform administrator.", +"label.account.object.storage.stage.legacy": "Account key", +"message.account.object.storage.stage.legacy": "All buckets share the account key", +"label.account.object.storage.stage.buckets": "Migrate buckets", +"message.account.object.storage.stage.buckets": "Each bucket gets its own keys", +"message.account.object.storage.stage.buckets.remaining": "{count} still on the account key", +"label.account.object.storage.stage.rotate": "Rotate account key", +"message.account.object.storage.stage.rotate": "Old shared key stops working", +"label.account.object.storage.stage.complete": "Complete", +"message.account.object.storage.stage.complete": "Only per-bucket keys in use", +"message.account.object.storage.now.legacy": "This account has not been migrated on this store: its {count} bucket(s) share the account key. Migrating converts the account's object storage identity so that each bucket can have its own keys. This is permanent. Existing buckets and their keys keep working unchanged.", +"message.account.object.storage.now.buckets": "The account is migrated. {count} bucket(s) still use the account key; move each one to its own keys with \"Migrate to Per-Bucket Credential\" on the bucket. Consumers keep working with the account key until you rotate it in the next stage.", +"label.account.object.storage.show.buckets": "Show Buckets", +"message.account.object.storage.now.complete": "Every bucket on this store has its own keys, and new buckets are created with their own keys.", +"label.migrated": "Migrated", +"label.legacy": "Legacy", +"message.account.object.storage.description": "Per-bucket credentials require the account to be migrated once on each object store. After every bucket has been moved to its own credential, rotate the account key to complete the migration.", +"message.account.object.storage.migrate": "Migrate account {account} on {store} to per-bucket credentials? The account's object storage identity is converted so that each bucket can have its own keys. This is permanent. Existing buckets keep working with their current keys and each bucket's keys can be migrated individually.", +"message.account.object.storage.migrated": "Account migrated on {store}. Buckets can now be moved to per-bucket credentials.", +"message.account.key.rotation.pending": "All of this account's buckets on {store} now use per-bucket credentials, but the original account key still has full access to every bucket. Rotate it to complete the migration. Anything still using the old account key, such as backup jobs, scripts or third-party tools, will lose access when you do.", +"message.account.key.rotate": "Rotate the account key for {account} on {store}? The account key opens every bucket this account owns. Any person or system still using this account key loses that access immediately after rotation. Each bucket's own individual keys keep working.", +"message.account.key.rotated": "Account key rotated on {store}. The old key no longer works.", +"message.bucket.keys.description": "This bucket has its own credential with two independent key slots. Rotate one slot while consumers keep using the other, move them to the new key, then revoke the old one.", +"message.bucket.key.rotate": "Replace the key in this slot with a new key pair? The old key stops working immediately. Update any consumer still using it to the new key, or to the key in the other slot.", +"message.bucket.key.create": "Create a new key pair in this empty slot?", +"message.bucket.key.revoke": "Revoke the key in this slot? Consumers using it will lose access immediately.", +"message.bucket.key.revoke.last": "A bucket always keeps one active key. Create a key in the other slot before revoking this one.", +"message.bucket.credential.migrate": "Move this bucket to its own credential with rotatable keys? This applies to this bucket only, and the account key keeps working on this bucket until an administrator revokes it.", "label.create.bucket": "Create Bucket", "label.cniconfiguration": "CNI Configuration", "label.cniconfigname": "Associated CNI Configuration", diff --git a/ui/src/components/view/AccountObjectStorageTab.vue b/ui/src/components/view/AccountObjectStorageTab.vue new file mode 100644 index 000000000000..10799214c59f --- /dev/null +++ b/ui/src/components/view/AccountObjectStorageTab.vue @@ -0,0 +1,259 @@ +// 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. + + + + + + diff --git a/ui/src/components/view/BucketKeysTab.vue b/ui/src/components/view/BucketKeysTab.vue new file mode 100644 index 000000000000..7f6a64eb1833 --- /dev/null +++ b/ui/src/components/view/BucketKeysTab.vue @@ -0,0 +1,244 @@ +// 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. + + + + + + diff --git a/ui/src/components/view/DetailsTab.vue b/ui/src/components/view/DetailsTab.vue index d2aabacb10c9..fa80c469035f 100644 --- a/ui/src/components/view/DetailsTab.vue +++ b/ui/src/components/view/DetailsTab.vue @@ -147,6 +147,12 @@
{{ dataResource[item].join(', ') }}
+
+ {{ $t('label.credentialscope.' + dataResource[item]) }} +
+
+ {{ dataResource[item] ? $t('label.ready') : $t('label.not.ready') }} +
{{ dataResource[item] }}
@@ -489,7 +495,7 @@ export default { } if (typeof details === 'function') { - details = details() + details = details(this.resource) } let detailsKeys = [] diff --git a/ui/src/components/view/ListView.vue b/ui/src/components/view/ListView.vue index 9a7d874fef3e..c330c9908684 100644 --- a/ui/src/components/view/ListView.vue +++ b/ui/src/components/view/ListView.vue @@ -465,6 +465,9 @@ +