diff --git a/src/control-plane-services/api-keys/AGENTS.md b/src/control-plane-services/api-keys/AGENTS.md index 4e50b75026..02123f9d82 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 +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 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..dd577d16e5 --- /dev/null +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/facade/KeyManagementFacade.java @@ -0,0 +1,275 @@ +/* + * 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.KeyStatus; +import com.nvidia.apikeys.vo.KeysByScopeFilterVo; +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, as exactly one entry that is ACTIVE when nothing is + * stored. + */ + public KeyOwnerStatusesResponse getAccountOwnerStatuses(String ncaId, String userId) { + validator.assertRouteId("ncaId", ncaId); + validator.assertRouteId("userId", userId); + + 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(); + 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); + validator.requireService(serviceId); + + 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/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/services/KeysV2Service.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/services/KeysV2Service.java index d5a38cebda..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 @@ -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,14 +77,17 @@ @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"; 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'"; + private static final int ACCOUNT_SCAN_PAGE_SIZE = 100; + private static final Set FINAL_KEY_STATUSES = Set.of(KeyStatus.EXPIRED, KeyStatus.REVOKED); @@ -98,8 +107,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 +172,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 +257,63 @@ 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(); + } + + /** + * 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. @@ -244,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()); @@ -257,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)); @@ -308,6 +395,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. */ @@ -331,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/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/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/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/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..8a593750d1 --- /dev/null +++ b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/facade/KeyManagementFacadeIntegrationTest.java @@ -0,0 +1,561 @@ +/* + * 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.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(); + 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.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); + 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.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.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); + }); + } + + @Test + void statusesRejectInvalidCursorAndAccountAdminCrossAccountRead() { + 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 + 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.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); + }); + } + + @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, 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()); + } + + 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/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); 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); 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(); + } +}