diff --git a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/web/AccountAdminKeysController.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/web/AccountAdminKeysController.java new file mode 100644 index 0000000000..c43e55593d --- /dev/null +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/web/AccountAdminKeysController.java @@ -0,0 +1,225 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.apikeys.web; + +import static com.nvidia.apikeys.facade.KeyManagementActor.ACCOUNT_ADMIN; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +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.UpdateAuthorizationsRequest; +import com.nvidia.apikeys.dto.keysV2.UpdateKeyStatusRequest; +import com.nvidia.apikeys.dto.keysV2.UpdateOwnerStatusForAccountRequest; +import com.nvidia.apikeys.facade.KeyManagementFacade; +import com.nvidia.apikeys.vo.KeyStatus; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** + * Account admin endpoints. The account comes from the route. Admins manage a suspended user's + * keys; they cannot read cross-account statuses. + */ +@RestController +@RequiredArgsConstructor +@RequestMapping(path = "/v2/api-keys/account-admin", produces = APPLICATION_JSON_VALUE) +public class AccountAdminKeysController { + + private static final String NCA_ID = "nca-id"; + private static final String SERVICE_ID = "service-id"; + private static final String USER_ID = "user-id"; + private static final String KEY_ID = "key-id"; + + private final KeyManagementFacade facade; + + @Operation(summary = "Create api key", description = "Creates a new api key for the user.") + @PostMapping(path = "/accounts/{nca-id}/services/{service-id}/users/{user-id}/keys", + consumes = APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.OK) + public KeyResponse createKey( + @Parameter(name = NCA_ID, required = true, + description = "Unique identifier of the account.") + @PathVariable(NCA_ID) String ncaId, + @Parameter(name = SERVICE_ID, required = true, + description = "Unique identifier of the issuer service.") + @PathVariable(SERVICE_ID) String serviceId, + @Parameter(name = USER_ID, required = true, + description = "Unique identifier of the user.") + @PathVariable(USER_ID) String userId, + @RequestBody CreateKeyRequest request) { + return facade.createKey(ACCOUNT_ADMIN, ncaId, serviceId, userId, request); + } + + @Operation(summary = "List api keys", description = "Lists the user's keys in the account " + + "and issuer service.") + @GetMapping("/accounts/{nca-id}/services/{service-id}/users/{user-id}/keys") + @ResponseStatus(HttpStatus.OK) + public ListKeysResponse listScopedKeys( + @Parameter(name = NCA_ID, required = true, + description = "Unique identifier of the account.") + @PathVariable(NCA_ID) String ncaId, + @Parameter(name = SERVICE_ID, required = true, + description = "Unique identifier of the issuer service.") + @PathVariable(SERVICE_ID) String serviceId, + @Parameter(name = USER_ID, required = true, + description = "Unique identifier of the user.") + @PathVariable(USER_ID) String userId) { + return facade.listKeys(ACCOUNT_ADMIN, ncaId, serviceId, userId); + } + + @Operation(summary = "List api keys", description = "Pages every key in the account.") + @GetMapping("/accounts/{nca-id}/keys") + @ResponseStatus(HttpStatus.OK) + public ListKeysResponse listAccountKeys( + @Parameter(name = NCA_ID, required = true, + description = "Unique identifier of the account.") + @PathVariable(NCA_ID) String ncaId, + @RequestParam(name = "user-id", required = false) String userId, + @RequestParam(name = "service-id", required = false) String serviceId, + @RequestParam(name = "status", required = false) KeyStatus status, + @RequestParam(name = "limit", required = false) Integer limit, + @RequestParam(name = "cursor", required = false) String cursor) { + return facade.listKeys(ACCOUNT_ADMIN, ncaId, serviceId, userId, status, limit, cursor); + } + + @Operation(summary = "Get api key", description = "Retrieves one key by id.") + @GetMapping("/accounts/{nca-id}/services/{service-id}/users/{user-id}/keys/{key-id}") + @ResponseStatus(HttpStatus.OK) + public KeyResponse getKey( + @Parameter(name = NCA_ID, required = true, + description = "Unique identifier of the account.") + @PathVariable(NCA_ID) String ncaId, + @Parameter(name = SERVICE_ID, required = true, + description = "Unique identifier of the issuer service.") + @PathVariable(SERVICE_ID) String serviceId, + @Parameter(name = USER_ID, required = true, + description = "Unique identifier of the user.") + @PathVariable(USER_ID) String userId, + @Parameter(name = KEY_ID, required = true, description = "Unique identifier of the key.") + @PathVariable(KEY_ID) String keyId) { + return facade.getKey(ACCOUNT_ADMIN, ncaId, serviceId, userId, keyId); + } + + @Operation(summary = "Update api key status", description = "Sets the key status.") + @PutMapping( + path = "/accounts/{nca-id}/services/{service-id}/users/{user-id}/keys/{key-id}/status", + consumes = APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.OK) + public KeyResponse updateKeyStatus( + @Parameter(name = NCA_ID, required = true, + description = "Unique identifier of the account.") + @PathVariable(NCA_ID) String ncaId, + @Parameter(name = SERVICE_ID, required = true, + description = "Unique identifier of the issuer service.") + @PathVariable(SERVICE_ID) String serviceId, + @Parameter(name = USER_ID, required = true, + description = "Unique identifier of the user.") + @PathVariable(USER_ID) String userId, + @Parameter(name = KEY_ID, required = true, description = "Unique identifier of the key.") + @PathVariable(KEY_ID) String keyId, + @RequestBody UpdateKeyStatusRequest request) { + return facade.updateKeyStatus( + ACCOUNT_ADMIN, ncaId, serviceId, userId, keyId, request.getStatus()); + } + + @Operation(summary = "Update api key authorizations", + description = "Replaces the key's authorization document.") + @PutMapping( + path = "/accounts/{nca-id}/services/{service-id}/users/{user-id}/keys/{key-id}/authorizations", + consumes = APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.OK) + public KeyResponse updateKeyAuthorizations( + @Parameter(name = NCA_ID, required = true, + description = "Unique identifier of the account.") + @PathVariable(NCA_ID) String ncaId, + @Parameter(name = SERVICE_ID, required = true, + description = "Unique identifier of the issuer service.") + @PathVariable(SERVICE_ID) String serviceId, + @Parameter(name = USER_ID, required = true, + description = "Unique identifier of the user.") + @PathVariable(USER_ID) String userId, + @Parameter(name = KEY_ID, required = true, description = "Unique identifier of the key.") + @PathVariable(KEY_ID) String keyId, + @RequestBody UpdateAuthorizationsRequest request) { + return facade.updateKeyAuthorizations( + ACCOUNT_ADMIN, ncaId, serviceId, userId, keyId, request); + } + + @Operation(summary = "Delete api key", description = "Deletes one key by id.") + @DeleteMapping("/accounts/{nca-id}/services/{service-id}/users/{user-id}/keys/{key-id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteKey( + @Parameter(name = NCA_ID, required = true, + description = "Unique identifier of the account.") + @PathVariable(NCA_ID) String ncaId, + @Parameter(name = SERVICE_ID, required = true, + description = "Unique identifier of the issuer service.") + @PathVariable(SERVICE_ID) String serviceId, + @Parameter(name = USER_ID, required = true, + description = "Unique identifier of the user.") + @PathVariable(USER_ID) String userId, + @Parameter(name = KEY_ID, required = true, description = "Unique identifier of the key.") + @PathVariable(KEY_ID) String keyId) { + facade.deleteKey(ACCOUNT_ADMIN, ncaId, serviceId, userId, keyId); + } + + @Operation(summary = "Get owner status", description = "The user's status in this account, " + + "as exactly one entry.") + @GetMapping("/accounts/{nca-id}/users/{user-id}/statuses") + @ResponseStatus(HttpStatus.OK) + public KeyOwnerStatusesResponse getAccountOwnerStatuses( + @Parameter(name = NCA_ID, required = true, + description = "Unique identifier of the account.") + @PathVariable(NCA_ID) String ncaId, + @Parameter(name = USER_ID, required = true, + description = "Unique identifier of the user.") + @PathVariable(USER_ID) String userId) { + return facade.getAccountOwnerStatuses(ncaId, userId); + } + + @Operation(summary = "Update owner status", description = "Sets the user's status for the " + + "account.") + @PutMapping( + path = "/accounts/{nca-id}/users/{user-id}/status", + consumes = APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.OK) + public KeyOwnerStatusResponse updateAccountStatus( + @Parameter(name = NCA_ID, required = true, + description = "Unique identifier of the account.") + @PathVariable(NCA_ID) String ncaId, + @Parameter(name = USER_ID, required = true, + description = "Unique identifier of the user.") + @PathVariable(USER_ID) String userId, + @RequestBody UpdateOwnerStatusForAccountRequest request) { + return facade.updateAccountStatus(ACCOUNT_ADMIN, ncaId, userId, request); + } +} diff --git a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/web/KeyOwnerKeysController.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/web/KeyOwnerKeysController.java new file mode 100644 index 0000000000..594b70d7f6 --- /dev/null +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/web/KeyOwnerKeysController.java @@ -0,0 +1,204 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.apikeys.web; + +import static com.nvidia.apikeys.facade.KeyManagementActor.KEY_OWNER; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +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.ListKeysResponse; +import com.nvidia.apikeys.dto.keysV2.UpdateAuthorizationsRequest; +import com.nvidia.apikeys.dto.keysV2.UpdateKeyStatusRequest; +import com.nvidia.apikeys.facade.KeyManagementFacade; +import com.nvidia.apikeys.vo.KeyStatus; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** + * Key owner self-service endpoints. The user comes from the route; a suspended owner is denied. + */ +@RestController +@RequiredArgsConstructor +@RequestMapping(path = "/v2/api-keys/key-owner", produces = APPLICATION_JSON_VALUE) +public class KeyOwnerKeysController { + + private static final String NCA_ID = "nca-id"; + private static final String SERVICE_ID = "service-id"; + private static final String USER_ID = "user-id"; + private static final String KEY_ID = "key-id"; + + private final KeyManagementFacade facade; + + @Operation(summary = "Create api key", description = "Creates a new api key for the user.") + @PostMapping(path = "/accounts/{nca-id}/services/{service-id}/users/{user-id}/keys", + consumes = APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.OK) + public KeyResponse createKey( + @Parameter(name = NCA_ID, required = true, + description = "Unique identifier of the account.") + @PathVariable(NCA_ID) String ncaId, + @Parameter(name = SERVICE_ID, required = true, + description = "Unique identifier of the issuer service.") + @PathVariable(SERVICE_ID) String serviceId, + @Parameter(name = USER_ID, required = true, + description = "Unique identifier of the user.") + @PathVariable(USER_ID) String userId, + @RequestBody CreateKeyRequest request) { + return facade.createKey(KEY_OWNER, ncaId, serviceId, userId, request); + } + + @Operation(summary = "List api keys", description = "Lists the user's keys in the account " + + "and issuer service.") + @GetMapping("/accounts/{nca-id}/services/{service-id}/users/{user-id}/keys") + @ResponseStatus(HttpStatus.OK) + public ListKeysResponse listScopedKeys( + @Parameter(name = NCA_ID, required = true, + description = "Unique identifier of the account.") + @PathVariable(NCA_ID) String ncaId, + @Parameter(name = SERVICE_ID, required = true, + description = "Unique identifier of the issuer service.") + @PathVariable(SERVICE_ID) String serviceId, + @Parameter(name = USER_ID, required = true, + description = "Unique identifier of the user.") + @PathVariable(USER_ID) String userId) { + return facade.listKeys(KEY_OWNER, ncaId, serviceId, userId); + } + + @Operation(summary = "List api keys", description = "Pages the user's keys across accounts " + + "and issuer services.") + @GetMapping("/users/{user-id}/keys") + @ResponseStatus(HttpStatus.OK) + public ListKeysResponse listUserKeys( + @Parameter(name = USER_ID, required = true, + description = "Unique identifier of the user.") + @PathVariable(USER_ID) String userId, + @RequestParam(name = "nca-id", required = false) String ncaId, + @RequestParam(name = "service-id", required = false) String serviceId, + @RequestParam(name = "status", required = false) KeyStatus status, + @RequestParam(name = "limit", required = false) Integer limit, + @RequestParam(name = "cursor", required = false) String cursor) { + return facade.listKeys(KEY_OWNER, ncaId, serviceId, userId, status, limit, cursor); + } + + @Operation(summary = "Get api key", description = "Retrieves one key by id.") + @GetMapping("/accounts/{nca-id}/services/{service-id}/users/{user-id}/keys/{key-id}") + @ResponseStatus(HttpStatus.OK) + public KeyResponse getKey( + @Parameter(name = NCA_ID, required = true, + description = "Unique identifier of the account.") + @PathVariable(NCA_ID) String ncaId, + @Parameter(name = SERVICE_ID, required = true, + description = "Unique identifier of the issuer service.") + @PathVariable(SERVICE_ID) String serviceId, + @Parameter(name = USER_ID, required = true, + description = "Unique identifier of the user.") + @PathVariable(USER_ID) String userId, + @Parameter(name = KEY_ID, required = true, description = "Unique identifier of the key.") + @PathVariable(KEY_ID) String keyId) { + return facade.getKey(KEY_OWNER, ncaId, serviceId, userId, keyId); + } + + @Operation(summary = "Update api key status", description = "Sets the key status.") + @PutMapping( + path = "/accounts/{nca-id}/services/{service-id}/users/{user-id}/keys/{key-id}/status", + consumes = APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.OK) + public KeyResponse updateKeyStatus( + @Parameter(name = NCA_ID, required = true, + description = "Unique identifier of the account.") + @PathVariable(NCA_ID) String ncaId, + @Parameter(name = SERVICE_ID, required = true, + description = "Unique identifier of the issuer service.") + @PathVariable(SERVICE_ID) String serviceId, + @Parameter(name = USER_ID, required = true, + description = "Unique identifier of the user.") + @PathVariable(USER_ID) String userId, + @Parameter(name = KEY_ID, required = true, description = "Unique identifier of the key.") + @PathVariable(KEY_ID) String keyId, + @RequestBody UpdateKeyStatusRequest request) { + return facade.updateKeyStatus( + KEY_OWNER, ncaId, serviceId, userId, keyId, request.getStatus()); + } + + @Operation(summary = "Update api key authorizations", + description = "Replaces the key's authorization document.") + @PutMapping( + path = "/accounts/{nca-id}/services/{service-id}/users/{user-id}/keys/{key-id}/authorizations", + consumes = APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.OK) + public KeyResponse updateKeyAuthorizations( + @Parameter(name = NCA_ID, required = true, + description = "Unique identifier of the account.") + @PathVariable(NCA_ID) String ncaId, + @Parameter(name = SERVICE_ID, required = true, + description = "Unique identifier of the issuer service.") + @PathVariable(SERVICE_ID) String serviceId, + @Parameter(name = USER_ID, required = true, + description = "Unique identifier of the user.") + @PathVariable(USER_ID) String userId, + @Parameter(name = KEY_ID, required = true, description = "Unique identifier of the key.") + @PathVariable(KEY_ID) String keyId, + @RequestBody UpdateAuthorizationsRequest request) { + return facade.updateKeyAuthorizations(KEY_OWNER, ncaId, serviceId, userId, keyId, request); + } + + @Operation(summary = "Delete api key", description = "Deletes one key by id.") + @DeleteMapping("/accounts/{nca-id}/services/{service-id}/users/{user-id}/keys/{key-id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteKey( + @Parameter(name = NCA_ID, required = true, + description = "Unique identifier of the account.") + @PathVariable(NCA_ID) String ncaId, + @Parameter(name = SERVICE_ID, required = true, + description = "Unique identifier of the issuer service.") + @PathVariable(SERVICE_ID) String serviceId, + @Parameter(name = USER_ID, required = true, + description = "Unique identifier of the user.") + @PathVariable(USER_ID) String userId, + @Parameter(name = KEY_ID, required = true, description = "Unique identifier of the key.") + @PathVariable(KEY_ID) String keyId) { + facade.deleteKey(KEY_OWNER, ncaId, serviceId, userId, keyId); + } + + @Operation(summary = "List owner statuses", + description = "Pages the user's status across every account known to API Keys.") + @GetMapping("/users/{user-id}/statuses") + @ResponseStatus(HttpStatus.OK) + public KeyOwnerStatusesResponse listOwnerStatuses( + @Parameter(name = USER_ID, required = true, + description = "Unique identifier of the user.") + @PathVariable(USER_ID) String userId, + @RequestParam(name = "limit", required = false) Integer limit, + @RequestParam(name = "cursor", required = false) String cursor) { + return facade.listOwnerStatuses(KEY_OWNER, userId, limit, cursor); + } +} diff --git a/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/web/PlatformAdminKeysController.java b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/web/PlatformAdminKeysController.java new file mode 100644 index 0000000000..97c44a850e --- /dev/null +++ b/src/control-plane-services/api-keys/src/main/java/com/nvidia/apikeys/web/PlatformAdminKeysController.java @@ -0,0 +1,260 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.apikeys.web; + +import static com.nvidia.apikeys.facade.KeyManagementActor.PLATFORM_ADMIN; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +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.UpdateAuthorizationsRequest; +import com.nvidia.apikeys.dto.keysV2.UpdateKeyStatusRequest; +import com.nvidia.apikeys.dto.keysV2.UpdateOwnerStatusForAccountAndIssuerServiceRequest; +import com.nvidia.apikeys.dto.keysV2.UpdateOwnerStatusForAccountRequest; +import com.nvidia.apikeys.facade.KeyManagementFacade; +import com.nvidia.apikeys.vo.KeyStatus; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** + * Platform admin endpoints. Every dimension is optional; omitting all of them pages the full + * keys_v2 table. + */ +@RestController +@RequiredArgsConstructor +@RequestMapping(path = "/v2/api-keys/platform-admin", produces = APPLICATION_JSON_VALUE) +public class PlatformAdminKeysController { + + private static final String NCA_ID = "nca-id"; + private static final String SERVICE_ID = "service-id"; + private static final String USER_ID = "user-id"; + private static final String KEY_ID = "key-id"; + + private final KeyManagementFacade facade; + + @Operation(summary = "Create api key", description = "Creates a new api key for the user.") + @PostMapping(path = "/accounts/{nca-id}/services/{service-id}/users/{user-id}/keys", + consumes = APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.OK) + public KeyResponse createKey( + @Parameter(name = NCA_ID, required = true, + description = "Unique identifier of the account.") + @PathVariable(NCA_ID) String ncaId, + @Parameter(name = SERVICE_ID, required = true, + description = "Unique identifier of the issuer service.") + @PathVariable(SERVICE_ID) String serviceId, + @Parameter(name = USER_ID, required = true, + description = "Unique identifier of the user.") + @PathVariable(USER_ID) String userId, + @RequestBody CreateKeyRequest request) { + return facade.createKey(PLATFORM_ADMIN, ncaId, serviceId, userId, request); + } + + @Operation(summary = "List api keys", description = "Lists the user's keys in the account " + + "and issuer service.") + @GetMapping("/accounts/{nca-id}/services/{service-id}/users/{user-id}/keys") + @ResponseStatus(HttpStatus.OK) + public ListKeysResponse listScopedKeys( + @Parameter(name = NCA_ID, required = true, + description = "Unique identifier of the account.") + @PathVariable(NCA_ID) String ncaId, + @Parameter(name = SERVICE_ID, required = true, + description = "Unique identifier of the issuer service.") + @PathVariable(SERVICE_ID) String serviceId, + @Parameter(name = USER_ID, required = true, + description = "Unique identifier of the user.") + @PathVariable(USER_ID) String userId) { + return facade.listKeys(PLATFORM_ADMIN, ncaId, serviceId, userId); + } + + @Operation(summary = "List api keys", description = "Pages keys across every account, " + + "issuer service and user left unfiltered.") + @GetMapping("/keys") + @ResponseStatus(HttpStatus.OK) + public ListKeysResponse listKeys( + @RequestParam(name = "nca-id", required = false) String ncaId, + @RequestParam(name = "service-id", required = false) String serviceId, + @RequestParam(name = "user-id", required = false) String userId, + @RequestParam(name = "status", required = false) KeyStatus status, + @RequestParam(name = "limit", required = false) Integer limit, + @RequestParam(name = "cursor", required = false) String cursor) { + return facade.listKeys(PLATFORM_ADMIN, ncaId, serviceId, userId, status, limit, cursor); + } + + @Operation(summary = "Get api key", description = "Retrieves one key by id.") + @GetMapping("/accounts/{nca-id}/services/{service-id}/users/{user-id}/keys/{key-id}") + @ResponseStatus(HttpStatus.OK) + public KeyResponse getKey( + @Parameter(name = NCA_ID, required = true, + description = "Unique identifier of the account.") + @PathVariable(NCA_ID) String ncaId, + @Parameter(name = SERVICE_ID, required = true, + description = "Unique identifier of the issuer service.") + @PathVariable(SERVICE_ID) String serviceId, + @Parameter(name = USER_ID, required = true, + description = "Unique identifier of the user.") + @PathVariable(USER_ID) String userId, + @Parameter(name = KEY_ID, required = true, description = "Unique identifier of the key.") + @PathVariable(KEY_ID) String keyId) { + return facade.getKey(PLATFORM_ADMIN, ncaId, serviceId, userId, keyId); + } + + @Operation(summary = "Update api key status", description = "Sets the key status.") + @PutMapping( + path = "/accounts/{nca-id}/services/{service-id}/users/{user-id}/keys/{key-id}/status", + consumes = APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.OK) + public KeyResponse updateKeyStatus( + @Parameter(name = NCA_ID, required = true, + description = "Unique identifier of the account.") + @PathVariable(NCA_ID) String ncaId, + @Parameter(name = SERVICE_ID, required = true, + description = "Unique identifier of the issuer service.") + @PathVariable(SERVICE_ID) String serviceId, + @Parameter(name = USER_ID, required = true, + description = "Unique identifier of the user.") + @PathVariable(USER_ID) String userId, + @Parameter(name = KEY_ID, required = true, description = "Unique identifier of the key.") + @PathVariable(KEY_ID) String keyId, + @RequestBody UpdateKeyStatusRequest request) { + return facade.updateKeyStatus( + PLATFORM_ADMIN, ncaId, serviceId, userId, keyId, request.getStatus()); + } + + @Operation(summary = "Update api key authorizations", + description = "Replaces the key's authorization document.") + @PutMapping( + path = "/accounts/{nca-id}/services/{service-id}/users/{user-id}/keys/{key-id}/authorizations", + consumes = APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.OK) + public KeyResponse updateKeyAuthorizations( + @Parameter(name = NCA_ID, required = true, + description = "Unique identifier of the account.") + @PathVariable(NCA_ID) String ncaId, + @Parameter(name = SERVICE_ID, required = true, + description = "Unique identifier of the issuer service.") + @PathVariable(SERVICE_ID) String serviceId, + @Parameter(name = USER_ID, required = true, + description = "Unique identifier of the user.") + @PathVariable(USER_ID) String userId, + @Parameter(name = KEY_ID, required = true, description = "Unique identifier of the key.") + @PathVariable(KEY_ID) String keyId, + @RequestBody UpdateAuthorizationsRequest request) { + return facade.updateKeyAuthorizations( + PLATFORM_ADMIN, ncaId, serviceId, userId, keyId, request); + } + + @Operation(summary = "Delete api key", description = "Deletes one key by id.") + @DeleteMapping("/accounts/{nca-id}/services/{service-id}/users/{user-id}/keys/{key-id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteKey( + @Parameter(name = NCA_ID, required = true, + description = "Unique identifier of the account.") + @PathVariable(NCA_ID) String ncaId, + @Parameter(name = SERVICE_ID, required = true, + description = "Unique identifier of the issuer service.") + @PathVariable(SERVICE_ID) String serviceId, + @Parameter(name = USER_ID, required = true, + description = "Unique identifier of the user.") + @PathVariable(USER_ID) String userId, + @Parameter(name = KEY_ID, required = true, description = "Unique identifier of the key.") + @PathVariable(KEY_ID) String keyId) { + facade.deleteKey(PLATFORM_ADMIN, ncaId, serviceId, userId, keyId); + } + + @Operation(summary = "Get owner status", description = "The user's status in this account, " + + "as exactly one entry.") + @GetMapping("/accounts/{nca-id}/users/{user-id}/statuses") + @ResponseStatus(HttpStatus.OK) + public KeyOwnerStatusesResponse getAccountOwnerStatuses( + @Parameter(name = NCA_ID, required = true, + description = "Unique identifier of the account.") + @PathVariable(NCA_ID) String ncaId, + @Parameter(name = USER_ID, required = true, + description = "Unique identifier of the user.") + @PathVariable(USER_ID) String userId) { + return facade.getAccountOwnerStatuses(ncaId, userId); + } + + @Operation(summary = "List owner statuses", + description = "Pages the user's status across every account known to API Keys.") + @GetMapping("/users/{user-id}/statuses") + @ResponseStatus(HttpStatus.OK) + public KeyOwnerStatusesResponse listOwnerStatuses( + @Parameter(name = USER_ID, required = true, + description = "Unique identifier of the user.") + @PathVariable(USER_ID) String userId, + @RequestParam(name = "limit", required = false) Integer limit, + @RequestParam(name = "cursor", required = false) String cursor) { + return facade.listOwnerStatuses(PLATFORM_ADMIN, userId, limit, cursor); + } + + @Operation(summary = "Update owner status", description = "Sets the user's status for the " + + "account.") + @PutMapping( + path = "/accounts/{nca-id}/users/{user-id}/status", + consumes = APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.OK) + public KeyOwnerStatusResponse updateAccountStatus( + @Parameter(name = NCA_ID, required = true, + description = "Unique identifier of the account.") + @PathVariable(NCA_ID) String ncaId, + @Parameter(name = USER_ID, required = true, + description = "Unique identifier of the user.") + @PathVariable(USER_ID) String userId, + @RequestBody UpdateOwnerStatusForAccountRequest request) { + return facade.updateAccountStatus(PLATFORM_ADMIN, ncaId, userId, request); + } + + @Operation(summary = "Update owner status for an issuer service", + description = "Sets the user's status for one issuer service in the account. " + + "Service-scoped status is not supported for the legacy account.") + @PutMapping( + path = "/accounts/{nca-id}/services/{service-id}/users/{user-id}/status", + consumes = APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.OK) + public KeyOwnerStatusResponse updateIssuerServiceStatus( + @Parameter(name = NCA_ID, required = true, + description = "Unique identifier of the account.") + @PathVariable(NCA_ID) String ncaId, + @Parameter(name = SERVICE_ID, required = true, + description = "Unique identifier of the issuer service.") + @PathVariable(SERVICE_ID) String serviceId, + @Parameter(name = USER_ID, required = true, + description = "Unique identifier of the user.") + @PathVariable(USER_ID) String userId, + @RequestBody UpdateOwnerStatusForAccountAndIssuerServiceRequest request) { + return facade.updateIssuerServiceStatus( + PLATFORM_ADMIN, ncaId, serviceId, userId, request); + } +} diff --git a/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/web/AccountAdminKeysControllerIntegrationTest.java b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/web/AccountAdminKeysControllerIntegrationTest.java new file mode 100644 index 0000000000..71bce40e78 --- /dev/null +++ b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/web/AccountAdminKeysControllerIntegrationTest.java @@ -0,0 +1,136 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.apikeys.web; + +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_STRING; +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.App; +import com.nvidia.apikeys.config.IntegrationTestConfiguration; +import com.nvidia.apikeys.config.IntegrationTestConfiguration.TestCleanerExtension; +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.utils.TestClock; +import com.nvidia.apikeys.vo.KeyOwnerStatus; +import java.time.ZoneId; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ContextConfiguration; + +/** + * Routing for the account-admin prefix. {@link com.nvidia.apikeys.facade.KeyManagementFacadeIntegrationTest} + * covers the shared behavior; these tests only check that the controller wires the right actor + * and path to the facade. + */ +@ExtendWith(TestCleanerExtension.class) +@AutoConfigureTestRestTemplate +@SpringBootTest( + classes = {App.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=integrationtest") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +class AccountAdminKeysControllerIntegrationTest extends BaseIntegrationTest { + + private static final String NCA_ID = "nca-1"; + private static final String CREATE_PATH = + "/v2/api-keys/account-admin/accounts/" + NCA_ID + "/services/" + SERVICE_ID_1 + + "/users/" + USER_KEY_OWNER_ID_1 + "/keys"; + + @BeforeEach + void setUp() { + TestClock.setBaseClock(TestClock.fixed(TEST_TIME, ZoneId.systemDefault())); + } + + @AfterEach + void tearDown() { + TestClock.resetToDefaults(); + } + + @Test + void listsKeysByAccountAcrossIssuerServices() { + var createBody = """ + {"description":"%s","expiresAt":"%s","authorizations":%s} + """.formatted(KEY_DESCRIPTION_1, KEY_EXPIRES_AT_1_STRING, KEY_AUTHZ_1); + exchange(HttpMethod.POST, CREATE_PATH, createBody, KeyResponse.class); + + var listed = exchange( + HttpMethod.GET, "/v2/api-keys/account-admin/accounts/" + NCA_ID + "/keys", null, + ListKeysResponse.class); + assertThat(listed.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(listed.getBody().getKeys()).hasSize(1); + } + + @Test + void adminManagesASuspendedUsersKeys() { + var statusPath = + "/v2/api-keys/account-admin/accounts/" + NCA_ID + "/users/" + USER_KEY_OWNER_ID_1 + + "/status"; + var suspend = exchange( + HttpMethod.PUT, statusPath, "{\"status\":\"SUSPENDED\"}", + KeyOwnerStatusResponse.class); + assertThat(suspend.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(suspend.getBody().getStatus()).isEqualTo(KeyOwnerStatus.SUSPENDED); + + var createBody = """ + {"description":"%s","expiresAt":"%s","authorizations":%s} + """.formatted(KEY_DESCRIPTION_1, KEY_EXPIRES_AT_1_STRING, KEY_AUTHZ_1); + var created = exchange(HttpMethod.POST, CREATE_PATH, createBody, KeyResponse.class); + assertThat(created.getStatusCode()).isEqualTo(HttpStatus.OK); + + var statuses = exchange( + HttpMethod.GET, + "/v2/api-keys/account-admin/accounts/" + NCA_ID + "/users/" + + USER_KEY_OWNER_ID_1 + "/statuses", + null, KeyOwnerStatusesResponse.class); + assertThat(statuses.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(statuses.getBody().getAccounts()).singleElement().satisfies( + account -> assertThat(account.getStatus()).isEqualTo(KeyOwnerStatus.SUSPENDED)); + } + + @Test + void accountAdminHasNoCrossAccountStatusesRoute() { + var response = restTemplate.exchange( + "/v2/api-keys/account-admin/users/" + USER_KEY_OWNER_ID_1 + "/statuses", + HttpMethod.GET, new HttpEntity((HttpHeaders) null), String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + private ResponseEntity exchange( + HttpMethod method, String path, String body, Class type) { + var headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + return restTemplate.exchange(path, method, new HttpEntity<>(body, headers), type); + } +} diff --git a/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/web/KeyOwnerKeysControllerIntegrationTest.java b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/web/KeyOwnerKeysControllerIntegrationTest.java new file mode 100644 index 0000000000..c9b2116326 --- /dev/null +++ b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/web/KeyOwnerKeysControllerIntegrationTest.java @@ -0,0 +1,159 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.apikeys.web; + +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_STRING; +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.App; +import com.nvidia.apikeys.config.IntegrationTestConfiguration; +import com.nvidia.apikeys.config.IntegrationTestConfiguration.TestCleanerExtension; +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.utils.TestClock; +import java.time.ZoneId; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ContextConfiguration; + +/** + * Routing for the key-owner prefix. {@link com.nvidia.apikeys.facade.KeyManagementFacadeIntegrationTest} + * covers the shared behavior; these tests only check that the controller wires the right actor + * and path to the facade. + */ +@ExtendWith(TestCleanerExtension.class) +@AutoConfigureTestRestTemplate +@SpringBootTest( + classes = {App.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=integrationtest") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +class KeyOwnerKeysControllerIntegrationTest extends BaseIntegrationTest { + + private static final String NCA_ID = "nca-1"; + private static final String CREATE_PATH = + "/v2/api-keys/key-owner/accounts/" + NCA_ID + "/services/" + SERVICE_ID_1 + + "/users/" + USER_KEY_OWNER_ID_1 + "/keys"; + + @BeforeEach + void setUp() { + TestClock.setBaseClock(TestClock.fixed(TEST_TIME, ZoneId.systemDefault())); + } + + @AfterEach + void tearDown() { + TestClock.resetToDefaults(); + } + + @Test + void createGetUpdateAndDeleteRoundTripThroughHttp() { + var createBody = """ + {"description":"%s","expiresAt":"%s","authorizations":%s} + """.formatted(KEY_DESCRIPTION_1, KEY_EXPIRES_AT_1_STRING, KEY_AUTHZ_1); + var created = exchange(HttpMethod.POST, CREATE_PATH, createBody, KeyResponse.class); + assertThat(created.getStatusCode()).isEqualTo(HttpStatus.OK); + var key = created.getBody(); + assertThat(key.getValue()).doesNotContain("*"); + assertThat(key.getOwnerId()).isEqualTo(USER_KEY_OWNER_ID_1); + assertThat(key.getAccountId()).isEqualTo(NCA_ID); + + var keyPath = CREATE_PATH + "/" + key.getId(); + + var fetched = exchange(HttpMethod.GET, keyPath, null, KeyResponse.class); + assertThat(fetched.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(fetched.getBody().getValue()).contains("**********"); + + var suspended = exchange( + HttpMethod.PUT, keyPath + "/status", "{\"status\":\"SUSPENDED\"}", + KeyResponse.class); + assertThat(suspended.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(suspended.getBody().getStatus()).isEqualTo(KeyStatusV2.SUSPENDED); + + var deleted = restTemplate.exchange( + keyPath, HttpMethod.DELETE, new HttpEntity((HttpHeaders) null), + Void.class); + assertThat(deleted.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + + var afterDelete = restTemplate.exchange( + keyPath, HttpMethod.GET, new HttpEntity((HttpHeaders) null), String.class); + assertThat(afterDelete.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void routeMismatchReturnsNotFound() { + var createBody = """ + {"description":"%s","expiresAt":"%s","authorizations":%s} + """.formatted(KEY_DESCRIPTION_1, KEY_EXPIRES_AT_1_STRING, KEY_AUTHZ_1); + var created = exchange(HttpMethod.POST, CREATE_PATH, createBody, KeyResponse.class); + var keyId = created.getBody().getId(); + + var wrongAccount = + "/v2/api-keys/key-owner/accounts/nca-2/services/" + SERVICE_ID_1 + + "/users/" + USER_KEY_OWNER_ID_1 + "/keys/" + keyId; + var response = restTemplate.exchange( + wrongAccount, HttpMethod.GET, new HttpEntity((HttpHeaders) null), + String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void listByUserQueriesAcrossAccountsAndPages() { + var createBody = """ + {"description":"%s","expiresAt":"%s","authorizations":%s} + """.formatted(KEY_DESCRIPTION_1, KEY_EXPIRES_AT_1_STRING, KEY_AUTHZ_1); + exchange(HttpMethod.POST, CREATE_PATH, createBody, KeyResponse.class); + + var listPath = "/v2/api-keys/key-owner/users/" + USER_KEY_OWNER_ID_1 + "/keys" + + "?nca-id=" + NCA_ID + "&service-id=" + SERVICE_ID_1; + var listed = exchange(HttpMethod.GET, listPath, null, ListKeysResponse.class); + assertThat(listed.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(listed.getBody().getKeys()).hasSize(1); + } + + @Test + void ownerStatusesListPathReturnsActiveByDefault() { + var path = "/v2/api-keys/key-owner/users/" + USER_KEY_OWNER_ID_1 + "/statuses"; + var response = exchange(HttpMethod.GET, path, null, KeyOwnerStatusesResponse.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getOwnerId()).isEqualTo(USER_KEY_OWNER_ID_1); + } + + private ResponseEntity exchange( + HttpMethod method, String path, String body, Class type) { + var headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + return restTemplate.exchange(path, method, new HttpEntity<>(body, headers), type); + } +} diff --git a/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/web/PlatformAdminKeysControllerIntegrationTest.java b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/web/PlatformAdminKeysControllerIntegrationTest.java new file mode 100644 index 0000000000..fb9a373b2a --- /dev/null +++ b/src/control-plane-services/api-keys/src/test/java/com/nvidia/apikeys/web/PlatformAdminKeysControllerIntegrationTest.java @@ -0,0 +1,124 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.apikeys.web; + +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_STRING; +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.App; +import com.nvidia.apikeys.config.IntegrationTestConfiguration; +import com.nvidia.apikeys.config.IntegrationTestConfiguration.TestCleanerExtension; +import com.nvidia.apikeys.dto.keysV2.KeyOwnerStatusResponse; +import com.nvidia.apikeys.dto.keysV2.KeyResponse; +import com.nvidia.apikeys.dto.keysV2.ListKeysResponse; +import com.nvidia.apikeys.utils.TestClock; +import com.nvidia.apikeys.vo.KeyOwnerStatus; +import java.time.ZoneId; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ContextConfiguration; + +/** + * Routing for the platform-admin prefix. {@link com.nvidia.apikeys.facade.KeyManagementFacadeIntegrationTest} + * covers the shared behavior; these tests only check that the controller wires the right actor + * and path to the facade. + */ +@ExtendWith(TestCleanerExtension.class) +@AutoConfigureTestRestTemplate +@SpringBootTest( + classes = {App.class, IntegrationTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.active=integrationtest") +@ContextConfiguration(initializers = IntegrationTestConfiguration.Initializer.class) +class PlatformAdminKeysControllerIntegrationTest extends BaseIntegrationTest { + + private static final String NCA_ID = "nca-1"; + private static final String CREATE_PATH = + "/v2/api-keys/platform-admin/accounts/" + NCA_ID + "/services/" + SERVICE_ID_1 + + "/users/" + USER_KEY_OWNER_ID_1 + "/keys"; + + @BeforeEach + void setUp() { + TestClock.setBaseClock(TestClock.fixed(TEST_TIME, ZoneId.systemDefault())); + } + + @AfterEach + void tearDown() { + TestClock.resetToDefaults(); + } + + @Test + void listsKeysUnfilteredAcrossEveryAccount() { + var createBody = """ + {"description":"%s","expiresAt":"%s","authorizations":%s} + """.formatted(KEY_DESCRIPTION_1, KEY_EXPIRES_AT_1_STRING, KEY_AUTHZ_1); + exchange(HttpMethod.POST, CREATE_PATH, createBody, KeyResponse.class); + + var listed = exchange( + HttpMethod.GET, "/v2/api-keys/platform-admin/keys", null, ListKeysResponse.class); + assertThat(listed.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(listed.getBody().getKeys()).hasSize(1); + } + + @Test + void updatesIssuerServiceStatusAndReflectsItInAccountStatuses() { + var path = "/v2/api-keys/platform-admin/accounts/" + NCA_ID + "/services/" + + SERVICE_ID_1 + "/users/" + USER_KEY_OWNER_ID_1 + "/status"; + var updated = exchange( + HttpMethod.PUT, path, "{\"status\":\"SUSPENDED\"}", KeyOwnerStatusResponse.class); + assertThat(updated.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(updated.getBody().getIssuerServiceId()).isEqualTo(SERVICE_ID_1); + assertThat(updated.getBody().getStatus()).isEqualTo(KeyOwnerStatus.SUSPENDED); + } + + @Test + void accountAdminHasNoIssuerServiceStatusRoute() { + var path = "/v2/api-keys/account-admin/accounts/" + NCA_ID + "/services/" + + SERVICE_ID_1 + "/users/" + USER_KEY_OWNER_ID_1 + "/status"; + var response = restTemplate.exchange( + path, HttpMethod.PUT, + new HttpEntity<>("{\"status\":\"SUSPENDED\"}", jsonHeaders()), String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + private ResponseEntity exchange( + HttpMethod method, String path, String body, Class type) { + return restTemplate.exchange(path, method, new HttpEntity<>(body, jsonHeaders()), type); + } + + private static HttpHeaders jsonHeaders() { + var headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + return headers; + } +}