From b606ff455bc40f323775ed74613b4048eca169f3 Mon Sep 17 00:00:00 2001 From: Nilesh Vaghela Date: Mon, 28 Sep 2026 23:11:08 -0700 Subject: [PATCH 1/2] feat(api-keys): add multi-tenant persistence layer Add Spring Data Cassandra models, repositories, and DAOs for the multi-tenant API Keys tables: - keys.nca_id on KeyModel and KeyVo - keys_by_account_owner_and_service with encrypted key details, partition lookups, and paged account and issuer scans on the storage-attached indexes - owner_status_by_account and owner_status_by_account_and_service with an effective-status helper - key_operations_by_id for bulk operation progress and paging state Add Testcontainers integration tests for each DAO. Closes #2051 Signed-off-by: Nilesh Vaghela --- src/control-plane-services/api-keys/AGENTS.md | 5 + .../persistance/dao/AccountKeysDao.java | 173 ++++++++++++++ .../dao/AccountOwnerStatusDao.java | 106 +++++++++ ...yAccountOwnerAndServiceModelConverter.java | 46 ++++ .../persistance/dao/KeyOperationsDao.java | 84 +++++++ .../KeyByAccountOwnerAndServiceModel.java | 93 ++++++++ .../apikeys/persistance/models/KeyModel.java | 4 + .../persistance/models/KeyOperationModel.java | 115 ++++++++++ .../OwnerStatusByAccountAndServiceModel.java | 78 +++++++ .../models/OwnerStatusByAccountModel.java | 72 ++++++ ...KeyByAccountOwnerAndServiceRepository.java | 49 ++++ .../repositories/KeyOperationRepository.java | 30 +++ ...erStatusByAccountAndServiceRepository.java | 33 +++ .../OwnerStatusByAccountRepository.java | 32 +++ .../validators/KeyExpirationValidator.java | 9 + .../nvidia/apikeys/vo/AccountKeysPageVo.java | 28 +++ .../vo/KeyByAccountOwnerAndServiceVo.java | 70 ++++++ .../nvidia/apikeys/vo/KeyOperationStatus.java | 25 ++ .../java/com/nvidia/apikeys/vo/KeyVo.java | 1 + .../dao/AccountKeysDaoIntegrationTest.java | 213 ++++++++++++++++++ .../AccountOwnerStatusDaoIntegrationTest.java | 132 +++++++++++ .../dao/KeyOperationsDaoIntegrationTest.java | 137 +++++++++++ 22 files changed, 1535 insertions(+) create mode 100644 src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/dao/AccountKeysDao.java create mode 100644 src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/dao/AccountOwnerStatusDao.java create mode 100644 src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/dao/KeyByAccountOwnerAndServiceModelConverter.java create mode 100644 src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/dao/KeyOperationsDao.java create mode 100644 src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/models/KeyByAccountOwnerAndServiceModel.java create mode 100644 src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/models/KeyOperationModel.java create mode 100644 src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/models/OwnerStatusByAccountAndServiceModel.java create mode 100644 src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/models/OwnerStatusByAccountModel.java create mode 100644 src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/repositories/KeyByAccountOwnerAndServiceRepository.java create mode 100644 src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/repositories/KeyOperationRepository.java create mode 100644 src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/repositories/OwnerStatusByAccountAndServiceRepository.java create mode 100644 src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/repositories/OwnerStatusByAccountRepository.java create mode 100644 src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/vo/AccountKeysPageVo.java create mode 100644 src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/vo/KeyByAccountOwnerAndServiceVo.java create mode 100644 src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/vo/KeyOperationStatus.java create mode 100644 src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/persistance/dao/AccountKeysDaoIntegrationTest.java create mode 100644 src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/persistance/dao/AccountOwnerStatusDaoIntegrationTest.java create mode 100644 src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/persistance/dao/KeyOperationsDaoIntegrationTest.java diff --git a/src/control-plane-services/api-keys/AGENTS.md b/src/control-plane-services/api-keys/AGENTS.md index 6b4442c19c..37b11a9ffc 100644 --- a/src/control-plane-services/api-keys/AGENTS.md +++ b/src/control-plane-services/api-keys/AGENTS.md @@ -43,6 +43,11 @@ multi-tenant objects to `03`. `04_add_multi_tenant_schema.up.sql` is the deployed delta for new and existing clusters. Keep `keys_by_owner_and_service` until the dual-write migration stops using it. +Multi-tenant persistence lives in `AccountKeysDao`, `AccountOwnerStatusDao`, +and `KeyOperationsDao`. `EncryptedModelConverter` maps every model column to a +same-named value-object field, so a new column on an encrypted model also needs +that field on its `@ValueObject`. + Integration tests bind each `.cql` file in `local_env/docker-compose.test.yml` because Bazel runfiles are symlinks. Local Compose mounts the whole schema directory. diff --git a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/dao/AccountKeysDao.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/dao/AccountKeysDao.java new file mode 100644 index 0000000000..282a527a97 --- /dev/null +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/dao/AccountKeysDao.java @@ -0,0 +1,173 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed 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.nvidia.apikeys.persistance.dao; + +import com.nvidia.apikeys.config.exceptions.CassandraException; +import com.nvidia.apikeys.persistance.models.KeyByAccountOwnerAndServiceModel; +import com.nvidia.apikeys.persistance.models.KeyModel; +import com.nvidia.apikeys.persistance.repositories.KeyByAccountOwnerAndServiceRepository; +import com.nvidia.apikeys.vo.AccountKeysPageVo; +import com.nvidia.apikeys.vo.KeyByAccountOwnerAndServiceVo; +import com.nvidia.apikeys.vo.KeyOwnerType; +import com.nvidia.apikeys.vo.KeyVo; +import java.nio.ByteBuffer; +import java.util.Base64; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; +import lombok.RequiredArgsConstructor; +import org.springframework.data.cassandra.core.CassandraBatchOperations; +import org.springframework.data.cassandra.core.CassandraTemplate; +import org.springframework.data.cassandra.core.WriteResult; +import org.springframework.data.cassandra.core.query.CassandraPageRequest; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; + +/** + * Account-scoped key persistence. Writes the hash lookup row in keys and the management row in + * keys_by_account_owner_and_service in one logged batch. + */ +@Service +@RequiredArgsConstructor +public class AccountKeysDao { + + private final KeyByAccountOwnerAndServiceRepository repository; + private final KeyModelConverter keyConverter; + private final KeyByAccountOwnerAndServiceModelConverter accountKeyConverter; + private final KeysDao keysDao; + private final CassandraTemplate cassandraTemplate; + + public KeyByAccountOwnerAndServiceVo save(KeyVo key) { + if (key.getNcaId() == null) { + throw new IllegalArgumentException("nca_id is required for account-scoped keys"); + } + + KeyModel keyModel = keyConverter.voToModel(key); + KeyByAccountOwnerAndServiceModel accountKeyModel = + accountKeyConverter.voToModel(KeyByAccountOwnerAndServiceVo.from(key)); + + WriteResult writeResult = cassandraTemplate.batchOps() + .insert(List.of(keyModel)) + .insert(List.of(accountKeyModel)) + .execute(); + + if (!writeResult.wasApplied()) { + throw new CassandraException("Failed to write account key into db"); + } + + if (keysDao.getKeyByHash(key.getKeyHash()).isEmpty()) { + throw new CassandraException("Failed to read saved key"); + } + + return get(key.getNcaId(), key.getOwnerType(), key.getOwnerId(), + key.getIssuerServiceId(), key.getKeyId()) + .orElseThrow(() -> new CassandraException("Failed to read saved account key")); + } + + public Optional get( + String ncaId, KeyOwnerType ownerType, String ownerId, String issuerServiceId, + String keyId) { + return repository.findByNcaIdAndOwnerTypeAndOwnerIdAndIssuerServiceIdAndKeyId( + ncaId, ownerType, ownerId, issuerServiceId, keyId) + .map(accountKeyConverter::modelToVo); + } + + public List list( + String ncaId, KeyOwnerType ownerType, String ownerId) { + return repository.findByNcaIdAndOwnerTypeAndOwnerId(ncaId, ownerType, ownerId) + .stream() + .map(accountKeyConverter::modelToVo) + .toList(); + } + + public List list( + String ncaId, KeyOwnerType ownerType, String ownerId, String issuerServiceId) { + return repository.findByNcaIdAndOwnerTypeAndOwnerIdAndIssuerServiceId( + ncaId, ownerType, ownerId, issuerServiceId) + .stream() + .map(accountKeyConverter::modelToVo) + .toList(); + } + + public AccountKeysPageVo listByAccount(String ncaId, int pageSize, String pagingState) { + return page(pageable -> repository.findByNcaId(ncaId, pageable), pageSize, pagingState); + } + + public AccountKeysPageVo listByAccountAndService( + String ncaId, String issuerServiceId, int pageSize, String pagingState) { + return page(pageable -> repository.findByNcaIdAndIssuerServiceId( + ncaId, issuerServiceId, pageable), pageSize, pagingState); + } + + public void delete(KeyByAccountOwnerAndServiceVo key) { + KeyModel keyModel = KeyModel.builder() + .keyHash(key.getKeyHash()) + .keyStatus(key.getKeyStatus()) + .build(); + + KeyByAccountOwnerAndServiceModel accountKeyModel = KeyByAccountOwnerAndServiceModel.builder() + .ncaId(key.getNcaId()) + .ownerType(key.getOwnerType()) + .ownerId(key.getOwnerId()) + .issuerServiceId(key.getIssuerServiceId()) + .keyId(key.getKeyId()) + .build(); + + CassandraBatchOperations batchOperations = cassandraTemplate.batchOps() + .delete(List.of(keyModel)) + .delete(List.of(accountKeyModel)); + + if (!batchOperations.execute().wasApplied()) { + throw new CassandraException("Failed to delete account key."); + } + } + + private AccountKeysPageVo page( + Function> query, + int pageSize, String pagingState) { + Slice slice = query.apply(pageRequest(pageSize, + pagingState)); + List keys = slice.getContent().stream() + .map(accountKeyConverter::modelToVo) + .toList(); + return new AccountKeysPageVo(keys, nextPagingState(slice)); + } + + private static CassandraPageRequest pageRequest(int pageSize, String pagingState) { + if (pagingState == null) { + return CassandraPageRequest.first(pageSize); + } + ByteBuffer state = ByteBuffer.wrap(Base64.getUrlDecoder().decode(pagingState)); + return CassandraPageRequest.of(PageRequest.of(0, pageSize), state); + } + + private static String nextPagingState(Slice slice) { + if (!slice.hasNext()) { + return null; + } + ByteBuffer state = ((CassandraPageRequest) slice.nextPageable()).getPagingState(); + if (state == null) { + return null; + } + byte[] bytes = new byte[state.remaining()]; + state.duplicate().get(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } +} diff --git a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/dao/AccountOwnerStatusDao.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/dao/AccountOwnerStatusDao.java new file mode 100644 index 0000000000..dfce13ad7a --- /dev/null +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/dao/AccountOwnerStatusDao.java @@ -0,0 +1,106 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed 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.nvidia.apikeys.persistance.dao; + +import com.nvidia.apikeys.persistance.models.OwnerStatusByAccountAndServiceModel; +import com.nvidia.apikeys.persistance.models.OwnerStatusByAccountModel; +import com.nvidia.apikeys.persistance.repositories.OwnerStatusByAccountAndServiceRepository; +import com.nvidia.apikeys.persistance.repositories.OwnerStatusByAccountRepository; +import com.nvidia.apikeys.vo.KeyOwnerStatus; +import com.nvidia.apikeys.vo.KeyOwnerType; +import java.time.Clock; +import java.time.Instant; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +/** + * Owner status scoped to an account, and optionally to one issuer service within the account. + * A missing row means the owner is active. + */ +@Service +@RequiredArgsConstructor +public class AccountOwnerStatusDao { + + private final OwnerStatusByAccountRepository accountRepository; + private final OwnerStatusByAccountAndServiceRepository serviceRepository; + private final Clock clock; + + public Optional getAccountStatus( + String ncaId, KeyOwnerType ownerType, String ownerId) { + return accountRepository.findByNcaIdAndOwnerTypeAndOwnerId(ncaId, ownerType, ownerId); + } + + public Optional getServiceStatus( + String ncaId, KeyOwnerType ownerType, String ownerId, String issuerServiceId) { + return serviceRepository.findByNcaIdAndOwnerTypeAndOwnerIdAndIssuerServiceId( + ncaId, ownerType, ownerId, issuerServiceId); + } + + public OwnerStatusByAccountModel saveAccountStatus( + String ncaId, KeyOwnerType ownerType, String ownerId, KeyOwnerStatus status) { + Instant now = clock.instant(); + Instant createdAt = getAccountStatus(ncaId, ownerType, ownerId) + .map(OwnerStatusByAccountModel::getCreatedAt) + .orElse(now); + return accountRepository.save(OwnerStatusByAccountModel.builder() + .ncaId(ncaId) + .ownerType(ownerType) + .ownerId(ownerId) + .ownerStatus(status) + .createdAt(createdAt) + .updatedAt(now) + .build()); + } + + public OwnerStatusByAccountAndServiceModel saveServiceStatus( + String ncaId, KeyOwnerType ownerType, String ownerId, String issuerServiceId, + KeyOwnerStatus status) { + Instant now = clock.instant(); + Instant createdAt = getServiceStatus(ncaId, ownerType, ownerId, issuerServiceId) + .map(OwnerStatusByAccountAndServiceModel::getCreatedAt) + .orElse(now); + return serviceRepository.save(OwnerStatusByAccountAndServiceModel.builder() + .ncaId(ncaId) + .ownerType(ownerType) + .ownerId(ownerId) + .issuerServiceId(issuerServiceId) + .ownerStatus(status) + .createdAt(createdAt) + .updatedAt(now) + .build()); + } + + /** + * SUSPENDED if the owner is suspended for the whole account or for the issuer service. + */ + public KeyOwnerStatus getEffectiveStatus( + String ncaId, KeyOwnerType ownerType, String ownerId, String issuerServiceId) { + boolean accountSuspended = getAccountStatus(ncaId, ownerType, ownerId) + .map(OwnerStatusByAccountModel::getOwnerStatus) + .filter(KeyOwnerStatus.SUSPENDED::equals) + .isPresent(); + if (accountSuspended) { + return KeyOwnerStatus.SUSPENDED; + } + return getServiceStatus(ncaId, ownerType, ownerId, issuerServiceId) + .map(OwnerStatusByAccountAndServiceModel::getOwnerStatus) + .filter(KeyOwnerStatus.SUSPENDED::equals) + .orElse(KeyOwnerStatus.ACTIVE); + } +} diff --git a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/dao/KeyByAccountOwnerAndServiceModelConverter.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/dao/KeyByAccountOwnerAndServiceModelConverter.java new file mode 100644 index 0000000000..14f041fa1b --- /dev/null +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/dao/KeyByAccountOwnerAndServiceModelConverter.java @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed 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.nvidia.apikeys.persistance.dao; + +import com.nvidia.apikeys.persistance.models.KeyByAccountOwnerAndServiceModel; +import com.nvidia.apikeys.validators.KeyExpirationValidator; +import com.nvidia.apikeys.vo.KeyByAccountOwnerAndServiceVo; +import com.nvidia.boot.jwt.services.mapping.EncryptedModelConverter; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +/** + * This class wraps the converter and adds expiration validator on read. + */ +@Service +@RequiredArgsConstructor +public class KeyByAccountOwnerAndServiceModelConverter { + + private final EncryptedModelConverter converter; + private final KeyExpirationValidator expirationValidator; + + public KeyByAccountOwnerAndServiceModel voToModel(KeyByAccountOwnerAndServiceVo vo) { + return converter.voToModel(vo); + } + + public KeyByAccountOwnerAndServiceVo modelToVo(KeyByAccountOwnerAndServiceModel model) { + KeyByAccountOwnerAndServiceVo vo = converter.modelToVo(model); + return expirationValidator.validateStatus(vo); + } +} diff --git a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/dao/KeyOperationsDao.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/dao/KeyOperationsDao.java new file mode 100644 index 0000000000..2ada72efc1 --- /dev/null +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/dao/KeyOperationsDao.java @@ -0,0 +1,84 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed 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.nvidia.apikeys.persistance.dao; + +import com.nvidia.apikeys.config.exceptions.CassandraException; +import com.nvidia.apikeys.persistance.models.KeyOperationModel; +import com.nvidia.apikeys.persistance.repositories.KeyOperationRepository; +import com.nvidia.apikeys.vo.KeyOperationStatus; +import java.time.Clock; +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.data.cassandra.core.CassandraTemplate; +import org.springframework.data.cassandra.core.InsertOptions; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class KeyOperationsDao { + + private static final InsertOptions IF_NOT_EXISTS = InsertOptions.builder() + .withIfNotExists() + .build(); + + private final KeyOperationRepository repository; + private final CassandraTemplate cassandraTemplate; + private final Clock clock; + + /** + * Inserts a new operation. Assigns an id, PENDING status, and zero counters when unset. + */ + public KeyOperationModel create(KeyOperationModel operation) { + Instant now = clock.instant(); + KeyOperationModel model = operation.toBuilder() + .operationId(Optional.ofNullable(operation.getOperationId()) + .orElseGet(UUID::randomUUID)) + .operationStatus(Optional.ofNullable(operation.getOperationStatus()) + .orElse(KeyOperationStatus.PENDING)) + .matchedCount(zeroIfNull(operation.getMatchedCount())) + .completedCount(zeroIfNull(operation.getCompletedCount())) + .failedCount(zeroIfNull(operation.getFailedCount())) + .createdAt(now) + .updatedAt(now) + .build(); + + if (!cassandraTemplate.insert(model, IF_NOT_EXISTS).wasApplied()) { + throw new CassandraException("Key operation already exists: " + model.getOperationId()); + } + return model; + } + + public Optional get(UUID operationId) { + return repository.findByOperationId(operationId); + } + + /** + * Writes progress for an existing operation and refreshes updated_at. + */ + public KeyOperationModel update(KeyOperationModel operation) { + return repository.save(operation.toBuilder() + .updatedAt(clock.instant()) + .build()); + } + + private static Long zeroIfNull(Long value) { + return value == null ? 0L : value; + } +} diff --git a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/models/KeyByAccountOwnerAndServiceModel.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/models/KeyByAccountOwnerAndServiceModel.java new file mode 100644 index 0000000000..9bd0e35f9f --- /dev/null +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/models/KeyByAccountOwnerAndServiceModel.java @@ -0,0 +1,93 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed 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.nvidia.apikeys.persistance.models; + +import com.nvidia.apikeys.vo.KeyByAccountOwnerAndServiceVo; +import com.nvidia.apikeys.vo.KeyOwnerType; +import com.nvidia.apikeys.vo.KeyStatus; +import com.nvidia.boot.jwt.services.mapping.annotation.EncryptedFields; +import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.NonNull; +import org.springframework.data.annotation.PersistenceCreator; +import org.springframework.data.cassandra.core.cql.PrimaryKeyType; +import org.springframework.data.cassandra.core.mapping.Column; +import org.springframework.data.cassandra.core.mapping.PrimaryKeyColumn; +import org.springframework.data.cassandra.core.mapping.Table; + +/** + * Account-scoped management index for keys. Partition lookups serve one owner in one account. + * Storage-attached indexes on nca_id, owner, issuer, status, and created_at serve bulk selection. + */ +@Builder(toBuilder = true) +@Data +@NoArgsConstructor +@AllArgsConstructor(onConstructor_ = @PersistenceCreator) +@Table(KeyByAccountOwnerAndServiceModel.TABLE_NAME) +public class KeyByAccountOwnerAndServiceModel { + + public static final String TABLE_NAME = "keys_by_account_owner_and_service"; + public static final String COLUMN_NCA_ID = "nca_id"; + public static final String COLUMN_OWNER_TYPE = "owner_type"; + public static final String COLUMN_OWNER_ID = "owner_id"; + public static final String COLUMN_ISSUER_SERVICE_ID = "issuer_service_id"; + public static final String COLUMN_KEY_ID = "key_id"; + public static final String COLUMN_KEY_STATUS = "key_status"; + public static final String COLUMN_CREATED_AT = "created_at"; + public static final String COLUMN_EXPIRES_AT = "expires_at"; + public static final String COLUMN_DELETES_AT = "deletes_at"; + public static final String COLUMN_KEY_DETAILS = "key_details"; + + @NonNull + @PrimaryKeyColumn(name = COLUMN_NCA_ID, ordinal = 0, type = PrimaryKeyType.PARTITIONED) + private String ncaId; + + @NonNull + @PrimaryKeyColumn(name = COLUMN_OWNER_TYPE, ordinal = 1, type = PrimaryKeyType.PARTITIONED) + private KeyOwnerType ownerType; + + @NonNull + @PrimaryKeyColumn(name = COLUMN_OWNER_ID, ordinal = 2, type = PrimaryKeyType.PARTITIONED) + private String ownerId; + + @PrimaryKeyColumn(name = COLUMN_ISSUER_SERVICE_ID, ordinal = 3, type = PrimaryKeyType.CLUSTERED) + private String issuerServiceId; + + @PrimaryKeyColumn(name = COLUMN_KEY_ID, ordinal = 4, type = PrimaryKeyType.CLUSTERED) + private String keyId; + + @Column(COLUMN_KEY_STATUS) + private KeyStatus keyStatus; + + @Column(COLUMN_CREATED_AT) + private Instant createdAt; + + @Column(COLUMN_EXPIRES_AT) + private Instant expiresAt; + + @Column(COLUMN_DELETES_AT) + private Instant deletesAt; + + @Column(COLUMN_KEY_DETAILS) + @EncryptedFields(encryptionKeyName = "payload_jwe_kid", + valueObject = KeyByAccountOwnerAndServiceVo.class) + private String keyDetails; +} diff --git a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/models/KeyModel.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/models/KeyModel.java index 1347c2976a..f12c335813 100644 --- a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/models/KeyModel.java +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/models/KeyModel.java @@ -46,12 +46,16 @@ public class KeyModel { public static final String COLUMN_EXPIRES_AT = "expires_at"; public static final String COLUMN_DELETES_AT = "deletes_at"; public static final String COLUMN_KEY_DETAILS = "key_details"; + public static final String COLUMN_NCA_ID = "nca_id"; @DoNotTraceValue @NonNull @PrimaryKeyColumn(name = COLUMN_API_KEY_HASH, ordinal = 0, type = PrimaryKeyType.PARTITIONED) private String keyHash; + @Column(COLUMN_NCA_ID) + private String ncaId; + @NonNull @Column(COLUMN_STATUS) private KeyStatus keyStatus; diff --git a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/models/KeyOperationModel.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/models/KeyOperationModel.java new file mode 100644 index 0000000000..0238197440 --- /dev/null +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/models/KeyOperationModel.java @@ -0,0 +1,115 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed 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.nvidia.apikeys.persistance.models; + +import com.nvidia.apikeys.vo.KeyOperationStatus; +import java.time.Instant; +import java.util.Set; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.PersistenceCreator; +import org.springframework.data.cassandra.core.cql.PrimaryKeyType; +import org.springframework.data.cassandra.core.mapping.Column; +import org.springframework.data.cassandra.core.mapping.Frozen; +import org.springframework.data.cassandra.core.mapping.PrimaryKeyColumn; +import org.springframework.data.cassandra.core.mapping.Table; + +/** + * Progress record for a bulk key operation. The selection sets are the operation scope. The + * paging state lets a worker resume key selection after a restart. + */ +@Builder(toBuilder = true) +@Data +@NoArgsConstructor +@AllArgsConstructor(onConstructor_ = @PersistenceCreator) +@Table(KeyOperationModel.TABLE_NAME) +public class KeyOperationModel { + + public static final String TABLE_NAME = "key_operations_by_id"; + public static final String COLUMN_OPERATION_ID = "operation_id"; + public static final String COLUMN_ACTOR_TYPE = "actor_type"; + public static final String COLUMN_ACTOR_ID = "actor_id"; + public static final String COLUMN_OPERATION = "operation"; + public static final String COLUMN_NCA_IDS = "nca_ids"; + public static final String COLUMN_ISSUER_SERVICE_IDS = "issuer_service_ids"; + public static final String COLUMN_USER_IDS = "user_ids"; + public static final String COLUMN_OPERATION_STATUS = "operation_status"; + public static final String COLUMN_MATCHED_COUNT = "matched_count"; + public static final String COLUMN_COMPLETED_COUNT = "completed_count"; + public static final String COLUMN_FAILED_COUNT = "failed_count"; + public static final String COLUMN_SELECTION_STATE = "selection_state"; + public static final String COLUMN_PAGING_STATE = "paging_state"; + public static final String COLUMN_REASON = "reason"; + public static final String COLUMN_CUTOFF_AT = "cutoff_at"; + public static final String COLUMN_CREATED_AT = "created_at"; + public static final String COLUMN_UPDATED_AT = "updated_at"; + + @PrimaryKeyColumn(name = COLUMN_OPERATION_ID, ordinal = 0, type = PrimaryKeyType.PARTITIONED) + private UUID operationId; + + @Column(COLUMN_ACTOR_TYPE) + private String actorType; + + @Column(COLUMN_ACTOR_ID) + private String actorId; + + @Column(COLUMN_OPERATION) + private String operation; + + @Column(COLUMN_NCA_IDS) + private @Frozen Set ncaIds; + + @Column(COLUMN_ISSUER_SERVICE_IDS) + private @Frozen Set issuerServiceIds; + + @Column(COLUMN_USER_IDS) + private @Frozen Set userIds; + + @Column(COLUMN_OPERATION_STATUS) + private KeyOperationStatus operationStatus; + + @Column(COLUMN_MATCHED_COUNT) + private Long matchedCount; + + @Column(COLUMN_COMPLETED_COUNT) + private Long completedCount; + + @Column(COLUMN_FAILED_COUNT) + private Long failedCount; + + @Column(COLUMN_SELECTION_STATE) + private String selectionState; + + @Column(COLUMN_PAGING_STATE) + private String pagingState; + + @Column(COLUMN_REASON) + private String reason; + + @Column(COLUMN_CUTOFF_AT) + private Instant cutoffAt; + + @Column(COLUMN_CREATED_AT) + private Instant createdAt; + + @Column(COLUMN_UPDATED_AT) + private Instant updatedAt; +} diff --git a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/models/OwnerStatusByAccountAndServiceModel.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/models/OwnerStatusByAccountAndServiceModel.java new file mode 100644 index 0000000000..61a6c1cf09 --- /dev/null +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/models/OwnerStatusByAccountAndServiceModel.java @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed 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.nvidia.apikeys.persistance.models; + +import com.nvidia.apikeys.vo.KeyOwnerStatus; +import com.nvidia.apikeys.vo.KeyOwnerType; +import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.NonNull; +import org.springframework.data.annotation.PersistenceCreator; +import org.springframework.data.cassandra.core.cql.PrimaryKeyType; +import org.springframework.data.cassandra.core.mapping.Column; +import org.springframework.data.cassandra.core.mapping.PrimaryKeyColumn; +import org.springframework.data.cassandra.core.mapping.Table; + +/** + * Owner status within one account for keys from one issuer service. + */ +@Builder(toBuilder = true) +@Data +@NoArgsConstructor +@AllArgsConstructor(onConstructor_ = @PersistenceCreator) +@Table(OwnerStatusByAccountAndServiceModel.TABLE_NAME) +public class OwnerStatusByAccountAndServiceModel { + + public static final String TABLE_NAME = "owner_status_by_account_and_service"; + public static final String COLUMN_NCA_ID = "nca_id"; + public static final String COLUMN_OWNER_TYPE = "owner_type"; + public static final String COLUMN_OWNER_ID = "owner_id"; + public static final String COLUMN_ISSUER_SERVICE_ID = "issuer_service_id"; + public static final String COLUMN_OWNER_STATUS = "owner_status"; + public static final String COLUMN_CREATED_AT = "created_at"; + public static final String COLUMN_UPDATED_AT = "updated_at"; + + @NonNull + @PrimaryKeyColumn(name = COLUMN_NCA_ID, ordinal = 0, type = PrimaryKeyType.PARTITIONED) + private String ncaId; + + @NonNull + @PrimaryKeyColumn(name = COLUMN_OWNER_TYPE, ordinal = 1, type = PrimaryKeyType.PARTITIONED) + private KeyOwnerType ownerType; + + @NonNull + @PrimaryKeyColumn(name = COLUMN_OWNER_ID, ordinal = 2, type = PrimaryKeyType.PARTITIONED) + private String ownerId; + + @NonNull + @PrimaryKeyColumn(name = COLUMN_ISSUER_SERVICE_ID, ordinal = 3, + type = PrimaryKeyType.PARTITIONED) + private String issuerServiceId; + + @Column(COLUMN_OWNER_STATUS) + private KeyOwnerStatus ownerStatus; + + @Column(COLUMN_CREATED_AT) + private Instant createdAt; + + @Column(COLUMN_UPDATED_AT) + private Instant updatedAt; +} diff --git a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/models/OwnerStatusByAccountModel.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/models/OwnerStatusByAccountModel.java new file mode 100644 index 0000000000..c16f474c4f --- /dev/null +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/models/OwnerStatusByAccountModel.java @@ -0,0 +1,72 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed 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.nvidia.apikeys.persistance.models; + +import com.nvidia.apikeys.vo.KeyOwnerStatus; +import com.nvidia.apikeys.vo.KeyOwnerType; +import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.NonNull; +import org.springframework.data.annotation.PersistenceCreator; +import org.springframework.data.cassandra.core.cql.PrimaryKeyType; +import org.springframework.data.cassandra.core.mapping.Column; +import org.springframework.data.cassandra.core.mapping.PrimaryKeyColumn; +import org.springframework.data.cassandra.core.mapping.Table; + +/** + * Owner status within one account. Applies to keys from every issuer service. + */ +@Builder(toBuilder = true) +@Data +@NoArgsConstructor +@AllArgsConstructor(onConstructor_ = @PersistenceCreator) +@Table(OwnerStatusByAccountModel.TABLE_NAME) +public class OwnerStatusByAccountModel { + + public static final String TABLE_NAME = "owner_status_by_account"; + public static final String COLUMN_NCA_ID = "nca_id"; + public static final String COLUMN_OWNER_TYPE = "owner_type"; + public static final String COLUMN_OWNER_ID = "owner_id"; + public static final String COLUMN_OWNER_STATUS = "owner_status"; + public static final String COLUMN_CREATED_AT = "created_at"; + public static final String COLUMN_UPDATED_AT = "updated_at"; + + @NonNull + @PrimaryKeyColumn(name = COLUMN_NCA_ID, ordinal = 0, type = PrimaryKeyType.PARTITIONED) + private String ncaId; + + @NonNull + @PrimaryKeyColumn(name = COLUMN_OWNER_TYPE, ordinal = 1, type = PrimaryKeyType.PARTITIONED) + private KeyOwnerType ownerType; + + @NonNull + @PrimaryKeyColumn(name = COLUMN_OWNER_ID, ordinal = 2, type = PrimaryKeyType.PARTITIONED) + private String ownerId; + + @Column(COLUMN_OWNER_STATUS) + private KeyOwnerStatus ownerStatus; + + @Column(COLUMN_CREATED_AT) + private Instant createdAt; + + @Column(COLUMN_UPDATED_AT) + private Instant updatedAt; +} diff --git a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/repositories/KeyByAccountOwnerAndServiceRepository.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/repositories/KeyByAccountOwnerAndServiceRepository.java new file mode 100644 index 0000000000..bd49347baa --- /dev/null +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/repositories/KeyByAccountOwnerAndServiceRepository.java @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed 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.nvidia.apikeys.persistance.repositories; + +import com.nvidia.apikeys.persistance.models.KeyByAccountOwnerAndServiceModel; +import com.nvidia.apikeys.vo.KeyOwnerType; +import java.util.List; +import java.util.Optional; +import org.springframework.data.cassandra.repository.MapIdCassandraRepository; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Repository; + +@Repository +public interface KeyByAccountOwnerAndServiceRepository extends + MapIdCassandraRepository { + + List findByNcaIdAndOwnerTypeAndOwnerId( + String ncaId, KeyOwnerType ownerType, String ownerId); + + List findByNcaIdAndOwnerTypeAndOwnerIdAndIssuerServiceId( + String ncaId, KeyOwnerType ownerType, String ownerId, String issuerServiceId); + + Optional + findByNcaIdAndOwnerTypeAndOwnerIdAndIssuerServiceIdAndKeyId( + String ncaId, KeyOwnerType ownerType, String ownerId, String issuerServiceId, + String keyId); + + // served by storage-attached indexes, not by the partition key + Slice findByNcaId(String ncaId, Pageable pageable); + + Slice findByNcaIdAndIssuerServiceId( + String ncaId, String issuerServiceId, Pageable pageable); +} diff --git a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/repositories/KeyOperationRepository.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/repositories/KeyOperationRepository.java new file mode 100644 index 0000000000..81e8126567 --- /dev/null +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/repositories/KeyOperationRepository.java @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed 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.nvidia.apikeys.persistance.repositories; + +import com.nvidia.apikeys.persistance.models.KeyOperationModel; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.cassandra.repository.MapIdCassandraRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface KeyOperationRepository extends MapIdCassandraRepository { + + Optional findByOperationId(UUID operationId); +} diff --git a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/repositories/OwnerStatusByAccountAndServiceRepository.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/repositories/OwnerStatusByAccountAndServiceRepository.java new file mode 100644 index 0000000000..191d44f15e --- /dev/null +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/repositories/OwnerStatusByAccountAndServiceRepository.java @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed 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.nvidia.apikeys.persistance.repositories; + +import com.nvidia.apikeys.persistance.models.OwnerStatusByAccountAndServiceModel; +import com.nvidia.apikeys.vo.KeyOwnerType; +import java.util.Optional; +import org.springframework.data.cassandra.repository.MapIdCassandraRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface OwnerStatusByAccountAndServiceRepository extends + MapIdCassandraRepository { + + Optional + findByNcaIdAndOwnerTypeAndOwnerIdAndIssuerServiceId( + String ncaId, KeyOwnerType ownerType, String ownerId, String issuerServiceId); +} diff --git a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/repositories/OwnerStatusByAccountRepository.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/repositories/OwnerStatusByAccountRepository.java new file mode 100644 index 0000000000..9726a0b310 --- /dev/null +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/repositories/OwnerStatusByAccountRepository.java @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed 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.nvidia.apikeys.persistance.repositories; + +import com.nvidia.apikeys.persistance.models.OwnerStatusByAccountModel; +import com.nvidia.apikeys.vo.KeyOwnerType; +import java.util.Optional; +import org.springframework.data.cassandra.repository.MapIdCassandraRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface OwnerStatusByAccountRepository extends + MapIdCassandraRepository { + + Optional findByNcaIdAndOwnerTypeAndOwnerId( + String ncaId, KeyOwnerType ownerType, String ownerId); +} diff --git a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/validators/KeyExpirationValidator.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/validators/KeyExpirationValidator.java index ca78f6c351..c159c2ef44 100644 --- a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/validators/KeyExpirationValidator.java +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/validators/KeyExpirationValidator.java @@ -19,6 +19,7 @@ import static com.nvidia.apikeys.vo.KeyStatus.ACTIVE; +import com.nvidia.apikeys.vo.KeyByAccountOwnerAndServiceVo; import com.nvidia.apikeys.vo.KeyByOwnerAndServiceVo; import com.nvidia.apikeys.vo.KeyStatus; import com.nvidia.apikeys.vo.KeyVo; @@ -54,6 +55,14 @@ public KeyByOwnerAndServiceVo validateStatus(KeyByOwnerAndServiceVo key) { : key; } + public KeyByAccountOwnerAndServiceVo validateStatus(KeyByAccountOwnerAndServiceVo key) { + return isExpired(key::getExpiresAt, key::getKeyStatus) + ? key.toBuilder() + .keyStatus(KeyStatus.EXPIRED) + .build() + : key; + } + private boolean isExpired( Supplier expirationSupplier, Supplier statusSupplier) { return statusSupplier.get() == ACTIVE diff --git a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/vo/AccountKeysPageVo.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/vo/AccountKeysPageVo.java new file mode 100644 index 0000000000..609e5596d1 --- /dev/null +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/vo/AccountKeysPageVo.java @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed 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.nvidia.apikeys.vo; + +import java.util.List; + +/** + * One page of account-scoped keys. nextPagingState is null on the last page. + */ +public record AccountKeysPageVo( + List keys, + String nextPagingState) { +} diff --git a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/vo/KeyByAccountOwnerAndServiceVo.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/vo/KeyByAccountOwnerAndServiceVo.java new file mode 100644 index 0000000000..233484c458 --- /dev/null +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/vo/KeyByAccountOwnerAndServiceVo.java @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed 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.nvidia.apikeys.vo; + +import com.nvidia.apikeys.persistance.models.KeyByAccountOwnerAndServiceModel; +import com.nvidia.boot.jwt.services.mapping.annotation.ValueObject; +import java.time.Instant; +import java.util.Set; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Builder(toBuilder = true) +@Data +@NoArgsConstructor +@AllArgsConstructor +@ValueObject(model = KeyByAccountOwnerAndServiceModel.class) +public class KeyByAccountOwnerAndServiceVo { + + private String ncaId; + private KeyOwnerType ownerType; + private String ownerId; + + private String issuerServiceId; + private String keyId; + private Instant createdAt; + private Instant expiresAt; + private Instant deletesAt; + private KeyStatus keyStatus; + + private String keyHash; + private String apiKeySuffix; + private String description; + private Set audienceServiceIds; + + public static KeyByAccountOwnerAndServiceVo from(KeyVo key) { + // authorizations stay only on the keys table + return KeyByAccountOwnerAndServiceVo.builder() + .ncaId(key.getNcaId()) + .ownerType(key.getOwnerType()) + .ownerId(key.getOwnerId()) + .issuerServiceId(key.getIssuerServiceId()) + .keyId(key.getKeyId()) + .createdAt(key.getCreatedAt()) + .expiresAt(key.getExpiresAt()) + .deletesAt(key.getDeletesAt()) + .keyStatus(key.getKeyStatus()) + .keyHash(key.getKeyHash()) + .apiKeySuffix(key.getApiKeySuffix()) + .description(key.getDescription()) + .audienceServiceIds(key.getAudienceServiceIds()) + .build(); + } +} diff --git a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/vo/KeyOperationStatus.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/vo/KeyOperationStatus.java new file mode 100644 index 0000000000..1acc3373f6 --- /dev/null +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/vo/KeyOperationStatus.java @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed 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.nvidia.apikeys.vo; + +public enum KeyOperationStatus { + PENDING, + RUNNING, + COMPLETED, + FAILED +} diff --git a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/vo/KeyVo.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/vo/KeyVo.java index b2485f42dc..5ed1548e84 100644 --- a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/vo/KeyVo.java +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/vo/KeyVo.java @@ -34,6 +34,7 @@ public class KeyVo { private KeyStatus keyStatus; + private String ncaId; private KeyOwnerType ownerType; private String ownerId; private String issuerServiceId; diff --git a/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/persistance/dao/AccountKeysDaoIntegrationTest.java b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/persistance/dao/AccountKeysDaoIntegrationTest.java new file mode 100644 index 0000000000..3fe04e86a9 --- /dev/null +++ b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/persistance/dao/AccountKeysDaoIntegrationTest.java @@ -0,0 +1,213 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed 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.nvidia.apikeys.persistance.dao; + +import static com.nvidia.apikeys.TestData.TEST_TIME; +import static com.nvidia.apikeys.config.IntegrationTestConfiguration.KEY_SPACE; +import static com.nvidia.apikeys.vo.KeyOwnerType.USER; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.datastax.driver.core.Row; +import com.nvidia.apikeys.App; +import com.nvidia.apikeys.config.IntegrationTestConfiguration; +import com.nvidia.apikeys.config.IntegrationTestConfiguration.TestCleanerExtension; +import com.nvidia.apikeys.utils.TestClock; +import com.nvidia.apikeys.vo.AccountKeysPageVo; +import com.nvidia.apikeys.vo.KeyByAccountOwnerAndServiceVo; +import com.nvidia.apikeys.vo.KeyStatus; +import com.nvidia.apikeys.vo.KeyVo; +import java.time.Duration; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; + +@ExtendWith(TestCleanerExtension.class) +@AutoConfigureTestRestTemplate +@SpringBootTest( + classes = App.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=integrationtest") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +class AccountKeysDaoIntegrationTest { + + private static final String NCA_1 = "nca-1"; + private static final String NCA_2 = "nca-2"; + private static final String OWNER_1 = "owner-1@example.com"; + private static final String OWNER_2 = "owner-2@example.com"; + private static final String SERVICE_A = "service-a"; + private static final String SERVICE_B = "service-b"; + + @Autowired + private AccountKeysDao dao; + + @Autowired + private KeysDao keysDao; + + @BeforeEach + void setUp() { + TestClock.setBaseClock(TestClock.fixed(TEST_TIME, ZoneId.systemDefault())); + } + + @AfterEach + void tearDown() { + TestClock.resetToDefaults(); + } + + @Test + void saveWritesHashRowAndEncryptedAccountRow() { + KeyVo key = key(NCA_1, OWNER_1, SERVICE_A, "key-1"); + + KeyByAccountOwnerAndServiceVo saved = dao.save(key); + + assertThat(saved).isEqualTo(KeyByAccountOwnerAndServiceVo.from(key)); + assertThat(keysDao.getKeyByHash(key.getKeyHash())) + .get() + .satisfies(stored -> { + assertThat(stored.getNcaId()).isEqualTo(NCA_1); + assertThat(stored.getAuthorizations()).isEqualTo(key.getAuthorizations()); + }); + + Row row = IntegrationTestConfiguration.CQL_SESSION.execute( + "SELECT key_status, key_details FROM " + KEY_SPACE + + ".keys_by_account_owner_and_service WHERE nca_id = ? AND owner_type = ?" + + " AND owner_id = ?", NCA_1, USER.name(), OWNER_1).one(); + assertThat(row.getString("key_status")).isEqualTo(KeyStatus.ACTIVE.name()); + assertThat(row.getString("key_details")) + .doesNotContain(key.getDescription()) + .doesNotContain(key.getKeyHash()); + } + + @Test + void saveRequiresNcaId() { + KeyVo key = key(null, OWNER_1, SERVICE_A, "key-1"); + + assertThatThrownBy(() -> dao.save(key)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("nca_id"); + } + + @Test + void listIsScopedToAccountOwnerAndService() { + dao.save(key(NCA_1, OWNER_1, SERVICE_A, "key-1")); + dao.save(key(NCA_1, OWNER_1, SERVICE_B, "key-2")); + dao.save(key(NCA_2, OWNER_1, SERVICE_A, "key-3")); + + assertThat(dao.list(NCA_1, USER, OWNER_1)) + .extracting(KeyByAccountOwnerAndServiceVo::getKeyId) + .containsExactlyInAnyOrder("key-1", "key-2"); + assertThat(dao.list(NCA_1, USER, OWNER_1, SERVICE_A)) + .extracting(KeyByAccountOwnerAndServiceVo::getKeyId) + .containsExactly("key-1"); + assertThat(dao.list(NCA_2, USER, OWNER_1)) + .extracting(KeyByAccountOwnerAndServiceVo::getKeyId) + .containsExactly("key-3"); + assertThat(dao.get(NCA_2, USER, OWNER_1, SERVICE_A, "key-1")).isEmpty(); + } + + @Test + void listByAccountPagesAcrossOwners() { + dao.save(key(NCA_1, OWNER_1, SERVICE_A, "key-1")); + dao.save(key(NCA_1, OWNER_1, SERVICE_B, "key-2")); + dao.save(key(NCA_1, OWNER_2, SERVICE_A, "key-3")); + dao.save(key(NCA_1, OWNER_2, SERVICE_B, "key-4")); + dao.save(key(NCA_1, "owner-3@example.com", SERVICE_A, "key-5")); + dao.save(key(NCA_2, OWNER_1, SERVICE_A, "key-6")); + + List keyIds = new ArrayList<>(); + String pagingState = null; + int pages = 0; + do { + AccountKeysPageVo page = dao.listByAccount(NCA_1, 2, pagingState); + assertThat(page.keys()).hasSizeLessThanOrEqualTo(2); + page.keys().forEach(key -> keyIds.add(key.getKeyId())); + pagingState = page.nextPagingState(); + pages++; + } while (pagingState != null && pages < 10); + + assertThat(keyIds).containsExactlyInAnyOrder("key-1", "key-2", "key-3", "key-4", "key-5"); + assertThat(pages).isGreaterThan(1); + } + + @Test + void listByAccountAndServiceFiltersIssuer() { + dao.save(key(NCA_1, OWNER_1, SERVICE_A, "key-1")); + dao.save(key(NCA_1, OWNER_2, SERVICE_A, "key-2")); + dao.save(key(NCA_1, OWNER_2, SERVICE_B, "key-3")); + dao.save(key(NCA_2, OWNER_1, SERVICE_A, "key-4")); + + AccountKeysPageVo page = dao.listByAccountAndService(NCA_1, SERVICE_A, 100, null); + + assertThat(page.keys()) + .extracting(KeyByAccountOwnerAndServiceVo::getKeyId) + .containsExactlyInAnyOrder("key-1", "key-2"); + assertThat(page.nextPagingState()).isNull(); + } + + @Test + void expiredKeyReadsAsExpired() { + KeyVo key = key(NCA_1, OWNER_1, SERVICE_A, "key-1").toBuilder() + .expiresAt(TEST_TIME.minus(Duration.ofDays(1))) + .build(); + dao.save(key); + + assertThat(dao.get(NCA_1, USER, OWNER_1, SERVICE_A, "key-1")) + .get() + .extracting(KeyByAccountOwnerAndServiceVo::getKeyStatus) + .isEqualTo(KeyStatus.EXPIRED); + } + + @Test + void deleteRemovesHashRowAndAccountRow() { + KeyVo key = key(NCA_1, OWNER_1, SERVICE_A, "key-1"); + KeyByAccountOwnerAndServiceVo saved = dao.save(key); + + dao.delete(saved); + + assertThat(dao.get(NCA_1, USER, OWNER_1, SERVICE_A, "key-1")).isEmpty(); + assertThat(keysDao.getKeyByHash(key.getKeyHash())).isEmpty(); + } + + private static KeyVo key(String ncaId, String ownerId, String serviceId, String keyId) { + return KeyVo.builder() + .keyStatus(KeyStatus.ACTIVE) + .ncaId(ncaId) + .ownerType(USER) + .ownerId(ownerId) + .issuerServiceId(serviceId) + .audienceServiceIds(Set.of(serviceId)) + .keyId(keyId) + .keyHash("hash-" + keyId) + .createdAt(TEST_TIME) + .expiresAt(TEST_TIME.plus(Duration.ofDays(30))) + .deletesAt(TEST_TIME.plus(Duration.ofDays(60))) + .apiKeySuffix("suffix-" + keyId) + .authorizations("{\"policies\":[]}") + .description("description for " + keyId) + .build(); + } +} diff --git a/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/persistance/dao/AccountOwnerStatusDaoIntegrationTest.java b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/persistance/dao/AccountOwnerStatusDaoIntegrationTest.java new file mode 100644 index 0000000000..bc801577af --- /dev/null +++ b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/persistance/dao/AccountOwnerStatusDaoIntegrationTest.java @@ -0,0 +1,132 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed 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.nvidia.apikeys.persistance.dao; + +import static com.nvidia.apikeys.TestData.TEST_TIME; +import static com.nvidia.apikeys.vo.KeyOwnerStatus.ACTIVE; +import static com.nvidia.apikeys.vo.KeyOwnerStatus.SUSPENDED; +import static com.nvidia.apikeys.vo.KeyOwnerType.USER; +import static org.assertj.core.api.Assertions.assertThat; + +import com.nvidia.apikeys.App; +import com.nvidia.apikeys.config.IntegrationTestConfiguration; +import com.nvidia.apikeys.config.IntegrationTestConfiguration.TestCleanerExtension; +import com.nvidia.apikeys.persistance.models.OwnerStatusByAccountModel; +import com.nvidia.apikeys.utils.TestClock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; + +@ExtendWith(TestCleanerExtension.class) +@AutoConfigureTestRestTemplate +@SpringBootTest( + classes = App.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=integrationtest") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +class AccountOwnerStatusDaoIntegrationTest { + + private static final String NCA_1 = "nca-1"; + private static final String NCA_2 = "nca-2"; + private static final String OWNER = "owner-1@example.com"; + private static final String SERVICE_A = "service-a"; + private static final String SERVICE_B = "service-b"; + + @Autowired + private AccountOwnerStatusDao dao; + + @BeforeEach + void setUp() { + TestClock.setBaseClock(TestClock.fixed(TEST_TIME, ZoneId.systemDefault())); + } + + @AfterEach + void tearDown() { + TestClock.resetToDefaults(); + } + + @Test + void missingStatusIsActive() { + assertThat(dao.getAccountStatus(NCA_1, USER, OWNER)).isEmpty(); + assertThat(dao.getServiceStatus(NCA_1, USER, OWNER, SERVICE_A)).isEmpty(); + assertThat(dao.getEffectiveStatus(NCA_1, USER, OWNER, SERVICE_A)).isEqualTo(ACTIVE); + } + + @Test + void accountSuspensionAppliesToEveryIssuerInThatAccount() { + dao.saveAccountStatus(NCA_1, USER, OWNER, SUSPENDED); + + assertThat(dao.getEffectiveStatus(NCA_1, USER, OWNER, SERVICE_A)).isEqualTo(SUSPENDED); + assertThat(dao.getEffectiveStatus(NCA_1, USER, OWNER, SERVICE_B)).isEqualTo(SUSPENDED); + assertThat(dao.getEffectiveStatus(NCA_2, USER, OWNER, SERVICE_A)).isEqualTo(ACTIVE); + } + + @Test + void serviceSuspensionAppliesOnlyToThatIssuerAndAccount() { + dao.saveServiceStatus(NCA_1, USER, OWNER, SERVICE_A, SUSPENDED); + + assertThat(dao.getEffectiveStatus(NCA_1, USER, OWNER, SERVICE_A)).isEqualTo(SUSPENDED); + assertThat(dao.getEffectiveStatus(NCA_1, USER, OWNER, SERVICE_B)).isEqualTo(ACTIVE); + assertThat(dao.getEffectiveStatus(NCA_2, USER, OWNER, SERVICE_A)).isEqualTo(ACTIVE); + } + + @Test + void reactivatingKeepsCreatedAtAndRefreshesUpdatedAt() { + dao.saveAccountStatus(NCA_1, USER, OWNER, SUSPENDED); + + Instant later = TEST_TIME.plus(Duration.ofHours(1)); + TestClock.setBaseClock(TestClock.fixed(later, ZoneId.systemDefault())); + dao.saveAccountStatus(NCA_1, USER, OWNER, ACTIVE); + + assertThat(dao.getAccountStatus(NCA_1, USER, OWNER)) + .get() + .satisfies(status -> { + assertThat(status.getOwnerStatus()).isEqualTo(ACTIVE); + assertThat(status.getCreatedAt()).isEqualTo(TEST_TIME); + assertThat(status.getUpdatedAt()).isEqualTo(later); + }); + assertThat(dao.getAccountStatus(NCA_1, USER, OWNER)) + .map(OwnerStatusByAccountModel::getNcaId) + .contains(NCA_1); + } + + @Test + void serviceStatusKeepsCreatedAtOnUpdate() { + dao.saveServiceStatus(NCA_1, USER, OWNER, SERVICE_A, SUSPENDED); + + Instant later = TEST_TIME.plus(Duration.ofHours(1)); + TestClock.setBaseClock(TestClock.fixed(later, ZoneId.systemDefault())); + dao.saveServiceStatus(NCA_1, USER, OWNER, SERVICE_A, ACTIVE); + + assertThat(dao.getServiceStatus(NCA_1, USER, OWNER, SERVICE_A)) + .get() + .satisfies(status -> { + assertThat(status.getOwnerStatus()).isEqualTo(ACTIVE); + assertThat(status.getCreatedAt()).isEqualTo(TEST_TIME); + assertThat(status.getUpdatedAt()).isEqualTo(later); + }); + } +} diff --git a/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/persistance/dao/KeyOperationsDaoIntegrationTest.java b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/persistance/dao/KeyOperationsDaoIntegrationTest.java new file mode 100644 index 0000000000..c57a7abb4f --- /dev/null +++ b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/persistance/dao/KeyOperationsDaoIntegrationTest.java @@ -0,0 +1,137 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed 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.nvidia.apikeys.persistance.dao; + +import static com.nvidia.apikeys.TestData.TEST_TIME; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.nvidia.apikeys.App; +import com.nvidia.apikeys.config.IntegrationTestConfiguration; +import com.nvidia.apikeys.config.IntegrationTestConfiguration.TestCleanerExtension; +import com.nvidia.apikeys.config.exceptions.CassandraException; +import com.nvidia.apikeys.persistance.models.KeyOperationModel; +import com.nvidia.apikeys.utils.TestClock; +import com.nvidia.apikeys.vo.KeyOperationStatus; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; + +@ExtendWith(TestCleanerExtension.class) +@AutoConfigureTestRestTemplate +@SpringBootTest( + classes = App.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=integrationtest") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +class KeyOperationsDaoIntegrationTest { + + @Autowired + private KeyOperationsDao dao; + + @BeforeEach + void setUp() { + TestClock.setBaseClock(TestClock.fixed(TEST_TIME, ZoneId.systemDefault())); + } + + @AfterEach + void tearDown() { + TestClock.resetToDefaults(); + } + + @Test + void createAssignsDefaultsAndRoundTripsScope() { + KeyOperationModel created = dao.create(operation().build()); + + assertThat(created.getOperationId()).isNotNull(); + assertThat(created.getOperationStatus()).isEqualTo(KeyOperationStatus.PENDING); + assertThat(created.getMatchedCount()).isZero(); + assertThat(created.getCompletedCount()).isZero(); + assertThat(created.getFailedCount()).isZero(); + assertThat(created.getCreatedAt()).isEqualTo(TEST_TIME); + assertThat(created.getUpdatedAt()).isEqualTo(TEST_TIME); + + assertThat(dao.get(created.getOperationId())).contains(created); + } + + @Test + void createRejectsExistingOperationId() { + UUID operationId = UUID.randomUUID(); + dao.create(operation().operationId(operationId).build()); + + assertThatThrownBy(() -> dao.create(operation().operationId(operationId).build())) + .isInstanceOf(CassandraException.class) + .hasMessageContaining(operationId.toString()); + } + + @Test + void updatePersistsProgressAndPagingState() { + KeyOperationModel created = dao.create(operation().build()); + + Instant later = TEST_TIME.plus(Duration.ofMinutes(5)); + TestClock.setBaseClock(TestClock.fixed(later, ZoneId.systemDefault())); + dao.update(created.toBuilder() + .operationStatus(KeyOperationStatus.RUNNING) + .matchedCount(10L) + .completedCount(4L) + .failedCount(1L) + .selectionState("keys_by_account_owner_and_service") + .pagingState("opaque-paging-state") + .build()); + + assertThat(dao.get(created.getOperationId())) + .get() + .satisfies(stored -> { + assertThat(stored.getOperationStatus()).isEqualTo(KeyOperationStatus.RUNNING); + assertThat(stored.getMatchedCount()).isEqualTo(10L); + assertThat(stored.getCompletedCount()).isEqualTo(4L); + assertThat(stored.getFailedCount()).isEqualTo(1L); + assertThat(stored.getPagingState()).isEqualTo("opaque-paging-state"); + assertThat(stored.getCreatedAt()).isEqualTo(TEST_TIME); + assertThat(stored.getUpdatedAt()).isEqualTo(later); + assertThat(stored.getNcaIds()).containsExactlyInAnyOrder("nca-1", "nca-2"); + }); + } + + @Test + void getReturnsEmptyForUnknownOperation() { + assertThat(dao.get(UUID.randomUUID())).isEmpty(); + } + + private static KeyOperationModel.KeyOperationModelBuilder operation() { + return KeyOperationModel.builder() + .actorType("SERVICE") + .actorId("service-admin") + .operation("SUSPEND") + .ncaIds(Set.of("nca-1", "nca-2")) + .issuerServiceIds(Set.of("service-a")) + .userIds(Set.of("owner-1@example.com")) + .reason("account offboarding") + .cutoffAt(TEST_TIME); + } +} From c7e241c88140bba455439cc31a1995849b36f613 Mon Sep 17 00:00:00 2001 From: Nilesh Vaghela Date: Wed, 30 Sep 2026 01:04:11 -0700 Subject: [PATCH 2/2] test(api-keys): expand multi-tenant persistence tests and use hex cursor paging Match the Cloud Tasks paging pattern: account key listings return AccountKeysSliceVo with a hex paging-state cursor and limit set only when more rows remain. A malformed cursor is reported as a bad request. Add unit and integration coverage for batch writes, deletes, paging boundaries, invalid cursors, effective owner status, and key operations. Relates to #2051 Signed-off-by: Nilesh Vaghela --- .../persistance/dao/AccountKeysDao.java | 73 ++--- ...eysPageVo.java => AccountKeysSliceVo.java} | 9 +- .../dao/AccountKeysDaoIntegrationTest.java | 250 +++++++++++++++--- .../persistance/dao/AccountKeysDaoTest.java | 199 ++++++++++++++ .../AccountOwnerStatusDaoIntegrationTest.java | 54 ++++ .../dao/KeyOperationsDaoIntegrationTest.java | 93 ++++++- .../KeyExpirationValidatorTest.java | 30 +++ .../vo/KeyByAccountOwnerAndServiceVoTest.java | 52 ++++ 8 files changed, 682 insertions(+), 78 deletions(-) rename src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/vo/{AccountKeysPageVo.java => AccountKeysSliceVo.java} (80%) create mode 100644 src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/persistance/dao/AccountKeysDaoTest.java create mode 100644 src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/vo/KeyByAccountOwnerAndServiceVoTest.java diff --git a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/dao/AccountKeysDao.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/dao/AccountKeysDao.java index 282a527a97..b6f03ea8a0 100644 --- a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/dao/AccountKeysDao.java +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/dao/AccountKeysDao.java @@ -17,20 +17,23 @@ package com.nvidia.apikeys.persistance.dao; +import static com.datastax.oss.driver.api.core.data.ByteUtils.fromHexString; +import static com.datastax.oss.driver.api.core.data.ByteUtils.toHexString; + import com.nvidia.apikeys.config.exceptions.CassandraException; import com.nvidia.apikeys.persistance.models.KeyByAccountOwnerAndServiceModel; import com.nvidia.apikeys.persistance.models.KeyModel; import com.nvidia.apikeys.persistance.repositories.KeyByAccountOwnerAndServiceRepository; -import com.nvidia.apikeys.vo.AccountKeysPageVo; +import com.nvidia.apikeys.vo.AccountKeysSliceVo; import com.nvidia.apikeys.vo.KeyByAccountOwnerAndServiceVo; import com.nvidia.apikeys.vo.KeyOwnerType; import com.nvidia.apikeys.vo.KeyVo; -import java.nio.ByteBuffer; -import java.util.Base64; +import com.nvidia.boot.exceptions.BadRequestException; import java.util.List; import java.util.Optional; import java.util.function.Function; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.cassandra.core.CassandraBatchOperations; import org.springframework.data.cassandra.core.CassandraTemplate; import org.springframework.data.cassandra.core.WriteResult; @@ -44,10 +47,13 @@ * Account-scoped key persistence. Writes the hash lookup row in keys and the management row in * keys_by_account_owner_and_service in one logged batch. */ +@Slf4j @Service @RequiredArgsConstructor public class AccountKeysDao { + private static final String MESG_INVALID_CURSOR = "Invalid cursor: '%s'"; + private final KeyByAccountOwnerAndServiceRepository repository; private final KeyModelConverter keyConverter; private final KeyByAccountOwnerAndServiceModelConverter accountKeyConverter; @@ -106,14 +112,14 @@ public List list( .toList(); } - public AccountKeysPageVo listByAccount(String ncaId, int pageSize, String pagingState) { - return page(pageable -> repository.findByNcaId(ncaId, pageable), pageSize, pagingState); + public AccountKeysSliceVo listByAccount(String ncaId, int limit, String cursor) { + return slice(pageable -> repository.findByNcaId(ncaId, pageable), limit, cursor); } - public AccountKeysPageVo listByAccountAndService( - String ncaId, String issuerServiceId, int pageSize, String pagingState) { - return page(pageable -> repository.findByNcaIdAndIssuerServiceId( - ncaId, issuerServiceId, pageable), pageSize, pagingState); + public AccountKeysSliceVo listByAccountAndService( + String ncaId, String issuerServiceId, int limit, String cursor) { + return slice(pageable -> repository.findByNcaIdAndIssuerServiceId( + ncaId, issuerServiceId, pageable), limit, cursor); } public void delete(KeyByAccountOwnerAndServiceVo key) { @@ -139,35 +145,32 @@ public void delete(KeyByAccountOwnerAndServiceVo key) { } } - private AccountKeysPageVo page( + private AccountKeysSliceVo slice( Function> query, - int pageSize, String pagingState) { - Slice slice = query.apply(pageRequest(pageSize, - pagingState)); - List keys = slice.getContent().stream() - .map(accountKeyConverter::modelToVo) - .toList(); - return new AccountKeysPageVo(keys, nextPagingState(slice)); - } - - private static CassandraPageRequest pageRequest(int pageSize, String pagingState) { - if (pagingState == null) { - return CassandraPageRequest.first(pageSize); + int limit, String cursor) { + Slice pagedResult; + try { + var byteBuffer = cursor == null ? null : fromHexString(cursor); + var pageRequest = CassandraPageRequest.of(PageRequest.of(0, limit), byteBuffer); + pagedResult = query.apply(pageRequest); + } catch (RuntimeException e) { + if (cursor == null) { + throw e; + } + var mesg = MESG_INVALID_CURSOR.formatted(cursor); + log.error(mesg); + throw new BadRequestException(mesg, e); } - ByteBuffer state = ByteBuffer.wrap(Base64.getUrlDecoder().decode(pagingState)); - return CassandraPageRequest.of(PageRequest.of(0, pageSize), state); - } - private static String nextPagingState(Slice slice) { - if (!slice.hasNext()) { - return null; - } - ByteBuffer state = ((CassandraPageRequest) slice.nextPageable()).getPagingState(); - if (state == null) { - return null; + var keys = pagedResult.getContent().stream() + .map(accountKeyConverter::modelToVo) + .toList(); + var builder = AccountKeysSliceVo.builder().keys(keys); + if (pagedResult.hasNext()) { + var pagingState = ((CassandraPageRequest) pagedResult.getPageable()).getPagingState(); + builder.cursor(toHexString(pagingState)); + builder.limit(limit); } - byte[] bytes = new byte[state.remaining()]; - state.duplicate().get(bytes); - return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + return builder.build(); } } diff --git a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/vo/AccountKeysPageVo.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/vo/AccountKeysSliceVo.java similarity index 80% rename from src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/vo/AccountKeysPageVo.java rename to src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/vo/AccountKeysSliceVo.java index 609e5596d1..5d3bbfcbf1 100644 --- a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/vo/AccountKeysPageVo.java +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/vo/AccountKeysSliceVo.java @@ -18,11 +18,14 @@ package com.nvidia.apikeys.vo; import java.util.List; +import lombok.Builder; /** - * One page of account-scoped keys. nextPagingState is null on the last page. + * One slice of account-scoped keys. cursor and limit are null on the last slice. */ -public record AccountKeysPageVo( +@Builder +public record AccountKeysSliceVo( List keys, - String nextPagingState) { + String cursor, + Integer limit) { } diff --git a/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/persistance/dao/AccountKeysDaoIntegrationTest.java b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/persistance/dao/AccountKeysDaoIntegrationTest.java index 3fe04e86a9..3c8d35c525 100644 --- a/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/persistance/dao/AccountKeysDaoIntegrationTest.java +++ b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/persistance/dao/AccountKeysDaoIntegrationTest.java @@ -20,6 +20,7 @@ import static com.nvidia.apikeys.TestData.TEST_TIME; import static com.nvidia.apikeys.config.IntegrationTestConfiguration.KEY_SPACE; import static com.nvidia.apikeys.vo.KeyOwnerType.USER; +import static com.nvidia.apikeys.utils.TestUtils.assertThrowsExceptionWithDetails; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -28,19 +29,25 @@ import com.nvidia.apikeys.config.IntegrationTestConfiguration; import com.nvidia.apikeys.config.IntegrationTestConfiguration.TestCleanerExtension; import com.nvidia.apikeys.utils.TestClock; -import com.nvidia.apikeys.vo.AccountKeysPageVo; +import com.nvidia.apikeys.vo.AccountKeysSliceVo; import com.nvidia.apikeys.vo.KeyByAccountOwnerAndServiceVo; +import com.nvidia.apikeys.vo.KeyOwnerStatus; +import com.nvidia.apikeys.vo.KeyOwnerVo; import com.nvidia.apikeys.vo.KeyStatus; import com.nvidia.apikeys.vo.KeyVo; +import com.nvidia.boot.exceptions.BadRequestException; import java.time.Duration; import java.time.ZoneId; import java.util.ArrayList; import java.util.List; import java.util.Set; +import java.util.function.BiFunction; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; import org.springframework.boot.test.context.SpringBootTest; @@ -61,6 +68,7 @@ class AccountKeysDaoIntegrationTest { private static final String OWNER_2 = "owner-2@example.com"; private static final String SERVICE_A = "service-a"; private static final String SERVICE_B = "service-b"; + private static final int MAX_SLICES = 20; @Autowired private AccountKeysDao dao; @@ -90,16 +98,24 @@ void saveWritesHashRowAndEncryptedAccountRow() { .satisfies(stored -> { assertThat(stored.getNcaId()).isEqualTo(NCA_1); assertThat(stored.getAuthorizations()).isEqualTo(key.getAuthorizations()); + assertThat(stored.getAudienceServiceIds()) + .isEqualTo(key.getAudienceServiceIds()); }); - Row row = IntegrationTestConfiguration.CQL_SESSION.execute( - "SELECT key_status, key_details FROM " + KEY_SPACE - + ".keys_by_account_owner_and_service WHERE nca_id = ? AND owner_type = ?" - + " AND owner_id = ?", NCA_1, USER.name(), OWNER_1).one(); - assertThat(row.getString("key_status")).isEqualTo(KeyStatus.ACTIVE.name()); - assertThat(row.getString("key_details")) + Row hashRow = IntegrationTestConfiguration.CQL_SESSION.execute( + "SELECT nca_id, status FROM " + KEY_SPACE + ".keys WHERE api_key_hash = ?", + key.getKeyHash()).one(); + assertThat(hashRow.getString("nca_id")).isEqualTo(NCA_1); + assertThat(hashRow.getString("status")).isEqualTo(KeyStatus.ACTIVE.name()); + + Row accountRow = accountRow(NCA_1, OWNER_1); + assertThat(accountRow.getString("key_status")).isEqualTo(KeyStatus.ACTIVE.name()); + assertThat(accountRow.getTimestamp("created_at").toInstant()).isEqualTo(TEST_TIME); + assertThat(accountRow.getString("key_details")) .doesNotContain(key.getDescription()) - .doesNotContain(key.getKeyHash()); + .doesNotContain(key.getKeyHash()) + .doesNotContain(key.getApiKeySuffix()) + .doesNotContain(key.getAuthorizations()); } @Test @@ -109,13 +125,43 @@ void saveRequiresNcaId() { assertThatThrownBy(() -> dao.save(key)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("nca_id"); + assertThat(keysDao.getKeyByHash(key.getKeyHash())).isEmpty(); + } + + @Test + void saveTwiceKeepsOneRowAndLatestStatus() { + KeyVo key = key(NCA_1, OWNER_1, SERVICE_A, "key-1"); + dao.save(key); + + dao.save(key.toBuilder().keyStatus(KeyStatus.SUSPENDED).build()); + + assertThat(dao.list(NCA_1, USER, OWNER_1)) + .singleElement() + .extracting(KeyByAccountOwnerAndServiceVo::getKeyStatus) + .isEqualTo(KeyStatus.SUSPENDED); + assertThat(keysDao.getKeyByHash(key.getKeyHash())) + .get() + .extracting(KeyVo::getKeyStatus) + .isEqualTo(KeyStatus.SUSPENDED); + } + + @Test + void getMatchesFullPrimaryKeyOnly() { + dao.save(key(NCA_1, OWNER_1, SERVICE_A, "key-1")); + + assertThat(dao.get(NCA_1, USER, OWNER_1, SERVICE_A, "key-1")).isPresent(); + assertThat(dao.get(NCA_2, USER, OWNER_1, SERVICE_A, "key-1")).isEmpty(); + assertThat(dao.get(NCA_1, USER, OWNER_2, SERVICE_A, "key-1")).isEmpty(); + assertThat(dao.get(NCA_1, USER, OWNER_1, SERVICE_B, "key-1")).isEmpty(); + assertThat(dao.get(NCA_1, USER, OWNER_1, SERVICE_A, "key-2")).isEmpty(); } @Test void listIsScopedToAccountOwnerAndService() { dao.save(key(NCA_1, OWNER_1, SERVICE_A, "key-1")); dao.save(key(NCA_1, OWNER_1, SERVICE_B, "key-2")); - dao.save(key(NCA_2, OWNER_1, SERVICE_A, "key-3")); + dao.save(key(NCA_1, OWNER_2, SERVICE_A, "key-3")); + dao.save(key(NCA_2, OWNER_1, SERVICE_A, "key-4")); assertThat(dao.list(NCA_1, USER, OWNER_1)) .extracting(KeyByAccountOwnerAndServiceVo::getKeyId) @@ -125,8 +171,9 @@ void listIsScopedToAccountOwnerAndService() { .containsExactly("key-1"); assertThat(dao.list(NCA_2, USER, OWNER_1)) .extracting(KeyByAccountOwnerAndServiceVo::getKeyId) - .containsExactly("key-3"); - assertThat(dao.get(NCA_2, USER, OWNER_1, SERVICE_A, "key-1")).isEmpty(); + .containsExactly("key-4"); + assertThat(dao.list(NCA_2, USER, OWNER_2)).isEmpty(); + assertThat(dao.list(NCA_1, USER, OWNER_1, "unknown-service")).isEmpty(); } @Test @@ -138,19 +185,55 @@ void listByAccountPagesAcrossOwners() { dao.save(key(NCA_1, "owner-3@example.com", SERVICE_A, "key-5")); dao.save(key(NCA_2, OWNER_1, SERVICE_A, "key-6")); - List keyIds = new ArrayList<>(); - String pagingState = null; - int pages = 0; - do { - AccountKeysPageVo page = dao.listByAccount(NCA_1, 2, pagingState); - assertThat(page.keys()).hasSizeLessThanOrEqualTo(2); - page.keys().forEach(key -> keyIds.add(key.getKeyId())); - pagingState = page.nextPagingState(); - pages++; - } while (pagingState != null && pages < 10); + List slices = readAll( + (limit, cursor) -> dao.listByAccount(NCA_1, limit, cursor), 2); + + assertThat(keyIds(slices)) + .containsExactlyInAnyOrder("key-1", "key-2", "key-3", "key-4", "key-5"); + assertThat(slices).hasSizeGreaterThan(1); + slices.forEach(slice -> assertThat(slice.keys()).hasSizeLessThanOrEqualTo(2)); + } + + @Test + void listByAccountStopsAtExactSliceBoundary() { + dao.save(key(NCA_1, OWNER_1, SERVICE_A, "key-1")); + dao.save(key(NCA_1, OWNER_1, SERVICE_B, "key-2")); + dao.save(key(NCA_1, OWNER_2, SERVICE_A, "key-3")); + dao.save(key(NCA_1, OWNER_2, SERVICE_B, "key-4")); + + List slices = readAll( + (limit, cursor) -> dao.listByAccount(NCA_1, limit, cursor), 2); + + assertThat(keyIds(slices)) + .containsExactlyInAnyOrder("key-1", "key-2", "key-3", "key-4"); + assertThat(slices.getLast().cursor()).isNull(); + } + + @Test + void sliceReportsCursorAndLimitOnlyWhenMoreKeysRemain() { + dao.save(key(NCA_1, OWNER_1, SERVICE_A, "key-1")); + dao.save(key(NCA_1, OWNER_2, SERVICE_A, "key-2")); + dao.save(key(NCA_1, "owner-3@example.com", SERVICE_A, "key-3")); + + AccountKeysSliceVo first = dao.listByAccount(NCA_1, 2, null); + assertThat(first.keys()).hasSize(2); + assertThat(first.cursor()).startsWith("0x"); + assertThat(first.limit()).isEqualTo(2); - assertThat(keyIds).containsExactlyInAnyOrder("key-1", "key-2", "key-3", "key-4", "key-5"); - assertThat(pages).isGreaterThan(1); + AccountKeysSliceVo all = dao.listByAccount(NCA_1, 100, null); + assertThat(all.keys()).hasSize(3); + assertThat(all.cursor()).isNull(); + assertThat(all.limit()).isNull(); + } + + @Test + void listByAccountReturnsEmptySliceForUnknownAccount() { + dao.save(key(NCA_1, OWNER_1, SERVICE_A, "key-1")); + + AccountKeysSliceVo slice = dao.listByAccount("unknown-nca", 10, null); + + assertThat(slice.keys()).isEmpty(); + assertThat(slice.cursor()).isNull(); } @Test @@ -160,12 +243,37 @@ void listByAccountAndServiceFiltersIssuer() { dao.save(key(NCA_1, OWNER_2, SERVICE_B, "key-3")); dao.save(key(NCA_2, OWNER_1, SERVICE_A, "key-4")); - AccountKeysPageVo page = dao.listByAccountAndService(NCA_1, SERVICE_A, 100, null); + AccountKeysSliceVo slice = dao.listByAccountAndService(NCA_1, SERVICE_A, 100, null); - assertThat(page.keys()) + assertThat(slice.keys()) .extracting(KeyByAccountOwnerAndServiceVo::getKeyId) .containsExactlyInAnyOrder("key-1", "key-2"); - assertThat(page.nextPagingState()).isNull(); + assertThat(slice.cursor()).isNull(); + } + + @Test + void listByAccountAndServicePagesWithCursor() { + for (int i = 1; i <= 5; i++) { + dao.save(key(NCA_1, "owner-" + i + "@example.com", SERVICE_A, "key-a" + i)); + dao.save(key(NCA_1, "owner-" + i + "@example.com", SERVICE_B, "key-b" + i)); + } + + List slices = readAll( + (limit, cursor) -> dao.listByAccountAndService(NCA_1, SERVICE_A, limit, cursor), + 2); + + assertThat(keyIds(slices)) + .containsExactlyInAnyOrder("key-a1", "key-a2", "key-a3", "key-a4", "key-a5"); + } + + @ParameterizedTest + @ValueSource(strings = {"not-hex", "0xzz", "0xdeadbeef"}) + void invalidCursorIsBadRequest(String cursor) { + dao.save(key(NCA_1, OWNER_1, SERVICE_A, "key-1")); + + assertThrowsExceptionWithDetails( + BadRequestException.class, () -> dao.listByAccount(NCA_1, 10, cursor), + "Invalid cursor: '" + cursor + "'"); } @Test @@ -179,17 +287,99 @@ void expiredKeyReadsAsExpired() { .get() .extracting(KeyByAccountOwnerAndServiceVo::getKeyStatus) .isEqualTo(KeyStatus.EXPIRED); + assertThat(dao.listByAccount(NCA_1, 10, null).keys()) + .extracting(KeyByAccountOwnerAndServiceVo::getKeyStatus) + .containsExactly(KeyStatus.EXPIRED); + assertThat(accountRow(NCA_1, OWNER_1).getString("key_status")) + .isEqualTo(KeyStatus.ACTIVE.name()); + } + + @Test + void suspendedKeyPastExpiryStaysSuspended() { + KeyVo key = key(NCA_1, OWNER_1, SERVICE_A, "key-1").toBuilder() + .keyStatus(KeyStatus.SUSPENDED) + .expiresAt(TEST_TIME.minus(Duration.ofDays(1))) + .build(); + dao.save(key); + + assertThat(dao.get(NCA_1, USER, OWNER_1, SERVICE_A, "key-1")) + .get() + .extracting(KeyByAccountOwnerAndServiceVo::getKeyStatus) + .isEqualTo(KeyStatus.SUSPENDED); } @Test - void deleteRemovesHashRowAndAccountRow() { + void deleteRemovesOnlyThatKey() { KeyVo key = key(NCA_1, OWNER_1, SERVICE_A, "key-1"); + KeyVo sibling = key(NCA_1, OWNER_1, SERVICE_A, "key-2"); + KeyVo otherAccount = key(NCA_2, OWNER_1, SERVICE_A, "key-1").toBuilder() + .keyHash("hash-key-1-nca-2") + .build(); KeyByAccountOwnerAndServiceVo saved = dao.save(key); + dao.save(sibling); + dao.save(otherAccount); dao.delete(saved); assertThat(dao.get(NCA_1, USER, OWNER_1, SERVICE_A, "key-1")).isEmpty(); assertThat(keysDao.getKeyByHash(key.getKeyHash())).isEmpty(); + assertThat(dao.get(NCA_1, USER, OWNER_1, SERVICE_A, "key-2")).isPresent(); + assertThat(keysDao.getKeyByHash(sibling.getKeyHash())).isPresent(); + assertThat(dao.get(NCA_2, USER, OWNER_1, SERVICE_A, "key-1")).isPresent(); + assertThat(keysDao.getKeyByHash(otherAccount.getKeyHash())).isPresent(); + } + + @Test + void legacyKeysDaoWritesNoNcaIdAndNoAccountRow() { + KeyVo key = key(null, OWNER_1, SERVICE_A, "key-1"); + KeyOwnerVo owner = KeyOwnerVo.builder() + .ownerType(USER) + .ownerId(OWNER_1) + .ownerStatus(KeyOwnerStatus.ACTIVE) + .ownerStatusUpdatedAt(TEST_TIME) + .build(); + + keysDao.save(key, owner); + + assertThat(keysDao.getKeyByHash(key.getKeyHash())) + .get() + .satisfies(stored -> { + assertThat(stored.getNcaId()).isNull(); + assertThat(stored.getKeyId()).isEqualTo("key-1"); + }); + assertThat(keysDao.list(USER, OWNER_1)) + .extracting(k -> k.getKeyId()) + .containsExactly("key-1"); + assertThat(IntegrationTestConfiguration.CQL_SESSION.execute( + "SELECT COUNT(*) FROM " + KEY_SPACE + ".keys_by_account_owner_and_service") + .one().getLong(0)).isZero(); + } + + private static List readAll( + BiFunction query, int limit) { + List slices = new ArrayList<>(); + String cursor = null; + do { + AccountKeysSliceVo slice = query.apply(limit, cursor); + slices.add(slice); + cursor = slice.cursor(); + } while (cursor != null && slices.size() < MAX_SLICES); + assertThat(cursor).as("paging did not terminate").isNull(); + return slices; + } + + private static List keyIds(List slices) { + return slices.stream() + .flatMap(slice -> slice.keys().stream()) + .map(KeyByAccountOwnerAndServiceVo::getKeyId) + .toList(); + } + + private static Row accountRow(String ncaId, String ownerId) { + return IntegrationTestConfiguration.CQL_SESSION.execute( + "SELECT key_status, created_at, key_details FROM " + KEY_SPACE + + ".keys_by_account_owner_and_service WHERE nca_id = ? AND owner_type = ?" + + " AND owner_id = ?", ncaId, USER.name(), ownerId).one(); } private static KeyVo key(String ncaId, String ownerId, String serviceId, String keyId) { @@ -199,14 +389,14 @@ private static KeyVo key(String ncaId, String ownerId, String serviceId, String .ownerType(USER) .ownerId(ownerId) .issuerServiceId(serviceId) - .audienceServiceIds(Set.of(serviceId)) + .audienceServiceIds(Set.of(serviceId, "audience-" + keyId)) .keyId(keyId) - .keyHash("hash-" + keyId) + .keyHash("hash-" + keyId + "-" + ownerId) .createdAt(TEST_TIME) .expiresAt(TEST_TIME.plus(Duration.ofDays(30))) .deletesAt(TEST_TIME.plus(Duration.ofDays(60))) .apiKeySuffix("suffix-" + keyId) - .authorizations("{\"policies\":[]}") + .authorizations("{\"policies\":[\"" + keyId + "\"]}") .description("description for " + keyId) .build(); } diff --git a/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/persistance/dao/AccountKeysDaoTest.java b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/persistance/dao/AccountKeysDaoTest.java new file mode 100644 index 0000000000..c8d1493dcf --- /dev/null +++ b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/persistance/dao/AccountKeysDaoTest.java @@ -0,0 +1,199 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed 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.nvidia.apikeys.persistance.dao; + +import static com.nvidia.apikeys.TestData.KEY_VO_1; +import static com.nvidia.apikeys.utils.TestUtils.assertThrowsExceptionWithDetails; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.nvidia.apikeys.config.exceptions.CassandraException; +import com.nvidia.apikeys.persistance.models.KeyByAccountOwnerAndServiceModel; +import com.nvidia.apikeys.persistance.models.KeyModel; +import com.nvidia.apikeys.persistance.repositories.KeyByAccountOwnerAndServiceRepository; +import com.nvidia.apikeys.vo.KeyByAccountOwnerAndServiceVo; +import com.nvidia.apikeys.vo.KeyVo; +import com.nvidia.boot.exceptions.BadRequestException; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.cassandra.core.CassandraBatchOperations; +import org.springframework.data.cassandra.core.CassandraTemplate; +import org.springframework.data.cassandra.core.WriteResult; +import org.springframework.data.domain.Pageable; + +@ExtendWith(MockitoExtension.class) +class AccountKeysDaoTest { + + private static final KeyVo KEY = KEY_VO_1.toBuilder().ncaId("nca-1").build(); + private static final KeyByAccountOwnerAndServiceVo ACCOUNT_KEY = + KeyByAccountOwnerAndServiceVo.from(KEY); + + @Mock + private KeyByAccountOwnerAndServiceRepository repository; + @Mock + private KeyModelConverter keyConverter; + @Mock + private KeyByAccountOwnerAndServiceModelConverter accountKeyConverter; + @Mock + private KeysDao keysDao; + @Mock + private CassandraTemplate cassandraTemplate; + @Mock + private CassandraBatchOperations batchOperations; + @Mock + private WriteResult writeResult; + @Mock + private KeyModel keyModel; + @Mock + private KeyByAccountOwnerAndServiceModel accountKeyModel; + + @InjectMocks + private AccountKeysDao dao; + + @Test + void saveThrowsWhenBatchIsNotApplied() { + mockInsertBatch(); + when(writeResult.wasApplied()).thenReturn(false); + + assertThrowsExceptionWithDetails( + CassandraException.class, () -> dao.save(KEY), + "Failed to write account key into db"); + verify(keysDao, never()).getKeyByHash(any()); + } + + @Test + void saveThrowsWhenHashRowCannotBeReadBack() { + mockInsertBatch(); + when(writeResult.wasApplied()).thenReturn(true); + when(keysDao.getKeyByHash(KEY.getKeyHash())).thenReturn(Optional.empty()); + + assertThrowsExceptionWithDetails( + CassandraException.class, () -> dao.save(KEY), + "Failed to read saved key"); + } + + @Test + void saveThrowsWhenAccountRowCannotBeReadBack() { + mockInsertBatch(); + when(writeResult.wasApplied()).thenReturn(true); + when(keysDao.getKeyByHash(KEY.getKeyHash())).thenReturn(Optional.of(KEY)); + when(repository.findByNcaIdAndOwnerTypeAndOwnerIdAndIssuerServiceIdAndKeyId( + KEY.getNcaId(), KEY.getOwnerType(), KEY.getOwnerId(), KEY.getIssuerServiceId(), + KEY.getKeyId())) + .thenReturn(Optional.empty()); + + assertThrowsExceptionWithDetails( + CassandraException.class, () -> dao.save(KEY), + "Failed to read saved account key"); + } + + @Test + void saveWritesBothRowsInOneBatch() { + mockInsertBatch(); + when(writeResult.wasApplied()).thenReturn(true); + when(keysDao.getKeyByHash(KEY.getKeyHash())).thenReturn(Optional.of(KEY)); + when(repository.findByNcaIdAndOwnerTypeAndOwnerIdAndIssuerServiceIdAndKeyId( + KEY.getNcaId(), KEY.getOwnerType(), KEY.getOwnerId(), KEY.getIssuerServiceId(), + KEY.getKeyId())) + .thenReturn(Optional.of(accountKeyModel)); + when(accountKeyConverter.modelToVo(accountKeyModel)).thenReturn(ACCOUNT_KEY); + + assertThat(dao.save(KEY)).isEqualTo(ACCOUNT_KEY); + verify(batchOperations).insert(List.of(keyModel)); + verify(batchOperations).insert(List.of(accountKeyModel)); + verify(batchOperations).execute(); + } + + @Test + void deleteUsesPrimaryKeysOnly() { + mockBatch(); + when(batchOperations.delete(anyList())).thenReturn(batchOperations); + when(writeResult.wasApplied()).thenReturn(true); + + dao.delete(ACCOUNT_KEY); + + @SuppressWarnings("unchecked") + ArgumentCaptor> deleted = ArgumentCaptor.forClass(List.class); + verify(batchOperations, times(2)).delete(deleted.capture()); + KeyModel hashRow = (KeyModel) deleted.getAllValues().get(0).get(0); + KeyByAccountOwnerAndServiceModel accountRow = + (KeyByAccountOwnerAndServiceModel) deleted.getAllValues().get(1).get(0); + assertThat(hashRow.getKeyHash()).isEqualTo(KEY.getKeyHash()); + assertThat(hashRow.getKeyDetails()).isNull(); + assertThat(accountRow.getNcaId()).isEqualTo("nca-1"); + assertThat(accountRow.getKeyId()).isEqualTo(KEY.getKeyId()); + assertThat(accountRow.getKeyDetails()).isNull(); + verify(accountKeyConverter, never()).voToModel(any()); + } + + @Test + void deleteThrowsWhenBatchIsNotApplied() { + mockBatch(); + when(batchOperations.delete(anyList())).thenReturn(batchOperations); + when(writeResult.wasApplied()).thenReturn(false); + + assertThrowsExceptionWithDetails( + CassandraException.class, () -> dao.delete(ACCOUNT_KEY), + "Failed to delete account key."); + } + + private void mockBatch() { + when(cassandraTemplate.batchOps()).thenReturn(batchOperations); + when(batchOperations.execute()).thenReturn(writeResult); + } + + private void mockInsertBatch() { + mockBatch(); + when(keyConverter.voToModel(KEY)).thenReturn(keyModel); + when(accountKeyConverter.voToModel(ACCOUNT_KEY)).thenReturn(accountKeyModel); + when(batchOperations.insert(anyList())).thenReturn(batchOperations); + } + + @Test + void queryFailureWithoutCursorIsNotReportedAsBadCursor() { + var failure = new IllegalStateException("cassandra unavailable"); + when(repository.findByNcaId(eq("nca-1"), any(Pageable.class))).thenThrow(failure); + + assertThatThrownBy(() -> dao.listByAccount("nca-1", 10, null)).isSameAs(failure); + } + + @Test + void queryFailureWithCursorIsReportedAsBadCursor() { + when(repository.findByNcaIdAndIssuerServiceId(eq("nca-1"), eq("service-a"), + any(Pageable.class))) + .thenThrow(new IllegalStateException("bad paging state")); + + assertThrowsExceptionWithDetails( + BadRequestException.class, + () -> dao.listByAccountAndService("nca-1", "service-a", 10, "0x00"), + "Invalid cursor: '0x00'"); + } +} diff --git a/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/persistance/dao/AccountOwnerStatusDaoIntegrationTest.java b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/persistance/dao/AccountOwnerStatusDaoIntegrationTest.java index bc801577af..5cdc6acb12 100644 --- a/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/persistance/dao/AccountOwnerStatusDaoIntegrationTest.java +++ b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/persistance/dao/AccountOwnerStatusDaoIntegrationTest.java @@ -93,6 +93,60 @@ void serviceSuspensionAppliesOnlyToThatIssuerAndAccount() { assertThat(dao.getEffectiveStatus(NCA_2, USER, OWNER, SERVICE_A)).isEqualTo(ACTIVE); } + @Test + void accountSuspensionWinsOverActiveServiceStatus() { + dao.saveAccountStatus(NCA_1, USER, OWNER, SUSPENDED); + dao.saveServiceStatus(NCA_1, USER, OWNER, SERVICE_A, ACTIVE); + + assertThat(dao.getEffectiveStatus(NCA_1, USER, OWNER, SERVICE_A)).isEqualTo(SUSPENDED); + } + + @Test + void serviceSuspensionWinsOverActiveAccountStatus() { + dao.saveAccountStatus(NCA_1, USER, OWNER, ACTIVE); + dao.saveServiceStatus(NCA_1, USER, OWNER, SERVICE_A, SUSPENDED); + + assertThat(dao.getEffectiveStatus(NCA_1, USER, OWNER, SERVICE_A)).isEqualTo(SUSPENDED); + assertThat(dao.getEffectiveStatus(NCA_1, USER, OWNER, SERVICE_B)).isEqualTo(ACTIVE); + } + + @Test + void reactivatingAccountRestoresActiveStatus() { + dao.saveAccountStatus(NCA_1, USER, OWNER, SUSPENDED); + dao.saveAccountStatus(NCA_1, USER, OWNER, ACTIVE); + + assertThat(dao.getEffectiveStatus(NCA_1, USER, OWNER, SERVICE_A)).isEqualTo(ACTIVE); + } + + @Test + void suspensionDoesNotAffectOtherOwnersInTheAccount() { + dao.saveAccountStatus(NCA_1, USER, OWNER, SUSPENDED); + dao.saveServiceStatus(NCA_1, USER, OWNER, SERVICE_A, SUSPENDED); + + assertThat(dao.getEffectiveStatus(NCA_1, USER, "owner-2@example.com", SERVICE_A)) + .isEqualTo(ACTIVE); + assertThat(dao.getAccountStatus(NCA_1, USER, "owner-2@example.com")).isEmpty(); + } + + @Test + void serviceStatusDoesNotCreateAccountStatus() { + dao.saveServiceStatus(NCA_1, USER, OWNER, SERVICE_A, SUSPENDED); + + assertThat(dao.getAccountStatus(NCA_1, USER, OWNER)).isEmpty(); + assertThat(dao.getServiceStatus(NCA_1, USER, OWNER, SERVICE_B)).isEmpty(); + assertThat(dao.getServiceStatus(NCA_2, USER, OWNER, SERVICE_A)).isEmpty(); + } + + @Test + void saveReturnsStoredRow() { + OwnerStatusByAccountModel saved = dao.saveAccountStatus(NCA_1, USER, OWNER, SUSPENDED); + + assertThat(dao.getAccountStatus(NCA_1, USER, OWNER)).contains(saved); + assertThat(dao.saveServiceStatus(NCA_1, USER, OWNER, SERVICE_A, SUSPENDED)) + .satisfies(status -> assertThat(dao.getServiceStatus( + NCA_1, USER, OWNER, SERVICE_A)).contains(status)); + } + @Test void reactivatingKeepsCreatedAtAndRefreshesUpdatedAt() { dao.saveAccountStatus(NCA_1, USER, OWNER, SUSPENDED); diff --git a/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/persistance/dao/KeyOperationsDaoIntegrationTest.java b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/persistance/dao/KeyOperationsDaoIntegrationTest.java index c57a7abb4f..bc2cd0bdad 100644 --- a/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/persistance/dao/KeyOperationsDaoIntegrationTest.java +++ b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/persistance/dao/KeyOperationsDaoIntegrationTest.java @@ -79,16 +79,6 @@ void createAssignsDefaultsAndRoundTripsScope() { assertThat(dao.get(created.getOperationId())).contains(created); } - @Test - void createRejectsExistingOperationId() { - UUID operationId = UUID.randomUUID(); - dao.create(operation().operationId(operationId).build()); - - assertThatThrownBy(() -> dao.create(operation().operationId(operationId).build())) - .isInstanceOf(CassandraException.class) - .hasMessageContaining(operationId.toString()); - } - @Test void updatePersistsProgressAndPagingState() { KeyOperationModel created = dao.create(operation().build()); @@ -118,6 +108,89 @@ void updatePersistsProgressAndPagingState() { }); } + @Test + void createKeepsSuppliedIdStatusAndCounters() { + UUID operationId = UUID.randomUUID(); + + KeyOperationModel created = dao.create(operation() + .operationId(operationId) + .operationStatus(KeyOperationStatus.RUNNING) + .matchedCount(7L) + .completedCount(2L) + .failedCount(1L) + .build()); + + assertThat(created.getOperationId()).isEqualTo(operationId); + assertThat(dao.get(operationId)) + .get() + .satisfies(stored -> { + assertThat(stored.getOperationStatus()).isEqualTo(KeyOperationStatus.RUNNING); + assertThat(stored.getMatchedCount()).isEqualTo(7L); + assertThat(stored.getCompletedCount()).isEqualTo(2L); + assertThat(stored.getFailedCount()).isEqualTo(1L); + }); + } + + @Test + void createDoesNotOverwriteExistingOperation() { + UUID operationId = UUID.randomUUID(); + dao.create(operation().operationId(operationId).reason("first").build()); + + assertThatThrownBy(() -> dao.create(operation() + .operationId(operationId) + .reason("second") + .build())) + .isInstanceOf(CassandraException.class) + .hasMessageContaining(operationId.toString()); + + assertThat(dao.get(operationId)) + .get() + .extracting(KeyOperationModel::getReason) + .isEqualTo("first"); + } + + @Test + void updateWithNullClearsPagingStateOnCompletion() { + KeyOperationModel running = dao.update(dao.create(operation().build()).toBuilder() + .operationStatus(KeyOperationStatus.RUNNING) + .pagingState("opaque-paging-state") + .build()); + + dao.update(running.toBuilder() + .operationStatus(KeyOperationStatus.COMPLETED) + .pagingState(null) + .build()); + + assertThat(dao.get(running.getOperationId())) + .get() + .satisfies(stored -> { + assertThat(stored.getOperationStatus()) + .isEqualTo(KeyOperationStatus.COMPLETED); + assertThat(stored.getPagingState()).isNull(); + assertThat(stored.getReason()).isEqualTo("account offboarding"); + }); + } + + @Test + void scopeSetsRoundTripWhenPartlyUnset() { + KeyOperationModel created = dao.create(operation() + .issuerServiceIds(null) + .userIds(null) + .build()); + + assertThat(dao.get(created.getOperationId())) + .get() + .satisfies(stored -> { + assertThat(stored.getNcaIds()).containsExactlyInAnyOrder("nca-1", "nca-2"); + assertThat(stored.getIssuerServiceIds()).isNullOrEmpty(); + assertThat(stored.getUserIds()).isNullOrEmpty(); + assertThat(stored.getCutoffAt()).isEqualTo(TEST_TIME); + assertThat(stored.getActorType()).isEqualTo("SERVICE"); + assertThat(stored.getActorId()).isEqualTo("service-admin"); + assertThat(stored.getOperation()).isEqualTo("SUSPEND"); + }); + } + @Test void getReturnsEmptyForUnknownOperation() { assertThat(dao.get(UUID.randomUUID())).isEmpty(); diff --git a/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/validators/KeyExpirationValidatorTest.java b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/validators/KeyExpirationValidatorTest.java index 62ef9b9fe0..f7e980f654 100644 --- a/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/validators/KeyExpirationValidatorTest.java +++ b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/validators/KeyExpirationValidatorTest.java @@ -23,6 +23,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; +import com.nvidia.apikeys.vo.KeyByAccountOwnerAndServiceVo; import com.nvidia.apikeys.vo.KeyStatus; import com.nvidia.apikeys.vo.KeyVo; import java.time.Clock; @@ -96,4 +97,33 @@ void validateStatus_KeyByOwner_setStatusToExpired() { assertThat(validator.validateStatus(KEY_BY_OWNER_AND_SERVICE_VO_1)).isEqualTo(expected); } + @Test + void validateStatus_KeyByAccount_noChangesIfKeyNotExpired() { + when(clockMock.instant()).thenReturn(KEY_EXPIRES_AT_1.plusSeconds(-1)); + var key = KeyByAccountOwnerAndServiceVo.from(KEY_VO_1); + + assertThat(validator.validateStatus(key)).isEqualTo(key); + } + + @Test + void validateStatus_KeyByAccount_noChangesIfKeySuspended() { + var key = KeyByAccountOwnerAndServiceVo.from(KEY_VO_1).toBuilder() + .keyStatus(KeyStatus.SUSPENDED) + .build(); + + assertThat(validator.validateStatus(key)).isEqualTo(key); + } + + @Test + void validateStatus_KeyByAccount_setStatusToExpired() { + when(clockMock.instant()).thenReturn(KEY_EXPIRES_AT_1.plusSeconds(1)); + var key = KeyByAccountOwnerAndServiceVo.from(KEY_VO_1); + + var expected = key.toBuilder() + .keyStatus(KeyStatus.EXPIRED) + .build(); + + assertThat(validator.validateStatus(key)).isEqualTo(expected); + } + } diff --git a/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/vo/KeyByAccountOwnerAndServiceVoTest.java b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/vo/KeyByAccountOwnerAndServiceVoTest.java new file mode 100644 index 0000000000..cc06d552f4 --- /dev/null +++ b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/vo/KeyByAccountOwnerAndServiceVoTest.java @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed 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.nvidia.apikeys.vo; + +import static com.nvidia.apikeys.TestData.KEY_VO_1; +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class KeyByAccountOwnerAndServiceVoTest { + + @Test + void fromCopiesEveryManagementFieldFromKey() { + KeyVo key = KEY_VO_1.toBuilder().ncaId("nca-1").build(); + + KeyByAccountOwnerAndServiceVo vo = KeyByAccountOwnerAndServiceVo.from(key); + + assertThat(vo.getNcaId()).isEqualTo("nca-1"); + assertThat(vo.getOwnerType()).isEqualTo(key.getOwnerType()); + assertThat(vo.getOwnerId()).isEqualTo(key.getOwnerId()); + assertThat(vo.getIssuerServiceId()).isEqualTo(key.getIssuerServiceId()); + assertThat(vo.getKeyId()).isEqualTo(key.getKeyId()); + assertThat(vo.getCreatedAt()).isEqualTo(key.getCreatedAt()); + assertThat(vo.getExpiresAt()).isEqualTo(key.getExpiresAt()); + assertThat(vo.getDeletesAt()).isEqualTo(key.getDeletesAt()); + assertThat(vo.getKeyStatus()).isEqualTo(key.getKeyStatus()); + assertThat(vo.getKeyHash()).isEqualTo(key.getKeyHash()); + assertThat(vo.getApiKeySuffix()).isEqualTo(key.getApiKeySuffix()); + assertThat(vo.getDescription()).isEqualTo(key.getDescription()); + assertThat(vo.getAudienceServiceIds()).isEqualTo(key.getAudienceServiceIds()); + } + + @Test + void fromKeepsNullAccount() { + assertThat(KeyByAccountOwnerAndServiceVo.from(KEY_VO_1).getNcaId()).isNull(); + } +}