From cfd62370c2ca51e2b5b91673d3ceb338ae1c77b0 Mon Sep 17 00:00:00 2001 From: Shelley Shen Date: Fri, 23 Oct 2026 01:05:19 -0700 Subject: [PATCH 1/6] feat(api-keys): add filtered key and owner-status queries The v2 actor endpoints need queries the persistence layer does not provide yet: a user's keys across accounts, platform-wide key lists, status filters, and a user's statuses across accounts and services. KeysV2Dao.list takes a KeysByScopeFilterVo and pages the keys that match every non-null field. Queries that do not cover the whole partition key are served by the storage-attached indexes. The owner status repositories gain findByOwnerId, served by the owner_id index, and OwnerStatusByAccountDao lists an owner's statuses across accounts or within one account. Signed-off-by: Shelley Shen --- .../apikeys/persistance/dao/KeysV2Dao.java | 33 ++++ .../dao/OwnerStatusByAccountDao.java | 32 ++++ ...erStatusByAccountAndServiceRepository.java | 4 + .../OwnerStatusByAccountRepository.java | 4 + .../apikeys/vo/KeysByScopeFilterVo.java | 33 ++++ .../dao/KeysV2DaoIntegrationTest.java | 142 ++++++++++++++++++ .../dao/OwnerStatusDaoIntegrationTest.java | 51 +++++++ 7 files changed, 299 insertions(+) create mode 100644 src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/vo/KeysByScopeFilterVo.java diff --git a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/dao/KeysV2Dao.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/dao/KeysV2Dao.java index ca49931b57..69659c4202 100644 --- a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/dao/KeysV2Dao.java +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/dao/KeysV2Dao.java @@ -29,7 +29,9 @@ import com.nvidia.apikeys.vo.KeyV2Vo; import com.nvidia.apikeys.vo.KeysByAccountSliceVo; import com.nvidia.apikeys.vo.KeysByOwnerAndAccountAndServiceVo; +import com.nvidia.apikeys.vo.KeysByScopeFilterVo; import java.nio.ByteBuffer; +import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -39,6 +41,9 @@ import org.apache.commons.lang3.StringUtils; import org.springframework.data.cassandra.core.CassandraTemplate; import org.springframework.data.cassandra.core.query.CassandraPageRequest; +import org.springframework.data.cassandra.core.query.Criteria; +import org.springframework.data.cassandra.core.query.CriteriaDefinition; +import org.springframework.data.cassandra.core.query.Query; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; @@ -128,6 +133,28 @@ public KeysByAccountSliceVo list( limit, cursor); } + /** + * Pages keys matching every non-null filter field, served by the storage-attached indexes + * when the filter does not cover the whole partition key. An empty filter pages every key. + */ + public KeysByAccountSliceVo list(KeysByScopeFilterVo filter, int limit, String cursor) { + var criteria = new ArrayList(); + addEquals(criteria, KeysByOwnerAndAccountAndServiceModel.COLUMN_NCA_ID, filter.ncaId()); + addEquals(criteria, KeysByOwnerAndAccountAndServiceModel.COLUMN_OWNER_TYPE, + filter.ownerType()); + addEquals(criteria, KeysByOwnerAndAccountAndServiceModel.COLUMN_OWNER_ID, + filter.ownerId()); + addEquals(criteria, KeysByOwnerAndAccountAndServiceModel.COLUMN_ISSUER_SERVICE_ID, + filter.issuerServiceId()); + addEquals(criteria, KeysByOwnerAndAccountAndServiceModel.COLUMN_KEY_STATUS, + filter.keyStatus()); + var query = criteria.isEmpty() ? Query.empty() : Query.query(criteria); + return slice( + pageable -> cassandraTemplate.slice( + query.pageRequest(pageable), KeysByOwnerAndAccountAndServiceModel.class), + limit, cursor); + } + public void delete(KeysByOwnerAndAccountAndServiceVo key) { var keyModel = KeyV2Model.builder() .keyHash(key.getKeyHash()) @@ -152,6 +179,12 @@ public void delete(KeysByOwnerAndAccountAndServiceVo key) { } } + private static void addEquals(List criteria, String column, Object value) { + if (value != null) { + criteria.add(Criteria.where(column).is(value)); + } + } + private KeysByAccountSliceVo slice( Function> query, int limit, String cursor) { diff --git a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/dao/OwnerStatusByAccountDao.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/dao/OwnerStatusByAccountDao.java index bb94b59d41..38260612e2 100644 --- a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/dao/OwnerStatusByAccountDao.java +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/persistance/dao/OwnerStatusByAccountDao.java @@ -24,6 +24,7 @@ import com.nvidia.apikeys.vo.KeyOwnerStatus; import com.nvidia.apikeys.vo.KeyOwnerType; import java.time.Instant; +import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -50,6 +51,37 @@ public Optional getOwnerStatusByAccountAndS ncaId, ownerType, ownerId, issuerServiceId); } + /** + * The owner's account-scoped status in every account that has one. Only owner_id is indexed, + * so the owner type is matched after the read. + */ + public List listOwnerStatusByAccount( + KeyOwnerType ownerType, String ownerId) { + return accountRepository.findByOwnerId(ownerId).stream() + .filter(status -> status.getOwnerType() == ownerType) + .toList(); + } + + /** + * The owner's service-scoped status in every account that has one. + */ + public List listOwnerStatusByAccountAndService( + KeyOwnerType ownerType, String ownerId) { + return serviceRepository.findByOwnerId(ownerId).stream() + .filter(status -> status.getOwnerType() == ownerType) + .toList(); + } + + /** + * The owner's service-scoped status in one account. + */ + public List listOwnerStatusByAccountAndService( + String ncaId, KeyOwnerType ownerType, String ownerId) { + return listOwnerStatusByAccountAndService(ownerType, ownerId).stream() + .filter(status -> status.getNcaId().equals(ncaId)) + .toList(); + } + public OwnerStatusByAccountModel saveOwnerStatusByAccount( String ncaId, KeyOwnerType ownerType, String ownerId, KeyOwnerStatus status) { var now = Instant.now(); 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 index 191d44f15e..1a75a4f589 100644 --- 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 @@ -19,6 +19,7 @@ import com.nvidia.apikeys.persistance.models.OwnerStatusByAccountAndServiceModel; import com.nvidia.apikeys.vo.KeyOwnerType; +import java.util.List; import java.util.Optional; import org.springframework.data.cassandra.repository.MapIdCassandraRepository; import org.springframework.stereotype.Repository; @@ -30,4 +31,7 @@ public interface OwnerStatusByAccountAndServiceRepository extends Optional findByNcaIdAndOwnerTypeAndOwnerIdAndIssuerServiceId( String ncaId, KeyOwnerType ownerType, String ownerId, String issuerServiceId); + + // served by the owner_id storage-attached index, not by the partition key + List findByOwnerId(String ownerId); } 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 index 9726a0b310..b033b1360c 100644 --- 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 @@ -19,6 +19,7 @@ import com.nvidia.apikeys.persistance.models.OwnerStatusByAccountModel; import com.nvidia.apikeys.vo.KeyOwnerType; +import java.util.List; import java.util.Optional; import org.springframework.data.cassandra.repository.MapIdCassandraRepository; import org.springframework.stereotype.Repository; @@ -29,4 +30,7 @@ public interface OwnerStatusByAccountRepository extends Optional findByNcaIdAndOwnerTypeAndOwnerId( String ncaId, KeyOwnerType ownerType, String ownerId); + + // served by the owner_id storage-attached index, not by the partition key + List findByOwnerId(String ownerId); } diff --git a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/vo/KeysByScopeFilterVo.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/vo/KeysByScopeFilterVo.java new file mode 100644 index 0000000000..626b99665e --- /dev/null +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/vo/KeysByScopeFilterVo.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.vo; + +import lombok.Builder; + +/** + * Optional filters for account-scoped key queries. A null field applies no restriction on that + * dimension. + */ +@Builder +public record KeysByScopeFilterVo( + String ncaId, + KeyOwnerType ownerType, + String ownerId, + String issuerServiceId, + KeyStatus keyStatus) { +} diff --git a/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/persistance/dao/KeysV2DaoIntegrationTest.java b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/persistance/dao/KeysV2DaoIntegrationTest.java index ee9752f89d..c07ba49214 100644 --- a/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/persistance/dao/KeysV2DaoIntegrationTest.java +++ b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/persistance/dao/KeysV2DaoIntegrationTest.java @@ -30,6 +30,7 @@ import com.nvidia.apikeys.utils.TestClock; import com.nvidia.apikeys.vo.KeysByAccountSliceVo; import com.nvidia.apikeys.vo.KeysByOwnerAndAccountAndServiceVo; +import com.nvidia.apikeys.vo.KeysByScopeFilterVo; import com.nvidia.apikeys.vo.KeyStatus; import com.nvidia.apikeys.vo.KeyV2Vo; import java.time.Duration; @@ -349,6 +350,143 @@ void saveDoesNotWriteLegacyTables() { assertThat(countRows("keys_by_owner_and_service")).isZero(); } + @Test + void listByFilterOwnerSpansAccounts() { + 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-4")); + + var slice = dao.list(filter().ownerType(USER).ownerId(OWNER_1).build(), 100, null); + + assertThat(slice.keys()) + .extracting(KeysByOwnerAndAccountAndServiceVo::getKeyId) + .containsExactlyInAnyOrder("key-1", "key-2", "key-3"); + assertThat(slice.cursor()).isNull(); + } + + @Test + void listByFilterOwnerAndServiceSpansAccounts() { + 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_2, OWNER_2, SERVICE_A, "key-4")); + + var slice = dao.list( + filter().ownerType(USER).ownerId(OWNER_1).issuerServiceId(SERVICE_A).build(), + 100, null); + + assertThat(slice.keys()) + .extracting(KeysByOwnerAndAccountAndServiceVo::getKeyId) + .containsExactlyInAnyOrder("key-1", "key-3"); + } + + @Test + void listByFilterServiceSpansAccountsAndOwners() { + dao.save(key(NCA_1, OWNER_1, SERVICE_A, "key-1")); + dao.save(key(NCA_2, OWNER_2, SERVICE_A, "key-2")); + dao.save(key(NCA_2, OWNER_2, SERVICE_B, "key-3")); + + var slice = dao.list(filter().issuerServiceId(SERVICE_A).build(), 100, null); + + assertThat(slice.keys()) + .extracting(KeysByOwnerAndAccountAndServiceVo::getKeyId) + .containsExactlyInAnyOrder("key-1", "key-2"); + } + + @Test + void listByFilterStatusNarrowsEveryScope() { + dao.save(key(NCA_1, OWNER_1, SERVICE_A, "key-1")); + dao.save(key(NCA_1, OWNER_1, SERVICE_A, "key-2").toBuilder() + .keyStatus(KeyStatus.SUSPENDED).build()); + dao.save(key(NCA_2, OWNER_2, SERVICE_B, "key-3").toBuilder() + .keyStatus(KeyStatus.SUSPENDED).build()); + + assertThat(keyIds(dao.list(filter().keyStatus(KeyStatus.SUSPENDED).build(), 100, null))) + .containsExactlyInAnyOrder("key-2", "key-3"); + assertThat(keyIds(dao.list( + filter().ncaId(NCA_1).keyStatus(KeyStatus.SUSPENDED).build(), 100, null))) + .containsExactly("key-2"); + assertThat(keyIds(dao.list( + filter().ncaId(NCA_1).ownerType(USER).ownerId(OWNER_1) + .keyStatus(KeyStatus.ACTIVE).build(), 100, null))) + .containsExactly("key-1"); + assertThat(keyIds(dao.list( + filter().ownerType(USER).ownerId(OWNER_1) + .keyStatus(KeyStatus.REVOKED).build(), 100, null))) + .isEmpty(); + } + + @Test + void listByFilterAccountAndOwnerWithoutOwnerTypeMatchesTheOwner() { + 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_2, OWNER_1, SERVICE_A, "key-3")); + + assertThat(keyIds(dao.list(filter().ncaId(NCA_1).ownerId(OWNER_1).build(), 100, null))) + .containsExactly("key-1"); + } + + @Test + void listByEmptyFilterPagesEveryKey() { + dao.save(key(NCA_1, OWNER_1, SERVICE_A, "key-1")); + dao.save(key(NCA_1, OWNER_2, SERVICE_B, "key-2")); + dao.save(key(NCA_2, OWNER_1, SERVICE_A, "key-3")); + dao.save(key(NCA_2, OWNER_2, SERVICE_B, "key-4")); + dao.save(key(NCA_2, "owner-3@example.com", SERVICE_A, "key-5")); + + List slices = readAll( + (limit, cursor) -> dao.list(filter().build(), 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 listByFilterPagesWithCursor() { + for (int i = 1; i <= 5; i++) { + dao.save(key("nca-" + i, OWNER_1, SERVICE_A, "key-" + i)); + } + dao.save(key(NCA_1, OWNER_2, SERVICE_A, "other")); + + List slices = readAll( + (limit, cursor) -> dao.list( + filter().ownerType(USER).ownerId(OWNER_1).build(), limit, cursor), + 2); + + assertThat(keyIds(slices)) + .containsExactlyInAnyOrder("key-1", "key-2", "key-3", "key-4", "key-5"); + assertThat(slices).hasSizeGreaterThan(1); + assertThat(slices.getLast().cursor()).isNull(); + } + + @Test + void listByFilterReturnsEmptySliceWhenNothingMatches() { + dao.save(key(NCA_1, OWNER_1, SERVICE_A, "key-1")); + + var slice = dao.list(filter().ownerType(USER).ownerId(OWNER_2).build(), 10, null); + + assertThat(slice.keys()).isEmpty(); + assertThat(slice.cursor()).isNull(); + } + + @ParameterizedTest + @ValueSource(strings = {"not-hex", "0xzz", "0xdeadbeef"}) + void listByFilterRejectsInvalidCursor(String cursor) { + dao.save(key(NCA_1, OWNER_1, SERVICE_A, "key-1")); + + assertThatThrownBy(() -> dao.list( + filter().ownerType(USER).ownerId(OWNER_1).build(), 10, cursor)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid cursor: '" + cursor + "'"); + } + + private static KeysByScopeFilterVo.KeysByScopeFilterVoBuilder filter() { + return KeysByScopeFilterVo.builder(); + } + private static long countRows(String table) { return IntegrationTestConfiguration.CQL_SESSION.execute( "SELECT COUNT(*) FROM " + KEY_SPACE + "." + table).one().getLong(0); @@ -367,6 +505,10 @@ private static List readAll( return slices; } + private static List keyIds(KeysByAccountSliceVo slice) { + return keyIds(List.of(slice)); + } + private static List keyIds(List slices) { return slices.stream() .flatMap(slice -> slice.keys().stream()) diff --git a/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/persistance/dao/OwnerStatusDaoIntegrationTest.java b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/persistance/dao/OwnerStatusDaoIntegrationTest.java index 78a1d09134..068ff46961 100644 --- a/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/persistance/dao/OwnerStatusDaoIntegrationTest.java +++ b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/persistance/dao/OwnerStatusDaoIntegrationTest.java @@ -21,6 +21,7 @@ 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 static org.assertj.core.api.Assertions.tuple; import com.nvidia.apikeys.App; import com.nvidia.apikeys.config.IntegrationTestConfiguration; @@ -28,6 +29,7 @@ import com.nvidia.apikeys.persistance.models.OwnerStatusByAccountAndServiceModel; import com.nvidia.apikeys.persistance.models.OwnerStatusByAccountModel; import java.time.Instant; +import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; @@ -95,6 +97,55 @@ void ownerStatusByAccountAndServiceWinsOverActiveOwnerStatusByAccount() { assertThat(dao.getEffectiveStatus(NCA_1, USER, OWNER, SERVICE_B)).isEqualTo(ACTIVE); } + @Test + void listOwnerStatusByAccountSpansAccountsForOneOwner() { + dao.saveOwnerStatusByAccount(NCA_1, USER, OWNER, SUSPENDED); + dao.saveOwnerStatusByAccount(NCA_2, USER, OWNER, ACTIVE); + dao.saveOwnerStatusByAccount(NCA_1, USER, "owner-2@example.com", SUSPENDED); + dao.saveOwnerStatusByAccountAndService(NCA_1, USER, OWNER, SERVICE_A, SUSPENDED); + + assertThat(dao.listOwnerStatusByAccount(USER, OWNER)) + .extracting(OwnerStatusByAccountModel::getNcaId, + OwnerStatusByAccountModel::getOwnerStatus) + .containsExactlyInAnyOrder( + tuple(NCA_1, SUSPENDED), + tuple(NCA_2, ACTIVE)); + assertThat(dao.listOwnerStatusByAccount(USER, "unknown-owner")).isEmpty(); + } + + @Test + void listOwnerStatusByAccountAndServiceSpansAccountsAndServices() { + dao.saveOwnerStatusByAccountAndService(NCA_1, USER, OWNER, SERVICE_A, SUSPENDED); + dao.saveOwnerStatusByAccountAndService(NCA_1, USER, OWNER, SERVICE_B, ACTIVE); + dao.saveOwnerStatusByAccountAndService(NCA_2, USER, OWNER, SERVICE_A, SUSPENDED); + dao.saveOwnerStatusByAccountAndService(NCA_1, USER, "owner-2@example.com", SERVICE_A, + SUSPENDED); + dao.saveOwnerStatusByAccount(NCA_1, USER, OWNER, SUSPENDED); + + List all = + dao.listOwnerStatusByAccountAndService(USER, OWNER); + + assertThat(all) + .extracting(OwnerStatusByAccountAndServiceModel::getNcaId, + OwnerStatusByAccountAndServiceModel::getIssuerServiceId) + .containsExactlyInAnyOrder( + tuple(NCA_1, SERVICE_A), + tuple(NCA_1, SERVICE_B), + tuple(NCA_2, SERVICE_A)); + } + + @Test + void listOwnerStatusByAccountAndServiceCanBeScopedToOneAccount() { + dao.saveOwnerStatusByAccountAndService(NCA_1, USER, OWNER, SERVICE_A, SUSPENDED); + dao.saveOwnerStatusByAccountAndService(NCA_1, USER, OWNER, SERVICE_B, ACTIVE); + dao.saveOwnerStatusByAccountAndService(NCA_2, USER, OWNER, SERVICE_A, SUSPENDED); + + assertThat(dao.listOwnerStatusByAccountAndService(NCA_1, USER, OWNER)) + .extracting(OwnerStatusByAccountAndServiceModel::getIssuerServiceId) + .containsExactlyInAnyOrder(SERVICE_A, SERVICE_B); + assertThat(dao.listOwnerStatusByAccountAndService("unknown-nca", USER, OWNER)).isEmpty(); + } + @Test void reactivatingAccountRestoresActiveStatus() { dao.saveOwnerStatusByAccount(NCA_1, USER, OWNER, SUSPENDED); From ace875e6a72138866841d6274ca87104d8b3bb1f Mon Sep 17 00:00:00 2001 From: Shelley Shen Date: Fri, 23 Oct 2026 12:25:31 -0700 Subject: [PATCH 2/6] feat(api-keys): add shared v2 key management layer The key owner, account admin and platform admin endpoints share one implementation. KeyManagementFacade holds the operations and a KeyManagementActor selects the required route dimensions and whether a suspended owner is denied. The NCA, issuer service and user always come from the route. KeysV2Service gains a paged filtered key list, a cross-account owner status list, a public owner-active check and a createKey overload that lets admins create keys for suspended users. Signed-off-by: Shelley Shen --- src/control-plane-services/api-keys/AGENTS.md | 6 + .../converters/KeyV2ResponseBuilder.java | 168 ++++++ .../apikeys/facade/KeyManagementActor.java | 29 + .../apikeys/facade/KeyManagementFacade.java | 277 ++++++++++ .../apikeys/services/KeysV2Service.java | 106 +++- .../services/LegacyAccountTranslator.java | 4 + .../KeyManagementRequestValidator.java | 132 +++++ .../apikeys/vo/CreateKeyV2RequestVo.java | 28 + .../apikeys/vo/OwnerAccountStatusVo.java | 35 ++ .../apikeys/web/KeyManagementRoutes.java | 59 ++ .../converters/KeyV2ResponseBuilderTest.java | 189 +++++++ .../KeyManagementFacadeIntegrationTest.java | 518 ++++++++++++++++++ .../KeyManagementRequestValidatorTest.java | 244 +++++++++ 13 files changed, 1792 insertions(+), 3 deletions(-) create mode 100644 src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/converters/KeyV2ResponseBuilder.java create mode 100644 src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/facade/KeyManagementActor.java create mode 100644 src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/facade/KeyManagementFacade.java create mode 100644 src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/validators/KeyManagementRequestValidator.java create mode 100644 src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/vo/CreateKeyV2RequestVo.java create mode 100644 src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/vo/OwnerAccountStatusVo.java create mode 100644 src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/web/KeyManagementRoutes.java create mode 100644 src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/converters/KeyV2ResponseBuilderTest.java create mode 100644 src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/facade/KeyManagementFacadeIntegrationTest.java create mode 100644 src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/validators/KeyManagementRequestValidatorTest.java diff --git a/src/control-plane-services/api-keys/AGENTS.md b/src/control-plane-services/api-keys/AGENTS.md index 4e50b75026..8bc1752dc7 100644 --- a/src/control-plane-services/api-keys/AGENTS.md +++ b/src/control-plane-services/api-keys/AGENTS.md @@ -64,6 +64,12 @@ legacy routing in these two classes instead of the DAOs or controllers. 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`. +`KeyManagementFacade` holds the operations behind the key-owner, account-admin +and platform-admin endpoints under `/v2/api-keys/`. Controllers only map +`KeyManagementRoutes` paths to facade calls with a `KeyManagementActor`. The +actor selects the route dimensions it must supply and whether a suspended owner +is denied. The NCA, issuer service and user always come from the route. + 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/converters/KeyV2ResponseBuilder.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/converters/KeyV2ResponseBuilder.java new file mode 100644 index 0000000000..6ecc474448 --- /dev/null +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/converters/KeyV2ResponseBuilder.java @@ -0,0 +1,168 @@ +/* + * 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.converters; + +import com.nvidia.apikeys.dto.keysV2.KeyOwnerStatusResponse; +import com.nvidia.apikeys.dto.keysV2.KeyOwnerStatusesResponse; +import com.nvidia.apikeys.dto.keysV2.KeyResponse; +import com.nvidia.apikeys.dto.keysV2.KeyStatusV2; +import com.nvidia.apikeys.dto.keysV2.ListKeysResponse; +import com.nvidia.apikeys.dto.keysV2.OwnerStatusByAccount; +import com.nvidia.apikeys.dto.keysV2.OwnerStatusByService; +import com.nvidia.apikeys.persistance.models.OwnerStatusByAccountAndServiceModel; +import com.nvidia.apikeys.persistance.models.OwnerStatusByAccountModel; +import com.nvidia.apikeys.services.MillisecondPrecisionClock; +import com.nvidia.apikeys.vo.GeneratedKeyVo; +import com.nvidia.apikeys.vo.KeyStatus; +import com.nvidia.apikeys.vo.KeyV2Vo; +import com.nvidia.apikeys.vo.KeysByOwnerAndAccountAndServiceVo; +import com.nvidia.apikeys.vo.OwnerAccountStatusVo; +import com.nvidia.boot.exceptions.UnprocessableEntityException; +import java.time.Instant; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; + +/** + * Builds the shared v2 responses. Only the create response carries the full key value, every + * other response carries the masked value. + */ +@Service +@RequiredArgsConstructor +public class KeyV2ResponseBuilder { + + private final JsonMapper jsonMapper; + private final MillisecondPrecisionClock clock; + + public KeyResponse toCreateResponse(KeyV2Vo key, GeneratedKeyVo generatedKey) { + return toResponse(key).toBuilder() + .value(generatedKey.getFormattedApiKey()) + .build(); + } + + public KeyResponse toResponse(KeyV2Vo key) { + return KeyResponse.builder() + .id(key.getKeyId()) + .value(key.getApiKeySuffix()) + .status(toStatus(key.getKeyStatus(), key.getExpiresAt())) + .ownerId(key.getOwnerId()) + .accountId(key.getNcaId()) + .issuerServiceId(key.getIssuerServiceId()) + .audienceServiceIds(key.getAudienceServiceIds()) + .description(key.getDescription()) + .createdAt(key.getCreatedAt()) + .expiresAt(key.getExpiresAt()) + .authorizations(toJsonNode(key.getAuthorizations())) + .build(); + } + + /** + * List items omit authorizations. + */ + public KeyResponse toListItem(KeysByOwnerAndAccountAndServiceVo key) { + return KeyResponse.builder() + .id(key.getKeyId()) + .value(key.getApiKeySuffix()) + .status(toStatus(key.getKeyStatus(), key.getExpiresAt())) + .ownerId(key.getOwnerId()) + .accountId(key.getNcaId()) + .issuerServiceId(key.getIssuerServiceId()) + .audienceServiceIds(key.getAudienceServiceIds()) + .description(key.getDescription()) + .createdAt(key.getCreatedAt()) + .expiresAt(key.getExpiresAt()) + .build(); + } + + public ListKeysResponse toListResponse( + List keys, Integer limit, String cursor) { + return ListKeysResponse.builder() + .keys(keys.stream().map(this::toListItem).toList()) + .limit(limit) + .cursor(cursor) + .build(); + } + + public KeyOwnerStatusResponse toStatusResponse(OwnerStatusByAccountModel status) { + return KeyOwnerStatusResponse.builder() + .ownerId(status.getOwnerId()) + .accountId(status.getNcaId()) + .status(status.getOwnerStatus()) + .updatedAt(status.getUpdatedAt()) + .build(); + } + + public KeyOwnerStatusResponse toStatusResponse(OwnerStatusByAccountAndServiceModel status) { + return KeyOwnerStatusResponse.builder() + .ownerId(status.getOwnerId()) + .accountId(status.getNcaId()) + .issuerServiceId(status.getIssuerServiceId()) + .status(status.getOwnerStatus()) + .updatedAt(status.getUpdatedAt()) + .build(); + } + + public KeyOwnerStatusesResponse toStatusesResponse( + String ownerId, List accounts, Integer limit, String cursor) { + return KeyOwnerStatusesResponse.builder() + .ownerId(ownerId) + .accounts(accounts.stream().map(this::toAccountStatus).toList()) + .limit(limit) + .cursor(cursor) + .build(); + } + + private OwnerStatusByAccount toAccountStatus(OwnerAccountStatusVo account) { + return OwnerStatusByAccount.builder() + .accountId(account.ncaId()) + .status(account.status()) + .issuerServices(account.issuerServices().stream() + .map(issuer -> OwnerStatusByService.builder() + .issuerServiceId(issuer.issuerServiceId()) + .status(issuer.status()) + .updatedAt(issuer.updatedAt()) + .build()) + .toList()) + .build(); + } + + /** + * A revoked key stays REVOKED. Any other key past its expiry is EXPIRED. + */ + private KeyStatusV2 toStatus(KeyStatus status, Instant expiresAt) { + if (status == KeyStatus.REVOKED) { + return KeyStatusV2.REVOKED; + } + if (status == KeyStatus.EXPIRED || (expiresAt != null && !expiresAt.isAfter(clock.instant()))) { + return KeyStatusV2.EXPIRED; + } + return KeyStatusV2.valueOf(status.name()); + } + + private JsonNode toJsonNode(String authorizations) { + try { + return jsonMapper.readTree(authorizations); + } catch (JacksonException e) { + throw new UnprocessableEntityException("Failed to build authorizations json"); + } + } +} diff --git a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/facade/KeyManagementActor.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/facade/KeyManagementActor.java new file mode 100644 index 0000000000..e1da9b33ac --- /dev/null +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/facade/KeyManagementActor.java @@ -0,0 +1,29 @@ +/* + * 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.facade; + +/** + * The actor family behind a /v2/api-keys/ route. The service does no authentication, the + * route prefix identifies the actor. + */ +public enum KeyManagementActor { + KEY_OWNER, + ACCOUNT_ADMIN, + PLATFORM_ADMIN +} diff --git a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/facade/KeyManagementFacade.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/facade/KeyManagementFacade.java new file mode 100644 index 0000000000..90d04304e3 --- /dev/null +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/facade/KeyManagementFacade.java @@ -0,0 +1,277 @@ +/* + * 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.facade; + +import static com.nvidia.apikeys.vo.KeyOwnerType.USER; + +import com.nvidia.apikeys.converters.KeyV2ResponseBuilder; +import com.nvidia.apikeys.dto.keysV2.CreateKeyRequest; +import com.nvidia.apikeys.dto.keysV2.KeyOwnerStatusResponse; +import com.nvidia.apikeys.dto.keysV2.KeyOwnerStatusesResponse; +import com.nvidia.apikeys.dto.keysV2.KeyResponse; +import com.nvidia.apikeys.dto.keysV2.ListKeysResponse; +import com.nvidia.apikeys.dto.keysV2.SettableKeyStatus; +import com.nvidia.apikeys.dto.keysV2.UpdateAuthorizationsRequest; +import com.nvidia.apikeys.dto.keysV2.UpdateOwnerStatusForAccountAndIssuerServiceRequest; +import com.nvidia.apikeys.dto.keysV2.UpdateOwnerStatusForAccountRequest; +import com.nvidia.apikeys.services.KeysV2Service; +import com.nvidia.apikeys.validators.KeyManagementRequestValidator; +import com.nvidia.apikeys.vo.KeyOwnerStatus; +import com.nvidia.apikeys.vo.KeyStatus; +import com.nvidia.apikeys.vo.KeysByScopeFilterVo; +import com.nvidia.apikeys.vo.OwnerAccountStatusVo; +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.boot.exceptions.NotFoundException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; + +/** + * Key management behind the key-owner, account-admin and platform-admin endpoints. The three + * actors share every operation. The actor selects the route dimensions it must supply and + * whether a suspended owner is denied: a key owner is denied, administrators are not. + * + *

The NCA, issuer service and user always come from the route. + */ +@Service +@RequiredArgsConstructor +public class KeyManagementFacade { + + static final String MESG_ACTOR_NOT_ALLOWED = "Operation is not available to this actor"; + static final String MESG_INVALID_CURSOR = "Invalid cursor: '%s'"; + + private final KeysV2Service keysService; + private final KeyManagementRequestValidator validator; + private final KeyV2ResponseBuilder responseBuilder; + + public KeyResponse createKey( + KeyManagementActor actor, String ncaId, String serviceId, String userId, + CreateKeyRequest request) { + assertScope(ncaId, serviceId, userId); + var service = validator.requireService(serviceId); + var create = validator.validateCreateKeyRequest(service, ncaId, userId, request); + + keysService.createKey(create.key(), service.getMinAuthzUpdateIntervalSeconds(), + actor == KeyManagementActor.KEY_OWNER); + return responseBuilder.toCreateResponse(create.key(), create.generatedKey()); + } + + /** + * Every key the user has in the issuer service of the account, including legacy keys. The + * count is bounded by the service key limit per user, so the list is not paged. + */ + public ListKeysResponse listKeys( + KeyManagementActor actor, String ncaId, String serviceId, String userId) { + assertScope(ncaId, serviceId, userId); + guardOwner(actor, ncaId, serviceId, userId); + + var keys = keysService.listKeys(ncaId, USER, userId, serviceId); + return responseBuilder.toListResponse(keys, null, null); + } + + /** + * Pages keys across the dimensions the caller leaves open. The actor must supply the + * dimension its route fixes: the user for a key owner and the NCA for an account admin. An + * omitted filter applies no restriction. Only a platform admin may omit every filter. + */ + public ListKeysResponse listKeys( + KeyManagementActor actor, String ncaId, String serviceId, String userId, + KeyStatus status, Integer limit, String cursor) { + switch (actor) { + case KEY_OWNER -> validator.assertRouteId("userId", userId); + case ACCOUNT_ADMIN -> validator.assertRouteId("ncaId", ncaId); + case PLATFORM_ADMIN -> { } + } + assertOptionalId("ncaId", ncaId); + assertOptionalId("serviceId", serviceId); + assertOptionalId("userId", userId); + + var unfiltered = ncaId == null && serviceId == null && userId == null; + var resolvedLimit = validator.resolveLimit(limit, unfiltered); + var filter = KeysByScopeFilterVo.builder() + .ncaId(ncaId) + .ownerType(userId == null ? null : USER) + .ownerId(userId) + .issuerServiceId(serviceId) + .keyStatus(status) + .build(); + + try { + var slice = keysService.listKeys(filter, resolvedLimit, cursor); + return responseBuilder.toListResponse(slice.keys(), resolvedLimit, slice.cursor()); + } catch (IllegalArgumentException e) { + throw new BadRequestException(e.getMessage()); + } + } + + public KeyResponse getKey( + KeyManagementActor actor, String ncaId, String serviceId, String userId, + String keyId) { + assertKeyRoute(ncaId, serviceId, userId, keyId); + guardOwner(actor, ncaId, serviceId, userId); + + return keysService.getKeyById(ncaId, USER, userId, serviceId, keyId) + .map(responseBuilder::toResponse) + .orElseThrow(() -> new NotFoundException(KeysV2Service.MESG_KEY_NOT_FOUND)); + } + + public KeyResponse updateKeyStatus( + KeyManagementActor actor, String ncaId, String serviceId, String userId, + String keyId, SettableKeyStatus status) { + assertKeyRoute(ncaId, serviceId, userId, keyId); + guardOwner(actor, ncaId, serviceId, userId); + + var updated = keysService.updateKeyStatus( + ncaId, USER, userId, serviceId, keyId, KeyStatus.valueOf(status.name())); + return responseBuilder.toResponse(updated); + } + + public KeyResponse updateKeyAuthorizations( + KeyManagementActor actor, String ncaId, String serviceId, String userId, + String keyId, UpdateAuthorizationsRequest request) { + assertKeyRoute(ncaId, serviceId, userId, keyId); + var service = validator.requireService(serviceId); + var authorizations = validator.validateAuthorizations(service, request.getAuthorizations()); + guardOwner(actor, ncaId, serviceId, userId); + + var updated = keysService.updateKeyAuthorizations( + ncaId, USER, userId, serviceId, keyId, authorizations, + service.getMinAuthzUpdateIntervalSeconds()); + return responseBuilder.toResponse(updated); + } + + public void deleteKey( + KeyManagementActor actor, String ncaId, String serviceId, String userId, + String keyId) { + assertKeyRoute(ncaId, serviceId, userId, keyId); + guardOwner(actor, ncaId, serviceId, userId); + + keysService.deleteKeyById(ncaId, USER, userId, serviceId, keyId); + } + + /** + * The user's status in one account, or in every account known to API Keys when ncaId is + * null. A single account is always returned as one entry, ACTIVE when nothing is stored. + */ + public KeyOwnerStatusesResponse getOwnerStatuses( + KeyManagementActor actor, String ncaId, String userId, Integer limit, String cursor) { + validator.assertRouteId("userId", userId); + var accounts = keysService.listOwnerAccountStatuses(USER, userId); + + if (ncaId != null) { + validator.assertRouteId("ncaId", ncaId); + var account = accounts.stream() + .filter(status -> status.ncaId().equals(ncaId)) + .findFirst() + .orElseGet(() -> OwnerAccountStatusVo.builder() + .ncaId(ncaId).status(KeyOwnerStatus.ACTIVE).issuerServices(List.of()) + .build()); + return responseBuilder.toStatusesResponse(userId, List.of(account), null, null); + } + + if (actor == KeyManagementActor.ACCOUNT_ADMIN) { + throw new ForbiddenException(MESG_ACTOR_NOT_ALLOWED); + } + var resolvedLimit = validator.resolveLimit(limit, false); + var after = decodeCursor(cursor); + var remaining = accounts.stream() + .filter(status -> after == null || status.ncaId().compareTo(after) > 0) + .toList(); + var page = remaining.stream().limit(resolvedLimit).toList(); + var nextCursor = remaining.size() > page.size() + ? encodeCursor(page.getLast().ncaId()) + : null; + return responseBuilder.toStatusesResponse(userId, page, resolvedLimit, nextCursor); + } + + public KeyOwnerStatusResponse updateAccountStatus( + KeyManagementActor actor, String ncaId, String userId, + UpdateOwnerStatusForAccountRequest request) { + assertAdmin(actor); + validator.assertRouteId("ncaId", ncaId); + validator.assertRouteId("userId", userId); + + var saved = keysService.updateOwnerStatusByAccount( + ncaId, USER, userId, request.getStatus()); + return responseBuilder.toStatusResponse(saved); + } + + public KeyOwnerStatusResponse updateIssuerServiceStatus( + KeyManagementActor actor, String ncaId, String serviceId, String userId, + UpdateOwnerStatusForAccountAndIssuerServiceRequest request) { + if (actor != KeyManagementActor.PLATFORM_ADMIN) { + throw new ForbiddenException(MESG_ACTOR_NOT_ALLOWED); + } + assertScope(ncaId, serviceId, userId); + + var saved = keysService.updateOwnerStatusByAccountAndService( + ncaId, USER, userId, serviceId, request.getStatus()); + return responseBuilder.toStatusResponse(saved); + } + + private void guardOwner( + KeyManagementActor actor, String ncaId, String serviceId, String userId) { + if (actor == KeyManagementActor.KEY_OWNER) { + keysService.assertOwnerActive(ncaId, USER, userId, serviceId); + } + } + + private static void assertAdmin(KeyManagementActor actor) { + if (actor == KeyManagementActor.KEY_OWNER) { + throw new ForbiddenException(MESG_ACTOR_NOT_ALLOWED); + } + } + + private void assertScope(String ncaId, String serviceId, String userId) { + validator.assertRouteId("ncaId", ncaId); + validator.assertRouteId("serviceId", serviceId); + validator.assertRouteId("userId", userId); + } + + private void assertKeyRoute(String ncaId, String serviceId, String userId, String keyId) { + assertScope(ncaId, serviceId, userId); + validator.assertRouteId("keyId", keyId); + } + + private void assertOptionalId(String name, String value) { + if (value != null) { + validator.assertRouteId(name, value); + } + } + + private static String encodeCursor(String ncaId) { + return Base64.getUrlEncoder().withoutPadding() + .encodeToString(ncaId.getBytes(StandardCharsets.UTF_8)); + } + + private static String decodeCursor(String cursor) { + if (StringUtils.isBlank(cursor)) { + return null; + } + try { + return new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + throw new BadRequestException(MESG_INVALID_CURSOR.formatted(cursor)); + } + } +} diff --git a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/services/KeysV2Service.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/services/KeysV2Service.java index d5a38cebda..f3985f0268 100644 --- a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/services/KeysV2Service.java +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/services/KeysV2Service.java @@ -42,15 +42,21 @@ import com.nvidia.apikeys.vo.KeyOwnerVo; import com.nvidia.apikeys.vo.KeyStatus; import com.nvidia.apikeys.vo.KeyV2Vo; +import com.nvidia.apikeys.vo.KeysByAccountSliceVo; import com.nvidia.apikeys.vo.KeysByOwnerAndAccountAndServiceVo; +import com.nvidia.apikeys.vo.KeysByScopeFilterVo; +import com.nvidia.apikeys.vo.OwnerAccountStatusVo; import com.nvidia.boot.exceptions.BadRequestException; import com.nvidia.boot.exceptions.ConflictException; import com.nvidia.boot.exceptions.ForbiddenException; import com.nvidia.boot.exceptions.NotFoundException; import com.nvidia.boot.exceptions.TooManyRequestsException; +import java.util.Comparator; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.TreeSet; import java.util.stream.Collectors; import java.util.stream.Stream; import lombok.RequiredArgsConstructor; @@ -71,7 +77,7 @@ @RequiredArgsConstructor public class KeysV2Service { - static final String MESG_KEY_NOT_FOUND = "Key not found"; + public static final String MESG_KEY_NOT_FOUND = "Key not found"; static final String MESG_KEY_ALREADY_EXISTS = "Key already exists"; static final String MESG_KEY_NOT_ACTIVE = "Key is not active"; static final String MESG_KEY_STATUS_FINAL = "Key status cannot change from %s"; @@ -79,6 +85,8 @@ public class KeysV2Service { static final String MESG_OWNER_STATUS_FOR_ACCOUNT_AND_SERVICE_UNSUPPORTED = "Owner status for account and service is not supported for account '%s'"; + private static final int ACCOUNT_SCAN_PAGE_SIZE = 100; + private static final Set FINAL_KEY_STATUSES = Set.of(KeyStatus.EXPIRED, KeyStatus.REVOKED); @@ -98,8 +106,19 @@ public class KeysV2Service { * authorization update cannot run until minAuthzUpdateIntervalSeconds has passed. */ public KeyV2Vo createKey(KeyV2Vo key, int minAuthzUpdateIntervalSeconds) { - assertOwnerActive(getEffectiveOwnerStatus( - key.getNcaId(), key.getOwnerType(), key.getOwnerId(), key.getIssuerServiceId())); + return createKey(key, minAuthzUpdateIntervalSeconds, true); + } + + /** + * Administrators create keys for suspended owners, so they pass false for + * requireActiveOwner. The new key stays unusable until the owner is active again. + */ + public KeyV2Vo createKey( + KeyV2Vo key, int minAuthzUpdateIntervalSeconds, boolean requireActiveOwner) { + if (requireActiveOwner) { + assertOwnerActive(key.getNcaId(), key.getOwnerType(), key.getOwnerId(), + key.getIssuerServiceId()); + } if (getKeyByHash(key.getKeyHash()).isPresent()) { throw new ConflictException(MESG_KEY_ALREADY_EXISTS); } @@ -152,6 +171,14 @@ public List listKeys( return Stream.concat(keys.stream(), legacyKeys).toList(); } + /** + * Pages keys_v2 keys matching every non-null filter field. Legacy keys are not included, + * use {@link #listKeys(String, KeyOwnerType, String, String)} for a fully scoped list. + */ + public KeysByAccountSliceVo listKeys(KeysByScopeFilterVo filter, int limit, String cursor) { + return keysV2Dao.list(filter, limit, cursor); + } + /** * Sets ACTIVE, SUSPENDED, or REVOKED. EXPIRED and REVOKED keys keep their status. */ @@ -229,6 +256,40 @@ public Optional getOwnerStatusByAccount( return ownerStatusDao.getOwnerStatusByAccount(ncaId, ownerType, ownerId); } + /** + * The owner's status in every account known to API Keys, ordered by account ID. An account + * is known when the owner has a key or a stored status in it. An account without a stored + * account-scoped status is ACTIVE. + */ + public List listOwnerAccountStatuses( + KeyOwnerType ownerType, String ownerId) { + var accountStatuses = ownerStatusDao.listOwnerStatusByAccount(ownerType, ownerId).stream() + .collect(Collectors.toMap(OwnerStatusByAccountModel::getNcaId, s -> s)); + var issuerStatuses = ownerStatusDao.listOwnerStatusByAccountAndService(ownerType, ownerId) + .stream() + .collect(Collectors.groupingBy(OwnerStatusByAccountAndServiceModel::getNcaId)); + + var ncaIds = new TreeSet(); + ncaIds.addAll(accountStatuses.keySet()); + ncaIds.addAll(issuerStatuses.keySet()); + ncaIds.addAll(listKeyAccountIds(ownerType, ownerId)); + if (keysDao.getKeyOwner(ownerType, ownerId).isPresent()) { + ncaIds.add(legacyAccount.legacyNcaId()); + } + + return ncaIds.stream() + .map(ncaId -> OwnerAccountStatusVo.builder() + .ncaId(ncaId) + .status(accountStatus(ncaId, ownerType, ownerId, accountStatuses)) + .issuerServices(issuerStatuses.getOrDefault(ncaId, List.of()).stream() + .map(KeysV2Service::toIssuerStatus) + .sorted(Comparator.comparing( + OwnerAccountStatusVo.IssuerStatus::issuerServiceId)) + .toList()) + .build()) + .toList(); + } + /** * Owner status for the account and issuer service. The legacy account has no * account-and-service records. @@ -308,6 +369,45 @@ private void save(StoredKey stored, KeyV2Vo updated) { } } + private Set listKeyAccountIds(KeyOwnerType ownerType, String ownerId) { + var filter = KeysByScopeFilterVo.builder().ownerType(ownerType).ownerId(ownerId).build(); + var ncaIds = new TreeSet(); + String cursor = null; + do { + var slice = keysV2Dao.list(filter, ACCOUNT_SCAN_PAGE_SIZE, cursor); + slice.keys().forEach(key -> ncaIds.add(key.getNcaId())); + cursor = slice.cursor(); + } while (cursor != null); + return ncaIds; + } + + private KeyOwnerStatus accountStatus( + String ncaId, KeyOwnerType ownerType, String ownerId, + Map storedStatuses) { + if (legacyAccount.isLegacy(ncaId)) { + return getOwnerStatusByAccount(ncaId, ownerType, ownerId) + .map(OwnerStatusByAccountModel::getOwnerStatus) + .orElse(KeyOwnerStatus.ACTIVE); + } + return Optional.ofNullable(storedStatuses.get(ncaId)) + .map(OwnerStatusByAccountModel::getOwnerStatus) + .orElse(KeyOwnerStatus.ACTIVE); + } + + private static OwnerAccountStatusVo.IssuerStatus toIssuerStatus( + OwnerStatusByAccountAndServiceModel model) { + return new OwnerAccountStatusVo.IssuerStatus( + model.getIssuerServiceId(), model.getOwnerStatus(), model.getUpdatedAt()); + } + + /** + * Denies the operation when the owner is suspended for the account or the issuer service. + */ + public void assertOwnerActive( + String ncaId, KeyOwnerType ownerType, String ownerId, String issuerServiceId) { + assertOwnerActive(getEffectiveOwnerStatus(ncaId, ownerType, ownerId, issuerServiceId)); + } + /** * The legacy account keeps owner status with the owner's legacy keys. */ diff --git a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/services/LegacyAccountTranslator.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/services/LegacyAccountTranslator.java index 0558ba6c39..ed15032bea 100644 --- a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/services/LegacyAccountTranslator.java +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/services/LegacyAccountTranslator.java @@ -52,6 +52,10 @@ public boolean isLegacy(String ncaId) { return Strings.CS.equals(nakProperties.getNcaId(), ncaId); } + public String legacyNcaId() { + return nakProperties.getNcaId(); + } + public Optional findLegacyIssuerServiceId(String serviceId) { return Optional.ofNullable(serviceIdMap().get(serviceId)); } diff --git a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/validators/KeyManagementRequestValidator.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/validators/KeyManagementRequestValidator.java new file mode 100644 index 0000000000..232c002065 --- /dev/null +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/validators/KeyManagementRequestValidator.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.validators; + +import com.nvidia.apikeys.config.NakProperties; +import com.nvidia.apikeys.converters.ValidatingAuthorizationsConverter; +import com.nvidia.apikeys.dto.keysV2.CreateKeyRequest; +import com.nvidia.apikeys.services.CredentialService; +import com.nvidia.apikeys.services.MillisecondPrecisionClock; +import com.nvidia.apikeys.services.ServicesService; +import com.nvidia.apikeys.vo.CreateKeyV2RequestVo; +import com.nvidia.apikeys.vo.KeyOwnerType; +import com.nvidia.apikeys.vo.KeyStatus; +import com.nvidia.apikeys.vo.KeyV2Vo; +import com.nvidia.apikeys.vo.ServiceVo; +import com.nvidia.boot.exceptions.BadRequestException; +import java.time.temporal.ChronoUnit; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import tools.jackson.databind.JsonNode; + +/** + * Validation shared by the key-owner, account-admin and platform-admin endpoints. The NCA, + * issuer service and user come from the route and the body holds only key attributes. + */ +@Service +@RequiredArgsConstructor +public class KeyManagementRequestValidator { + + public static final int DEFAULT_LIMIT = 50; + public static final int MAX_LIMIT = 100; + // a list with no NCA, service or user restriction scans the whole table + public static final int MAX_UNFILTERED_LIMIT = 50; + + static final String RESERVED_ID = "*"; + static final String MESG_ID_INVALID = "%s is invalid"; + static final String MESG_UNKNOWN_SERVICE = "Unknown issuer service: '%s'"; + static final String MESG_LIMIT_INVALID = "limit must be between 1 and %d"; + + private final NakProperties nakProperties; + private final ServicesService servicesService; + private final CredentialService credentialService; + private final KeyRequestValidator keyRequestValidator; + private final ValidatingAuthorizationsConverter authorizationsConverter; + private final MillisecondPrecisionClock clock; + + /** + * A route ID must be present and must not be the reserved wildcard. + */ + public void assertRouteId(String name, String value) { + if (StringUtils.isBlank(value) || RESERVED_ID.equals(value)) { + throw new BadRequestException(MESG_ID_INVALID.formatted(name)); + } + } + + /** + * Resolves a registered issuer service. An unregistered ID, including a legacy service ID + * that is not registered, is rejected. + */ + public ServiceVo requireService(String serviceId) { + assertRouteId("serviceId", serviceId); + return servicesService.getServiceById(serviceId) + .orElseThrow(() -> new BadRequestException(MESG_UNKNOWN_SERVICE.formatted(serviceId))); + } + + public CreateKeyV2RequestVo validateCreateKeyRequest( + ServiceVo service, String ncaId, String userId, CreateKeyRequest request) { + keyRequestValidator.assertDescriptionValid(request.getDescription()); + keyRequestValidator.assertExpirationDateValid(service, request.getExpiresAt()); + var audienceServiceIds = keyRequestValidator.getValidAudienceServiceIds( + service, request.getAudienceServiceIds()); + var authorizations = validateAuthorizations(service, request.getAuthorizations()); + + var generatedKey = credentialService.generateApiKey(); + var key = KeyV2Vo.builder() + .keyStatus(KeyStatus.ACTIVE) + .ncaId(ncaId) + .ownerType(KeyOwnerType.USER) + .ownerId(userId) + .issuerServiceId(service.getServiceId()) + .audienceServiceIds(audienceServiceIds) + .keyId(generatedKey.getKeyId()) + .keyHash(generatedKey.getKeyHash()) + .createdAt(clock.instant().truncatedTo(ChronoUnit.MILLIS)) + .expiresAt(request.getExpiresAt()) + .deletesAt(request.getExpiresAt().plus(nakProperties.getKeepAfterExpiredDuration())) + .apiKeySuffix(generatedKey.getKeySuffix()) + .authorizations(authorizations) + .description(request.getDescription()) + .build(); + return CreateKeyV2RequestVo.builder() + .service(service) + .key(key) + .generatedKey(generatedKey) + .build(); + } + + public String validateAuthorizations(ServiceVo service, JsonNode authorizations) { + return authorizationsConverter.readValidAuthorizations(service, authorizations); + } + + /** + * An absent limit uses the default. A list without any restriction has a lower cap. + */ + public int resolveLimit(Integer requested, boolean unfiltered) { + var max = unfiltered ? MAX_UNFILTERED_LIMIT : MAX_LIMIT; + if (requested == null) { + return Math.min(DEFAULT_LIMIT, max); + } + if (requested < 1 || requested > max) { + throw new BadRequestException(MESG_LIMIT_INVALID.formatted(max)); + } + return requested; + } +} diff --git a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/vo/CreateKeyV2RequestVo.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/vo/CreateKeyV2RequestVo.java new file mode 100644 index 0000000000..2149d30c61 --- /dev/null +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/vo/CreateKeyV2RequestVo.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 lombok.Builder; + +/** + * A validated create request. generatedKey carries the one-time full key value. + */ +@Builder +public record CreateKeyV2RequestVo(ServiceVo service, KeyV2Vo key, GeneratedKeyVo generatedKey) { +} diff --git a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/vo/OwnerAccountStatusVo.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/vo/OwnerAccountStatusVo.java new file mode 100644 index 0000000000..a02ac29509 --- /dev/null +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/vo/OwnerAccountStatusVo.java @@ -0,0 +1,35 @@ +/* + * 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.time.Instant; +import java.util.List; +import lombok.Builder; + +/** + * An owner's status in one account. issuerServices holds only issuer services with a stored + * issuer-scoped status. + */ +@Builder +public record OwnerAccountStatusVo( + String ncaId, KeyOwnerStatus status, List issuerServices) { + + public record IssuerStatus(String issuerServiceId, KeyOwnerStatus status, Instant updatedAt) { + } +} diff --git a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/web/KeyManagementRoutes.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/web/KeyManagementRoutes.java new file mode 100644 index 0000000000..05eb31d184 --- /dev/null +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/web/KeyManagementRoutes.java @@ -0,0 +1,59 @@ +/* + * 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.web; + +/** + * Paths and parameter names shared by the key-owner, account-admin and platform-admin + * controllers. The actor prefix selects the actor, the remaining paths are the same. + */ +public final class KeyManagementRoutes { + + public static final String KEY_OWNER_PREFIX = "/v2/api-keys/key-owner"; + public static final String ACCOUNT_ADMIN_PREFIX = "/v2/api-keys/account-admin"; + public static final String PLATFORM_ADMIN_PREFIX = "/v2/api-keys/platform-admin"; + + public static final String NCA_ID = "ncaId"; + public static final String SERVICE_ID = "serviceId"; + public static final String USER_ID = "userId"; + public static final String KEY_ID = "keyId"; + + public static final String QUERY_NCA_ID = "nca-id"; + public static final String QUERY_SERVICE_ID = "service-id"; + public static final String QUERY_USER_ID = "user-id"; + public static final String QUERY_STATUS = "status"; + public static final String QUERY_LIMIT = "limit"; + public static final String QUERY_CURSOR = "cursor"; + + public static final String SCOPED_KEYS = + "/accounts/{ncaId}/services/{serviceId}/users/{userId}/keys"; + public static final String SCOPED_KEY = SCOPED_KEYS + "/{keyId}"; + public static final String SCOPED_KEY_STATUS = SCOPED_KEY + "/status"; + public static final String SCOPED_KEY_AUTHORIZATIONS = SCOPED_KEY + "/authorizations"; + public static final String ACCOUNT_KEYS = "/accounts/{ncaId}/keys"; + public static final String USER_KEYS = "/users/{userId}/keys"; + public static final String ALL_KEYS = "/keys"; + public static final String USER_STATUSES = "/users/{userId}/statuses"; + public static final String ACCOUNT_USER_STATUSES = "/accounts/{ncaId}/users/{userId}/statuses"; + public static final String ACCOUNT_USER_STATUS = "/accounts/{ncaId}/users/{userId}/status"; + public static final String SCOPED_USER_STATUS = + "/accounts/{ncaId}/services/{serviceId}/users/{userId}/status"; + + private KeyManagementRoutes() { + } +} diff --git a/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/converters/KeyV2ResponseBuilderTest.java b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/converters/KeyV2ResponseBuilderTest.java new file mode 100644 index 0000000000..4a771919bc --- /dev/null +++ b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/converters/KeyV2ResponseBuilderTest.java @@ -0,0 +1,189 @@ +/* + * 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.converters; + +import static com.nvidia.apikeys.TestData.API_KEY_1; +import static com.nvidia.apikeys.TestData.API_KEY_SUFFIX_1; +import static com.nvidia.apikeys.TestData.GENERATED_KEY_VO_1; +import static com.nvidia.apikeys.TestData.KEY_AUTHZ_1; +import static com.nvidia.apikeys.TestData.KEY_EXPIRES_AT_1; +import static com.nvidia.apikeys.TestData.KEY_ID_1; +import static com.nvidia.apikeys.TestData.KEY_V2_VO_1; +import static com.nvidia.apikeys.TestData.NCA_ID_1; +import static com.nvidia.apikeys.TestData.SERVICE_ID_1; +import static com.nvidia.apikeys.TestData.TEST_TIME; +import static com.nvidia.apikeys.TestData.USER_KEY_OWNER_ID_1; +import static org.assertj.core.api.Assertions.assertThat; + +import com.nvidia.apikeys.dto.keysV2.KeyStatusV2; +import com.nvidia.apikeys.persistance.models.OwnerStatusByAccountAndServiceModel; +import com.nvidia.apikeys.persistance.models.OwnerStatusByAccountModel; +import com.nvidia.apikeys.services.MillisecondPrecisionClock; +import com.nvidia.apikeys.utils.TestClock; +import com.nvidia.apikeys.vo.KeyOwnerStatus; +import com.nvidia.apikeys.vo.KeyOwnerType; +import com.nvidia.apikeys.vo.KeyStatus; +import com.nvidia.apikeys.vo.KeysByOwnerAndAccountAndServiceVo; +import com.nvidia.apikeys.vo.OwnerAccountStatusVo; +import java.time.Instant; +import java.time.ZoneId; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import tools.jackson.databind.json.JsonMapper; + +class KeyV2ResponseBuilderTest { + + private static final JsonMapper JSON_MAPPER = new JsonMapper(); + + private final KeyV2ResponseBuilder builder = new KeyV2ResponseBuilder( + JSON_MAPPER, + new MillisecondPrecisionClock(TestClock.fixed(TEST_TIME, ZoneId.systemDefault()))); + + @Test + void toCreateResponseCarriesFullKeyValue() { + var response = builder.toCreateResponse(KEY_V2_VO_1, GENERATED_KEY_VO_1); + + assertThat(response.getValue()).isEqualTo(API_KEY_1); + assertThat(response.getId()).isEqualTo(KEY_ID_1); + assertThat(response.getAuthorizations()).isEqualTo(JSON_MAPPER.readTree(KEY_AUTHZ_1)); + } + + @Test + void toResponseCarriesMaskedValueAndAuthorizations() { + var response = builder.toResponse(KEY_V2_VO_1); + + assertThat(response.getValue()).isEqualTo(API_KEY_SUFFIX_1); + assertThat(response.getId()).isEqualTo(KEY_ID_1); + assertThat(response.getOwnerId()).isEqualTo(USER_KEY_OWNER_ID_1); + assertThat(response.getAccountId()).isEqualTo(NCA_ID_1); + assertThat(response.getIssuerServiceId()).isEqualTo(SERVICE_ID_1); + assertThat(response.getStatus()).isEqualTo(KeyStatusV2.ACTIVE); + assertThat(response.getExpiresAt()).isEqualTo(KEY_EXPIRES_AT_1); + assertThat(response.getAuthorizations()).isEqualTo(JSON_MAPPER.readTree(KEY_AUTHZ_1)); + } + + @Test + void toListItemOmitsAuthorizations() { + var response = builder.toListItem(KeysByOwnerAndAccountAndServiceVo.from(KEY_V2_VO_1)); + + assertThat(response.getAuthorizations()).isNull(); + assertThat(response.getValue()).isEqualTo(API_KEY_SUFFIX_1); + assertThat(response.getAccountId()).isEqualTo(NCA_ID_1); + } + + @ParameterizedTest + @CsvSource({ + "ACTIVE, 2023-10-04T08:25:24Z, ACTIVE", + "SUSPENDED, 2023-10-04T08:25:24Z, SUSPENDED", + "REVOKED, 2023-10-04T08:25:24Z, REVOKED", + "ACTIVE, 2023-10-03T08:25:23Z, EXPIRED", + "ACTIVE, 2023-10-03T08:25:24Z, EXPIRED", + "SUSPENDED, 2023-10-03T08:25:23Z, EXPIRED", + "REVOKED, 2023-10-03T08:25:23Z, REVOKED", + "EXPIRED, 2023-10-04T08:25:24Z, EXPIRED", + }) + void statusIsDerivedFromStoredStatusAndExpiry( + KeyStatus stored, Instant expiresAt, KeyStatusV2 expected) { + var key = KEY_V2_VO_1.toBuilder().keyStatus(stored).expiresAt(expiresAt).build(); + + assertThat(builder.toResponse(key).getStatus()).isEqualTo(expected); + assertThat(builder.toListItem(KeysByOwnerAndAccountAndServiceVo.from(key)).getStatus()) + .isEqualTo(expected); + } + + @Test + void toListResponseCarriesLimitAndCursor() { + var response = builder.toListResponse( + List.of(KeysByOwnerAndAccountAndServiceVo.from(KEY_V2_VO_1)), 10, "next"); + + assertThat(response.getKeys()).singleElement() + .satisfies(key -> assertThat(key.getId()).isEqualTo(KEY_ID_1)); + assertThat(response.getLimit()).isEqualTo(10); + assertThat(response.getCursor()).isEqualTo("next"); + } + + @Test + void toStatusResponseForAccountOmitsIssuerService() { + var model = OwnerStatusByAccountModel.builder() + .ncaId(NCA_ID_1) + .ownerType(KeyOwnerType.USER) + .ownerId(USER_KEY_OWNER_ID_1) + .ownerStatus(KeyOwnerStatus.SUSPENDED) + .updatedAt(TEST_TIME) + .build(); + + var response = builder.toStatusResponse(model); + + assertThat(response.getOwnerId()).isEqualTo(USER_KEY_OWNER_ID_1); + assertThat(response.getAccountId()).isEqualTo(NCA_ID_1); + assertThat(response.getIssuerServiceId()).isNull(); + assertThat(response.getStatus()).isEqualTo(KeyOwnerStatus.SUSPENDED); + assertThat(response.getUpdatedAt()).isEqualTo(TEST_TIME); + } + + @Test + void toStatusResponseForIssuerServiceCarriesIssuerService() { + var model = OwnerStatusByAccountAndServiceModel.builder() + .ncaId(NCA_ID_1) + .ownerType(KeyOwnerType.USER) + .ownerId(USER_KEY_OWNER_ID_1) + .issuerServiceId(SERVICE_ID_1) + .ownerStatus(KeyOwnerStatus.SUSPENDED) + .updatedAt(TEST_TIME) + .build(); + + var response = builder.toStatusResponse(model); + + assertThat(response.getIssuerServiceId()).isEqualTo(SERVICE_ID_1); + assertThat(response.getStatus()).isEqualTo(KeyOwnerStatus.SUSPENDED); + } + + @Test + void toStatusesResponseMapsAccountsAndIssuerServices() { + var withIssuer = OwnerAccountStatusVo.builder() + .ncaId(NCA_ID_1) + .status(KeyOwnerStatus.ACTIVE) + .issuerServices(List.of(new OwnerAccountStatusVo.IssuerStatus( + SERVICE_ID_1, KeyOwnerStatus.SUSPENDED, TEST_TIME))) + .build(); + var withoutIssuer = OwnerAccountStatusVo.builder() + .ncaId("nca-2") + .status(KeyOwnerStatus.SUSPENDED) + .issuerServices(List.of()) + .build(); + + var response = builder.toStatusesResponse( + USER_KEY_OWNER_ID_1, List.of(withIssuer, withoutIssuer), 5, "next"); + + assertThat(response.getOwnerId()).isEqualTo(USER_KEY_OWNER_ID_1); + assertThat(response.getLimit()).isEqualTo(5); + assertThat(response.getCursor()).isEqualTo("next"); + assertThat(response.getAccounts()).hasSize(2); + assertThat(response.getAccounts().get(0).getAccountId()).isEqualTo(NCA_ID_1); + assertThat(response.getAccounts().get(0).getIssuerServices()).singleElement() + .satisfies(issuer -> { + assertThat(issuer.getIssuerServiceId()).isEqualTo(SERVICE_ID_1); + assertThat(issuer.getStatus()).isEqualTo(KeyOwnerStatus.SUSPENDED); + assertThat(issuer.getUpdatedAt()).isEqualTo(TEST_TIME); + }); + assertThat(response.getAccounts().get(1).getStatus()).isEqualTo(KeyOwnerStatus.SUSPENDED); + } +} diff --git a/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/facade/KeyManagementFacadeIntegrationTest.java b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/facade/KeyManagementFacadeIntegrationTest.java new file mode 100644 index 0000000000..ac5099dc04 --- /dev/null +++ b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/facade/KeyManagementFacadeIntegrationTest.java @@ -0,0 +1,518 @@ +/* + * 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.facade; + +import static com.nvidia.apikeys.TestData.KEY_AUTHZ_1; +import static com.nvidia.apikeys.TestData.KEY_DESCRIPTION_1; +import static com.nvidia.apikeys.TestData.KEY_EXPIRES_AT_1; +import static com.nvidia.apikeys.TestData.KEY_ID_1; +import static com.nvidia.apikeys.TestData.KEY_OWNER_VO_1; +import static com.nvidia.apikeys.TestData.KEY_VO_1; +import static com.nvidia.apikeys.TestData.NCA_ID_1; +import static com.nvidia.apikeys.TestData.SERVICE_ID_1; +import static com.nvidia.apikeys.TestData.TEST_TIME; +import static com.nvidia.apikeys.TestData.USER_KEY_OWNER_ID_1; +import static com.nvidia.apikeys.config.IntegrationTestConfiguration.KEY_SPACE; +import static com.nvidia.apikeys.facade.KeyManagementActor.ACCOUNT_ADMIN; +import static com.nvidia.apikeys.facade.KeyManagementActor.KEY_OWNER; +import static com.nvidia.apikeys.facade.KeyManagementActor.PLATFORM_ADMIN; +import static com.nvidia.apikeys.utils.TestUtils.assertThrowsExceptionWithDetails; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.nvidia.apikeys.App; +import com.nvidia.apikeys.config.IntegrationTestConfiguration; +import com.nvidia.apikeys.config.IntegrationTestConfiguration.TestCleanerExtension; +import com.nvidia.apikeys.dto.keysV2.CreateKeyRequest; +import com.nvidia.apikeys.dto.keysV2.KeyOwnerStatusesResponse; +import com.nvidia.apikeys.dto.keysV2.KeyResponse; +import com.nvidia.apikeys.dto.keysV2.KeyStatusV2; +import com.nvidia.apikeys.dto.keysV2.OwnerStatusByAccount; +import com.nvidia.apikeys.dto.keysV2.SettableKeyStatus; +import com.nvidia.apikeys.dto.keysV2.UpdateAuthorizationsRequest; +import com.nvidia.apikeys.dto.keysV2.UpdateOwnerStatusForAccountAndIssuerServiceRequest; +import com.nvidia.apikeys.dto.keysV2.UpdateOwnerStatusForAccountRequest; +import com.nvidia.apikeys.persistance.dao.KeysDao; +import com.nvidia.apikeys.utils.TestClock; +import com.nvidia.apikeys.vo.KeyOwnerStatus; +import com.nvidia.apikeys.vo.KeyStatus; +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.ConflictException; +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.boot.exceptions.NotFoundException; +import com.nvidia.boot.exceptions.TooManyRequestsException; +import java.time.ZoneId; +import java.util.ArrayList; +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; +import tools.jackson.databind.json.JsonMapper; + +@ExtendWith(TestCleanerExtension.class) +@AutoConfigureTestRestTemplate +@SpringBootTest( + classes = App.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=integrationtest") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +class KeyManagementFacadeIntegrationTest { + + // apikeys.nca-id and the apikeys.service-id-map key for SERVICE_ID_1 in + // application-integrationtest.yaml + private static final String LEGACY_NCA_ID = "test-nca-id"; + private static final String LEGACY_CANONICAL_SERVICE_ID = "nvcf"; + private static final String NCA_ID_2 = "nca-2"; + private static final String NCA_ID_3 = "nca-3"; + private static final String USER_ID_2 = "user-2"; + private static final JsonMapper JSON_MAPPER = new JsonMapper(); + + @Autowired + private KeyManagementFacade facade; + + @Autowired + private KeysDao keysDao; + + @BeforeEach + void setUp() { + TestClock.setBaseClock(TestClock.fixed(TEST_TIME, ZoneId.systemDefault())); + } + + @AfterEach + void tearDown() { + TestClock.resetToDefaults(); + } + + @Test + void createReturnsFullValueAndLaterReadsReturnMaskedValue() { + var created = create(KEY_OWNER, NCA_ID_1, USER_KEY_OWNER_ID_1); + + assertThat(created.getValue()).startsWith("nvcfapi-test-").doesNotContain("*"); + assertThat(created.getStatus()).isEqualTo(KeyStatusV2.ACTIVE); + assertThat(created.getOwnerId()).isEqualTo(USER_KEY_OWNER_ID_1); + assertThat(created.getAccountId()).isEqualTo(NCA_ID_1); + assertThat(created.getIssuerServiceId()).isEqualTo(SERVICE_ID_1); + assertThat(created.getAuthorizations()).isEqualTo(JSON_MAPPER.readTree(KEY_AUTHZ_1)); + + var fetched = get(KEY_OWNER, NCA_ID_1, USER_KEY_OWNER_ID_1, created.getId()); + assertThat(fetched.getValue()).contains("**********").isNotEqualTo(created.getValue()); + assertThat(fetched.getId()).isEqualTo(created.getId()); + assertThat(fetched.getAuthorizations()).isEqualTo(created.getAuthorizations()); + } + + @Test + void sameUserInTwoAccountsKeepsKeysSeparate() { + var first = create(ACCOUNT_ADMIN, NCA_ID_1, USER_KEY_OWNER_ID_1); + var second = create(ACCOUNT_ADMIN, NCA_ID_2, USER_KEY_OWNER_ID_1); + + assertThat(facade.listKeys(ACCOUNT_ADMIN, NCA_ID_1, SERVICE_ID_1, USER_KEY_OWNER_ID_1) + .getKeys()) + .extracting(KeyResponse::getId).containsExactly(first.getId()); + assertThat(facade.listKeys(ACCOUNT_ADMIN, NCA_ID_2, SERVICE_ID_1, USER_KEY_OWNER_ID_1) + .getKeys()) + .extracting(KeyResponse::getId).containsExactly(second.getId()); + } + + @Test + void singleKeyRoutesReturnNotFoundWhenRouteDoesNotMatchStoredKey() { + var key = create(KEY_OWNER, NCA_ID_1, USER_KEY_OWNER_ID_1); + + assertThrowsNotFound(() -> get(KEY_OWNER, NCA_ID_2, USER_KEY_OWNER_ID_1, key.getId())); + assertThrowsNotFound(() -> get(KEY_OWNER, NCA_ID_1, USER_ID_2, key.getId())); + assertThrowsNotFound(() -> facade.getKey( + PLATFORM_ADMIN, NCA_ID_1, "other-service", USER_KEY_OWNER_ID_1, key.getId())); + assertThrowsNotFound(() -> facade.deleteKey( + ACCOUNT_ADMIN, NCA_ID_2, SERVICE_ID_1, USER_KEY_OWNER_ID_1, key.getId())); + assertThat(get(KEY_OWNER, NCA_ID_1, USER_KEY_OWNER_ID_1, key.getId())).isNotNull(); + } + + @Test + void keyOwnerIsDeniedWhenSuspendedButAdministratorsStillManageKeys() { + var existing = create(ACCOUNT_ADMIN, NCA_ID_1, USER_KEY_OWNER_ID_1); + suspendAccount(NCA_ID_1, USER_KEY_OWNER_ID_1); + + assertForbidden(() -> create(KEY_OWNER, NCA_ID_1, USER_KEY_OWNER_ID_1)); + assertForbidden(() -> get(KEY_OWNER, NCA_ID_1, USER_KEY_OWNER_ID_1, existing.getId())); + assertForbidden(() -> facade.listKeys( + KEY_OWNER, NCA_ID_1, SERVICE_ID_1, USER_KEY_OWNER_ID_1)); + assertForbidden(() -> facade.updateKeyStatus( + KEY_OWNER, NCA_ID_1, SERVICE_ID_1, USER_KEY_OWNER_ID_1, existing.getId(), + SettableKeyStatus.REVOKED)); + assertForbidden(() -> facade.deleteKey( + KEY_OWNER, NCA_ID_1, SERVICE_ID_1, USER_KEY_OWNER_ID_1, existing.getId())); + + var created = create(ACCOUNT_ADMIN, NCA_ID_1, USER_KEY_OWNER_ID_1); + assertThat(created.getStatus()).isEqualTo(KeyStatusV2.ACTIVE); + assertThat(get(PLATFORM_ADMIN, NCA_ID_1, USER_KEY_OWNER_ID_1, existing.getId())).isNotNull(); + assertThat(facade.updateKeyStatus( + ACCOUNT_ADMIN, NCA_ID_1, SERVICE_ID_1, USER_KEY_OWNER_ID_1, existing.getId(), + SettableKeyStatus.SUSPENDED).getStatus()).isEqualTo(KeyStatusV2.SUSPENDED); + assertThat(create(PLATFORM_ADMIN, NCA_ID_1, USER_KEY_OWNER_ID_1)).isNotNull(); + } + + @Test + void suspensionInOneAccountDoesNotDenyKeyOwnerInAnotherAccount() { + suspendAccount(NCA_ID_1, USER_KEY_OWNER_ID_1); + + assertThat(create(KEY_OWNER, NCA_ID_2, USER_KEY_OWNER_ID_1)).isNotNull(); + } + + @Test + void issuerServiceSuspensionDeniesKeyOwnerForThatIssuerOnly() { + facade.updateIssuerServiceStatus( + PLATFORM_ADMIN, NCA_ID_1, SERVICE_ID_1, USER_KEY_OWNER_ID_1, + issuerStatusRequest(KeyOwnerStatus.SUSPENDED)); + + assertForbidden(() -> create(KEY_OWNER, NCA_ID_1, USER_KEY_OWNER_ID_1)); + assertThat(create(ACCOUNT_ADMIN, NCA_ID_1, USER_KEY_OWNER_ID_1)).isNotNull(); + } + + @Test + void updateKeyStatusAppliesTransitionsAndRejectsChangeFromRevoked() { + var key = create(KEY_OWNER, NCA_ID_1, USER_KEY_OWNER_ID_1); + + assertThat(setStatus(key, SettableKeyStatus.SUSPENDED).getStatus()) + .isEqualTo(KeyStatusV2.SUSPENDED); + assertThat(setStatus(key, SettableKeyStatus.ACTIVE).getStatus()) + .isEqualTo(KeyStatusV2.ACTIVE); + assertThat(setStatus(key, SettableKeyStatus.REVOKED).getStatus()) + .isEqualTo(KeyStatusV2.REVOKED); + + assertThrows(ConflictException.class, () -> setStatus(key, SettableKeyStatus.ACTIVE)); + } + + @Test + void updateAuthorizationsReplacesDocumentAndEnforcesMinimumInterval() throws Exception { + var key = create(KEY_OWNER, NCA_ID_1, USER_KEY_OWNER_ID_1); + var update = UpdateAuthorizationsRequest.builder() + .authorizations(JSON_MAPPER.readTree("{\"allow\":false}")) + .build(); + + assertThrows(TooManyRequestsException.class, () -> facade.updateKeyAuthorizations( + KEY_OWNER, NCA_ID_1, SERVICE_ID_1, USER_KEY_OWNER_ID_1, key.getId(), update)); + + releaseAuthorizationLocks(); + var updated = facade.updateKeyAuthorizations( + KEY_OWNER, NCA_ID_1, SERVICE_ID_1, USER_KEY_OWNER_ID_1, key.getId(), update); + + assertThat(updated.getAuthorizations()).isEqualTo(JSON_MAPPER.readTree("{\"allow\":false}")); + assertThat(get(KEY_OWNER, NCA_ID_1, USER_KEY_OWNER_ID_1, key.getId()).getAuthorizations()) + .isEqualTo(updated.getAuthorizations()); + } + + @Test + void deleteRemovesKey() { + var key = create(KEY_OWNER, NCA_ID_1, USER_KEY_OWNER_ID_1); + + facade.deleteKey(KEY_OWNER, NCA_ID_1, SERVICE_ID_1, USER_KEY_OWNER_ID_1, key.getId()); + + assertThrowsNotFound(() -> get(KEY_OWNER, NCA_ID_1, USER_KEY_OWNER_ID_1, key.getId())); + assertThat(facade.listKeys(KEY_OWNER, NCA_ID_1, SERVICE_ID_1, USER_KEY_OWNER_ID_1) + .getKeys()).isEmpty(); + } + + @Test + void createRejectsUnregisteredServiceAndBlankRouteIds() { + assertThrowsExceptionWithDetails( + BadRequestException.class, + () -> facade.createKey(KEY_OWNER, NCA_ID_1, "other-service", + USER_KEY_OWNER_ID_1, createRequest()), + "Unknown issuer service: 'other-service'"); + assertThrowsExceptionWithDetails( + BadRequestException.class, + () -> facade.createKey(KEY_OWNER, " ", SERVICE_ID_1, USER_KEY_OWNER_ID_1, + createRequest()), + "ncaId is invalid"); + assertThrowsExceptionWithDetails( + BadRequestException.class, + () -> facade.getKey(KEY_OWNER, NCA_ID_1, SERVICE_ID_1, "*", KEY_ID_1), + "userId is invalid"); + } + + @Test + void keyOwnerListsOwnKeysAcrossAccountsInPages() { + var ids = new ArrayList(); + ids.add(create(KEY_OWNER, NCA_ID_1, USER_KEY_OWNER_ID_1).getId()); + ids.add(create(KEY_OWNER, NCA_ID_1, USER_KEY_OWNER_ID_1).getId()); + ids.add(create(KEY_OWNER, NCA_ID_2, USER_KEY_OWNER_ID_1).getId()); + create(KEY_OWNER, NCA_ID_1, USER_ID_2); + + var first = facade.listKeys(KEY_OWNER, null, null, USER_KEY_OWNER_ID_1, null, 2, null); + assertThat(first.getKeys()).hasSize(2); + assertThat(first.getLimit()).isEqualTo(2); + assertThat(first.getCursor()).isNotBlank(); + + var second = facade.listKeys( + KEY_OWNER, null, null, USER_KEY_OWNER_ID_1, null, 2, first.getCursor()); + assertThat(second.getCursor()).isNull(); + + var listed = new ArrayList<>(first.getKeys()); + listed.addAll(second.getKeys()); + assertThat(listed).extracting(KeyResponse::getId).containsExactlyInAnyOrderElementsOf(ids); + assertThat(listed).allSatisfy(key -> { + assertThat(key.getOwnerId()).isEqualTo(USER_KEY_OWNER_ID_1); + assertThat(key.getAuthorizations()).isNull(); + assertThat(key.getValue()).contains("**********"); + }); + } + + @Test + void listNarrowsByAccountServiceAndStatus() { + var active = create(ACCOUNT_ADMIN, NCA_ID_1, USER_KEY_OWNER_ID_1); + var suspended = create(ACCOUNT_ADMIN, NCA_ID_1, USER_KEY_OWNER_ID_1); + setStatus(suspended, SettableKeyStatus.SUSPENDED); + create(ACCOUNT_ADMIN, NCA_ID_2, USER_KEY_OWNER_ID_1); + + assertThat(facade.listKeys(ACCOUNT_ADMIN, NCA_ID_1, null, null, null, null, null).getKeys()) + .extracting(KeyResponse::getId) + .containsExactlyInAnyOrder(active.getId(), suspended.getId()); + assertThat(facade.listKeys(ACCOUNT_ADMIN, NCA_ID_1, SERVICE_ID_1, USER_KEY_OWNER_ID_1, + KeyStatus.SUSPENDED, null, null).getKeys()) + .extracting(KeyResponse::getId).containsExactly(suspended.getId()); + assertThat(facade.listKeys(ACCOUNT_ADMIN, NCA_ID_1, "other-service", null, null, null, null) + .getKeys()).isEmpty(); + } + + @Test + void listRequiresTheDimensionFixedByTheActorRoute() { + assertThrowsExceptionWithDetails( + BadRequestException.class, + () -> facade.listKeys(KEY_OWNER, NCA_ID_1, null, null, null, null, null), + "userId is invalid"); + assertThrowsExceptionWithDetails( + BadRequestException.class, + () -> facade.listKeys(ACCOUNT_ADMIN, null, null, USER_KEY_OWNER_ID_1, null, null, + null), + "ncaId is invalid"); + assertThrowsExceptionWithDetails( + BadRequestException.class, + () -> facade.listKeys(PLATFORM_ADMIN, " ", null, null, null, null, null), + "ncaId is invalid"); + } + + @Test + void platformAdminListsEveryKeyWithStrictCapWhenUnfiltered() { + create(ACCOUNT_ADMIN, NCA_ID_1, USER_KEY_OWNER_ID_1); + create(ACCOUNT_ADMIN, NCA_ID_2, USER_ID_2); + + assertThat(facade.listKeys(PLATFORM_ADMIN, null, null, null, null, null, null).getKeys()) + .hasSize(2); + assertThrows(BadRequestException.class, () -> facade.listKeys( + PLATFORM_ADMIN, null, null, null, null, 101, null)); + assertThat(facade.listKeys(PLATFORM_ADMIN, NCA_ID_1, null, null, null, 100, null) + .getKeys()).hasSize(1); + } + + @Test + void listRejectsInvalidCursor() { + assertThrowsExceptionWithDetails( + BadRequestException.class, + () -> facade.listKeys( + PLATFORM_ADMIN, null, null, null, null, 10, "not-a-cursor"), + "Invalid cursor: 'not-a-cursor'"); + } + + @Test + void legacyKeyIsManagedThroughCanonicalServiceId() { + keysDao.save(KEY_VO_1, KEY_OWNER_VO_1); + + var fetched = facade.getKey( + KEY_OWNER, LEGACY_NCA_ID, LEGACY_CANONICAL_SERVICE_ID, USER_KEY_OWNER_ID_1, + KEY_ID_1); + assertThat(fetched.getAccountId()).isEqualTo(LEGACY_NCA_ID); + assertThat(fetched.getIssuerServiceId()).isEqualTo(LEGACY_CANONICAL_SERVICE_ID); + assertThat(fetched.getExpiresAt()).isEqualTo(KEY_EXPIRES_AT_1); + + assertThat(facade.listKeys( + ACCOUNT_ADMIN, LEGACY_NCA_ID, LEGACY_CANONICAL_SERVICE_ID, USER_KEY_OWNER_ID_1) + .getKeys()) + .extracting(KeyResponse::getId).containsExactly(KEY_ID_1); + + assertThat(facade.updateKeyStatus( + PLATFORM_ADMIN, LEGACY_NCA_ID, LEGACY_CANONICAL_SERVICE_ID, USER_KEY_OWNER_ID_1, + KEY_ID_1, SettableKeyStatus.REVOKED).getStatus()).isEqualTo(KeyStatusV2.REVOKED); + + facade.deleteKey(KEY_OWNER, LEGACY_NCA_ID, LEGACY_CANONICAL_SERVICE_ID, + USER_KEY_OWNER_ID_1, KEY_ID_1); + assertThrowsNotFound(() -> facade.getKey( + KEY_OWNER, LEGACY_NCA_ID, LEGACY_CANONICAL_SERVICE_ID, USER_KEY_OWNER_ID_1, + KEY_ID_1)); + } + + @Test + void statusesListEveryKnownAccountInPagesAndDefaultToActive() { + create(ACCOUNT_ADMIN, NCA_ID_1, USER_KEY_OWNER_ID_1); + suspendAccount(NCA_ID_2, USER_KEY_OWNER_ID_1); + facade.updateIssuerServiceStatus( + PLATFORM_ADMIN, NCA_ID_3, SERVICE_ID_1, USER_KEY_OWNER_ID_1, + issuerStatusRequest(KeyOwnerStatus.SUSPENDED)); + create(ACCOUNT_ADMIN, NCA_ID_1, USER_ID_2); + + var first = facade.getOwnerStatuses(KEY_OWNER, null, USER_KEY_OWNER_ID_1, 2, null); + assertThat(first.getOwnerId()).isEqualTo(USER_KEY_OWNER_ID_1); + assertThat(first.getLimit()).isEqualTo(2); + assertThat(first.getCursor()).isNotBlank(); + assertThat(first.getAccounts()).extracting(OwnerStatusByAccount::getAccountId) + .containsExactly(NCA_ID_1, NCA_ID_2); + assertThat(first.getAccounts()).extracting(OwnerStatusByAccount::getStatus) + .containsExactly(KeyOwnerStatus.ACTIVE, KeyOwnerStatus.SUSPENDED); + + var second = facade.getOwnerStatuses( + PLATFORM_ADMIN, null, USER_KEY_OWNER_ID_1, 2, first.getCursor()); + assertThat(second.getCursor()).isNull(); + assertThat(second.getAccounts()).singleElement().satisfies(account -> { + assertThat(account.getAccountId()).isEqualTo(NCA_ID_3); + assertThat(account.getStatus()).isEqualTo(KeyOwnerStatus.ACTIVE); + assertThat(account.getIssuerServices()).singleElement().satisfies(issuer -> { + assertThat(issuer.getIssuerServiceId()).isEqualTo(SERVICE_ID_1); + assertThat(issuer.getStatus()).isEqualTo(KeyOwnerStatus.SUSPENDED); + }); + }); + } + + @Test + void statusesForOneAccountReturnExactlyOneEntryWithoutCursor() { + suspendAccount(NCA_ID_2, USER_KEY_OWNER_ID_1); + create(ACCOUNT_ADMIN, NCA_ID_1, USER_KEY_OWNER_ID_1); + + KeyOwnerStatusesResponse known = facade.getOwnerStatuses( + ACCOUNT_ADMIN, NCA_ID_2, USER_KEY_OWNER_ID_1, null, null); + assertThat(known.getAccounts()).singleElement() + .satisfies(account -> assertThat(account.getStatus()) + .isEqualTo(KeyOwnerStatus.SUSPENDED)); + assertThat(known.getCursor()).isNull(); + assertThat(known.getLimit()).isNull(); + + var unknown = facade.getOwnerStatuses( + PLATFORM_ADMIN, "unknown-nca", USER_KEY_OWNER_ID_1, null, null); + assertThat(unknown.getAccounts()).singleElement().satisfies(account -> { + assertThat(account.getAccountId()).isEqualTo("unknown-nca"); + assertThat(account.getStatus()).isEqualTo(KeyOwnerStatus.ACTIVE); + }); + } + + @Test + void statusesRejectInvalidCursorAndAccountAdminCrossAccountRead() { + assertThrows(BadRequestException.class, () -> facade.getOwnerStatuses( + PLATFORM_ADMIN, null, USER_KEY_OWNER_ID_1, 10, "***")); + assertForbidden(() -> facade.getOwnerStatuses( + ACCOUNT_ADMIN, null, USER_KEY_OWNER_ID_1, 10, null)); + } + + @Test + void legacyAccountStatusIsReportedForOwnerWithLegacyKeys() { + keysDao.save(KEY_VO_1, KEY_OWNER_VO_1); + facade.updateAccountStatus( + ACCOUNT_ADMIN, LEGACY_NCA_ID, USER_KEY_OWNER_ID_1, + accountStatusRequest(KeyOwnerStatus.SUSPENDED)); + + assertThat(facade.getOwnerStatuses( + PLATFORM_ADMIN, null, USER_KEY_OWNER_ID_1, null, null).getAccounts()) + .singleElement().satisfies(account -> { + assertThat(account.getAccountId()).isEqualTo(LEGACY_NCA_ID); + assertThat(account.getStatus()).isEqualTo(KeyOwnerStatus.SUSPENDED); + }); + } + + @Test + void administratorsUpdateAccountStatusAndKeyOwnerCannot() { + var response = facade.updateAccountStatus( + ACCOUNT_ADMIN, NCA_ID_1, USER_KEY_OWNER_ID_1, + accountStatusRequest(KeyOwnerStatus.SUSPENDED)); + + assertThat(response.getOwnerId()).isEqualTo(USER_KEY_OWNER_ID_1); + assertThat(response.getAccountId()).isEqualTo(NCA_ID_1); + assertThat(response.getIssuerServiceId()).isNull(); + assertThat(response.getStatus()).isEqualTo(KeyOwnerStatus.SUSPENDED); + assertThat(response.getUpdatedAt()).isNotNull(); + assertForbidden(() -> facade.updateAccountStatus( + KEY_OWNER, NCA_ID_1, USER_KEY_OWNER_ID_1, + accountStatusRequest(KeyOwnerStatus.ACTIVE))); + } + + @Test + void issuerServiceStatusIsPlatformAdminOnlyAndUnsupportedForLegacyAccount() { + var response = facade.updateIssuerServiceStatus( + PLATFORM_ADMIN, NCA_ID_1, SERVICE_ID_1, USER_KEY_OWNER_ID_1, + issuerStatusRequest(KeyOwnerStatus.SUSPENDED)); + + assertThat(response.getIssuerServiceId()).isEqualTo(SERVICE_ID_1); + assertThat(response.getStatus()).isEqualTo(KeyOwnerStatus.SUSPENDED); + assertForbidden(() -> facade.updateIssuerServiceStatus( + ACCOUNT_ADMIN, NCA_ID_1, SERVICE_ID_1, USER_KEY_OWNER_ID_1, + issuerStatusRequest(KeyOwnerStatus.SUSPENDED))); + assertThrows(BadRequestException.class, () -> facade.updateIssuerServiceStatus( + PLATFORM_ADMIN, LEGACY_NCA_ID, LEGACY_CANONICAL_SERVICE_ID, USER_KEY_OWNER_ID_1, + issuerStatusRequest(KeyOwnerStatus.SUSPENDED))); + } + + private KeyResponse create(KeyManagementActor actor, String ncaId, String userId) { + return facade.createKey(actor, ncaId, SERVICE_ID_1, userId, createRequest()); + } + + private KeyResponse get(KeyManagementActor actor, String ncaId, String userId, String keyId) { + return facade.getKey(actor, ncaId, SERVICE_ID_1, userId, keyId); + } + + private KeyResponse setStatus(KeyResponse key, SettableKeyStatus status) { + return facade.updateKeyStatus( + PLATFORM_ADMIN, key.getAccountId(), key.getIssuerServiceId(), key.getOwnerId(), + key.getId(), status); + } + + // the lock expires by Cassandra TTL, which the test clock does not move + private static void releaseAuthorizationLocks() { + IntegrationTestConfiguration.CQL_SESSION.execute( + "TRUNCATE " + KEY_SPACE + ".row_update_lock"); + } + + private void suspendAccount(String ncaId, String userId) { + facade.updateAccountStatus( + PLATFORM_ADMIN, ncaId, userId, accountStatusRequest(KeyOwnerStatus.SUSPENDED)); + } + + private static CreateKeyRequest createRequest() { + return CreateKeyRequest.builder() + .description(KEY_DESCRIPTION_1) + .expiresAt(KEY_EXPIRES_AT_1) + .authorizations(JSON_MAPPER.readTree(KEY_AUTHZ_1)) + .build(); + } + + private static UpdateOwnerStatusForAccountRequest accountStatusRequest(KeyOwnerStatus status) { + return UpdateOwnerStatusForAccountRequest.builder().status(status).build(); + } + + private static UpdateOwnerStatusForAccountAndIssuerServiceRequest issuerStatusRequest( + KeyOwnerStatus status) { + return UpdateOwnerStatusForAccountAndIssuerServiceRequest.builder().status(status).build(); + } + + private static void assertForbidden(org.junit.jupiter.api.function.Executable call) { + assertThrows(ForbiddenException.class, call); + } + + private static void assertThrowsNotFound(org.junit.jupiter.api.function.Executable call) { + assertThrows(NotFoundException.class, call); + } +} diff --git a/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/validators/KeyManagementRequestValidatorTest.java b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/validators/KeyManagementRequestValidatorTest.java new file mode 100644 index 0000000000..8f9df83b41 --- /dev/null +++ b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/validators/KeyManagementRequestValidatorTest.java @@ -0,0 +1,244 @@ +/* + * 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.validators; + +import static com.nvidia.apikeys.TestData.API_KEY_SUFFIX_1; +import static com.nvidia.apikeys.TestData.GENERATED_KEY_VO_1; +import static com.nvidia.apikeys.TestData.KEY_AUTHZ_1; +import static com.nvidia.apikeys.TestData.KEY_DESCRIPTION_1; +import static com.nvidia.apikeys.TestData.KEY_EXPIRES_AT_1; +import static com.nvidia.apikeys.TestData.NCA_ID_1; +import static com.nvidia.apikeys.TestData.SERVICE_ID_1; +import static com.nvidia.apikeys.TestData.SERVICE_VO_1; +import static com.nvidia.apikeys.TestData.TEST_TIME; +import static com.nvidia.apikeys.TestData.USER_KEY_OWNER_ID_1; +import static com.nvidia.apikeys.utils.TestUtils.assertThrowsExceptionWithDetails; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.nvidia.apikeys.config.NakProperties; +import com.nvidia.apikeys.converters.ValidatingAuthorizationsConverter; +import com.nvidia.apikeys.dto.keysV2.CreateKeyRequest; +import com.nvidia.apikeys.services.CredentialService; +import com.nvidia.apikeys.services.MillisecondPrecisionClock; +import com.nvidia.apikeys.services.ServicesService; +import com.nvidia.apikeys.utils.TestClock; +import com.nvidia.apikeys.vo.KeyOwnerType; +import com.nvidia.apikeys.vo.KeyStatus; +import com.nvidia.boot.exceptions.BadRequestException; +import java.time.Duration; +import java.time.ZoneId; +import java.util.Optional; +import java.util.Set; +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.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import tools.jackson.databind.json.JsonMapper; + +@ExtendWith(MockitoExtension.class) +class KeyManagementRequestValidatorTest { + + private static final Duration KEEP_AFTER_EXPIRED = Duration.ofDays(5); + + @Mock + private ServicesService servicesService; + @Mock + private CredentialService credentialService; + + private KeyManagementRequestValidator validator; + + @BeforeEach + void setUp() { + var clock = TestClock.fixed(TEST_TIME, ZoneId.systemDefault()); + validator = new KeyManagementRequestValidator( + NakProperties.builder().keepAfterExpiredDuration(KEEP_AFTER_EXPIRED).build(), + servicesService, + credentialService, + new KeyRequestValidator(clock), + new ValidatingAuthorizationsConverter(), + new MillisecondPrecisionClock(clock)); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "*"}) + void assertRouteIdRejectsMissingAndReservedValues(String value) { + assertThrowsExceptionWithDetails( + BadRequestException.class, () -> validator.assertRouteId("userId", value), + "userId is invalid"); + } + + @Test + void assertRouteIdAcceptsOrdinaryValue() { + validator.assertRouteId("userId", USER_KEY_OWNER_ID_1); + } + + @Test + void requireServiceReturnsRegisteredService() { + when(servicesService.getServiceById(SERVICE_ID_1)).thenReturn(Optional.of(SERVICE_VO_1)); + + assertThat(validator.requireService(SERVICE_ID_1)).isEqualTo(SERVICE_VO_1); + } + + @Test + void requireServiceRejectsUnregisteredService() { + when(servicesService.getServiceById("unknown")).thenReturn(Optional.empty()); + + assertThrowsExceptionWithDetails( + BadRequestException.class, () -> validator.requireService("unknown"), + "Unknown issuer service: 'unknown'"); + } + + @Test + void requireServiceRejectsReservedServiceWithoutLookup() { + assertThrowsExceptionWithDetails( + BadRequestException.class, () -> validator.requireService("*"), + "serviceId is invalid"); + verifyNoInteractions(servicesService); + } + + @Test + void validateCreateKeyRequestBuildsActiveUserKeyFromRoute() throws Exception { + when(credentialService.generateApiKey()).thenReturn(GENERATED_KEY_VO_1); + + var create = validator.validateCreateKeyRequest( + SERVICE_VO_1, NCA_ID_1, USER_KEY_OWNER_ID_1, createRequest()); + + assertThat(create.generatedKey()).isEqualTo(GENERATED_KEY_VO_1); + assertThat(create.service()).isEqualTo(SERVICE_VO_1); + assertThat(create.key()).satisfies(key -> { + assertThat(key.getKeyStatus()).isEqualTo(KeyStatus.ACTIVE); + assertThat(key.getNcaId()).isEqualTo(NCA_ID_1); + assertThat(key.getOwnerType()).isEqualTo(KeyOwnerType.USER); + assertThat(key.getOwnerId()).isEqualTo(USER_KEY_OWNER_ID_1); + assertThat(key.getIssuerServiceId()).isEqualTo(SERVICE_ID_1); + assertThat(key.getAudienceServiceIds()).containsExactly(SERVICE_ID_1); + assertThat(key.getKeyId()).isEqualTo(GENERATED_KEY_VO_1.getKeyId()); + assertThat(key.getKeyHash()).isEqualTo(GENERATED_KEY_VO_1.getKeyHash()); + assertThat(key.getApiKeySuffix()).isEqualTo(API_KEY_SUFFIX_1); + assertThat(key.getCreatedAt()).isEqualTo(TEST_TIME); + assertThat(key.getExpiresAt()).isEqualTo(KEY_EXPIRES_AT_1); + assertThat(key.getDeletesAt()).isEqualTo(KEY_EXPIRES_AT_1.plus(KEEP_AFTER_EXPIRED)); + assertThat(key.getAuthorizations()).isEqualTo(KEY_AUTHZ_1); + assertThat(key.getDescription()).isEqualTo(KEY_DESCRIPTION_1); + }); + } + + @Test + void validateCreateKeyRequestRejectsInvalidDescriptionBeforeGeneratingKey() throws Exception { + var request = createRequest().toBuilder().description("!invalid").build(); + + assertThrowsExceptionWithDetails( + BadRequestException.class, + () -> validator.validateCreateKeyRequest( + SERVICE_VO_1, NCA_ID_1, USER_KEY_OWNER_ID_1, request), + "description is invalid"); + verifyNoInteractions(credentialService); + } + + @Test + void validateCreateKeyRequestRejectsExpirationBeyondServiceLimit() throws Exception { + var request = createRequest().toBuilder() + .expiresAt(TEST_TIME.plus(Duration.ofDays(2))) + .build(); + + assertThrowsExceptionWithDetails( + BadRequestException.class, + () -> validator.validateCreateKeyRequest( + SERVICE_VO_1, NCA_ID_1, USER_KEY_OWNER_ID_1, request), + "expires_at must be in the future and not more than 1 days from now"); + } + + @Test + void validateCreateKeyRequestRejectsUnregisteredAudience() throws Exception { + var request = createRequest().toBuilder() + .audienceServiceIds(Set.of("other-service")) + .build(); + + assertThrowsExceptionWithDetails( + BadRequestException.class, + () -> validator.validateCreateKeyRequest( + SERVICE_VO_1, NCA_ID_1, USER_KEY_OWNER_ID_1, request), + "service '%s' cannot use audiences: other-service".formatted(SERVICE_ID_1)); + } + + @Test + void validateCreateKeyRequestRejectsMissingAuthorizations() throws Exception { + var request = createRequest().toBuilder().authorizations(null).build(); + + assertThrowsExceptionWithDetails( + BadRequestException.class, + () -> validator.validateCreateKeyRequest( + SERVICE_VO_1, NCA_ID_1, USER_KEY_OWNER_ID_1, request), + "authorizations can not be null"); + } + + @Test + void validateAuthorizationsDelegatesToConverter() throws Exception { + var node = new JsonMapper().readTree(KEY_AUTHZ_1); + + assertThat(validator.validateAuthorizations(SERVICE_VO_1, node)).isEqualTo(KEY_AUTHZ_1); + } + + @Test + void resolveLimitUsesDefaultWhenAbsent() { + assertThat(validator.resolveLimit(null, false)) + .isEqualTo(KeyManagementRequestValidator.DEFAULT_LIMIT); + } + + @Test + void resolveLimitAcceptsMaximum() { + assertThat(validator.resolveLimit(KeyManagementRequestValidator.MAX_LIMIT, false)) + .isEqualTo(KeyManagementRequestValidator.MAX_LIMIT); + } + + @ParameterizedTest + @ValueSource(ints = {Integer.MIN_VALUE, -1, 0, KeyManagementRequestValidator.MAX_LIMIT + 1}) + void resolveLimitRejectsOutOfRange(int limit) { + assertThrowsExceptionWithDetails( + BadRequestException.class, () -> validator.resolveLimit(limit, false), + "limit must be between 1 and %d".formatted(KeyManagementRequestValidator.MAX_LIMIT)); + } + + @Test + void resolveLimitAppliesLowerCapToUnfilteredList() { + var tooLarge = KeyManagementRequestValidator.MAX_UNFILTERED_LIMIT + 1; + + assertThat(validator.resolveLimit(KeyManagementRequestValidator.MAX_UNFILTERED_LIMIT, true)) + .isEqualTo(KeyManagementRequestValidator.MAX_UNFILTERED_LIMIT); + assertThrowsExceptionWithDetails( + BadRequestException.class, () -> validator.resolveLimit(tooLarge, true), + "limit must be between 1 and %d" + .formatted(KeyManagementRequestValidator.MAX_UNFILTERED_LIMIT)); + } + + private static CreateKeyRequest createRequest() throws Exception { + return CreateKeyRequest.builder() + .description(KEY_DESCRIPTION_1) + .expiresAt(KEY_EXPIRES_AT_1) + .authorizations(new JsonMapper().readTree(KEY_AUTHZ_1)) + .build(); + } +} From 0bcd6c8d2570cda6ba4ff34ab7951ccc2311a4da Mon Sep 17 00:00:00 2001 From: Shelley Shen Date: Fri, 23 Oct 2026 18:40:34 -0700 Subject: [PATCH 3/6] fix(api-keys): read single-account owner statuses without scanning keys The single-account statuses request loaded the owner's statuses and keys across every account and filtered one account afterward. It now reads the account status by key and the owner's service-scoped status records, and never scans keys. The facade splits the single-account and cross-account status operations into getAccountOwnerStatuses and listOwnerStatuses. Signed-off-by: Shelley Shen --- .../apikeys/facade/KeyManagementFacade.java | 31 +++++----- .../apikeys/services/KeysV2Service.java | 23 ++++++++ .../KeyManagementFacadeIntegrationTest.java | 58 ++++++++++++++----- 3 files changed, 82 insertions(+), 30 deletions(-) diff --git a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/facade/KeyManagementFacade.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/facade/KeyManagementFacade.java index 90d04304e3..a55acde44a 100644 --- a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/facade/KeyManagementFacade.java +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/facade/KeyManagementFacade.java @@ -32,10 +32,8 @@ import com.nvidia.apikeys.dto.keysV2.UpdateOwnerStatusForAccountRequest; import com.nvidia.apikeys.services.KeysV2Service; import com.nvidia.apikeys.validators.KeyManagementRequestValidator; -import com.nvidia.apikeys.vo.KeyOwnerStatus; import com.nvidia.apikeys.vo.KeyStatus; import com.nvidia.apikeys.vo.KeysByScopeFilterVo; -import com.nvidia.apikeys.vo.OwnerAccountStatusVo; import com.nvidia.boot.exceptions.BadRequestException; import com.nvidia.boot.exceptions.ForbiddenException; import com.nvidia.boot.exceptions.NotFoundException; @@ -170,30 +168,29 @@ public void deleteKey( } /** - * The user's status in one account, or in every account known to API Keys when ncaId is - * null. A single account is always returned as one entry, ACTIVE when nothing is stored. + * The user's status in one account, as exactly one entry that is ACTIVE when nothing is + * stored. */ - public KeyOwnerStatusesResponse getOwnerStatuses( - KeyManagementActor actor, String ncaId, String userId, Integer limit, String cursor) { + public KeyOwnerStatusesResponse getAccountOwnerStatuses(String ncaId, String userId) { + validator.assertRouteId("ncaId", ncaId); validator.assertRouteId("userId", userId); - var accounts = keysService.listOwnerAccountStatuses(USER, userId); - if (ncaId != null) { - validator.assertRouteId("ncaId", ncaId); - var account = accounts.stream() - .filter(status -> status.ncaId().equals(ncaId)) - .findFirst() - .orElseGet(() -> OwnerAccountStatusVo.builder() - .ncaId(ncaId).status(KeyOwnerStatus.ACTIVE).issuerServices(List.of()) - .build()); - return responseBuilder.toStatusesResponse(userId, List.of(account), null, null); - } + var account = keysService.getOwnerAccountStatus(ncaId, USER, userId); + return responseBuilder.toStatusesResponse(userId, List.of(account), null, null); + } + /** + * The user's status in every account known to API Keys, in pages ordered by account ID. + */ + public KeyOwnerStatusesResponse listOwnerStatuses( + KeyManagementActor actor, String userId, Integer limit, String cursor) { + validator.assertRouteId("userId", userId); if (actor == KeyManagementActor.ACCOUNT_ADMIN) { throw new ForbiddenException(MESG_ACTOR_NOT_ALLOWED); } var resolvedLimit = validator.resolveLimit(limit, false); var after = decodeCursor(cursor); + var accounts = keysService.listOwnerAccountStatuses(USER, userId); var remaining = accounts.stream() .filter(status -> after == null || status.ncaId().compareTo(after) > 0) .toList(); diff --git a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/services/KeysV2Service.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/services/KeysV2Service.java index f3985f0268..a97ada1cd1 100644 --- a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/services/KeysV2Service.java +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/services/KeysV2Service.java @@ -290,6 +290,29 @@ public List listOwnerAccountStatuses( .toList(); } + /** + * The owner's status in one account, ACTIVE when nothing is stored. Reads only the owner's + * status records, never keys or other accounts' keys. The legacy account has no + * account-and-service records. + */ + public OwnerAccountStatusVo getOwnerAccountStatus( + String ncaId, KeyOwnerType ownerType, String ownerId) { + var issuerStatuses = legacyAccount.isLegacy(ncaId) + ? List.of() + : ownerStatusDao.listOwnerStatusByAccountAndService(ncaId, ownerType, ownerId); + return OwnerAccountStatusVo.builder() + .ncaId(ncaId) + .status(getOwnerStatusByAccount(ncaId, ownerType, ownerId) + .map(OwnerStatusByAccountModel::getOwnerStatus) + .orElse(KeyOwnerStatus.ACTIVE)) + .issuerServices(issuerStatuses.stream() + .map(KeysV2Service::toIssuerStatus) + .sorted(Comparator.comparing( + OwnerAccountStatusVo.IssuerStatus::issuerServiceId)) + .toList()) + .build(); + } + /** * Owner status for the account and issuer service. The legacy account has no * account-and-service records. diff --git a/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/facade/KeyManagementFacadeIntegrationTest.java b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/facade/KeyManagementFacadeIntegrationTest.java index ac5099dc04..d6de024289 100644 --- a/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/facade/KeyManagementFacadeIntegrationTest.java +++ b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/facade/KeyManagementFacadeIntegrationTest.java @@ -368,7 +368,7 @@ void statusesListEveryKnownAccountInPagesAndDefaultToActive() { issuerStatusRequest(KeyOwnerStatus.SUSPENDED)); create(ACCOUNT_ADMIN, NCA_ID_1, USER_ID_2); - var first = facade.getOwnerStatuses(KEY_OWNER, null, USER_KEY_OWNER_ID_1, 2, null); + var first = facade.listOwnerStatuses(KEY_OWNER, USER_KEY_OWNER_ID_1, 2, null); assertThat(first.getOwnerId()).isEqualTo(USER_KEY_OWNER_ID_1); assertThat(first.getLimit()).isEqualTo(2); assertThat(first.getCursor()).isNotBlank(); @@ -377,8 +377,8 @@ void statusesListEveryKnownAccountInPagesAndDefaultToActive() { assertThat(first.getAccounts()).extracting(OwnerStatusByAccount::getStatus) .containsExactly(KeyOwnerStatus.ACTIVE, KeyOwnerStatus.SUSPENDED); - var second = facade.getOwnerStatuses( - PLATFORM_ADMIN, null, USER_KEY_OWNER_ID_1, 2, first.getCursor()); + var second = facade.listOwnerStatuses( + PLATFORM_ADMIN, USER_KEY_OWNER_ID_1, 2, first.getCursor()); assertThat(second.getCursor()).isNull(); assertThat(second.getAccounts()).singleElement().satisfies(account -> { assertThat(account.getAccountId()).isEqualTo(NCA_ID_3); @@ -395,16 +395,16 @@ void statusesForOneAccountReturnExactlyOneEntryWithoutCursor() { suspendAccount(NCA_ID_2, USER_KEY_OWNER_ID_1); create(ACCOUNT_ADMIN, NCA_ID_1, USER_KEY_OWNER_ID_1); - KeyOwnerStatusesResponse known = facade.getOwnerStatuses( - ACCOUNT_ADMIN, NCA_ID_2, USER_KEY_OWNER_ID_1, null, null); + KeyOwnerStatusesResponse known = facade.getAccountOwnerStatuses( + NCA_ID_2, USER_KEY_OWNER_ID_1); assertThat(known.getAccounts()).singleElement() .satisfies(account -> assertThat(account.getStatus()) .isEqualTo(KeyOwnerStatus.SUSPENDED)); assertThat(known.getCursor()).isNull(); assertThat(known.getLimit()).isNull(); - var unknown = facade.getOwnerStatuses( - PLATFORM_ADMIN, "unknown-nca", USER_KEY_OWNER_ID_1, null, null); + var unknown = facade.getAccountOwnerStatuses( + "unknown-nca", USER_KEY_OWNER_ID_1); assertThat(unknown.getAccounts()).singleElement().satisfies(account -> { assertThat(account.getAccountId()).isEqualTo("unknown-nca"); assertThat(account.getStatus()).isEqualTo(KeyOwnerStatus.ACTIVE); @@ -413,10 +413,42 @@ void statusesForOneAccountReturnExactlyOneEntryWithoutCursor() { @Test void statusesRejectInvalidCursorAndAccountAdminCrossAccountRead() { - assertThrows(BadRequestException.class, () -> facade.getOwnerStatuses( - PLATFORM_ADMIN, null, USER_KEY_OWNER_ID_1, 10, "***")); - assertForbidden(() -> facade.getOwnerStatuses( - ACCOUNT_ADMIN, null, USER_KEY_OWNER_ID_1, 10, null)); + assertThrows(BadRequestException.class, () -> facade.listOwnerStatuses( + PLATFORM_ADMIN, USER_KEY_OWNER_ID_1, 10, "***")); + assertForbidden(() -> facade.listOwnerStatuses( + ACCOUNT_ADMIN, USER_KEY_OWNER_ID_1, 10, null)); + } + + @Test + void accountStatusesIncludeOnlyThatAccountsIssuerStatuses() { + suspendAccount(NCA_ID_1, USER_KEY_OWNER_ID_1); + facade.updateIssuerServiceStatus( + PLATFORM_ADMIN, NCA_ID_1, SERVICE_ID_1, USER_KEY_OWNER_ID_1, + issuerStatusRequest(KeyOwnerStatus.SUSPENDED)); + facade.updateIssuerServiceStatus( + PLATFORM_ADMIN, NCA_ID_2, SERVICE_ID_1, USER_KEY_OWNER_ID_1, + issuerStatusRequest(KeyOwnerStatus.SUSPENDED)); + + var response = facade.getAccountOwnerStatuses(NCA_ID_1, USER_KEY_OWNER_ID_1); + + assertThat(response.getAccounts()).singleElement().satisfies(account -> { + assertThat(account.getAccountId()).isEqualTo(NCA_ID_1); + assertThat(account.getStatus()).isEqualTo(KeyOwnerStatus.SUSPENDED); + assertThat(account.getIssuerServices()).singleElement().satisfies(issuer -> { + assertThat(issuer.getIssuerServiceId()).isEqualTo(SERVICE_ID_1); + assertThat(issuer.getStatus()).isEqualTo(KeyOwnerStatus.SUSPENDED); + }); + }); + } + + @Test + void accountStatusesRejectInvalidRouteIds() { + assertThrows(BadRequestException.class, + () -> facade.getAccountOwnerStatuses("*", USER_KEY_OWNER_ID_1)); + assertThrows(BadRequestException.class, + () -> facade.getAccountOwnerStatuses(NCA_ID_1, "*")); + assertThrows(BadRequestException.class, + () -> facade.listOwnerStatuses(PLATFORM_ADMIN, "*", null, null)); } @Test @@ -426,8 +458,8 @@ void legacyAccountStatusIsReportedForOwnerWithLegacyKeys() { ACCOUNT_ADMIN, LEGACY_NCA_ID, USER_KEY_OWNER_ID_1, accountStatusRequest(KeyOwnerStatus.SUSPENDED)); - assertThat(facade.getOwnerStatuses( - PLATFORM_ADMIN, null, USER_KEY_OWNER_ID_1, null, null).getAccounts()) + assertThat(facade.listOwnerStatuses( + PLATFORM_ADMIN, USER_KEY_OWNER_ID_1, null, null).getAccounts()) .singleElement().satisfies(account -> { assertThat(account.getAccountId()).isEqualTo(LEGACY_NCA_ID); assertThat(account.getStatus()).isEqualTo(KeyOwnerStatus.SUSPENDED); From 410c7ce2eb7ff74076f77f0db9e059e8b890ca5b Mon Sep 17 00:00:00 2001 From: Shelley Shen Date: Wed, 28 Oct 2026 14:51:19 -0700 Subject: [PATCH 4/6] refactor(api-keys): remove key management route constants The route and parameter name constants had no users yet. The actor controllers will write their paths in their mappings, as the existing controllers do. Signed-off-by: Shelley Shen --- src/control-plane-services/api-keys/AGENTS.md | 6 +- .../apikeys/web/KeyManagementRoutes.java | 59 ------------------- 2 files changed, 3 insertions(+), 62 deletions(-) delete mode 100644 src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/web/KeyManagementRoutes.java diff --git a/src/control-plane-services/api-keys/AGENTS.md b/src/control-plane-services/api-keys/AGENTS.md index 8bc1752dc7..02123f9d82 100644 --- a/src/control-plane-services/api-keys/AGENTS.md +++ b/src/control-plane-services/api-keys/AGENTS.md @@ -66,9 +66,9 @@ an encrypted model also needs that field on its `@ValueObject`. `KeyManagementFacade` holds the operations behind the key-owner, account-admin and platform-admin endpoints under `/v2/api-keys/`. Controllers only map -`KeyManagementRoutes` paths to facade calls with a `KeyManagementActor`. The -actor selects the route dimensions it must supply and whether a suspended owner -is denied. The NCA, issuer service and user always come from the route. +their paths to facade calls with a `KeyManagementActor`. The actor selects the +route dimensions it must supply and whether a suspended owner is denied. The +NCA, issuer service and user always come from the route. Integration tests bind each `.cql` file in `local_env/docker-compose.test.yml` because Bazel runfiles are symlinks. Local Compose mounts the whole schema diff --git a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/web/KeyManagementRoutes.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/web/KeyManagementRoutes.java deleted file mode 100644 index 05eb31d184..0000000000 --- a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/web/KeyManagementRoutes.java +++ /dev/null @@ -1,59 +0,0 @@ -/* - * 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.web; - -/** - * Paths and parameter names shared by the key-owner, account-admin and platform-admin - * controllers. The actor prefix selects the actor, the remaining paths are the same. - */ -public final class KeyManagementRoutes { - - public static final String KEY_OWNER_PREFIX = "/v2/api-keys/key-owner"; - public static final String ACCOUNT_ADMIN_PREFIX = "/v2/api-keys/account-admin"; - public static final String PLATFORM_ADMIN_PREFIX = "/v2/api-keys/platform-admin"; - - public static final String NCA_ID = "ncaId"; - public static final String SERVICE_ID = "serviceId"; - public static final String USER_ID = "userId"; - public static final String KEY_ID = "keyId"; - - public static final String QUERY_NCA_ID = "nca-id"; - public static final String QUERY_SERVICE_ID = "service-id"; - public static final String QUERY_USER_ID = "user-id"; - public static final String QUERY_STATUS = "status"; - public static final String QUERY_LIMIT = "limit"; - public static final String QUERY_CURSOR = "cursor"; - - public static final String SCOPED_KEYS = - "/accounts/{ncaId}/services/{serviceId}/users/{userId}/keys"; - public static final String SCOPED_KEY = SCOPED_KEYS + "/{keyId}"; - public static final String SCOPED_KEY_STATUS = SCOPED_KEY + "/status"; - public static final String SCOPED_KEY_AUTHORIZATIONS = SCOPED_KEY + "/authorizations"; - public static final String ACCOUNT_KEYS = "/accounts/{ncaId}/keys"; - public static final String USER_KEYS = "/users/{userId}/keys"; - public static final String ALL_KEYS = "/keys"; - public static final String USER_STATUSES = "/users/{userId}/statuses"; - public static final String ACCOUNT_USER_STATUSES = "/accounts/{ncaId}/users/{userId}/statuses"; - public static final String ACCOUNT_USER_STATUS = "/accounts/{ncaId}/users/{userId}/status"; - public static final String SCOPED_USER_STATUS = - "/accounts/{ncaId}/services/{serviceId}/users/{userId}/status"; - - private KeyManagementRoutes() { - } -} From 4e52f8fd19f95c1c822704369096f9b7073bdb60 Mon Sep 17 00:00:00 2001 From: Shelley Shen Date: Thu, 29 Oct 2026 22:37:47 -0700 Subject: [PATCH 5/6] fix(api-keys): reject unregistered issuer service in status update Updating the owner status for an account and issuer service accepted any service ID and stored a record for it. It now returns 400 for a service that is not registered, as key creation already does. The legacy account assertion in the existing test uses a registered service so it still covers the legacy rule. Signed-off-by: Shelley Shen --- .../nvidia/apikeys/facade/KeyManagementFacade.java | 1 + .../facade/KeyManagementFacadeIntegrationTest.java | 13 ++++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/facade/KeyManagementFacade.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/facade/KeyManagementFacade.java index a55acde44a..dd577d16e5 100644 --- a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/facade/KeyManagementFacade.java +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/facade/KeyManagementFacade.java @@ -220,6 +220,7 @@ public KeyOwnerStatusResponse updateIssuerServiceStatus( throw new ForbiddenException(MESG_ACTOR_NOT_ALLOWED); } assertScope(ncaId, serviceId, userId); + validator.requireService(serviceId); var saved = keysService.updateOwnerStatusByAccountAndService( ncaId, USER, userId, serviceId, request.getStatus()); diff --git a/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/facade/KeyManagementFacadeIntegrationTest.java b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/facade/KeyManagementFacadeIntegrationTest.java index d6de024289..8a593750d1 100644 --- a/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/facade/KeyManagementFacadeIntegrationTest.java +++ b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/facade/KeyManagementFacadeIntegrationTest.java @@ -494,10 +494,21 @@ void issuerServiceStatusIsPlatformAdminOnlyAndUnsupportedForLegacyAccount() { ACCOUNT_ADMIN, NCA_ID_1, SERVICE_ID_1, USER_KEY_OWNER_ID_1, issuerStatusRequest(KeyOwnerStatus.SUSPENDED))); assertThrows(BadRequestException.class, () -> facade.updateIssuerServiceStatus( - PLATFORM_ADMIN, LEGACY_NCA_ID, LEGACY_CANONICAL_SERVICE_ID, USER_KEY_OWNER_ID_1, + PLATFORM_ADMIN, LEGACY_NCA_ID, SERVICE_ID_1, USER_KEY_OWNER_ID_1, issuerStatusRequest(KeyOwnerStatus.SUSPENDED))); } + @Test + void issuerServiceStatusRejectsUnregisteredService() { + assertThrows(BadRequestException.class, () -> facade.updateIssuerServiceStatus( + PLATFORM_ADMIN, NCA_ID_1, "unregistered-service", USER_KEY_OWNER_ID_1, + issuerStatusRequest(KeyOwnerStatus.SUSPENDED))); + + assertThat(facade.getAccountOwnerStatuses(NCA_ID_1, USER_KEY_OWNER_ID_1).getAccounts()) + .singleElement() + .satisfies(account -> assertThat(account.getIssuerServices()).isNullOrEmpty()); + } + private KeyResponse create(KeyManagementActor actor, String ncaId, String userId) { return facade.createKey(actor, ncaId, SERVICE_ID_1, userId, createRequest()); } From 7fcccf30e2aefb7e0e25df374a35a627f8c3d10e Mon Sep 17 00:00:00 2001 From: Shelley Shen Date: Fri, 30 Oct 2026 15:29:34 -0700 Subject: [PATCH 6/6] fix(api-keys): require a status when updating owner status Updating the owner status for an account, or for an account and issuer service, accepted a null status. For a non-legacy account the record was saved with no status, which un-suspended the user. For the legacy account the update threw a NullPointerException. Both now return 400. Signed-off-by: Shelley Shen --- .../apikeys/services/KeysV2Service.java | 9 +++++++ .../KeysV2ServiceIntegrationTest.java | 26 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/services/KeysV2Service.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/services/KeysV2Service.java index a97ada1cd1..f0ee1c2beb 100644 --- a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/services/KeysV2Service.java +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/services/KeysV2Service.java @@ -82,6 +82,7 @@ public class KeysV2Service { static final String MESG_KEY_NOT_ACTIVE = "Key is not active"; static final String MESG_KEY_STATUS_FINAL = "Key status cannot change from %s"; static final String MESG_OWNER_SUSPENDED = "Key owner suspended."; + static final String MESG_OWNER_STATUS_REQUIRED = "Owner status is required"; static final String MESG_OWNER_STATUS_FOR_ACCOUNT_AND_SERVICE_UNSUPPORTED = "Owner status for account and service is not supported for account '%s'"; @@ -328,6 +329,7 @@ public Optional getOwnerStatusByAccountAndS public OwnerStatusByAccountModel updateOwnerStatusByAccount( String ncaId, KeyOwnerType ownerType, String ownerId, KeyOwnerStatus status) { + requireOwnerStatus(status); if (legacyAccount.isLegacy(ncaId)) { var owner = keyOwnerService.loadKeyOwnerByTypeAndIdOrCreateNew(ownerType, ownerId); var saved = keyOwnerService.updateKeyOwner(owner, status, clock.instant()); @@ -341,6 +343,7 @@ public OwnerStatusByAccountModel updateOwnerStatusByAccount( public OwnerStatusByAccountAndServiceModel updateOwnerStatusByAccountAndService( String ncaId, KeyOwnerType ownerType, String ownerId, String issuerServiceId, KeyOwnerStatus status) { + requireOwnerStatus(status); if (legacyAccount.isLegacy(ncaId)) { throw new BadRequestException( MESG_OWNER_STATUS_FOR_ACCOUNT_AND_SERVICE_UNSUPPORTED.formatted(ncaId)); @@ -454,6 +457,12 @@ private void lockForAuthorizationUpdates( } } + private static void requireOwnerStatus(KeyOwnerStatus status) { + if (status == null) { + throw new BadRequestException(MESG_OWNER_STATUS_REQUIRED); + } + } + private static void assertOwnerActive(KeyOwnerStatus status) { if (status != KeyOwnerStatus.ACTIVE) { throw new ForbiddenException(MESG_OWNER_SUSPENDED); diff --git a/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/services/KeysV2ServiceIntegrationTest.java b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/services/KeysV2ServiceIntegrationTest.java index 51245bc9be..747d098e71 100644 --- a/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/services/KeysV2ServiceIntegrationTest.java +++ b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/services/KeysV2ServiceIntegrationTest.java @@ -425,6 +425,32 @@ void deleteKeyByIdOfMissingKeyThrowsNotFound() { assertThat(keysDao.getKeyByHash(API_KEY_HASH_1)).isPresent(); } + @Test + void ownerStatusUpdateRequiresAStatus() { + service.updateOwnerStatusByAccount( + NCA_ID_1, USER, USER_KEY_OWNER_ID_1, KeyOwnerStatus.SUSPENDED); + + assertThrowsExceptionWithDetails( + BadRequestException.class, + () -> service.updateOwnerStatusByAccount( + NCA_ID_1, USER, USER_KEY_OWNER_ID_1, null), + "Owner status is required"); + assertThrowsExceptionWithDetails( + BadRequestException.class, + () -> service.updateOwnerStatusByAccount( + LEGACY_NCA_ID, USER, USER_KEY_OWNER_ID_1, null), + "Owner status is required"); + assertThrowsExceptionWithDetails( + BadRequestException.class, + () -> service.updateOwnerStatusByAccountAndService( + NCA_ID_1, USER, USER_KEY_OWNER_ID_1, SERVICE_ID_1, null), + "Owner status is required"); + assertThat(ownerStatusDao.getOwnerStatusByAccount(NCA_ID_1, USER, USER_KEY_OWNER_ID_1)) + .get() + .extracting(OwnerStatusByAccountModel::getOwnerStatus) + .isEqualTo(KeyOwnerStatus.SUSPENDED); + } + @Test void ownerStatusByAccountIsStoredWithLegacyOwner() { keysDao.save(KEY_VO_1, KEY_OWNER_VO_1);