diff --git a/docs/auth/authentication/using-authentication.mdx b/docs/auth/authentication/using-authentication.mdx index ece8448bd9..1a4efcc822 100644 --- a/docs/auth/authentication/using-authentication.mdx +++ b/docs/auth/authentication/using-authentication.mdx @@ -134,17 +134,23 @@ Pass `--expires-in ` to request a specific finite lifetime. Pass `--expires-in none` only for deployments where the administrator has explicitly allowed unlimited keys. -List keys or revoke one by its stable `jti`: +List keys, temporarily suspend and restore one, or permanently revoke one by its stable `jti`: ```bash nemo auth access-keys list nemo auth access-keys list --page 2 --page-size 100 +nemo auth access-keys suspend ak_0123456789abcdef0123456789abcdef +nemo auth access-keys unsuspend ak_0123456789abcdef0123456789abcdef nemo auth access-keys revoke ak_0123456789abcdef0123456789abcdef ``` -The list includes each key's `ACTIVE`, `EXPIRED`, or `REVOKED` status plus its -description, issuer, audiences, creation time, and expiration time. Revocation takes -effect on subsequent authenticated platform requests. Rotation is not implemented. +The list includes each key's `ACTIVE`, `EXPIRED`, `SUSPENDED`, or `REVOKED` status +plus its description, issuer, audiences, creation time, and expiration time. Suspension +and revocation take effect on subsequent authenticated platform requests. Use suspension +to temporarily block a key, such as while investigating suspected misuse, without +permanently revoking it. An unexpired suspended key can be restored with `unsuspend`. If +the key expires while suspended, `unsuspend` is a no-op and reports `EXPIRED`. A revoked +key cannot be restored. Rotation is not implemented. ### Token Inspection diff --git a/docs/cli/reference.mdx b/docs/cli/reference.mdx index 106657bc3f..b9922737ed 100644 --- a/docs/cli/reference.mdx +++ b/docs/cli/reference.mdx @@ -281,6 +281,8 @@ nemo auth access-keys [OPTIONS] COMMAND [ARGS]... * `create`: Create a Scoped Access Key for the currently... * `list`: List Scoped Access Keys owned by the currently... * `revoke`: Revoke a Scoped Access Key owned by the currently... +* `suspend`: Temporarily suspend a Scoped Access Key owned by the... +* `unsuspend`: Restore a suspended Scoped Access Key owned by the... ##### nemo auth access-keys create @@ -339,6 +341,44 @@ nemo auth access-keys revoke [OPTIONS] JTI * `--help, -h`: Show this message and exit. +##### nemo auth access-keys suspend + +Temporarily suspend a Scoped Access Key owned by the current user. + +Unlike revocation, suspension is reversible until the key expires. + +**Usage:** + +```shell +nemo auth access-keys suspend [OPTIONS] JTI +``` + +**Arguments:** + +* ``: Stable ID of the Scoped Access Key to suspend. + +**Help:** + +* `--help, -h`: Show this message and exit. + +##### nemo auth access-keys unsuspend + +Restore a suspended Scoped Access Key owned by the current user. + +**Usage:** + +```shell +nemo auth access-keys unsuspend [OPTIONS] JTI +``` + +**Arguments:** + +* ``: Stable ID of the Scoped Access Key to unsuspend. + +**Help:** + +* `--help, -h`: Show this message and exit. + ### nemo services Run platform services locally. diff --git a/openapi/ga/individual/platform.openapi.yaml b/openapi/ga/individual/platform.openapi.yaml index 863de164a3..809f03c244 100644 --- a/openapi/ga/individual/platform.openapi.yaml +++ b/openapi/ga/individual/platform.openapi.yaml @@ -280,9 +280,9 @@ paths: schema: type: string pattern: ^ak_[0-9a-f]{32}$ - description: Stable JWT ID of the Scoped Access Key to revoke. + description: Stable JWT ID of the Scoped Access Key for the lifecycle operation. title: Jti - description: Stable JWT ID of the Scoped Access Key to revoke. + description: Stable JWT ID of the Scoped Access Key for the lifecycle operation. responses: '200': description: Successful Response @@ -314,6 +314,100 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' + /apis/auth/v2/access-keys/{jti}/suspend: + post: + tags: + - Scoped Access Keys + summary: Suspend Access Key + operationId: suspend_access_key_apis_auth_v2_access_keys__jti__suspend_post + parameters: + - name: jti + in: path + required: true + schema: + type: string + pattern: ^ak_[0-9a-f]{32}$ + description: Stable JWT ID of the Scoped Access Key for the lifecycle operation. + title: Jti + description: Stable JWT ID of the Scoped Access Key for the lifecycle operation. + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyStatusChangeResponse' + '404': + description: Scoped Access Keys are not enabled or the key was not found + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' + '409': + description: Invalid or concurrent access-key state transition + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' + '501': + description: Not Implemented + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyNotImplementedErrorResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/auth/v2/access-keys/{jti}/unsuspend: + post: + tags: + - Scoped Access Keys + summary: Unsuspend Access Key + operationId: unsuspend_access_key_apis_auth_v2_access_keys__jti__unsuspend_post + parameters: + - name: jti + in: path + required: true + schema: + type: string + pattern: ^ak_[0-9a-f]{32}$ + description: Stable JWT ID of the Scoped Access Key for the lifecycle operation. + title: Jti + description: Stable JWT ID of the Scoped Access Key for the lifecycle operation. + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyStatusChangeResponse' + '404': + description: Scoped Access Keys are not enabled or the key was not found + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' + '409': + description: Invalid or concurrent access-key state transition + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' + '501': + description: Not Implemented + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyNotImplementedErrorResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /apis/auth/v2/iam/role-bindings: get: tags: @@ -8148,6 +8242,7 @@ components: - ACTIVE - EXPIRED - REVOKED + - SUSPENDED title: Status issuer: type: string @@ -8248,6 +8343,7 @@ components: - ACTIVE - EXPIRED - REVOKED + - SUSPENDED title: Status issuer: type: string @@ -8305,6 +8401,31 @@ components: - revoked title: AccessKeyRevokeResponse description: Response returned after a Scoped Access Key revoke request. + AccessKeyStatusChangeResponse: + properties: + jti: + type: string + title: Jti + description: Stable JWT ID for this Scoped Access Key. + status: + type: string + enum: + - ACTIVE + - EXPIRED + - SUSPENDED + title: Status + description: Resulting effective status of the key, including expiration. + changed: + type: boolean + title: Changed + description: True when this request changed the key's persistent status. + type: object + required: + - jti + - status + - changed + title: AccessKeyStatusChangeResponse + description: Response returned after a reversible Scoped Access Key status change. ActionRails: properties: instant_actions: diff --git a/openapi/ga/openapi.yaml b/openapi/ga/openapi.yaml index 863de164a3..809f03c244 100644 --- a/openapi/ga/openapi.yaml +++ b/openapi/ga/openapi.yaml @@ -280,9 +280,9 @@ paths: schema: type: string pattern: ^ak_[0-9a-f]{32}$ - description: Stable JWT ID of the Scoped Access Key to revoke. + description: Stable JWT ID of the Scoped Access Key for the lifecycle operation. title: Jti - description: Stable JWT ID of the Scoped Access Key to revoke. + description: Stable JWT ID of the Scoped Access Key for the lifecycle operation. responses: '200': description: Successful Response @@ -314,6 +314,100 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' + /apis/auth/v2/access-keys/{jti}/suspend: + post: + tags: + - Scoped Access Keys + summary: Suspend Access Key + operationId: suspend_access_key_apis_auth_v2_access_keys__jti__suspend_post + parameters: + - name: jti + in: path + required: true + schema: + type: string + pattern: ^ak_[0-9a-f]{32}$ + description: Stable JWT ID of the Scoped Access Key for the lifecycle operation. + title: Jti + description: Stable JWT ID of the Scoped Access Key for the lifecycle operation. + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyStatusChangeResponse' + '404': + description: Scoped Access Keys are not enabled or the key was not found + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' + '409': + description: Invalid or concurrent access-key state transition + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' + '501': + description: Not Implemented + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyNotImplementedErrorResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/auth/v2/access-keys/{jti}/unsuspend: + post: + tags: + - Scoped Access Keys + summary: Unsuspend Access Key + operationId: unsuspend_access_key_apis_auth_v2_access_keys__jti__unsuspend_post + parameters: + - name: jti + in: path + required: true + schema: + type: string + pattern: ^ak_[0-9a-f]{32}$ + description: Stable JWT ID of the Scoped Access Key for the lifecycle operation. + title: Jti + description: Stable JWT ID of the Scoped Access Key for the lifecycle operation. + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyStatusChangeResponse' + '404': + description: Scoped Access Keys are not enabled or the key was not found + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' + '409': + description: Invalid or concurrent access-key state transition + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' + '501': + description: Not Implemented + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyNotImplementedErrorResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /apis/auth/v2/iam/role-bindings: get: tags: @@ -8148,6 +8242,7 @@ components: - ACTIVE - EXPIRED - REVOKED + - SUSPENDED title: Status issuer: type: string @@ -8248,6 +8343,7 @@ components: - ACTIVE - EXPIRED - REVOKED + - SUSPENDED title: Status issuer: type: string @@ -8305,6 +8401,31 @@ components: - revoked title: AccessKeyRevokeResponse description: Response returned after a Scoped Access Key revoke request. + AccessKeyStatusChangeResponse: + properties: + jti: + type: string + title: Jti + description: Stable JWT ID for this Scoped Access Key. + status: + type: string + enum: + - ACTIVE + - EXPIRED + - SUSPENDED + title: Status + description: Resulting effective status of the key, including expiration. + changed: + type: boolean + title: Changed + description: True when this request changed the key's persistent status. + type: object + required: + - jti + - status + - changed + title: AccessKeyStatusChangeResponse + description: Response returned after a reversible Scoped Access Key status change. ActionRails: properties: instant_actions: diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 863de164a3..809f03c244 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -280,9 +280,9 @@ paths: schema: type: string pattern: ^ak_[0-9a-f]{32}$ - description: Stable JWT ID of the Scoped Access Key to revoke. + description: Stable JWT ID of the Scoped Access Key for the lifecycle operation. title: Jti - description: Stable JWT ID of the Scoped Access Key to revoke. + description: Stable JWT ID of the Scoped Access Key for the lifecycle operation. responses: '200': description: Successful Response @@ -314,6 +314,100 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' + /apis/auth/v2/access-keys/{jti}/suspend: + post: + tags: + - Scoped Access Keys + summary: Suspend Access Key + operationId: suspend_access_key_apis_auth_v2_access_keys__jti__suspend_post + parameters: + - name: jti + in: path + required: true + schema: + type: string + pattern: ^ak_[0-9a-f]{32}$ + description: Stable JWT ID of the Scoped Access Key for the lifecycle operation. + title: Jti + description: Stable JWT ID of the Scoped Access Key for the lifecycle operation. + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyStatusChangeResponse' + '404': + description: Scoped Access Keys are not enabled or the key was not found + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' + '409': + description: Invalid or concurrent access-key state transition + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' + '501': + description: Not Implemented + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyNotImplementedErrorResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/auth/v2/access-keys/{jti}/unsuspend: + post: + tags: + - Scoped Access Keys + summary: Unsuspend Access Key + operationId: unsuspend_access_key_apis_auth_v2_access_keys__jti__unsuspend_post + parameters: + - name: jti + in: path + required: true + schema: + type: string + pattern: ^ak_[0-9a-f]{32}$ + description: Stable JWT ID of the Scoped Access Key for the lifecycle operation. + title: Jti + description: Stable JWT ID of the Scoped Access Key for the lifecycle operation. + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyStatusChangeResponse' + '404': + description: Scoped Access Keys are not enabled or the key was not found + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' + '409': + description: Invalid or concurrent access-key state transition + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' + '501': + description: Not Implemented + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyNotImplementedErrorResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /apis/auth/v2/iam/role-bindings: get: tags: @@ -8148,6 +8242,7 @@ components: - ACTIVE - EXPIRED - REVOKED + - SUSPENDED title: Status issuer: type: string @@ -8248,6 +8343,7 @@ components: - ACTIVE - EXPIRED - REVOKED + - SUSPENDED title: Status issuer: type: string @@ -8305,6 +8401,31 @@ components: - revoked title: AccessKeyRevokeResponse description: Response returned after a Scoped Access Key revoke request. + AccessKeyStatusChangeResponse: + properties: + jti: + type: string + title: Jti + description: Stable JWT ID for this Scoped Access Key. + status: + type: string + enum: + - ACTIVE + - EXPIRED + - SUSPENDED + title: Status + description: Resulting effective status of the key, including expiration. + changed: + type: boolean + title: Changed + description: True when this request changed the key's persistent status. + type: object + required: + - jti + - status + - changed + title: AccessKeyStatusChangeResponse + description: Response returned after a reversible Scoped Access Key status change. ActionRails: properties: instant_actions: diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/auth.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/auth.py index 64a049186c..db54c6de82 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/auth.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/auth.py @@ -925,6 +925,47 @@ def revoke_access_key( typer.echo(f"Scoped Access Key {jti} was already revoked.") +@access_keys_app.command("suspend") +@handle_errors +def suspend_access_key( + ctx: typer.Context, + jti: Annotated[str, typer.Argument(help="Stable ID of the Scoped Access Key to suspend.")], +) -> None: + """Temporarily suspend a Scoped Access Key owned by the current user. + + Unlike revocation, suspension is reversible until the key expires. + """ + try: + result = _access_key_issuer(ctx).suspend(jti) + except AccessKeyFeatureDisabledError as exc: + _raise_access_key_disabled(exc) + except AccessKeyOperationNotImplementedError as exc: + _raise_access_key_not_implemented(exc) + if result.changed: + typer.echo(f"Suspended Scoped Access Key {jti}.") + else: + typer.echo(f"Scoped Access Key {jti} was already {result.status.lower()}.") + + +@access_keys_app.command("unsuspend") +@handle_errors +def unsuspend_access_key( + ctx: typer.Context, + jti: Annotated[str, typer.Argument(help="Stable ID of the Scoped Access Key to unsuspend.")], +) -> None: + """Restore a suspended Scoped Access Key owned by the current user.""" + try: + result = _access_key_issuer(ctx).unsuspend(jti) + except AccessKeyFeatureDisabledError as exc: + _raise_access_key_disabled(exc) + except AccessKeyOperationNotImplementedError as exc: + _raise_access_key_not_implemented(exc) + if result.changed: + typer.echo(f"Unsuspended Scoped Access Key {jti}.") + else: + typer.echo(f"Scoped Access Key {jti} was already {result.status.lower()}.") + + @app.command("status") @handle_errors def status(ctx: typer.Context) -> None: diff --git a/packages/nemo_platform_ext/tests/cli/commands/test_auth.py b/packages/nemo_platform_ext/tests/cli/commands/test_auth.py index 5a8404b3e7..d2b108dcb1 100644 --- a/packages/nemo_platform_ext/tests/cli/commands/test_auth.py +++ b/packages/nemo_platform_ext/tests/cli/commands/test_auth.py @@ -20,6 +20,7 @@ AccessKeyListResponse, AccessKeyMetadataResponse, AccessKeyRevokeResponse, + AccessKeyStatusChangeResponse, ) from nemo_platform_plugin.client.constants import WORKLOAD_IDENTITY_TOKEN_FILE_ENVVAR from nemo_platform_plugin.client.errors import NotFoundError @@ -630,6 +631,48 @@ def test_auth_access_keys_revoke_reports_missing_key(monkeypatch: pytest.MonkeyP fake_access_keys_client.revoke_access_key.assert_called_once_with(jti="ak_unknown") +def test_auth_access_keys_suspend_and_unsuspend(monkeypatch: pytest.MonkeyPatch) -> None: + fake_access_keys_client = MagicMock() + fake_access_keys_client.suspend_access_key.return_value.data.return_value = AccessKeyStatusChangeResponse( + jti="ak_example", status="SUSPENDED", changed=True + ) + fake_access_keys_client.unsuspend_access_key.return_value.data.return_value = AccessKeyStatusChangeResponse( + jti="ak_example", status="ACTIVE", changed=True + ) + monkeypatch.setattr("nemo_platform_ext.cli.core.context.CLIContext.get_client", lambda _self: MagicMock()) + monkeypatch.setattr( + "nemo_platform_ext.cli.commands.auth.client_from_platform", + lambda _platform, _client_cls: fake_access_keys_client, + ) + + suspend_result = runner.invoke(app, ["auth", "access-keys", "suspend", "ak_example"]) + unsuspend_result = runner.invoke(app, ["auth", "access-keys", "unsuspend", "ak_example"]) + + assert_exit_code(suspend_result, 0) + assert "Suspended Scoped Access Key ak_example." in suspend_result.output + assert_exit_code(unsuspend_result, 0) + assert "Unsuspended Scoped Access Key ak_example." in unsuspend_result.output + fake_access_keys_client.suspend_access_key.assert_called_once_with(jti="ak_example") + fake_access_keys_client.unsuspend_access_key.assert_called_once_with(jti="ak_example") + + +def test_auth_access_keys_unsuspend_noop_reports_expired_status(monkeypatch: pytest.MonkeyPatch) -> None: + fake_access_keys_client = MagicMock() + fake_access_keys_client.unsuspend_access_key.return_value.data.return_value = AccessKeyStatusChangeResponse( + jti="ak_example", status="EXPIRED", changed=False + ) + monkeypatch.setattr("nemo_platform_ext.cli.core.context.CLIContext.get_client", lambda _self: MagicMock()) + monkeypatch.setattr( + "nemo_platform_ext.cli.commands.auth.client_from_platform", + lambda _platform, _client_cls: fake_access_keys_client, + ) + + result = runner.invoke(app, ["auth", "access-keys", "unsuspend", "ak_example"]) + + assert_exit_code(result, 0) + assert "Scoped Access Key ak_example was already expired." in result.output + + def test_auth_access_keys_help_exposes_lifecycle_commands() -> None: result = runner.invoke(app, ["auth", "access-keys", "--help"]) @@ -637,6 +680,8 @@ def test_auth_access_keys_help_exposes_lifecycle_commands() -> None: assert "create" in result.output assert "list" in result.output assert "revoke" in result.output + assert "suspend" in result.output + assert "unsuspend" in result.output create_help = runner.invoke(app, ["auth", "access-keys", "create", "--help"]) assert_exit_code(create_help, 0) @@ -646,6 +691,10 @@ def test_auth_access_keys_help_exposes_lifecycle_commands() -> None: assert_exit_code(revoke_help, 0) assert "Stable ID of the Scoped Access Key" in " ".join(revoke_help.output.split()) + suspend_help = runner.invoke(app, ["auth", "access-keys", "suspend", "--help"]) + assert_exit_code(suspend_help, 0) + assert "Unlike revocation, suspension is reversible until the key expires" in " ".join(suspend_help.output.split()) + def test_auth_tokens_group_is_not_exposed() -> None: result = runner.invoke(app, ["auth", "tokens", "create"]) diff --git a/packages/nemo_platform_plugin/src/nemo_platform_plugin/auth/access_keys/client.py b/packages/nemo_platform_plugin/src/nemo_platform_plugin/auth/access_keys/client.py index 36c61a4850..805d378ea4 100644 --- a/packages/nemo_platform_plugin/src/nemo_platform_plugin/auth/access_keys/client.py +++ b/packages/nemo_platform_plugin/src/nemo_platform_plugin/auth/access_keys/client.py @@ -14,6 +14,7 @@ AccessKeyCreateResponse, AccessKeyListResponse, AccessKeyRevokeResponse, + AccessKeyStatusChangeResponse, ) from nemo_platform_plugin.client.client import AsyncNemoClient, NemoClient from nemo_platform_plugin.client.errors import NemoHTTPError @@ -24,6 +25,8 @@ class _AccessKeyMethods: create_access_key = method(endpoints.create_access_key) list_access_keys = method(endpoints.list_access_keys) revoke_access_key = method(endpoints.revoke_access_key) + suspend_access_key = method(endpoints.suspend_access_key) + unsuspend_access_key = method(endpoints.unsuspend_access_key) class AccessKeysClient(_AccessKeyMethods, NemoClient): @@ -44,25 +47,39 @@ def create(self, request: AccessKeyCreateRequest) -> AccessKeyCreateResponse: try: return self._client.create_access_key(body=request).data() except NemoHTTPError as exc: - _raise_domain_error_from_http(exc) + _raise_known_domain_error_from_http(exc) raise def list(self, *, page: int = 1, page_size: int = 100) -> AccessKeyListResponse: try: return self._client.list_access_keys(query_params={"page": page, "page_size": page_size}).data() except NemoHTTPError as exc: - _raise_domain_error_from_http(exc) + _raise_known_domain_error_from_http(exc) raise def revoke(self, jti: str) -> AccessKeyRevokeResponse: try: return self._client.revoke_access_key(jti=jti).data() except NemoHTTPError as exc: - _raise_domain_error_from_http(exc) + _raise_known_domain_error_from_http(exc) + raise + + def suspend(self, jti: str) -> AccessKeyStatusChangeResponse: + try: + return self._client.suspend_access_key(jti=jti).data() + except NemoHTTPError as exc: + _raise_known_domain_error_from_http(exc) + raise + + def unsuspend(self, jti: str) -> AccessKeyStatusChangeResponse: + try: + return self._client.unsuspend_access_key(jti=jti).data() + except NemoHTTPError as exc: + _raise_known_domain_error_from_http(exc) raise -def _raise_domain_error_from_http(exc: NemoHTTPError) -> None: +def _raise_known_domain_error_from_http(exc: NemoHTTPError) -> None: if exc.status_code == 501: raise AccessKeyOperationNotImplementedError(exc.detail) from exc if exc.status_code == 404: diff --git a/packages/nemo_platform_plugin/src/nemo_platform_plugin/auth/access_keys/endpoints.py b/packages/nemo_platform_plugin/src/nemo_platform_plugin/auth/access_keys/endpoints.py index 7bed60e380..b8559a0b85 100644 --- a/packages/nemo_platform_plugin/src/nemo_platform_plugin/auth/access_keys/endpoints.py +++ b/packages/nemo_platform_plugin/src/nemo_platform_plugin/auth/access_keys/endpoints.py @@ -11,6 +11,7 @@ AccessKeyListQueryParams, AccessKeyListResponse, AccessKeyRevokeResponse, + AccessKeyStatusChangeResponse, ) from nemo_platform_plugin.client.endpoint import delete, get, post @@ -28,3 +29,13 @@ def list_access_keys(*, query_params: AccessKeyListQueryParams | None = None) -> @delete("/apis/auth/v2/access-keys/{jti}") @abstractmethod def revoke_access_key(*, jti: str) -> AccessKeyRevokeResponse: ... + + +@post("/apis/auth/v2/access-keys/{jti}/suspend") +@abstractmethod +def suspend_access_key(*, jti: str) -> AccessKeyStatusChangeResponse: ... + + +@post("/apis/auth/v2/access-keys/{jti}/unsuspend") +@abstractmethod +def unsuspend_access_key(*, jti: str) -> AccessKeyStatusChangeResponse: ... diff --git a/packages/nemo_platform_plugin/src/nemo_platform_plugin/auth/access_keys/issuer.py b/packages/nemo_platform_plugin/src/nemo_platform_plugin/auth/access_keys/issuer.py index 724ab1bfc1..7b4e48834c 100644 --- a/packages/nemo_platform_plugin/src/nemo_platform_plugin/auth/access_keys/issuer.py +++ b/packages/nemo_platform_plugin/src/nemo_platform_plugin/auth/access_keys/issuer.py @@ -10,6 +10,7 @@ AccessKeyCreateResponse, AccessKeyListResponse, AccessKeyRevokeResponse, + AccessKeyStatusChangeResponse, ) @@ -29,3 +30,7 @@ def create(self, request: AccessKeyCreateRequest) -> AccessKeyCreateResponse: .. def list(self, *, page: int = 1, page_size: int = 100) -> AccessKeyListResponse: ... def revoke(self, jti: str) -> AccessKeyRevokeResponse: ... + + def suspend(self, jti: str) -> AccessKeyStatusChangeResponse: ... + + def unsuspend(self, jti: str) -> AccessKeyStatusChangeResponse: ... diff --git a/packages/nemo_platform_plugin/src/nemo_platform_plugin/auth/access_keys/types.py b/packages/nemo_platform_plugin/src/nemo_platform_plugin/auth/access_keys/types.py index 785e640b27..1f543abb00 100644 --- a/packages/nemo_platform_plugin/src/nemo_platform_plugin/auth/access_keys/types.py +++ b/packages/nemo_platform_plugin/src/nemo_platform_plugin/auth/access_keys/types.py @@ -8,7 +8,8 @@ from pydantic import BaseModel, ConfigDict, Field -AccessKeyStatus = Literal["ACTIVE", "EXPIRED", "REVOKED"] +AccessKeyStatus = Literal["ACTIVE", "EXPIRED", "REVOKED", "SUSPENDED"] +AccessKeyReversibleStatus = Literal["ACTIVE", "EXPIRED", "SUSPENDED"] class AccessKeyListQueryParams(TypedDict, total=False): @@ -96,6 +97,16 @@ class AccessKeyRevokeResponse(BaseModel): revoked: bool = Field(description="True when this request newly recorded the key's revocation.") +class AccessKeyStatusChangeResponse(BaseModel): + """Response returned after a reversible Scoped Access Key status change.""" + + jti: str = Field(description="Stable JWT ID for this Scoped Access Key.") + status: AccessKeyReversibleStatus = Field( + description="Resulting effective status of the key, including expiration." + ) + changed: bool = Field(description="True when this request changed the key's persistent status.") + + class AccessKeyNotImplementedErrorResponse(BaseModel): """Response returned by unsupported Scoped Access Key lifecycle endpoints.""" diff --git a/packages/nemo_platform_plugin/tests/auth/access_keys/test_client.py b/packages/nemo_platform_plugin/tests/auth/access_keys/test_client.py index c36040ce3e..3beb6eb559 100644 --- a/packages/nemo_platform_plugin/tests/auth/access_keys/test_client.py +++ b/packages/nemo_platform_plugin/tests/auth/access_keys/test_client.py @@ -16,6 +16,7 @@ AccessKeyCreateRequest, AccessKeyCreateResponse, AccessKeyRevokeResponse, + AccessKeyStatusChangeResponse, ) from nemo_platform_plugin.client.errors import NemoHTTPError @@ -25,6 +26,8 @@ def __init__(self) -> None: self.create_access_key = MagicMock() self.list_access_keys = MagicMock() self.revoke_access_key = MagicMock() + self.suspend_access_key = MagicMock() + self.unsuspend_access_key = MagicMock() def as_client(self) -> AccessKeysClient: return cast(AccessKeysClient, self) @@ -66,6 +69,22 @@ def test_access_key_issuer_client_revokes_by_jti() -> None: client.revoke_access_key.assert_called_once_with(jti="ak_example") +def test_access_key_issuer_client_suspends_and_unsuspends_by_jti() -> None: + client = _AccessKeysClientStub() + client.suspend_access_key.return_value.data.return_value = AccessKeyStatusChangeResponse( + jti="ak_example", status="SUSPENDED", changed=True + ) + client.unsuspend_access_key.return_value.data.return_value = AccessKeyStatusChangeResponse( + jti="ak_example", status="ACTIVE", changed=True + ) + issuer = AccessKeyIssuerClient(client.as_client()) + + assert issuer.suspend("ak_example").changed + assert issuer.unsuspend("ak_example").changed + client.suspend_access_key.assert_called_once_with(jti="ak_example") + client.unsuspend_access_key.assert_called_once_with(jti="ak_example") + + def test_access_key_issuer_client_lists_requested_page() -> None: client = _AccessKeysClientStub() issuer = AccessKeyIssuerClient(client.as_client()) diff --git a/packages/nemo_platform_plugin/tests/auth/access_keys/test_endpoints.py b/packages/nemo_platform_plugin/tests/auth/access_keys/test_endpoints.py index 3ff6f6deb9..759599cc82 100644 --- a/packages/nemo_platform_plugin/tests/auth/access_keys/test_endpoints.py +++ b/packages/nemo_platform_plugin/tests/auth/access_keys/test_endpoints.py @@ -37,3 +37,19 @@ def test_list_access_keys_endpoint_supports_pagination() -> None: assert prepared.method == "GET" assert prepared.path_template == "/apis/auth/v2/access-keys" assert prepared.query_params == {"page": 3, "page_size": 25} + + +def test_suspend_access_key_endpoint_uses_jti_path_param() -> None: + prepared = endpoints.suspend_access_key(jti="ak_example") + + assert prepared.method == "POST" + assert prepared.path_template == "/apis/auth/v2/access-keys/{jti}/suspend" + assert prepared.path_params == {"jti": "ak_example"} + + +def test_unsuspend_access_key_endpoint_uses_jti_path_param() -> None: + prepared = endpoints.unsuspend_access_key(jti="ak_example") + + assert prepared.method == "POST" + assert prepared.path_template == "/apis/auth/v2/access-keys/{jti}/unsuspend" + assert prepared.path_params == {"jti": "ak_example"} diff --git a/packages/nmp_common/src/nmp/common/auth/access_keys.py b/packages/nmp_common/src/nmp/common/auth/access_keys.py index 06eefdf1fd..6cd150f4ab 100644 --- a/packages/nmp_common/src/nmp/common/auth/access_keys.py +++ b/packages/nmp_common/src/nmp/common/auth/access_keys.py @@ -25,6 +25,7 @@ AccessKeyListResponse, AccessKeyRevokeResponse, AccessKeyStatus, + AccessKeyStatusChangeResponse, ) from nmp.common.config import AuthConfig, get_platform_config @@ -237,6 +238,14 @@ def revoke(self, jti: str) -> AccessKeyRevokeResponse: self._ensure_enabled() raise AccessKeyOperationNotImplementedError(f"Scoped Access Key revocation for {jti} is not implemented.") + def suspend(self, jti: str) -> AccessKeyStatusChangeResponse: + self._ensure_enabled() + raise AccessKeyOperationNotImplementedError(f"Scoped Access Key suspension for {jti} is not implemented.") + + def unsuspend(self, jti: str) -> AccessKeyStatusChangeResponse: + self._ensure_enabled() + raise AccessKeyOperationNotImplementedError(f"Scoped Access Key unsuspension for {jti} is not implemented.") + def _create_access_key_token( config: AuthConfig, diff --git a/sdk/python/nemo-platform/.nmpcontext/openapi.yaml b/sdk/python/nemo-platform/.nmpcontext/openapi.yaml index 473903a76d..d299452c47 100644 --- a/sdk/python/nemo-platform/.nmpcontext/openapi.yaml +++ b/sdk/python/nemo-platform/.nmpcontext/openapi.yaml @@ -283,9 +283,9 @@ paths: schema: type: string pattern: ^ak_[0-9a-f]{32}$ - description: Stable JWT ID of the Scoped Access Key to revoke. + description: Stable JWT ID of the Scoped Access Key for the lifecycle operation. title: Jti - description: Stable JWT ID of the Scoped Access Key to revoke. + description: Stable JWT ID of the Scoped Access Key for the lifecycle operation. responses: '200': description: Successful Response @@ -317,6 +317,100 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' + /apis/auth/v2/access-keys/{jti}/suspend: + post: + tags: + - Scoped Access Keys + summary: Suspend Access Key + operationId: suspend_access_key_apis_auth_v2_access_keys__jti__suspend_post + parameters: + - name: jti + in: path + required: true + schema: + type: string + pattern: ^ak_[0-9a-f]{32}$ + description: Stable JWT ID of the Scoped Access Key for the lifecycle operation. + title: Jti + description: Stable JWT ID of the Scoped Access Key for the lifecycle operation. + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyStatusChangeResponse' + '404': + description: Scoped Access Keys are not enabled or the key was not found + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' + '409': + description: Invalid or concurrent access-key state transition + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' + '501': + description: Not Implemented + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyNotImplementedErrorResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/auth/v2/access-keys/{jti}/unsuspend: + post: + tags: + - Scoped Access Keys + summary: Unsuspend Access Key + operationId: unsuspend_access_key_apis_auth_v2_access_keys__jti__unsuspend_post + parameters: + - name: jti + in: path + required: true + schema: + type: string + pattern: ^ak_[0-9a-f]{32}$ + description: Stable JWT ID of the Scoped Access Key for the lifecycle operation. + title: Jti + description: Stable JWT ID of the Scoped Access Key for the lifecycle operation. + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyStatusChangeResponse' + '404': + description: Scoped Access Keys are not enabled or the key was not found + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' + '409': + description: Invalid or concurrent access-key state transition + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' + '501': + description: Not Implemented + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyNotImplementedErrorResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /apis/auth/v2/iam/role-bindings: get: tags: @@ -8151,6 +8245,7 @@ components: - ACTIVE - EXPIRED - REVOKED + - SUSPENDED title: Status issuer: type: string @@ -8251,6 +8346,7 @@ components: - ACTIVE - EXPIRED - REVOKED + - SUSPENDED title: Status issuer: type: string @@ -8308,6 +8404,31 @@ components: - revoked title: AccessKeyRevokeResponse description: Response returned after a Scoped Access Key revoke request. + AccessKeyStatusChangeResponse: + properties: + jti: + type: string + title: Jti + description: Stable JWT ID for this Scoped Access Key. + status: + type: string + enum: + - ACTIVE + - EXPIRED + - SUSPENDED + title: Status + description: Resulting effective status of the key, including expiration. + changed: + type: boolean + title: Changed + description: True when this request changed the key's persistent status. + type: object + required: + - jti + - status + - changed + title: AccessKeyStatusChangeResponse + description: Response returned after a reversible Scoped Access Key status change. ActionRails: properties: instant_actions: diff --git a/sdk/python/nemo-platform/.nmpcontext/stainless.yaml b/sdk/python/nemo-platform/.nmpcontext/stainless.yaml index 9473a9c275..ed520ffe2b 100644 --- a/sdk/python/nemo-platform/.nmpcontext/stainless.yaml +++ b/sdk/python/nemo-platform/.nmpcontext/stainless.yaml @@ -979,7 +979,10 @@ resources: access_key_metadata_response: AccessKeyMetadataResponse access_key_not_implemented_error_response: AccessKeyNotImplementedErrorResponse access_key_revoke_response: AccessKeyRevokeResponse + access_key_status_change_response: AccessKeyStatusChangeResponse methods: list: get /apis/auth/v2/access-keys create: post /apis/auth/v2/access-keys delete: delete /apis/auth/v2/access-keys/{jti} + suspend: post /apis/auth/v2/access-keys/{jti}/suspend + unsuspend: post /apis/auth/v2/access-keys/{jti}/unsuspend diff --git a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/auth.py b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/auth.py index 326dfa1d2d..c3cb3314a6 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/auth.py +++ b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/auth.py @@ -925,6 +925,47 @@ def revoke_access_key( typer.echo(f"Scoped Access Key {jti} was already revoked.") +@access_keys_app.command("suspend") +@handle_errors +def suspend_access_key( + ctx: typer.Context, + jti: Annotated[str, typer.Argument(help="Stable ID of the Scoped Access Key to suspend.")], +) -> None: + """Temporarily suspend a Scoped Access Key owned by the current user. + + Unlike revocation, suspension is reversible until the key expires. + """ + try: + result = _access_key_issuer(ctx).suspend(jti) + except AccessKeyFeatureDisabledError as exc: + _raise_access_key_disabled(exc) + except AccessKeyOperationNotImplementedError as exc: + _raise_access_key_not_implemented(exc) + if result.changed: + typer.echo(f"Suspended Scoped Access Key {jti}.") + else: + typer.echo(f"Scoped Access Key {jti} was already {result.status.lower()}.") + + +@access_keys_app.command("unsuspend") +@handle_errors +def unsuspend_access_key( + ctx: typer.Context, + jti: Annotated[str, typer.Argument(help="Stable ID of the Scoped Access Key to unsuspend.")], +) -> None: + """Restore a suspended Scoped Access Key owned by the current user.""" + try: + result = _access_key_issuer(ctx).unsuspend(jti) + except AccessKeyFeatureDisabledError as exc: + _raise_access_key_disabled(exc) + except AccessKeyOperationNotImplementedError as exc: + _raise_access_key_not_implemented(exc) + if result.changed: + typer.echo(f"Unsuspended Scoped Access Key {jti}.") + else: + typer.echo(f"Scoped Access Key {jti} was already {result.status.lower()}.") + + @app.command("status") @handle_errors def status(ctx: typer.Context) -> None: diff --git a/sdk/python/nemo-platform/src/nemo_platform/resources/access_keys/access_keys.py b/sdk/python/nemo-platform/src/nemo_platform/resources/access_keys/access_keys.py index 767bd20275..83c06a9b85 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/resources/access_keys/access_keys.py +++ b/sdk/python/nemo-platform/src/nemo_platform/resources/access_keys/access_keys.py @@ -36,6 +36,7 @@ from ...types.access_keys.access_key_list_response import AccessKeyListResponse from ...types.access_keys.access_key_create_response import AccessKeyCreateResponse from ...types.access_keys.access_key_revoke_response import AccessKeyRevokeResponse +from ...types.access_keys.access_key_status_change_response import AccessKeyStatusChangeResponse __all__ = ["AccessKeysResource", "AsyncAccessKeysResource"] @@ -172,7 +173,7 @@ def delete( Revoke Access Key Args: - jti: Stable JWT ID of the Scoped Access Key to revoke. + jti: Stable JWT ID of the Scoped Access Key for the lifecycle operation. extra_headers: Send extra headers @@ -192,6 +193,76 @@ def delete( cast_to=AccessKeyRevokeResponse, ) + def suspend( + self, + jti: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AccessKeyStatusChangeResponse: + """ + Suspend Access Key + + Args: + jti: Stable JWT ID of the Scoped Access Key for the lifecycle operation. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not jti: + raise ValueError(f"Expected a non-empty value for `jti` but received {jti!r}") + return self._post( + path_template("/apis/auth/v2/access-keys/{jti}/suspend", jti=jti), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AccessKeyStatusChangeResponse, + ) + + def unsuspend( + self, + jti: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AccessKeyStatusChangeResponse: + """ + Unsuspend Access Key + + Args: + jti: Stable JWT ID of the Scoped Access Key for the lifecycle operation. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not jti: + raise ValueError(f"Expected a non-empty value for `jti` but received {jti!r}") + return self._post( + path_template("/apis/auth/v2/access-keys/{jti}/unsuspend", jti=jti), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AccessKeyStatusChangeResponse, + ) + class AsyncAccessKeysResource(AsyncAPIResource): @cached_property @@ -325,7 +396,7 @@ async def delete( Revoke Access Key Args: - jti: Stable JWT ID of the Scoped Access Key to revoke. + jti: Stable JWT ID of the Scoped Access Key for the lifecycle operation. extra_headers: Send extra headers @@ -345,6 +416,76 @@ async def delete( cast_to=AccessKeyRevokeResponse, ) + async def suspend( + self, + jti: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AccessKeyStatusChangeResponse: + """ + Suspend Access Key + + Args: + jti: Stable JWT ID of the Scoped Access Key for the lifecycle operation. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not jti: + raise ValueError(f"Expected a non-empty value for `jti` but received {jti!r}") + return await self._post( + path_template("/apis/auth/v2/access-keys/{jti}/suspend", jti=jti), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AccessKeyStatusChangeResponse, + ) + + async def unsuspend( + self, + jti: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AccessKeyStatusChangeResponse: + """ + Unsuspend Access Key + + Args: + jti: Stable JWT ID of the Scoped Access Key for the lifecycle operation. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not jti: + raise ValueError(f"Expected a non-empty value for `jti` but received {jti!r}") + return await self._post( + path_template("/apis/auth/v2/access-keys/{jti}/unsuspend", jti=jti), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AccessKeyStatusChangeResponse, + ) + class AccessKeysResourceWithRawResponse: def __init__(self, access_keys: AccessKeysResource) -> None: @@ -359,6 +500,12 @@ def __init__(self, access_keys: AccessKeysResource) -> None: self.delete = to_raw_response_wrapper( access_keys.delete, ) + self.suspend = to_raw_response_wrapper( + access_keys.suspend, + ) + self.unsuspend = to_raw_response_wrapper( + access_keys.unsuspend, + ) class AsyncAccessKeysResourceWithRawResponse: @@ -374,6 +521,12 @@ def __init__(self, access_keys: AsyncAccessKeysResource) -> None: self.delete = async_to_raw_response_wrapper( access_keys.delete, ) + self.suspend = async_to_raw_response_wrapper( + access_keys.suspend, + ) + self.unsuspend = async_to_raw_response_wrapper( + access_keys.unsuspend, + ) class AccessKeysResourceWithStreamingResponse: @@ -389,6 +542,12 @@ def __init__(self, access_keys: AccessKeysResource) -> None: self.delete = to_streamed_response_wrapper( access_keys.delete, ) + self.suspend = to_streamed_response_wrapper( + access_keys.suspend, + ) + self.unsuspend = to_streamed_response_wrapper( + access_keys.unsuspend, + ) class AsyncAccessKeysResourceWithStreamingResponse: @@ -404,3 +563,9 @@ def __init__(self, access_keys: AsyncAccessKeysResource) -> None: self.delete = async_to_streamed_response_wrapper( access_keys.delete, ) + self.suspend = async_to_streamed_response_wrapper( + access_keys.suspend, + ) + self.unsuspend = async_to_streamed_response_wrapper( + access_keys.unsuspend, + ) diff --git a/sdk/python/nemo-platform/src/nemo_platform/resources/access_keys/api.md b/sdk/python/nemo-platform/src/nemo_platform/resources/access_keys/api.md index 3a832b04db..316a673dda 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/resources/access_keys/api.md +++ b/sdk/python/nemo-platform/src/nemo_platform/resources/access_keys/api.md @@ -14,6 +14,7 @@ from nemo_platform.types.access_keys import ( AccessKeyMetadataResponse, AccessKeyNotImplementedErrorResponse, AccessKeyRevokeResponse, + AccessKeyStatusChangeResponse, ) ``` @@ -22,3 +23,5 @@ Methods: - client.access_keys.create(\*\*params) -> AccessKeyCreateResponse - client.access_keys.list(\*\*params) -> AccessKeyListResponse - client.access_keys.delete(jti) -> AccessKeyRevokeResponse +- client.access_keys.suspend(jti) -> AccessKeyStatusChangeResponse +- client.access_keys.unsuspend(jti) -> AccessKeyStatusChangeResponse diff --git a/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/__init__.py b/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/__init__.py index 9f63b1104d..02b6320df4 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/__init__.py +++ b/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/__init__.py @@ -23,3 +23,4 @@ from .access_key_create_response import AccessKeyCreateResponse as AccessKeyCreateResponse from .access_key_revoke_response import AccessKeyRevokeResponse as AccessKeyRevokeResponse from .access_key_metadata_response import AccessKeyMetadataResponse as AccessKeyMetadataResponse +from .access_key_status_change_response import AccessKeyStatusChangeResponse as AccessKeyStatusChangeResponse diff --git a/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_create_response.py b/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_create_response.py index 14c5d71ab3..91aeb98ce5 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_create_response.py +++ b/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_create_response.py @@ -43,7 +43,7 @@ class AccessKeyCreateResponse(BaseModel): principal: str """Principal ID stamped into the token.""" - status: Literal["ACTIVE", "EXPIRED", "REVOKED"] + status: Literal["ACTIVE", "EXPIRED", "REVOKED", "SUSPENDED"] token_type: Literal["Bearer"] diff --git a/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_metadata_response.py b/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_metadata_response.py index d2e327655a..f4f777d28c 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_metadata_response.py +++ b/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_metadata_response.py @@ -41,7 +41,7 @@ class AccessKeyMetadataResponse(BaseModel): principal: str """Principal ID stamped into the token.""" - status: Literal["ACTIVE", "EXPIRED", "REVOKED"] + status: Literal["ACTIVE", "EXPIRED", "REVOKED", "SUSPENDED"] description: Optional[str] = None """Human-readable description of the Scoped Access Key.""" diff --git a/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_status_change_response.py b/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_status_change_response.py new file mode 100644 index 0000000000..f0dd73e408 --- /dev/null +++ b/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_status_change_response.py @@ -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. + +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import Literal + +from ..._models import BaseModel + +__all__ = ["AccessKeyStatusChangeResponse"] + + +class AccessKeyStatusChangeResponse(BaseModel): + """Response returned after a reversible Scoped Access Key status change.""" + + changed: bool + """True when this request changed the key's persistent status.""" + + jti: str + """Stable JWT ID for this Scoped Access Key.""" + + status: Literal["ACTIVE", "EXPIRED", "SUSPENDED"] + """Resulting effective status of the key, including expiration.""" diff --git a/sdk/python/nemo-platform/tests/api_resources/test_access_keys.py b/sdk/python/nemo-platform/tests/api_resources/test_access_keys.py index 27fe1da911..b971bf55aa 100644 --- a/sdk/python/nemo-platform/tests/api_resources/test_access_keys.py +++ b/sdk/python/nemo-platform/tests/api_resources/test_access_keys.py @@ -28,6 +28,7 @@ AccessKeyListResponse, AccessKeyCreateResponse, AccessKeyRevokeResponse, + AccessKeyStatusChangeResponse, ) base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -153,6 +154,90 @@ def test_path_params_delete(self, client: NeMoPlatform) -> None: "", ) + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_suspend(self, client: NeMoPlatform) -> None: + access_key = client.access_keys.suspend( + "ak_ecc2efdd09bd231a9ad9bd2aada37aa7", + ) + assert_matches_type(AccessKeyStatusChangeResponse, access_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_suspend(self, client: NeMoPlatform) -> None: + response = client.access_keys.with_raw_response.suspend( + "ak_ecc2efdd09bd231a9ad9bd2aada37aa7", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + access_key = response.parse() + assert_matches_type(AccessKeyStatusChangeResponse, access_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_suspend(self, client: NeMoPlatform) -> None: + with client.access_keys.with_streaming_response.suspend( + "ak_ecc2efdd09bd231a9ad9bd2aada37aa7", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + access_key = response.parse() + assert_matches_type(AccessKeyStatusChangeResponse, access_key, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_suspend(self, client: NeMoPlatform) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `jti` but received ''"): + client.access_keys.with_raw_response.suspend( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_unsuspend(self, client: NeMoPlatform) -> None: + access_key = client.access_keys.unsuspend( + "ak_ecc2efdd09bd231a9ad9bd2aada37aa7", + ) + assert_matches_type(AccessKeyStatusChangeResponse, access_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_unsuspend(self, client: NeMoPlatform) -> None: + response = client.access_keys.with_raw_response.unsuspend( + "ak_ecc2efdd09bd231a9ad9bd2aada37aa7", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + access_key = response.parse() + assert_matches_type(AccessKeyStatusChangeResponse, access_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_unsuspend(self, client: NeMoPlatform) -> None: + with client.access_keys.with_streaming_response.unsuspend( + "ak_ecc2efdd09bd231a9ad9bd2aada37aa7", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + access_key = response.parse() + assert_matches_type(AccessKeyStatusChangeResponse, access_key, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_unsuspend(self, client: NeMoPlatform) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `jti` but received ''"): + client.access_keys.with_raw_response.unsuspend( + "", + ) + class TestAsyncAccessKeys: parametrize = pytest.mark.parametrize( @@ -275,3 +360,87 @@ async def test_path_params_delete(self, async_client: AsyncNeMoPlatform) -> None await async_client.access_keys.with_raw_response.delete( "", ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_suspend(self, async_client: AsyncNeMoPlatform) -> None: + access_key = await async_client.access_keys.suspend( + "ak_ecc2efdd09bd231a9ad9bd2aada37aa7", + ) + assert_matches_type(AccessKeyStatusChangeResponse, access_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_suspend(self, async_client: AsyncNeMoPlatform) -> None: + response = await async_client.access_keys.with_raw_response.suspend( + "ak_ecc2efdd09bd231a9ad9bd2aada37aa7", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + access_key = await response.parse() + assert_matches_type(AccessKeyStatusChangeResponse, access_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_suspend(self, async_client: AsyncNeMoPlatform) -> None: + async with async_client.access_keys.with_streaming_response.suspend( + "ak_ecc2efdd09bd231a9ad9bd2aada37aa7", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + access_key = await response.parse() + assert_matches_type(AccessKeyStatusChangeResponse, access_key, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_suspend(self, async_client: AsyncNeMoPlatform) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `jti` but received ''"): + await async_client.access_keys.with_raw_response.suspend( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_unsuspend(self, async_client: AsyncNeMoPlatform) -> None: + access_key = await async_client.access_keys.unsuspend( + "ak_ecc2efdd09bd231a9ad9bd2aada37aa7", + ) + assert_matches_type(AccessKeyStatusChangeResponse, access_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_unsuspend(self, async_client: AsyncNeMoPlatform) -> None: + response = await async_client.access_keys.with_raw_response.unsuspend( + "ak_ecc2efdd09bd231a9ad9bd2aada37aa7", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + access_key = await response.parse() + assert_matches_type(AccessKeyStatusChangeResponse, access_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_unsuspend(self, async_client: AsyncNeMoPlatform) -> None: + async with async_client.access_keys.with_streaming_response.unsuspend( + "ak_ecc2efdd09bd231a9ad9bd2aada37aa7", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + access_key = await response.parse() + assert_matches_type(AccessKeyStatusChangeResponse, access_key, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_unsuspend(self, async_client: AsyncNeMoPlatform) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `jti` but received ''"): + await async_client.access_keys.with_raw_response.unsuspend( + "", + ) diff --git a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_auth.py b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_auth.py index eb74c7350b..1f10dcb0ab 100644 --- a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_auth.py +++ b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_auth.py @@ -20,6 +20,7 @@ AccessKeyListResponse, AccessKeyMetadataResponse, AccessKeyRevokeResponse, + AccessKeyStatusChangeResponse, ) from nemo_platform_plugin.client.constants import WORKLOAD_IDENTITY_TOKEN_FILE_ENVVAR from nemo_platform_plugin.client.errors import NotFoundError @@ -630,6 +631,48 @@ def test_auth_access_keys_revoke_reports_missing_key(monkeypatch: pytest.MonkeyP fake_access_keys_client.revoke_access_key.assert_called_once_with(jti="ak_unknown") +def test_auth_access_keys_suspend_and_unsuspend(monkeypatch: pytest.MonkeyPatch) -> None: + fake_access_keys_client = MagicMock() + fake_access_keys_client.suspend_access_key.return_value.data.return_value = AccessKeyStatusChangeResponse( + jti="ak_example", status="SUSPENDED", changed=True + ) + fake_access_keys_client.unsuspend_access_key.return_value.data.return_value = AccessKeyStatusChangeResponse( + jti="ak_example", status="ACTIVE", changed=True + ) + monkeypatch.setattr("nemo_platform.cli.core.context.CLIContext.get_client", lambda _self: MagicMock()) + monkeypatch.setattr( + "nemo_platform.cli.commands.auth.client_from_platform", + lambda _platform, _client_cls: fake_access_keys_client, + ) + + suspend_result = runner.invoke(app, ["auth", "access-keys", "suspend", "ak_example"]) + unsuspend_result = runner.invoke(app, ["auth", "access-keys", "unsuspend", "ak_example"]) + + assert_exit_code(suspend_result, 0) + assert "Suspended Scoped Access Key ak_example." in suspend_result.output + assert_exit_code(unsuspend_result, 0) + assert "Unsuspended Scoped Access Key ak_example." in unsuspend_result.output + fake_access_keys_client.suspend_access_key.assert_called_once_with(jti="ak_example") + fake_access_keys_client.unsuspend_access_key.assert_called_once_with(jti="ak_example") + + +def test_auth_access_keys_unsuspend_noop_reports_expired_status(monkeypatch: pytest.MonkeyPatch) -> None: + fake_access_keys_client = MagicMock() + fake_access_keys_client.unsuspend_access_key.return_value.data.return_value = AccessKeyStatusChangeResponse( + jti="ak_example", status="EXPIRED", changed=False + ) + monkeypatch.setattr("nemo_platform.cli.core.context.CLIContext.get_client", lambda _self: MagicMock()) + monkeypatch.setattr( + "nemo_platform.cli.commands.auth.client_from_platform", + lambda _platform, _client_cls: fake_access_keys_client, + ) + + result = runner.invoke(app, ["auth", "access-keys", "unsuspend", "ak_example"]) + + assert_exit_code(result, 0) + assert "Scoped Access Key ak_example was already expired." in result.output + + def test_auth_access_keys_help_exposes_lifecycle_commands() -> None: result = runner.invoke(app, ["auth", "access-keys", "--help"]) @@ -637,6 +680,8 @@ def test_auth_access_keys_help_exposes_lifecycle_commands() -> None: assert "create" in result.output assert "list" in result.output assert "revoke" in result.output + assert "suspend" in result.output + assert "unsuspend" in result.output create_help = runner.invoke(app, ["auth", "access-keys", "create", "--help"]) assert_exit_code(create_help, 0) @@ -646,6 +691,10 @@ def test_auth_access_keys_help_exposes_lifecycle_commands() -> None: assert_exit_code(revoke_help, 0) assert "Stable ID of the Scoped Access Key" in " ".join(revoke_help.output.split()) + suspend_help = runner.invoke(app, ["auth", "access-keys", "suspend", "--help"]) + assert_exit_code(suspend_help, 0) + assert "Unlike revocation, suspension is reversible until the key expires" in " ".join(suspend_help.output.split()) + def test_auth_tokens_group_is_not_exposed() -> None: result = runner.invoke(app, ["auth", "tokens", "create"]) diff --git a/sdk/stainless.yaml b/sdk/stainless.yaml index 9473a9c275..ed520ffe2b 100644 --- a/sdk/stainless.yaml +++ b/sdk/stainless.yaml @@ -979,7 +979,10 @@ resources: access_key_metadata_response: AccessKeyMetadataResponse access_key_not_implemented_error_response: AccessKeyNotImplementedErrorResponse access_key_revoke_response: AccessKeyRevokeResponse + access_key_status_change_response: AccessKeyStatusChangeResponse methods: list: get /apis/auth/v2/access-keys create: post /apis/auth/v2/access-keys delete: delete /apis/auth/v2/access-keys/{jti} + suspend: post /apis/auth/v2/access-keys/{jti}/suspend + unsuspend: post /apis/auth/v2/access-keys/{jti}/unsuspend diff --git a/services/core/auth/src/nmp/core/auth/api/v2/access_keys/endpoints.py b/services/core/auth/src/nmp/core/auth/api/v2/access_keys/endpoints.py index a74603efdf..79e7adf7f7 100644 --- a/services/core/auth/src/nmp/core/auth/api/v2/access_keys/endpoints.py +++ b/services/core/auth/src/nmp/core/auth/api/v2/access_keys/endpoints.py @@ -3,6 +3,7 @@ from __future__ import annotations +from collections.abc import Awaitable, Callable from typing import Annotated, Any from fastapi import APIRouter, Depends, HTTPException, Path, Query, status @@ -11,6 +12,7 @@ AccessKeyFeatureDisabledError, AccessKeyOperationNotImplementedError, ) +from nemo_platform_plugin.auth.access_keys.types import AccessKeyReversibleStatus from nmp.common.auth import AuthClient, get_auth_client from nmp.common.auth.access_keys import ACCESS_KEY_JTI_PATTERN, AccessKeyValidationError from nmp.common.config import get_auth_config @@ -18,6 +20,7 @@ from nmp.core.auth.app.access_keys import ( AccessKeyNotFoundError, AccessKeyRegistry, + AccessKeyStateConflictError, PersistentAccessKeyIssuer, get_access_key_registry, ) @@ -32,7 +35,7 @@ str, Path( pattern=ACCESS_KEY_JTI_PATTERN, - description="Stable JWT ID of the Scoped Access Key to revoke.", + description="Stable JWT ID of the Scoped Access Key for the lifecycle operation.", ), ] @@ -52,6 +55,10 @@ "description": "Concurrent access-key update conflict", "model": schemas.AccessKeyErrorResponse, } +_ACCESS_KEY_STATE_CONFLICT_ERROR_RESPONSE: dict[str, Any] = { + "description": "Invalid or concurrent access-key state transition", + "model": schemas.AccessKeyErrorResponse, +} _ACCESS_KEY_CREATE_ERROR_RESPONSES: dict[int | str, dict[str, Any]] = { 400: { "description": "Scoped Access Key creation error", @@ -70,6 +77,11 @@ 409: _ACCESS_KEY_CONFLICT_ERROR_RESPONSE, 501: _ACCESS_KEY_NOT_IMPLEMENTED_ERROR_RESPONSE, } +_ACCESS_KEY_SUSPENSION_ERROR_RESPONSES: dict[int | str, dict[str, Any]] = { + 404: _ACCESS_KEY_DISABLED_OR_NOT_FOUND_ERROR_RESPONSE, + 409: _ACCESS_KEY_STATE_CONFLICT_ERROR_RESPONSE, + 501: _ACCESS_KEY_NOT_IMPLEMENTED_ERROR_RESPONSE, +} def get_access_key_issuer( @@ -90,6 +102,25 @@ def _disabled_response() -> JSONResponse: ) +async def _change_suspension_status( + jti: str, + transition: Callable[[str], Awaitable[tuple[bool, AccessKeyReversibleStatus]]], +) -> schemas.AccessKeyStatusChangeResponse | JSONResponse: + try: + changed, effective_status = await transition(jti) + except AccessKeyFeatureDisabledError: + return _disabled_response() + except AccessKeyOperationNotImplementedError as exc: + raise _not_implemented(exc) from exc + except AccessKeyNotFoundError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except AccessKeyStateConflictError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + except EntityConflictError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Concurrent update conflict; retry.") from exc + return schemas.AccessKeyStatusChangeResponse(jti=jti, status=effective_status, changed=changed) + + @router.post( "/v2/access-keys", response_model=schemas.AccessKeyCreateResponse, @@ -149,3 +180,27 @@ async def revoke_access_key( except EntityConflictError as exc: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Concurrent update conflict; retry.") from exc return schemas.AccessKeyRevokeResponse(jti=jti, revoked=revoked) + + +@router.post( + "/v2/access-keys/{jti}/suspend", + response_model=schemas.AccessKeyStatusChangeResponse, + responses=_ACCESS_KEY_SUSPENSION_ERROR_RESPONSES, +) +async def suspend_access_key( + jti: _AccessKeyJTI, + issuer: PersistentAccessKeyIssuer = Depends(get_access_key_issuer), +) -> schemas.AccessKeyStatusChangeResponse | JSONResponse: + return await _change_suspension_status(jti, issuer.suspend_async) + + +@router.post( + "/v2/access-keys/{jti}/unsuspend", + response_model=schemas.AccessKeyStatusChangeResponse, + responses=_ACCESS_KEY_SUSPENSION_ERROR_RESPONSES, +) +async def unsuspend_access_key( + jti: _AccessKeyJTI, + issuer: PersistentAccessKeyIssuer = Depends(get_access_key_issuer), +) -> schemas.AccessKeyStatusChangeResponse | JSONResponse: + return await _change_suspension_status(jti, issuer.unsuspend_async) diff --git a/services/core/auth/src/nmp/core/auth/api/v2/access_keys/schemas.py b/services/core/auth/src/nmp/core/auth/api/v2/access_keys/schemas.py index 9165bfff6b..92cb153c06 100644 --- a/services/core/auth/src/nmp/core/auth/api/v2/access_keys/schemas.py +++ b/services/core/auth/src/nmp/core/auth/api/v2/access_keys/schemas.py @@ -12,6 +12,9 @@ AccessKeyNotImplementedErrorResponse as AccessKeyNotImplementedErrorResponse, ) from nemo_platform_plugin.auth.access_keys.types import AccessKeyRevokeResponse as AccessKeyRevokeResponse +from nemo_platform_plugin.auth.access_keys.types import ( + AccessKeyStatusChangeResponse as AccessKeyStatusChangeResponse, +) from pydantic import BaseModel, Field diff --git a/services/core/auth/src/nmp/core/auth/app/access_keys.py b/services/core/auth/src/nmp/core/auth/app/access_keys.py index 56bb1eed14..e2f9512b15 100644 --- a/services/core/auth/src/nmp/core/auth/app/access_keys.py +++ b/services/core/auth/src/nmp/core/auth/app/access_keys.py @@ -7,6 +7,7 @@ import logging from datetime import UTC, datetime, timedelta +from typing import Literal from fastapi import Depends from nemo_platform_plugin.auth.access_keys.issuer import AccessKeyFeatureDisabledError @@ -15,6 +16,7 @@ AccessKeyCreateResponse, AccessKeyListResponse, AccessKeyMetadataResponse, + AccessKeyReversibleStatus, AccessKeyStatus, ) from nmp.common.auth.access_keys import LEGACY_ACCESS_KEY_METADATA_VERSION, AccessKeyIssuerService @@ -33,6 +35,10 @@ class AccessKeyNotFoundError(Exception): """Raised when a key does not exist or is not owned by the caller.""" +class AccessKeyStateConflictError(Exception): + """Raised when an irreversible lifecycle state prevents a transition.""" + + class AccessKeyRegistry: """Durable access-key lifecycle records stored by the entities service.""" @@ -92,6 +98,40 @@ async def revoke(self, jti: str, principal: str) -> bool: raise return True + async def suspend(self, jti: str, principal: str) -> tuple[bool, AccessKeyReversibleStatus]: + return await self._set_suspension(jti, principal, suspended=True) + + async def unsuspend(self, jti: str, principal: str) -> tuple[bool, AccessKeyReversibleStatus]: + return await self._set_suspension(jti, principal, suspended=False) + + async def _set_suspension( + self, jti: str, principal: str, *, suspended: bool + ) -> tuple[bool, AccessKeyReversibleStatus]: + target_status: Literal["ACTIVE", "SUSPENDED"] = "SUSPENDED" if suspended else "ACTIVE" + completed_action = "suspended" if suspended else "unsuspended" + record = await self._get_owned(jti, principal) + if record.status == "REVOKED": + raise AccessKeyStateConflictError(f"Revoked Scoped Access Key {jti} cannot be {completed_action}") + effective_status = self._reversible_status(record) + if effective_status == "EXPIRED": + return False, effective_status + if record.status == target_status: + return False, effective_status + updated = record.model_copy(update={"status": target_status}) + try: + await self._entity_client.update(updated) + except EntityConflictError: + # EntityClient.update uses db_version optimistic locking. Re-read to + # determine whether a concurrent update won the race. + current = await self._get_owned(jti, principal) + if current.status == "REVOKED": + raise AccessKeyStateConflictError(f"Revoked Scoped Access Key {jti} cannot be {completed_action}") + current_status = self._reversible_status(current) + if current_status == "EXPIRED" or current.status == target_status: + return False, current_status + raise + return True, self._reversible_status(updated) + async def is_active(self, jti: str, principal: str, *, claims: TokenClaims | None = None) -> bool: try: record = await self._get_owned(jti, principal) @@ -136,14 +176,21 @@ def _metadata(record: AccessKeyEntity) -> AccessKeyMetadataResponse: def _status(record: AccessKeyEntity, *, leeway_seconds: int = 0) -> AccessKeyStatus: if record.status == "REVOKED": return "REVOKED" - if record.status == "SUSPENDED": - return "REVOKED" if record.expires_at is not None and datetime.now(tz=UTC) >= record.expires_at + timedelta( seconds=leeway_seconds ): return "EXPIRED" + if record.status == "SUSPENDED": + return "SUSPENDED" return "ACTIVE" + @staticmethod + def _reversible_status(record: AccessKeyEntity) -> AccessKeyReversibleStatus: + effective_status = AccessKeyRegistry._status(record) + if effective_status == "REVOKED": + raise AssertionError("A reversible access-key transition cannot produce REVOKED status") + return effective_status + async def _backfill_legacy_record( self, jti: str, @@ -290,6 +337,38 @@ async def revoke_async(self, jti: str) -> bool: ) return revoked + async def suspend_async(self, jti: str) -> tuple[bool, AccessKeyReversibleStatus]: + self._ensure_enabled() + suspended, effective_status = await self._registry.suspend(jti, self.principal) + self._log_suspension(jti, changed=suspended, action="suspend") + return suspended, effective_status + + async def unsuspend_async(self, jti: str) -> tuple[bool, AccessKeyReversibleStatus]: + self._ensure_enabled() + unsuspended, effective_status = await self._registry.unsuspend(jti, self.principal) + self._log_suspension(jti, changed=unsuspended, action="unsuspend") + return unsuspended, effective_status + + def _log_suspension( + self, + jti: str, + *, + changed: bool, + action: Literal["suspend", "unsuspend"], + ) -> None: + completed_action = "suspended" if action == "suspend" else "unsuspended" + logger.info( + f"Scoped Access Key {completed_action}" + if changed + else f"Scoped Access Key {action} requested with no change", + extra={ + "audit_event": f"access_key.{action}ed" if changed else f"access_key.{action}_noop", + "actor_principal": self.principal, + "access_key_jti": jti, + "access_key_state_changed": changed, + }, + ) + def _ensure_enabled(self) -> None: if not self._config.access_keys.enabled: raise AccessKeyFeatureDisabledError("Scoped Access Keys are not enabled") diff --git a/services/core/auth/src/nmp/core/auth/assets/static-authz.yaml b/services/core/auth/src/nmp/core/auth/assets/static-authz.yaml index 6fb4f4fa19..6f98a0913e 100644 --- a/services/core/auth/src/nmp/core/auth/assets/static-authz.yaml +++ b/services/core/auth/src/nmp/core/auth/assets/static-authz.yaml @@ -460,6 +460,14 @@ authz: delete: permissions: [] scopes: [] + /apis/auth/v2/access-keys/{jti}/suspend: + post: + permissions: [] + scopes: [] + /apis/auth/v2/access-keys/{jti}/unsuspend: + post: + permissions: [] + scopes: [] /apis/auth/v2/iam/opa-bundle.tar.gz: get: permissions: diff --git a/services/core/auth/tests/integration/test_scoped_access_keys.py b/services/core/auth/tests/integration/test_scoped_access_keys.py index 684f2f626c..e7718131bc 100644 --- a/services/core/auth/tests/integration/test_scoped_access_keys.py +++ b/services/core/auth/tests/integration/test_scoped_access_keys.py @@ -168,6 +168,30 @@ def authenticate_with_access_key(token: str) -> Response: assert response.status_code == 200, response.text assert response.json()["name"] == workspace + suspend_key = client.post(f"{ACCESS_KEYS_PATH}/{access_key_jti}/suspend", headers=user_headers) + assert suspend_key.status_code == 200, suspend_key.text + assert suspend_key.json() == { + "jti": access_key_jti, + "status": "SUSPENDED", + "changed": True, + } + _wait_for_authorization_response( + lambda: authenticate_with_access_key(access_key), + expected_status_code=401, + ) + + unsuspend_key = client.post(f"{ACCESS_KEYS_PATH}/{access_key_jti}/unsuspend", headers=user_headers) + assert unsuspend_key.status_code == 200, unsuspend_key.text + assert unsuspend_key.json() == { + "jti": access_key_jti, + "status": "ACTIVE", + "changed": True, + } + _wait_for_authorization_response( + lambda: authenticate_with_access_key(access_key), + expected_status_code=200, + ) + authenticate_response = authenticate_with_access_key(access_key) invalid_authenticate_response = authenticate_with_access_key(_tamper_jwt(access_key)) diff --git a/services/core/auth/tests/test_access_key_registry.py b/services/core/auth/tests/test_access_key_registry.py index 387ec37b70..53d90f7876 100644 --- a/services/core/auth/tests/test_access_key_registry.py +++ b/services/core/auth/tests/test_access_key_registry.py @@ -9,7 +9,7 @@ from nemo_platform_plugin.auth.access_keys.types import AccessKeyCreateResponse from nmp.common.auth.jwt import TokenClaims from nmp.common.entities import EntityConflictError, EntityNotFoundError -from nmp.core.auth.app.access_keys import AccessKeyNotFoundError, AccessKeyRegistry +from nmp.core.auth.app.access_keys import AccessKeyNotFoundError, AccessKeyRegistry, AccessKeyStateConflictError from nmp.core.auth.entities import AccessKeyEntity NOW = datetime(2026, 8, 4, 18, 0, tzinfo=UTC) @@ -206,6 +206,20 @@ async def test_registry_concurrent_revoke_with_hard_delete_treats_as_already_rev assert entity_client.get.await_count == 2 +@pytest.mark.asyncio +async def test_registry_concurrent_suspend_with_hard_delete_reports_not_found() -> None: + entity_client = AsyncMock() + # First get succeeds; update conflicts; second get raises not-found (key deleted). + entity_client.get.side_effect = [_record(), EntityNotFoundError("gone")] + entity_client.update.side_effect = EntityConflictError("entity version changed") + registry = AccessKeyRegistry(entity_client) + + with pytest.raises(AccessKeyNotFoundError, match="Scoped Access Key ak_example was not found"): + await registry.suspend("ak_example", "alice@example.com") + + assert entity_client.get.await_count == 2 + + @pytest.mark.asyncio async def test_registry_can_newly_revoke_expired_key() -> None: entity_client = AsyncMock() @@ -277,7 +291,7 @@ async def test_registry_backfills_missing_legacy_access_key_from_validated_claim @pytest.mark.asyncio -async def test_registry_reports_suspended_key_as_revoked_in_list() -> None: +async def test_registry_reports_suspended_key_in_list() -> None: entity_client = AsyncMock() entity_client.list.return_value = SimpleNamespace( data=[_suspended_record()], pagination=SimpleNamespace(total_pages=1) @@ -286,7 +300,29 @@ async def test_registry_reports_suspended_key_as_revoked_in_list() -> None: result = await registry.list_for_principal("alice@example.com", page=1, page_size=100) - assert result.data[0].status == "REVOKED" + assert result.data[0].status == "SUSPENDED" + + +@pytest.mark.asyncio +async def test_registry_expiration_prevents_suspension_state_changes() -> None: + suspended_expired = _expired_record().model_copy(update={"status": "SUSPENDED"}) + entity_client = AsyncMock() + entity_client.list.return_value = SimpleNamespace( + data=[suspended_expired], pagination=SimpleNamespace(total_pages=1) + ) + entity_client.get.side_effect = [suspended_expired, _expired_record()] + registry = AccessKeyRegistry(entity_client) + + listed = await registry.list_for_principal("alice@example.com", page=1, page_size=100) + assert listed.data[0].status == "EXPIRED" + + unsuspend_changed, unsuspend_status = await registry.unsuspend("ak_expired", "alice@example.com") + suspend_changed, suspend_status = await registry.suspend("ak_expired", "alice@example.com") + assert not unsuspend_changed + assert unsuspend_status == "EXPIRED" + assert not suspend_changed + assert suspend_status == "EXPIRED" + entity_client.update.assert_not_awaited() @pytest.mark.asyncio @@ -324,6 +360,98 @@ async def test_registry_reports_suspended_key_as_inactive() -> None: assert not await registry.is_active("ak_suspended", "alice@example.com") +@pytest.mark.asyncio +async def test_registry_suspends_and_unsuspends_key() -> None: + entity_client = AsyncMock() + entity_client.get.side_effect = [_record(), _suspended_record()] + registry = AccessKeyRegistry(entity_client) + + suspend_changed, suspend_status = await registry.suspend("ak_example", "alice@example.com") + assert suspend_changed + assert suspend_status == "SUSPENDED" + assert entity_client.update.await_args_list[0].args[0].status == "SUSPENDED" + unsuspend_changed, unsuspend_status = await registry.unsuspend("ak_suspended", "alice@example.com") + assert unsuspend_changed + assert unsuspend_status == "ACTIVE" + assert entity_client.update.await_args_list[1].args[0].status == "ACTIVE" + + +@pytest.mark.asyncio +async def test_registry_suspension_operations_are_idempotent() -> None: + entity_client = AsyncMock() + entity_client.get.side_effect = [_suspended_record(), _record()] + registry = AccessKeyRegistry(entity_client) + + suspend_changed, suspend_status = await registry.suspend("ak_suspended", "alice@example.com") + assert not suspend_changed + assert suspend_status == "SUSPENDED" + unsuspend_changed, unsuspend_status = await registry.unsuspend("ak_example", "alice@example.com") + assert not unsuspend_changed + assert unsuspend_status == "ACTIVE" + entity_client.update.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("operation", ["suspend", "unsuspend"]) +async def test_registry_rejects_suspension_transition_for_revoked_key(operation: str) -> None: + entity_client = AsyncMock() + entity_client.get.return_value = _record(revoked=True) + registry = AccessKeyRegistry(entity_client) + + with pytest.raises(AccessKeyStateConflictError, match=f"cannot be {operation}ed"): + await getattr(registry, operation)("ak_example", "alice@example.com") + + entity_client.update.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("operation", "current"), + [("suspend", _suspended_record()), ("unsuspend", _record())], +) +async def test_registry_concurrent_suspension_transition_reports_target_state( + operation: str, current: AccessKeyEntity +) -> None: + initial = _record() if operation == "suspend" else _suspended_record() + entity_client = AsyncMock() + entity_client.get.side_effect = [initial, current] + entity_client.update.side_effect = EntityConflictError("entity version changed") + registry = AccessKeyRegistry(entity_client) + + changed, effective_status = await getattr(registry, operation)(initial.name, initial.principal) + assert not changed + assert effective_status == current.status + assert entity_client.get.await_count == 2 + entity_client.update.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_registry_concurrent_suspension_transition_reports_expiration() -> None: + entity_client = AsyncMock() + expired = _expired_record().model_copy(update={"name": "ak_example"}) + entity_client.get.side_effect = [_record(), expired] + entity_client.update.side_effect = EntityConflictError("entity version changed") + registry = AccessKeyRegistry(entity_client) + + changed, effective_status = await registry.suspend("ak_example", "alice@example.com") + + assert not changed + assert effective_status == "EXPIRED" + assert entity_client.get.await_count == 2 + entity_client.update.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_registry_concurrent_suspension_transition_rejects_revocation() -> None: + entity_client = AsyncMock() + entity_client.get.side_effect = [_record(), _record(revoked=True)] + entity_client.update.side_effect = EntityConflictError("entity version changed") + registry = AccessKeyRegistry(entity_client) + + with pytest.raises(AccessKeyStateConflictError, match="cannot be suspended"): + await registry.suspend("ak_example", "alice@example.com") + + @pytest.mark.asyncio async def test_registry_rejects_missing_current_access_key_record() -> None: entity_client = AsyncMock() diff --git a/services/core/auth/tests/test_access_keys.py b/services/core/auth/tests/test_access_keys.py index 4ec0d8e6d2..5ff2987038 100644 --- a/services/core/auth/tests/test_access_keys.py +++ b/services/core/auth/tests/test_access_keys.py @@ -16,13 +16,14 @@ from nmp.common.config import AuthConfig from nmp.common.config.base import AccessKeyConfig, TokenSigningConfig from nmp.core.auth.api.v2.access_keys.endpoints import get_access_key_issuer, router -from nmp.core.auth.app.access_keys import AccessKeyNotFoundError, get_access_key_registry +from nmp.core.auth.app.access_keys import AccessKeyNotFoundError, AccessKeyStateConflictError, get_access_key_registry class InMemoryAccessKeyRegistry: def __init__(self): self.keys = {} self.revoked = set() + self.suspended = set() async def add(self, key): self.keys[key.jti] = key @@ -32,6 +33,8 @@ def _status(self, jti, key): return "REVOKED" if key.expires_at is not None and key.expires_at <= datetime.now(tz=UTC) - timedelta(seconds=30): return "EXPIRED" + if jti in self.suspended: + return "SUSPENDED" return key.status async def list_for_principal(self, principal, *, page, page_size): @@ -60,8 +63,33 @@ async def revoke(self, jti, principal): raise AccessKeyNotFoundError(f"Scoped Access Key {jti} was not found") revoked = jti not in self.revoked self.revoked.add(jti) + self.suspended.discard(jti) return revoked + async def suspend(self, jti, principal): + key = self.keys.get(jti) + if key is None or key.principal != principal: + raise AccessKeyNotFoundError(f"Scoped Access Key {jti} was not found") + if jti in self.revoked: + raise AccessKeyStateConflictError(f"Revoked Scoped Access Key {jti} cannot be suspended") + if key.expires_at is not None and key.expires_at <= datetime.now(tz=UTC): + return False, "EXPIRED" + changed = jti not in self.suspended + self.suspended.add(jti) + return changed, self._status(jti, key) + + async def unsuspend(self, jti, principal): + key = self.keys.get(jti) + if key is None or key.principal != principal: + raise AccessKeyNotFoundError(f"Scoped Access Key {jti} was not found") + if jti in self.revoked: + raise AccessKeyStateConflictError(f"Revoked Scoped Access Key {jti} cannot be unsuspended") + if key.expires_at is not None and key.expires_at <= datetime.now(tz=UTC): + return False, "EXPIRED" + changed = jti in self.suspended + self.suspended.discard(jti) + return changed, self._status(jti, key) + async def is_active(self, jti, principal, **kwargs): key = self.keys.get(jti) return key is not None and key.principal == principal and self._status(jti, key) == "ACTIVE" @@ -172,6 +200,32 @@ def test_create_and_revoke_emit_actor_aware_audit_logs(client, caplog): assert "CI intake automation" not in caplog.text +def test_suspend_and_unsuspend_emit_actor_aware_audit_logs(client, caplog): + created = client.post("/v2/access-keys", json={"name": "ci-intake"}).json() + jti = created["jti"] + + with caplog.at_level(logging.INFO, logger="nmp.core.auth.app.access_keys"): + client.post(f"/v2/access-keys/{jti}/suspend") + client.post(f"/v2/access-keys/{jti}/suspend") + client.post(f"/v2/access-keys/{jti}/unsuspend") + client.post(f"/v2/access-keys/{jti}/unsuspend") + + events = {record.audit_event: record for record in caplog.records if hasattr(record, "audit_event")} + assert set(events) >= { + "access_key.suspended", + "access_key.suspend_noop", + "access_key.unsuspended", + "access_key.unsuspend_noop", + } + for event in events.values(): + assert event.actor_principal == "alice@example.com" + assert event.access_key_jti == jti + assert events["access_key.suspended"].access_key_state_changed + assert not events["access_key.suspend_noop"].access_key_state_changed + assert events["access_key.unsuspended"].access_key_state_changed + assert not events["access_key.unsuspend_noop"].access_key_state_changed + + @pytest.mark.asyncio async def test_in_memory_access_key_registry_reports_expired_status() -> None: registry = InMemoryAccessKeyRegistry() @@ -290,6 +344,17 @@ def test_revoke_access_key_is_disabled_by_default(disabled_client): assert body["code"] == "access_keys_disabled" +@pytest.mark.parametrize("action", ["suspend", "unsuspend"]) +def test_suspension_actions_are_disabled_by_default(disabled_client, action): + response = disabled_client.post(f"/v2/access-keys/ak_{'a' * 32}/{action}") + + assert response.status_code == 404 + assert response.json() == { + "detail": "Scoped Access Keys are not enabled", + "code": "access_keys_disabled", + } + + def test_access_key_specific_jwks_route_does_not_accept_get(client): response = client.get("/v2/access-keys/jwks") @@ -339,6 +404,15 @@ def test_access_key_lifecycle_openapi_documents_error_responses(client): assert list_schema["properties"]["has_more"]["default"] is False revoke_schema = openapi["components"]["schemas"]["AccessKeyRevokeResponse"] assert set(revoke_schema["required"]) == {"jti", "revoked"} + assert openapi["components"]["schemas"]["AccessKeyMetadataResponse"]["properties"]["status"]["enum"] == [ + "ACTIVE", + "EXPIRED", + "REVOKED", + "SUSPENDED", + ] + status_change_schema = openapi["components"]["schemas"]["AccessKeyStatusChangeResponse"] + assert set(status_change_schema["required"]) == {"jti", "status", "changed"} + assert status_change_schema["properties"]["status"]["enum"] == ["ACTIVE", "EXPIRED", "SUSPENDED"] error_code_schema = openapi["components"]["schemas"]["AccessKeyErrorResponse"]["properties"]["code"] assert error_code_schema["nullable"] is True assert error_code_schema["anyOf"][0]["const"] == "access_keys_disabled" @@ -376,10 +450,23 @@ def test_access_key_lifecycle_openapi_documents_error_responses(client): "$ref": "#/components/schemas/AccessKeyNotImplementedErrorResponse" } + for action in ["suspend", "unsuspend"]: + responses = openapi["paths"][f"/v2/access-keys/{{jti}}/{action}"]["post"]["responses"] + assert responses["200"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/AccessKeyStatusChangeResponse" + } + assert responses["404"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/AccessKeyErrorResponse" + } + assert responses["409"]["description"] == "Invalid or concurrent access-key state transition" + assert responses["409"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/AccessKeyErrorResponse" + } + revoke_operation = openapi["paths"]["/v2/access-keys/{jti}"]["delete"] jti_parameter = next(parameter for parameter in revoke_operation["parameters"] if parameter["name"] == "jti") assert jti_parameter["schema"]["pattern"] == "^ak_[0-9a-f]{32}$" - assert jti_parameter["description"] == "Stable JWT ID of the Scoped Access Key to revoke." + assert jti_parameter["description"] == "Stable JWT ID of the Scoped Access Key for the lifecycle operation." revoke_responses = revoke_operation["responses"] assert revoke_responses["200"]["content"]["application/json"]["schema"] == { "$ref": "#/components/schemas/AccessKeyRevokeResponse" @@ -476,3 +563,77 @@ def test_revoke_access_key_rejects_malformed_jti(client): detail = response.json()["detail"] assert isinstance(detail, list) assert detail[0]["type"] == "string_pattern_mismatch" + + +def test_suspend_and_unsuspend_access_key(client): + created = client.post("/v2/access-keys", json={"name": "ci-intake"}).json() + jti = created["jti"] + + suspended = client.post(f"/v2/access-keys/{jti}/suspend") + repeated_suspend = client.post(f"/v2/access-keys/{jti}/suspend") + + assert suspended.status_code == 200 + assert suspended.json() == {"jti": jti, "status": "SUSPENDED", "changed": True} + assert repeated_suspend.json() == {"jti": jti, "status": "SUSPENDED", "changed": False} + assert client.get("/v2/access-keys").json()["data"][0]["status"] == "SUSPENDED" + + unsuspended = client.post(f"/v2/access-keys/{jti}/unsuspend") + repeated_unsuspend = client.post(f"/v2/access-keys/{jti}/unsuspend") + + assert unsuspended.status_code == 200 + assert unsuspended.json() == {"jti": jti, "status": "ACTIVE", "changed": True} + assert repeated_unsuspend.json() == {"jti": jti, "status": "ACTIVE", "changed": False} + assert client.get("/v2/access-keys").json()["data"][0]["status"] == "ACTIVE" + + +def test_unsuspend_reports_expired_status_for_key_that_expired_while_suspended(client): + created = client.post("/v2/access-keys", json={"name": "ci-intake", "expires_in_seconds": 3600}).json() + jti = created["jti"] + assert client.post(f"/v2/access-keys/{jti}/suspend").status_code == 200 + + registry = client.app.dependency_overrides[get_access_key_registry]() + registry.keys[jti].expires_at = datetime.now(tz=UTC) - timedelta(hours=1) + + unsuspended = client.post(f"/v2/access-keys/{jti}/unsuspend") + + assert unsuspended.status_code == 200 + assert unsuspended.json() == {"jti": jti, "status": "EXPIRED", "changed": False} + assert client.get("/v2/access-keys").json()["data"][0]["status"] == "EXPIRED" + + +@pytest.mark.parametrize("action", ["suspend", "unsuspend"]) +def test_suspension_transition_reports_expired_at_expiration_boundary(client, action): + created = client.post("/v2/access-keys", json={"name": "ci-intake", "expires_in_seconds": 3600}).json() + jti = created["jti"] + if action == "unsuspend": + assert client.post(f"/v2/access-keys/{jti}/suspend").status_code == 200 + + registry = client.app.dependency_overrides[get_access_key_registry]() + registry.keys[jti].expires_at = datetime.now(tz=UTC) + + response = client.post(f"/v2/access-keys/{jti}/{action}") + + assert response.status_code == 200 + assert response.json() == {"jti": jti, "status": "EXPIRED", "changed": False} + + +@pytest.mark.parametrize("action", ["suspend", "unsuspend"]) +def test_revoked_access_key_cannot_be_suspended_or_unsuspended(client, action): + created = client.post("/v2/access-keys", json={}).json() + jti = created["jti"] + assert client.delete(f"/v2/access-keys/{jti}").status_code == 200 + + response = client.post(f"/v2/access-keys/{jti}/{action}") + + assert response.status_code == 409 + assert response.json()["detail"] == f"Revoked Scoped Access Key {jti} cannot be {action}ed" + + +@pytest.mark.parametrize("action", ["suspend", "unsuspend"]) +def test_suspension_action_returns_not_found_for_unknown_key(client, action): + unknown_jti = "ak_" + "0" * 32 + + response = client.post(f"/v2/access-keys/{unknown_jti}/{action}") + + assert response.status_code == 404 + assert response.json()["detail"] == f"Scoped Access Key {unknown_jti} was not found" diff --git a/services/core/auth/tests/test_embedded_pdp.py b/services/core/auth/tests/test_embedded_pdp.py index 0d905dc171..79ca74045e 100644 --- a/services/core/auth/tests/test_embedded_pdp.py +++ b/services/core/auth/tests/test_embedded_pdp.py @@ -6,7 +6,7 @@ from pathlib import Path from queue import Queue from threading import Thread -from typing import ClassVar +from typing import Any, ClassVar import pytest import yaml @@ -30,6 +30,16 @@ def static_authz_data(): return yaml.safe_load(f) +def test_access_key_lifecycle_routes_are_available_to_authenticated_owners( + static_authz_data: dict[str, Any], +) -> None: + endpoints = static_authz_data["authz"]["endpoints"] + + for action in ["suspend", "unsuspend"]: + rule = endpoints[f"/apis/auth/v2/access-keys/{{jti}}/{action}"]["post"] + assert rule == {"permissions": [], "scopes": []} + + @pytest.fixture def minimal_authz_data(): """Minimal authorization data for testing."""