From 50f79f165ab4c99c400d3784268807d5682f7b0c Mon Sep 17 00:00:00 2001 From: anastasia-nesterenko Date: Wed, 5 Aug 2026 12:27:48 -0600 Subject: [PATCH 1/8] feat(auth): persist and list access keys Signed-off-by: anastasia-nesterenko --- .../authentication/using-authentication.mdx | 12 +- docs/cli/reference.mdx | 19 +++ openapi/ga/individual/platform.openapi.yaml | 133 +++++++++++++--- openapi/ga/openapi.yaml | 133 +++++++++++++--- openapi/openapi.yaml | 133 +++++++++++++--- .../nemo_platform_ext/cli/commands/auth.py | 53 ++++++- .../src/nemo_platform_ext/cli/core/errors.py | 65 ++++++-- .../tests/cli/commands/test_auth.py | 144 +++++++++++++++--- .../tests/cli/core/test_errors.py | 11 ++ .../auth/access_keys/client.py | 4 +- .../auth/access_keys/endpoints.py | 3 +- .../auth/access_keys/issuer.py | 2 +- .../auth/access_keys/types.py | 42 ++++- .../tests/auth/access_keys/test_client.py | 18 ++- .../tests/auth/access_keys/test_endpoints.py | 5 +- .../src/nmp/common/auth/access_keys.py | 46 +++++- .../nmp_common/tests/auth/test_access_keys.py | 17 ++- .../nemo-platform/.nmpcontext/openapi.yaml | 23 +++ .../src/nemo_platform/cli/commands/auth.py | 53 ++++++- .../src/nemo_platform/cli/core/errors.py | 65 ++++++-- .../resources/access_keys/access_keys.py | 56 ++++++- .../resources/access_keys/api.md | 2 +- .../types/access_keys/__init__.py | 1 + .../access_keys/access_key_list_params.py | 28 ++++ .../tests/api_resources/test_access_keys.py | 12 ++ .../cli/commands/test_auth.py | 144 +++++++++++++++--- .../nemo_platform_ext/cli/core/test_errors.py | 11 ++ .../core/auth/api/v2/access_keys/endpoints.py | 28 ++-- .../auth/src/nmp/core/auth/app/access_keys.py | 125 +++++++++++++++ .../src/nmp/core/auth/entities/__init__.py | 4 +- .../src/nmp/core/auth/entities/entities.py | 15 ++ .../auth/tests/test_access_key_registry.py | 108 +++++++++++++ services/core/auth/tests/test_access_keys.py | 143 +++++++++++++++-- 33 files changed, 1471 insertions(+), 187 deletions(-) create mode 100644 sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_list_params.py create mode 100644 services/core/auth/src/nmp/core/auth/app/access_keys.py create mode 100644 services/core/auth/tests/test_access_key_registry.py diff --git a/docs/auth/authentication/using-authentication.mdx b/docs/auth/authentication/using-authentication.mdx index 7a8c669967..08ae4a3d9b 100644 --- a/docs/auth/authentication/using-authentication.mdx +++ b/docs/auth/authentication/using-authentication.mdx @@ -129,7 +129,17 @@ groups present when the key is created. By default, new keys use the platform's configured default expiry, which is 30 days unless the administrator changes it. Pass `--expires-in ` to request a specific finite lifetime. Pass `--expires-in none` only for deployments where the administrator has explicitly -allowed unlimited keys. Revocation and rotation are not implemented. +allowed unlimited keys. + +List keys with optional pagination: + +```bash +nemo auth access-keys list +nemo auth access-keys list --page 2 --page-size 100 +``` + +The list includes each key's status, description, issuer, audiences, creation +time, and expiration time. Revocation and rotation are not implemented. ### Token Inspection diff --git a/docs/cli/reference.mdx b/docs/cli/reference.mdx index 94d525e3b9..f08ae887b6 100644 --- a/docs/cli/reference.mdx +++ b/docs/cli/reference.mdx @@ -297,6 +297,25 @@ nemo auth access-keys create [OPTIONS] * `--help, -h`: Show this message and exit. +##### nemo auth access-keys list + +List Scoped Access Keys owned by the current authenticated user. + +**Usage:** + +```shell +nemo auth access-keys list [OPTIONS] +``` + +**Options:** + +* `--page `: Page number to retrieve. [default: 1] +* `--page-size `: Number of keys to retrieve per page. [default: 100] + +**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 7095029f6d..156dc2e7dd 100644 --- a/openapi/ga/individual/platform.openapi.yaml +++ b/openapi/ga/individual/platform.openapi.yaml @@ -167,18 +167,30 @@ paths: schema: $ref: '#/components/schemas/WorkloadTokenExchangeErrorResponse' /apis/auth/v2/access-keys: - get: + post: tags: - Scoped Access Keys - summary: List Access Keys - operationId: list_access_keys_apis_auth_v2_access_keys_get + summary: Create Access Key + operationId: create_access_key_apis_auth_v2_access_keys_post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyCreateRequest' responses: '200': description: Successful Response content: application/json: schema: - $ref: '#/components/schemas/AccessKeyListResponse' + $ref: '#/components/schemas/AccessKeyCreateResponse' + '400': + description: Scoped Access Key creation error + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' '404': description: Scoped Access Keys are not enabled content: @@ -191,30 +203,42 @@ paths: application/json: schema: $ref: '#/components/schemas/AccessKeyNotImplementedErrorResponse' - post: + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + get: tags: - Scoped Access Keys - summary: Create Access Key - operationId: create_access_key_apis_auth_v2_access_keys_post - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/AccessKeyCreateRequest' - required: true + summary: List Access Keys + operationId: list_access_keys_apis_auth_v2_access_keys_get + parameters: + - name: page + in: query + required: false + schema: + type: integer + minimum: 1 + default: 1 + title: Page + - name: page_size + in: query + required: false + schema: + type: integer + maximum: 100 + minimum: 1 + default: 100 + title: Page Size responses: '200': description: Successful Response content: application/json: schema: - $ref: '#/components/schemas/AccessKeyCreateResponse' - '400': - description: Scoped Access Key creation error - content: - application/json: - schema: - $ref: '#/components/schemas/AccessKeyErrorResponse' + $ref: '#/components/schemas/AccessKeyListResponse' '404': description: Scoped Access Keys are not enabled content: @@ -7932,14 +7956,22 @@ components: title: Name description: Optional human-readable Scoped Access Key label. The token jti remains the stable identifier. + nullable: true type: string maxLength: 128 minLength: 1 + description: + title: Description + description: Optional human-readable description of the Scoped Access Key. + nullable: true + type: string + maxLength: 1024 expires_in_seconds: title: Expires In Seconds description: Scoped Access Key lifetime in seconds. Omit to use auth.access_keys.default_expires_in_seconds. Send explicit null to request a non-time-delimited key, which requires auth.access_keys.max_expires_in_seconds to be disabled. + nullable: true type: integer minimum: 1.0 type: object @@ -7954,17 +7986,42 @@ components: name: title: Name description: Optional human-readable Scoped Access Key label. + nullable: true + type: string + description: + title: Description + description: Human-readable description of the Scoped Access Key. + nullable: true type: string principal: type: string title: Principal description: Principal ID stamped into the token. + status: + type: string + enum: + - ACTIVE + - EXPIRED + - REVOKED + title: Status + issuer: + type: string + title: Issuer + description: Issuer stamped into the Scoped Access Key JWT. + audiences: + items: + type: string + type: array + uniqueItems: true + title: Audiences + description: Audiences accepted for the Scoped Access Key JWT. created_at: type: string format: date-time title: Created At expires_at: title: Expires At + nullable: true type: string format: date-time token: @@ -7978,6 +8035,9 @@ components: required: - jti - principal + - status + - issuer + - audiences - created_at - token - token_type @@ -8000,6 +8060,11 @@ components: $ref: '#/components/schemas/AccessKeyMetadataResponse' type: array title: Data + has_more: + type: boolean + title: Has More + description: True when another page of keys is available. + default: false type: object required: - data @@ -8014,23 +8079,51 @@ components: name: title: Name description: Optional human-readable Scoped Access Key label. + nullable: true + type: string + description: + title: Description + description: Human-readable description of the Scoped Access Key. + nullable: true type: string principal: type: string title: Principal description: Principal ID stamped into the token. + status: + type: string + enum: + - ACTIVE + - EXPIRED + - REVOKED + title: Status + issuer: + type: string + title: Issuer + description: Issuer stamped into the Scoped Access Key JWT. + audiences: + items: + type: string + type: array + uniqueItems: true + title: Audiences + description: Audiences accepted for the Scoped Access Key JWT. created_at: type: string format: date-time title: Created At expires_at: title: Expires At + nullable: true type: string format: date-time type: object required: - jti - principal + - status + - issuer + - audiences - created_at title: AccessKeyMetadataResponse description: Metadata for a Scoped Access Key. diff --git a/openapi/ga/openapi.yaml b/openapi/ga/openapi.yaml index 7095029f6d..156dc2e7dd 100644 --- a/openapi/ga/openapi.yaml +++ b/openapi/ga/openapi.yaml @@ -167,18 +167,30 @@ paths: schema: $ref: '#/components/schemas/WorkloadTokenExchangeErrorResponse' /apis/auth/v2/access-keys: - get: + post: tags: - Scoped Access Keys - summary: List Access Keys - operationId: list_access_keys_apis_auth_v2_access_keys_get + summary: Create Access Key + operationId: create_access_key_apis_auth_v2_access_keys_post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyCreateRequest' responses: '200': description: Successful Response content: application/json: schema: - $ref: '#/components/schemas/AccessKeyListResponse' + $ref: '#/components/schemas/AccessKeyCreateResponse' + '400': + description: Scoped Access Key creation error + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' '404': description: Scoped Access Keys are not enabled content: @@ -191,30 +203,42 @@ paths: application/json: schema: $ref: '#/components/schemas/AccessKeyNotImplementedErrorResponse' - post: + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + get: tags: - Scoped Access Keys - summary: Create Access Key - operationId: create_access_key_apis_auth_v2_access_keys_post - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/AccessKeyCreateRequest' - required: true + summary: List Access Keys + operationId: list_access_keys_apis_auth_v2_access_keys_get + parameters: + - name: page + in: query + required: false + schema: + type: integer + minimum: 1 + default: 1 + title: Page + - name: page_size + in: query + required: false + schema: + type: integer + maximum: 100 + minimum: 1 + default: 100 + title: Page Size responses: '200': description: Successful Response content: application/json: schema: - $ref: '#/components/schemas/AccessKeyCreateResponse' - '400': - description: Scoped Access Key creation error - content: - application/json: - schema: - $ref: '#/components/schemas/AccessKeyErrorResponse' + $ref: '#/components/schemas/AccessKeyListResponse' '404': description: Scoped Access Keys are not enabled content: @@ -7932,14 +7956,22 @@ components: title: Name description: Optional human-readable Scoped Access Key label. The token jti remains the stable identifier. + nullable: true type: string maxLength: 128 minLength: 1 + description: + title: Description + description: Optional human-readable description of the Scoped Access Key. + nullable: true + type: string + maxLength: 1024 expires_in_seconds: title: Expires In Seconds description: Scoped Access Key lifetime in seconds. Omit to use auth.access_keys.default_expires_in_seconds. Send explicit null to request a non-time-delimited key, which requires auth.access_keys.max_expires_in_seconds to be disabled. + nullable: true type: integer minimum: 1.0 type: object @@ -7954,17 +7986,42 @@ components: name: title: Name description: Optional human-readable Scoped Access Key label. + nullable: true + type: string + description: + title: Description + description: Human-readable description of the Scoped Access Key. + nullable: true type: string principal: type: string title: Principal description: Principal ID stamped into the token. + status: + type: string + enum: + - ACTIVE + - EXPIRED + - REVOKED + title: Status + issuer: + type: string + title: Issuer + description: Issuer stamped into the Scoped Access Key JWT. + audiences: + items: + type: string + type: array + uniqueItems: true + title: Audiences + description: Audiences accepted for the Scoped Access Key JWT. created_at: type: string format: date-time title: Created At expires_at: title: Expires At + nullable: true type: string format: date-time token: @@ -7978,6 +8035,9 @@ components: required: - jti - principal + - status + - issuer + - audiences - created_at - token - token_type @@ -8000,6 +8060,11 @@ components: $ref: '#/components/schemas/AccessKeyMetadataResponse' type: array title: Data + has_more: + type: boolean + title: Has More + description: True when another page of keys is available. + default: false type: object required: - data @@ -8014,23 +8079,51 @@ components: name: title: Name description: Optional human-readable Scoped Access Key label. + nullable: true + type: string + description: + title: Description + description: Human-readable description of the Scoped Access Key. + nullable: true type: string principal: type: string title: Principal description: Principal ID stamped into the token. + status: + type: string + enum: + - ACTIVE + - EXPIRED + - REVOKED + title: Status + issuer: + type: string + title: Issuer + description: Issuer stamped into the Scoped Access Key JWT. + audiences: + items: + type: string + type: array + uniqueItems: true + title: Audiences + description: Audiences accepted for the Scoped Access Key JWT. created_at: type: string format: date-time title: Created At expires_at: title: Expires At + nullable: true type: string format: date-time type: object required: - jti - principal + - status + - issuer + - audiences - created_at title: AccessKeyMetadataResponse description: Metadata for a Scoped Access Key. diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 7095029f6d..156dc2e7dd 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -167,18 +167,30 @@ paths: schema: $ref: '#/components/schemas/WorkloadTokenExchangeErrorResponse' /apis/auth/v2/access-keys: - get: + post: tags: - Scoped Access Keys - summary: List Access Keys - operationId: list_access_keys_apis_auth_v2_access_keys_get + summary: Create Access Key + operationId: create_access_key_apis_auth_v2_access_keys_post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyCreateRequest' responses: '200': description: Successful Response content: application/json: schema: - $ref: '#/components/schemas/AccessKeyListResponse' + $ref: '#/components/schemas/AccessKeyCreateResponse' + '400': + description: Scoped Access Key creation error + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' '404': description: Scoped Access Keys are not enabled content: @@ -191,30 +203,42 @@ paths: application/json: schema: $ref: '#/components/schemas/AccessKeyNotImplementedErrorResponse' - post: + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + get: tags: - Scoped Access Keys - summary: Create Access Key - operationId: create_access_key_apis_auth_v2_access_keys_post - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/AccessKeyCreateRequest' - required: true + summary: List Access Keys + operationId: list_access_keys_apis_auth_v2_access_keys_get + parameters: + - name: page + in: query + required: false + schema: + type: integer + minimum: 1 + default: 1 + title: Page + - name: page_size + in: query + required: false + schema: + type: integer + maximum: 100 + minimum: 1 + default: 100 + title: Page Size responses: '200': description: Successful Response content: application/json: schema: - $ref: '#/components/schemas/AccessKeyCreateResponse' - '400': - description: Scoped Access Key creation error - content: - application/json: - schema: - $ref: '#/components/schemas/AccessKeyErrorResponse' + $ref: '#/components/schemas/AccessKeyListResponse' '404': description: Scoped Access Keys are not enabled content: @@ -7932,14 +7956,22 @@ components: title: Name description: Optional human-readable Scoped Access Key label. The token jti remains the stable identifier. + nullable: true type: string maxLength: 128 minLength: 1 + description: + title: Description + description: Optional human-readable description of the Scoped Access Key. + nullable: true + type: string + maxLength: 1024 expires_in_seconds: title: Expires In Seconds description: Scoped Access Key lifetime in seconds. Omit to use auth.access_keys.default_expires_in_seconds. Send explicit null to request a non-time-delimited key, which requires auth.access_keys.max_expires_in_seconds to be disabled. + nullable: true type: integer minimum: 1.0 type: object @@ -7954,17 +7986,42 @@ components: name: title: Name description: Optional human-readable Scoped Access Key label. + nullable: true + type: string + description: + title: Description + description: Human-readable description of the Scoped Access Key. + nullable: true type: string principal: type: string title: Principal description: Principal ID stamped into the token. + status: + type: string + enum: + - ACTIVE + - EXPIRED + - REVOKED + title: Status + issuer: + type: string + title: Issuer + description: Issuer stamped into the Scoped Access Key JWT. + audiences: + items: + type: string + type: array + uniqueItems: true + title: Audiences + description: Audiences accepted for the Scoped Access Key JWT. created_at: type: string format: date-time title: Created At expires_at: title: Expires At + nullable: true type: string format: date-time token: @@ -7978,6 +8035,9 @@ components: required: - jti - principal + - status + - issuer + - audiences - created_at - token - token_type @@ -8000,6 +8060,11 @@ components: $ref: '#/components/schemas/AccessKeyMetadataResponse' type: array title: Data + has_more: + type: boolean + title: Has More + description: True when another page of keys is available. + default: false type: object required: - data @@ -8014,23 +8079,51 @@ components: name: title: Name description: Optional human-readable Scoped Access Key label. + nullable: true + type: string + description: + title: Description + description: Human-readable description of the Scoped Access Key. + nullable: true type: string principal: type: string title: Principal description: Principal ID stamped into the token. + status: + type: string + enum: + - ACTIVE + - EXPIRED + - REVOKED + title: Status + issuer: + type: string + title: Issuer + description: Issuer stamped into the Scoped Access Key JWT. + audiences: + items: + type: string + type: array + uniqueItems: true + title: Audiences + description: Audiences accepted for the Scoped Access Key JWT. created_at: type: string format: date-time title: Created At expires_at: title: Expires At + nullable: true type: string format: date-time type: object required: - jti - principal + - status + - issuer + - audiences - created_at title: AccessKeyMetadataResponse description: Metadata for a Scoped Access Key. 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 32a7069ef0..b4107a489e 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 @@ -40,6 +40,7 @@ from nemo_platform_ext.auth.token_provider import OIDCTokenProvider, TokenSet from nemo_platform_ext.cli.core.context import CLIContext from nemo_platform_ext.cli.core.errors import handle_errors +from nemo_platform_ext.cli.core.formatters import Column, format_output from nemo_platform_ext.cli.core.help_formatter import create_typer_app from nemo_platform_ext.config.config import Config from nemo_platform_ext.config.models import ConfigParams, Context @@ -838,6 +839,10 @@ def create_access_key( str | None, typer.Option("--name", "-n", help="Optional human-readable label for the Scoped Access Key."), ] = None, + description: Annotated[ + str | None, + typer.Option("--description", "-d", help="Optional description for the Scoped Access Key."), + ] = None, expires_in: Annotated[ str | None, typer.Option( @@ -848,10 +853,11 @@ def create_access_key( ) -> None: """Create a Scoped Access Key for the current authenticated user.""" expires_in_was_set, parsed_expires_in = _parse_access_key_expires_in(expires_in) - if expires_in_was_set: - request = AccessKeyCreateRequest(name=name, expires_in_seconds=parsed_expires_in) - else: - request = AccessKeyCreateRequest(name=name) + request = AccessKeyCreateRequest( + name=name, + description=description, + **({"expires_in_seconds": parsed_expires_in} if expires_in_was_set else {}), + ) try: created = _access_key_issuer(ctx).create(request) except AccessKeyFeatureDisabledError as exc: @@ -861,6 +867,45 @@ def create_access_key( typer.echo(created.token) +@access_keys_app.command("list") +@handle_errors +def list_access_keys( + ctx: typer.Context, + page: Annotated[int, typer.Option("--page", min=1, help="Page number to retrieve.")] = 1, + page_size: Annotated[ + int, + typer.Option("--page-size", min=1, max=100, help="Number of keys to retrieve per page."), + ] = 100, +) -> None: + """List Scoped Access Keys owned by the current authenticated user.""" + try: + listed = _access_key_issuer(ctx).list(page=page, page_size=page_size) + except AccessKeyFeatureDisabledError as exc: + _raise_access_key_disabled(exc) + except AccessKeyOperationNotImplementedError as exc: + _raise_access_key_not_implemented(exc) + + state: CLIContext = ctx.obj + format_output( + listed.data, + is_list=True, + output_format=state.get_output_format(), + output_columns=[ + Column("jti", None), + Column("name", None), + Column("description", None), + Column("status", None), + Column("issuer", None), + Column("audiences", None), + Column("created_at", None), + Column("expires_at", None), + ], + timestamp_format=state.get_timestamp_format(), + ) + if listed.has_more: + typer.echo(f"More Scoped Access Keys are available; use --page {page + 1} to retrieve them.", err=True) + + @app.command("status") @handle_errors def status(ctx: typer.Context) -> None: diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/core/errors.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/core/errors.py index ce0b157112..1e12bf0da7 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/core/errors.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/core/errors.py @@ -18,7 +18,6 @@ REMOTE_ERROR_EXIT_CODE = 3 - class MissingRequiredFieldsError(Exception): """Raised when required fields are missing from CLI input.""" @@ -61,7 +60,7 @@ def _build_list_cmd(ctx: click.Context | None, prog: str) -> str | None: return " ".join([prog, *parts, "list"]) -def _format_api_error(error: APIError) -> str: +def _format_api_error(error: object) -> str: """Extract a clean error message from an API error.""" if hasattr(error, "body") and error.body is not None: if isinstance(error.body, dict): @@ -72,13 +71,17 @@ def _format_api_error(error: APIError) -> str: message = body.get("message") if message: return str(message) - if hasattr(error, "message") and error.message: - return error.message + message = getattr(error, "message", None) + if isinstance(message, str) and message: + return message return str(error) -def _format_api_request(error: APIError) -> str | None: +def _format_api_request(error: object) -> str | None: request = getattr(error, "request", None) + if request is None: + response = getattr(error, "http_response", None) + request = getattr(response, "request", None) method = getattr(request, "method", None) url = getattr(request, "url", None) if not isinstance(method, str) or not isinstance(url, (str, httpx.URL)): @@ -86,8 +89,11 @@ def _format_api_request(error: APIError) -> str | None: return f"{method} {url}" -def _format_api_target(error: APIError) -> str | None: +def _format_api_target(error: object) -> str | None: request = getattr(error, "request", None) + if request is None: + response = getattr(error, "http_response", None) + request = getattr(response, "request", None) url = getattr(request, "url", None) if isinstance(url, str): try: @@ -105,7 +111,7 @@ def _format_api_target(error: APIError) -> str | None: return f"route {path}" -def _print_api_request_context(console, error: APIError) -> None: +def _print_api_request_context(console, error: object) -> None: request = _format_api_request(error) if request: console.print(f"[bold]Request:[/] {request}") @@ -171,6 +177,30 @@ def handle_exception(error: Exception, ctx: click.Context | None = None) -> None PermissionDeniedError, RateLimitError, ) + from nemo_platform_plugin.client.errors import ( + AuthenticationError as PluginAuthenticationError, + ) + from nemo_platform_plugin.client.errors import ( + BadRequestError as PluginBadRequestError, + ) + from nemo_platform_plugin.client.errors import ( + ConflictError as PluginConflictError, + ) + from nemo_platform_plugin.client.errors import ( + InternalServerError as PluginInternalServerError, + ) + from nemo_platform_plugin.client.errors import ( + NemoHTTPError, + ) + from nemo_platform_plugin.client.errors import ( + NotFoundError as PluginNotFoundError, + ) + from nemo_platform_plugin.client.errors import ( + PermissionDeniedError as PluginPermissionDeniedError, + ) + from nemo_platform_plugin.client.errors import ( + RateLimitError as PluginRateLimitError, + ) prog = "nemo" @@ -235,40 +265,40 @@ def handle_exception(error: Exception, ctx: click.Context | None = None) -> None if isinstance(error, typer.Exit): # Re-raise typer.Exit with its original exit code (don't treat Exit(0) as error) raise error - if isinstance(error, AuthenticationError): + if isinstance(error, (AuthenticationError, PluginAuthenticationError)): console.print(f"[bold red]Authentication error:[/] ({error.status_code}) {_format_api_error(error)}") console.print( "[yellow]Hint:[/] Run [cyan]'nemo auth login'[/] or set the token manually " "with [cyan]nemo config set --access-token [/], or use [cyan]NMP_ACCESS_TOKEN[/]." ) raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) - elif isinstance(error, PermissionDeniedError): + elif isinstance(error, (PermissionDeniedError, PluginPermissionDeniedError)): console.print(f"[bold red]Permission denied:[/] ({error.status_code}) {_format_api_error(error)}") console.print( "[yellow]Hint:[/] Your current credentials do not have access to perform this operation. " "Contact your administrator to request access." ) raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) - elif isinstance(error, NotFoundError): + elif isinstance(error, (NotFoundError, PluginNotFoundError)): console.print(f"[bold red]Not found:[/] ({error.status_code}) {_format_api_error(error)}") _print_api_request_context(console, error) console.print(f"[yellow]Hint:[/] {_format_not_found_hint(ctx, prog)}") raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) - elif isinstance(error, BadRequestError): + elif isinstance(error, (BadRequestError, PluginBadRequestError)): console.print(f"[bold red]Bad request:[/] ({error.status_code}) {_format_api_error(error)}") console.print("[yellow]Hint:[/] Check your input values. Run with [cyan]--help[/] to see required options.") raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) - elif isinstance(error, ConflictError): + elif isinstance(error, (ConflictError, PluginConflictError)): console.print(f"[bold red]Conflict:[/] ({error.status_code}) {_format_api_error(error)}") console.print( "[yellow]Hint:[/] A resource with this name already exists. Try a different name or delete the existing one." ) raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) - elif isinstance(error, RateLimitError): + elif isinstance(error, (RateLimitError, PluginRateLimitError)): console.print(f"[bold red]Rate limit exceeded:[/] ({error.status_code}) {_format_api_error(error)}") console.print("[yellow]Hint:[/] Too many requests. Wait a moment and try again.") raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) - elif isinstance(error, InternalServerError): + elif isinstance(error, (InternalServerError, PluginInternalServerError)): formatted = _format_api_error(error) console.print(f"[bold red]Server error:[/] ({error.status_code}) {formatted}") list_cmd = _build_list_cmd(ctx, prog) if ("404" in formatted or "not found" in formatted.lower()) else None @@ -289,7 +319,12 @@ def handle_exception(error: Exception, ctx: click.Context | None = None) -> None "[yellow]Hint:[/] Check your network connection and verify that [cyan]base-url[/] you configured is correct." ) raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) - elif isinstance(error, APIStatusError): + elif isinstance(error, APITimeoutError): + console.print(f"[bold red]Timeout error:[/] {_format_api_error(error)}") + _print_api_request_context(console, error) + console.print("[yellow]Hint:[/] The request timed out. The server may be busy - try again later.") + raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) + elif isinstance(error, (APIStatusError, NemoHTTPError)): console.print(f"[bold red]API error:[/] ({error.status_code}) {_format_api_error(error)}") _print_api_request_context(console, error) raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) 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 eb5cf5a96c..b66b4eb4b1 100644 --- a/packages/nemo_platform_ext/tests/cli/commands/test_auth.py +++ b/packages/nemo_platform_ext/tests/cli/commands/test_auth.py @@ -14,7 +14,12 @@ from nemo_platform_ext.auth.helpers import decode_jwt_claims, generate_unsigned_jwt from nemo_platform_ext.cli.app import app from nemo_platform_plugin.auth.access_keys.issuer import AccessKeyFeatureDisabledError -from nemo_platform_plugin.auth.access_keys.types import AccessKeyCreateRequest, AccessKeyCreateResponse +from nemo_platform_plugin.auth.access_keys.types import ( + AccessKeyCreateRequest, + AccessKeyCreateResponse, + AccessKeyListResponse, + AccessKeyMetadataResponse, +) from nemo_platform_plugin.client.constants import WORKLOAD_IDENTITY_TOKEN_FILE_ENVVAR from typer.testing import CliRunner @@ -66,7 +71,10 @@ def _decode_jwt_noop(token: str) -> dict: return {} -def _created_access_key(name: str | None = None) -> AccessKeyCreateResponse: +def _created_access_key( + name: str | None = None, + description: str | None = None, +) -> AccessKeyCreateResponse: return AccessKeyCreateResponse( jti="ak_example", name=name, @@ -75,6 +83,10 @@ def _created_access_key(name: str | None = None) -> AccessKeyCreateResponse: principal="alice@example.com", created_at=datetime(2026, 7, 28, 12, 0, tzinfo=UTC), expires_at=None, + description=description, + status="ACTIVE", + issuer="https://platform.example.com/apis/auth", + audiences=["nemo-platform-access-key"], ) @@ -368,21 +380,40 @@ def test_auth_access_keys_create_prints_token(monkeypatch: pytest.MonkeyPatch): assert "expires_in_seconds" not in body.model_fields_set -def test_auth_access_keys_create_sends_optional_name_and_expiration(monkeypatch: pytest.MonkeyPatch): +def test_auth_access_keys_create_sends_optional_metadata_and_expiration(monkeypatch: pytest.MonkeyPatch): fake_platform_client = MagicMock() fake_access_keys_client = MagicMock() - fake_access_keys_client.create_access_key.return_value.data.return_value = _created_access_key("short-lived") + fake_access_keys_client.create_access_key.return_value.data.return_value = _created_access_key( + "short-lived", "CI automation" + ) monkeypatch.setattr("nemo_platform_ext.cli.core.context.CLIContext.get_client", lambda self: fake_platform_client) 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", "create", "--name", "short-lived", "--expires-in", "3600"]) + result = runner.invoke( + app, + [ + "auth", + "access-keys", + "create", + "--name", + "short-lived", + "--description", + "CI automation", + "--expires-in", + "3600", + ], + ) assert_exit_code(result, 0) fake_access_keys_client.create_access_key.assert_called_once_with( - body=AccessKeyCreateRequest(name="short-lived", expires_in_seconds=3600), + body=AccessKeyCreateRequest( + name="short-lived", + description="CI automation", + expires_in_seconds=3600, + ), ) @@ -439,27 +470,104 @@ def test_auth_access_keys_create_reports_disabled_feature(monkeypatch: pytest.Mo assert "Scoped Access Keys are not enabled" in result.output -def test_auth_access_keys_help_hides_unimplemented_lifecycle_commands() -> None: +def test_auth_access_keys_list_outputs_owned_keys(monkeypatch: pytest.MonkeyPatch) -> None: + fake_access_keys_client = MagicMock() + fake_access_keys_client.list_access_keys.return_value.data.return_value = AccessKeyListResponse( + data=[ + AccessKeyMetadataResponse( + jti="ak_example", + name="ci-build", + principal="alice@example.com", + created_at=datetime(2026, 7, 28, 12, 0, tzinfo=UTC), + description="CI automation", + status="ACTIVE", + issuer="https://platform.example.com/apis/auth", + audiences=["nemo-platform-access-key"], + ) + ] + ) + 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, ["--output-format", "json", "auth", "access-keys", "list"]) + + assert_exit_code(result, 0) + assert '"jti": "ak_example"' in result.output + assert '"name": "ci-build"' in result.output + assert '"description": "CI automation"' in result.output + assert '"status": "ACTIVE"' in result.output + fake_access_keys_client.list_access_keys.assert_called_once_with(query_params={"page": 1, "page_size": 100}) + + +def test_auth_access_keys_list_table_includes_key_name(monkeypatch: pytest.MonkeyPatch) -> None: + fake_access_keys_client = MagicMock() + fake_access_keys_client.list_access_keys.return_value.data.return_value = AccessKeyListResponse( + data=[ + AccessKeyMetadataResponse( + jti="ak_example", + name="ci-build", + principal="alice@example.com", + created_at=datetime(2026, 7, 28, 12, 0, tzinfo=UTC), + description="CI automation", + status="ACTIVE", + issuer="https://platform.example.com/apis/auth", + audiences=["nemo-platform-access-key"], + ) + ] + ) + 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", "list"]) + + assert_exit_code(result, 0) + assert "ak_example" in result.output + assert "ci-build" in result.output + assert "CI automation" in result.output + assert "ACTIVE" in result.output + fake_access_keys_client.list_access_keys.assert_called_once_with(query_params={"page": 1, "page_size": 100}) + + +def test_auth_access_keys_list_points_to_next_page(monkeypatch: pytest.MonkeyPatch) -> None: + fake_access_keys_client = MagicMock() + fake_access_keys_client.list_access_keys.return_value.data.return_value = AccessKeyListResponse( + data=[], + has_more=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, + ) + + result = runner.invoke( + app, + ["--output-format", "json", "auth", "access-keys", "list", "--page", "3", "--page-size", "25"], + ) + + assert_exit_code(result, 0) + assert result.stdout.strip() == "[]" + assert "use --page 4" in result.stderr + fake_access_keys_client.list_access_keys.assert_called_once_with(query_params={"page": 3, "page_size": 25}) + + +def test_auth_access_keys_help_exposes_lifecycle_commands() -> None: result = runner.invoke(app, ["auth", "access-keys", "--help"]) assert_exit_code(result, 0) assert "create" in result.output - assert "list" not in result.output - assert "revoke" not in result.output + assert "list" in result.output create_help = runner.invoke(app, ["auth", "access-keys", "create", "--help"]) assert_exit_code(create_help, 0) assert "Use 'none' to request no expiration" in " ".join(create_help.output.split()) - list_result = runner.invoke(app, ["auth", "access-keys", "list"]) - revoke_result = runner.invoke(app, ["auth", "access-keys", "revoke", "ak_example"]) - - assert list_result.exit_code != 0 - assert revoke_result.exit_code != 0 - assert "No such command" in list_result.output - assert "No such command" in revoke_result.output - - def test_auth_tokens_group_is_not_exposed() -> None: result = runner.invoke(app, ["auth", "tokens", "create"]) diff --git a/packages/nemo_platform_ext/tests/cli/core/test_errors.py b/packages/nemo_platform_ext/tests/cli/core/test_errors.py index 5d8c044717..2d450dfff4 100644 --- a/packages/nemo_platform_ext/tests/cli/core/test_errors.py +++ b/packages/nemo_platform_ext/tests/cli/core/test_errors.py @@ -60,6 +60,17 @@ def test_format_api_error_fallback_to_str(): assert _format_api_error(error) == "String representation of error" +def test_format_api_error_ignores_non_string_message(): + class APIErrorLike: + body = None + message = 404 + + def __str__(self) -> str: + return "String representation of error" + + assert _format_api_error(APIErrorLike()) == "String representation of error" + + @pytest.mark.parametrize( "error_class,status_code,expected_prefix,expected_hint", [ 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 9bce55aa4d..2551f1f193 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 @@ -46,9 +46,9 @@ def create(self, request: AccessKeyCreateRequest) -> AccessKeyCreateResponse: _raise_domain_error_from_http(exc) raise - def list(self) -> AccessKeyListResponse: + def list(self, *, page: int = 1, page_size: int = 100) -> AccessKeyListResponse: try: - return self._client.list_access_keys().data() + 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 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 8b27e6547e..b9b8fdc508 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 @@ -8,6 +8,7 @@ from nemo_platform_plugin.auth.access_keys.types import ( AccessKeyCreateRequest, AccessKeyCreateResponse, + AccessKeyListQueryParams, AccessKeyListResponse, ) from nemo_platform_plugin.client.endpoint import delete, get, post @@ -20,7 +21,7 @@ def create_access_key(*, body: AccessKeyCreateRequest) -> AccessKeyCreateRespons @get("/apis/auth/v2/access-keys") @abstractmethod -def list_access_keys() -> AccessKeyListResponse: ... +def list_access_keys(*, query_params: AccessKeyListQueryParams | None = None) -> AccessKeyListResponse: ... @delete("/apis/auth/v2/access-keys/{jti}") 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 c4482d1a83..c06d1e11de 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 @@ -25,6 +25,6 @@ class AccessKeyIssuer(Protocol): def create(self, request: AccessKeyCreateRequest) -> AccessKeyCreateResponse: ... - def list(self) -> AccessKeyListResponse: ... + def list(self, *, page: int = 1, page_size: int = 100) -> AccessKeyListResponse: ... def revoke(self, jti: str) -> None: ... 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 9fff86db73..e245550c8d 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 @@ -4,10 +4,19 @@ from __future__ import annotations from datetime import datetime -from typing import Literal +from typing import Literal, TypedDict from pydantic import BaseModel, ConfigDict, Field +AccessKeyStatus = Literal["ACTIVE", "EXPIRED", "REVOKED"] + + +class AccessKeyListQueryParams(TypedDict, total=False): + """Pagination parameters for Scoped Access Key listing.""" + + page: int + page_size: int + class AccessKeyCreateRequest(BaseModel): """Request body for creating a Scoped Access Key.""" @@ -16,11 +25,19 @@ class AccessKeyCreateRequest(BaseModel): default=None, min_length=1, max_length=128, + json_schema_extra={"nullable": True}, description="Optional human-readable Scoped Access Key label. The token jti remains the stable identifier.", ) + description: str | None = Field( + default=None, + max_length=1024, + json_schema_extra={"nullable": True}, + description="Optional human-readable description of the Scoped Access Key.", + ) expires_in_seconds: int | None = Field( default=None, ge=1, + json_schema_extra={"nullable": True}, description=( "Scoped Access Key lifetime in seconds. Omit to use " "auth.access_keys.default_expires_in_seconds. Send explicit null to request " @@ -34,10 +51,25 @@ class AccessKeyMetadataResponse(BaseModel): """Metadata for a Scoped Access Key.""" jti: str = Field(description="Stable JWT ID for this Scoped Access Key.") - name: str | None = Field(default=None, description="Optional human-readable Scoped Access Key label.") + name: str | None = Field( + default=None, + json_schema_extra={"nullable": True}, + description="Optional human-readable Scoped Access Key label.", + ) + description: str | None = Field( + default=None, + json_schema_extra={"nullable": True}, + description="Human-readable description of the Scoped Access Key.", + ) principal: str = Field(description="Principal ID stamped into the token.") + status: AccessKeyStatus + issuer: str = Field(description="Issuer stamped into the Scoped Access Key JWT.") + audiences: list[str] = Field( + description="Audiences accepted for the Scoped Access Key JWT.", + json_schema_extra={"uniqueItems": True}, + ) created_at: datetime - expires_at: datetime | None = None + expires_at: datetime | None = Field(default=None, json_schema_extra={"nullable": True}) class AccessKeyCreateResponse(AccessKeyMetadataResponse): @@ -51,6 +83,10 @@ class AccessKeyListResponse(BaseModel): """List response for Scoped Access Key metadata.""" data: list[AccessKeyMetadataResponse] + has_more: bool = Field( + default=False, + description="True when another page of keys is available.", + ) class AccessKeyAuthenticateResponse(BaseModel): 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 4a45c29895..9e62d5a048 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 @@ -12,7 +12,10 @@ AccessKeyFeatureDisabledError, AccessKeyOperationNotImplementedError, ) -from nemo_platform_plugin.auth.access_keys.types import AccessKeyCreateRequest, AccessKeyCreateResponse +from nemo_platform_plugin.auth.access_keys.types import ( + AccessKeyCreateRequest, + AccessKeyCreateResponse, +) from nemo_platform_plugin.client.errors import NemoHTTPError @@ -35,6 +38,10 @@ def test_access_key_issuer_client_delegates_create_to_client() -> None: principal="alice@example.com", created_at=datetime(2026, 7, 28, 12, 0, tzinfo=UTC), expires_at=None, + description=None, + status="ACTIVE", + issuer="https://platform.example.com/apis/auth", + audiences=["nemo-platform-access-key"], ) client = _AccessKeysClientStub() client.create_access_key.return_value.data.return_value = created @@ -46,14 +53,13 @@ def test_access_key_issuer_client_delegates_create_to_client() -> None: client.create_access_key.assert_called_once_with(body=AccessKeyCreateRequest()) -def test_access_key_issuer_client_revokes_by_jti() -> None: +def test_access_key_issuer_client_lists_requested_page() -> None: client = _AccessKeysClientStub() - client.revoke_access_key.return_value.data.return_value = None - issuer = AccessKeyIssuerClient(client.as_client()) - issuer.revoke("ak_example") - client.revoke_access_key.assert_called_once_with(jti="ak_example") + issuer.list(page=3, page_size=25) + + client.list_access_keys.assert_called_once_with(query_params={"page": 3, "page_size": 25}) def test_access_key_issuer_client_translates_http_501_to_domain_error() -> None: 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 4fc2425361..83a2222183 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 @@ -31,8 +31,9 @@ def test_revoke_access_key_endpoint_uses_jti_path_param() -> None: assert prepared.path_params == {"jti": "ak_example"} -def test_list_access_keys_endpoint_is_stable_for_future_persistence() -> None: - prepared = endpoints.list_access_keys() +def test_list_access_keys_endpoint_supports_pagination() -> None: + prepared = endpoints.list_access_keys(query_params={"page": 3, "page_size": 25}) assert prepared.method == "GET" assert prepared.path_template == "/apis/auth/v2/access-keys" + assert prepared.query_params == {"page": 3, "page_size": 25} 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 6e887794fd..8003de5260 100644 --- a/packages/nmp_common/src/nmp/common/auth/access_keys.py +++ b/packages/nmp_common/src/nmp/common/auth/access_keys.py @@ -31,6 +31,8 @@ ACCESS_KEY_TOKEN_TYPE = "access_key" ACCESS_KEY_JWKS_PATH = "/apis/auth/jwks" +ACCESS_KEY_METADATA_VERSION = 2 +LEGACY_ACCESS_KEY_METADATA_VERSION = 1 def platform_token_issuer(config: AuthConfig) -> str: @@ -61,7 +63,10 @@ class _AccessKeyTokenPayload: claims: dict[str, Any] jti: str name: str | None + description: str | None principal: str + issuer: str + audiences: list[str] created_at: datetime expires_at: datetime | None @@ -84,6 +89,24 @@ def _groups_from_claim(groups_claim: Any) -> list[str]: return [] +def is_access_key_token_candidate(token: str) -> bool: + """Return True when an untrusted token claims to be a Scoped Access Key.""" + try: + unverified = jwt.decode( + token, + options={ + "verify_signature": False, + "verify_exp": False, + "verify_iat": False, + "verify_nbf": False, + "verify_aud": False, + }, + ) + except jwt.DecodeError: + return False + return unverified.get("nmp_token_type") == ACCESS_KEY_TOKEN_TYPE + + def _access_key_signing_key(config: AuthConfig) -> RSASigningKey: return _ACCESS_KEY_SIGNING_KEY_CACHE.get_from_file( kid=config.token_signing.key_id, @@ -181,6 +204,7 @@ def create(self, request: AccessKeyCreateRequest) -> AccessKeyCreateResponse: self._config, principal=self._principal, name=request.name, + description=request.description, expires_in_seconds=expires_in_seconds, now=self._now(), ) @@ -192,11 +216,13 @@ async def create_async(self, request: AccessKeyCreateRequest) -> AccessKeyCreate self._config, principal=self._principal, name=request.name, + description=request.description, expires_in_seconds=expires_in_seconds, now=self._now(), ) - def list(self) -> AccessKeyListResponse: + def list(self, *, page: int = 1, page_size: int = 100) -> AccessKeyListResponse: + _ = page, page_size self._ensure_enabled() raise AccessKeyOperationNotImplementedError("Scoped Access Key listing is not implemented.") @@ -210,6 +236,7 @@ def _create_access_key_token( *, principal: Principal, name: str | None = None, + description: str | None = None, expires_in_seconds: int | None = None, now: int, ) -> AccessKeyCreateResponse: @@ -217,6 +244,7 @@ def _create_access_key_token( config, principal=principal, name=name, + description=description, expires_in_seconds=expires_in_seconds, now=now, ) @@ -228,6 +256,7 @@ async def _create_access_key_token_async( *, principal: Principal, name: str | None = None, + description: str | None = None, expires_in_seconds: int | None = None, now: int, ) -> AccessKeyCreateResponse: @@ -235,6 +264,7 @@ async def _create_access_key_token_async( config, principal=principal, name=name, + description=description, expires_in_seconds=expires_in_seconds, now=now, ) @@ -247,6 +277,7 @@ def _build_access_key_token_payload( *, principal: Principal, name: str | None, + description: str | None, expires_in_seconds: int | None, now: int, ) -> _AccessKeyTokenPayload: @@ -257,11 +288,13 @@ def _build_access_key_token_payload( issued_at = now jti = f"ak_{uuid.uuid4().hex}" - access_key_metadata: dict[str, Any] = {"version": 1} + access_key_metadata: dict[str, Any] = {"version": ACCESS_KEY_METADATA_VERSION} if name is not None: access_key_metadata["name"] = name + issuer = access_key_issuer(config) + audiences = [config.access_keys.audience] claims: dict[str, Any] = { - "iss": access_key_issuer(config), + "iss": issuer, "aud": config.access_keys.audience, "sub": principal.id, "iat": issued_at, @@ -286,7 +319,10 @@ def _build_access_key_token_payload( claims=claims, jti=jti, name=name, + description=description, principal=principal.id, + issuer=issuer, + audiences=audiences, created_at=datetime.fromtimestamp(issued_at, tz=UTC), expires_at=expires_at, ) @@ -305,9 +341,13 @@ def _access_key_response_from_payload( return AccessKeyCreateResponse( jti=payload.jti, name=payload.name, + description=payload.description, token=token, token_type="Bearer", principal=payload.principal, + status="ACTIVE", + issuer=payload.issuer, + audiences=payload.audiences, created_at=payload.created_at, expires_at=payload.expires_at, ) diff --git a/packages/nmp_common/tests/auth/test_access_keys.py b/packages/nmp_common/tests/auth/test_access_keys.py index 8e27cfad0f..b39835420b 100644 --- a/packages/nmp_common/tests/auth/test_access_keys.py +++ b/packages/nmp_common/tests/auth/test_access_keys.py @@ -275,19 +275,30 @@ def test_access_key_issuer_service_stamps_current_principal(tmp_path): principal = Principal(id="alice@example.com", email="alice@example.com", groups=["team-ml"]) issuer = AccessKeyIssuerService(config=config, principal=principal, now=lambda: 1785280000) - created = issuer.create(AccessKeyCreateRequest(name="gtc-intake", expires_in_seconds=600)) + created = issuer.create( + AccessKeyCreateRequest( + name="gtc-intake", + description="GTC intake automation", + expires_in_seconds=600, + ) + ) unverified = jwt.decode(created.token, options={"verify_signature": False}) assert created.jti.startswith("ak_") assert created.name == "gtc-intake" + assert created.description == "GTC intake automation" assert created.principal == "alice@example.com" assert created.expires_at == datetime.fromtimestamp(1785280600, tz=UTC) assert unverified["jti"] == created.jti assert unverified["sub"] == "alice@example.com" assert unverified["email"] == "alice@example.com" assert unverified["groups"] == "team-ml" + assert unverified["aud"] == "nemo-platform-access-key" assert unverified["nmp_token_type"] == ACCESS_KEY_TOKEN_TYPE - assert unverified["nmp_access_key"] == {"version": 1, "name": "gtc-intake"} + assert unverified["nmp_access_key"] == { + "version": 2, + "name": "gtc-intake", + } assert unverified["exp"] == 1785280600 @@ -316,7 +327,7 @@ def test_access_key_issuer_service_allows_unnamed_tokens(tmp_path): assert created.jti.startswith("ak_") assert created.name is None assert unverified["jti"] == created.jti - assert unverified["nmp_access_key"] == {"version": 1} + assert unverified["nmp_access_key"] == {"version": 2} assert unverified["exp"] == 1785280600 diff --git a/sdk/python/nemo-platform/.nmpcontext/openapi.yaml b/sdk/python/nemo-platform/.nmpcontext/openapi.yaml index 7095029f6d..b21d803a6a 100644 --- a/sdk/python/nemo-platform/.nmpcontext/openapi.yaml +++ b/sdk/python/nemo-platform/.nmpcontext/openapi.yaml @@ -172,6 +172,24 @@ paths: - Scoped Access Keys summary: List Access Keys operationId: list_access_keys_apis_auth_v2_access_keys_get + parameters: + - name: page + in: query + required: false + schema: + type: integer + minimum: 1 + default: 1 + title: Page + - name: page_size + in: query + required: false + schema: + type: integer + maximum: 100 + minimum: 1 + default: 100 + title: Page Size responses: '200': description: Successful Response @@ -8000,6 +8018,11 @@ components: $ref: '#/components/schemas/AccessKeyMetadataResponse' type: array title: Data + has_more: + type: boolean + title: Has More + description: True when another page of keys is available. + default: false type: object required: - data 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 821ed27fa7..e9e1f7a9c1 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 @@ -40,6 +40,7 @@ from nemo_platform.auth.token_provider import OIDCTokenProvider, TokenSet from nemo_platform.cli.core.context import CLIContext from nemo_platform.cli.core.errors import handle_errors +from nemo_platform.cli.core.formatters import Column, format_output from nemo_platform.cli.core.help_formatter import create_typer_app from nemo_platform.config.config import Config from nemo_platform.config.models import ConfigParams, Context @@ -838,6 +839,10 @@ def create_access_key( str | None, typer.Option("--name", "-n", help="Optional human-readable label for the Scoped Access Key."), ] = None, + description: Annotated[ + str | None, + typer.Option("--description", "-d", help="Optional description for the Scoped Access Key."), + ] = None, expires_in: Annotated[ str | None, typer.Option( @@ -848,10 +853,11 @@ def create_access_key( ) -> None: """Create a Scoped Access Key for the current authenticated user.""" expires_in_was_set, parsed_expires_in = _parse_access_key_expires_in(expires_in) - if expires_in_was_set: - request = AccessKeyCreateRequest(name=name, expires_in_seconds=parsed_expires_in) - else: - request = AccessKeyCreateRequest(name=name) + request = AccessKeyCreateRequest( + name=name, + description=description, + **({"expires_in_seconds": parsed_expires_in} if expires_in_was_set else {}), + ) try: created = _access_key_issuer(ctx).create(request) except AccessKeyFeatureDisabledError as exc: @@ -861,6 +867,45 @@ def create_access_key( typer.echo(created.token) +@access_keys_app.command("list") +@handle_errors +def list_access_keys( + ctx: typer.Context, + page: Annotated[int, typer.Option("--page", min=1, help="Page number to retrieve.")] = 1, + page_size: Annotated[ + int, + typer.Option("--page-size", min=1, max=100, help="Number of keys to retrieve per page."), + ] = 100, +) -> None: + """List Scoped Access Keys owned by the current authenticated user.""" + try: + listed = _access_key_issuer(ctx).list(page=page, page_size=page_size) + except AccessKeyFeatureDisabledError as exc: + _raise_access_key_disabled(exc) + except AccessKeyOperationNotImplementedError as exc: + _raise_access_key_not_implemented(exc) + + state: CLIContext = ctx.obj + format_output( + listed.data, + is_list=True, + output_format=state.get_output_format(), + output_columns=[ + Column("jti", None), + Column("name", None), + Column("description", None), + Column("status", None), + Column("issuer", None), + Column("audiences", None), + Column("created_at", None), + Column("expires_at", None), + ], + timestamp_format=state.get_timestamp_format(), + ) + if listed.has_more: + typer.echo(f"More Scoped Access Keys are available; use --page {page + 1} to retrieve them.", err=True) + + @app.command("status") @handle_errors def status(ctx: typer.Context) -> None: diff --git a/sdk/python/nemo-platform/src/nemo_platform/cli/core/errors.py b/sdk/python/nemo-platform/src/nemo_platform/cli/core/errors.py index 7b02834159..29ff24ea91 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/cli/core/errors.py +++ b/sdk/python/nemo-platform/src/nemo_platform/cli/core/errors.py @@ -18,7 +18,6 @@ REMOTE_ERROR_EXIT_CODE = 3 - class MissingRequiredFieldsError(Exception): """Raised when required fields are missing from CLI input.""" @@ -61,7 +60,7 @@ def _build_list_cmd(ctx: click.Context | None, prog: str) -> str | None: return " ".join([prog, *parts, "list"]) -def _format_api_error(error: APIError) -> str: +def _format_api_error(error: object) -> str: """Extract a clean error message from an API error.""" if hasattr(error, "body") and error.body is not None: if isinstance(error.body, dict): @@ -72,13 +71,17 @@ def _format_api_error(error: APIError) -> str: message = body.get("message") if message: return str(message) - if hasattr(error, "message") and error.message: - return error.message + message = getattr(error, "message", None) + if isinstance(message, str) and message: + return message return str(error) -def _format_api_request(error: APIError) -> str | None: +def _format_api_request(error: object) -> str | None: request = getattr(error, "request", None) + if request is None: + response = getattr(error, "http_response", None) + request = getattr(response, "request", None) method = getattr(request, "method", None) url = getattr(request, "url", None) if not isinstance(method, str) or not isinstance(url, (str, httpx.URL)): @@ -86,8 +89,11 @@ def _format_api_request(error: APIError) -> str | None: return f"{method} {url}" -def _format_api_target(error: APIError) -> str | None: +def _format_api_target(error: object) -> str | None: request = getattr(error, "request", None) + if request is None: + response = getattr(error, "http_response", None) + request = getattr(response, "request", None) url = getattr(request, "url", None) if isinstance(url, str): try: @@ -105,7 +111,7 @@ def _format_api_target(error: APIError) -> str | None: return f"route {path}" -def _print_api_request_context(console, error: APIError) -> None: +def _print_api_request_context(console, error: object) -> None: request = _format_api_request(error) if request: console.print(f"[bold]Request:[/] {request}") @@ -171,6 +177,30 @@ def handle_exception(error: Exception, ctx: click.Context | None = None) -> None PermissionDeniedError, RateLimitError, ) + from nemo_platform_plugin.client.errors import ( + AuthenticationError as PluginAuthenticationError, + ) + from nemo_platform_plugin.client.errors import ( + BadRequestError as PluginBadRequestError, + ) + from nemo_platform_plugin.client.errors import ( + ConflictError as PluginConflictError, + ) + from nemo_platform_plugin.client.errors import ( + InternalServerError as PluginInternalServerError, + ) + from nemo_platform_plugin.client.errors import ( + NemoHTTPError, + ) + from nemo_platform_plugin.client.errors import ( + NotFoundError as PluginNotFoundError, + ) + from nemo_platform_plugin.client.errors import ( + PermissionDeniedError as PluginPermissionDeniedError, + ) + from nemo_platform_plugin.client.errors import ( + RateLimitError as PluginRateLimitError, + ) prog = "nemo" @@ -235,40 +265,40 @@ def handle_exception(error: Exception, ctx: click.Context | None = None) -> None if isinstance(error, typer.Exit): # Re-raise typer.Exit with its original exit code (don't treat Exit(0) as error) raise error - if isinstance(error, AuthenticationError): + if isinstance(error, (AuthenticationError, PluginAuthenticationError)): console.print(f"[bold red]Authentication error:[/] ({error.status_code}) {_format_api_error(error)}") console.print( "[yellow]Hint:[/] Run [cyan]'nemo auth login'[/] or set the token manually " "with [cyan]nemo config set --access-token [/], or use [cyan]NMP_ACCESS_TOKEN[/]." ) raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) - elif isinstance(error, PermissionDeniedError): + elif isinstance(error, (PermissionDeniedError, PluginPermissionDeniedError)): console.print(f"[bold red]Permission denied:[/] ({error.status_code}) {_format_api_error(error)}") console.print( "[yellow]Hint:[/] Your current credentials do not have access to perform this operation. " "Contact your administrator to request access." ) raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) - elif isinstance(error, NotFoundError): + elif isinstance(error, (NotFoundError, PluginNotFoundError)): console.print(f"[bold red]Not found:[/] ({error.status_code}) {_format_api_error(error)}") _print_api_request_context(console, error) console.print(f"[yellow]Hint:[/] {_format_not_found_hint(ctx, prog)}") raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) - elif isinstance(error, BadRequestError): + elif isinstance(error, (BadRequestError, PluginBadRequestError)): console.print(f"[bold red]Bad request:[/] ({error.status_code}) {_format_api_error(error)}") console.print("[yellow]Hint:[/] Check your input values. Run with [cyan]--help[/] to see required options.") raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) - elif isinstance(error, ConflictError): + elif isinstance(error, (ConflictError, PluginConflictError)): console.print(f"[bold red]Conflict:[/] ({error.status_code}) {_format_api_error(error)}") console.print( "[yellow]Hint:[/] A resource with this name already exists. Try a different name or delete the existing one." ) raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) - elif isinstance(error, RateLimitError): + elif isinstance(error, (RateLimitError, PluginRateLimitError)): console.print(f"[bold red]Rate limit exceeded:[/] ({error.status_code}) {_format_api_error(error)}") console.print("[yellow]Hint:[/] Too many requests. Wait a moment and try again.") raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) - elif isinstance(error, InternalServerError): + elif isinstance(error, (InternalServerError, PluginInternalServerError)): formatted = _format_api_error(error) console.print(f"[bold red]Server error:[/] ({error.status_code}) {formatted}") list_cmd = _build_list_cmd(ctx, prog) if ("404" in formatted or "not found" in formatted.lower()) else None @@ -289,7 +319,12 @@ def handle_exception(error: Exception, ctx: click.Context | None = None) -> None "[yellow]Hint:[/] Check your network connection and verify that [cyan]base-url[/] you configured is correct." ) raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) - elif isinstance(error, APIStatusError): + elif isinstance(error, APITimeoutError): + console.print(f"[bold red]Timeout error:[/] {_format_api_error(error)}") + _print_api_request_context(console, error) + console.print("[yellow]Hint:[/] The request timed out. The server may be busy - try again later.") + raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) + elif isinstance(error, (APIStatusError, NemoHTTPError)): console.print(f"[bold red]API error:[/] ({error.status_code}) {_format_api_error(error)}") _print_api_request_context(console, error) raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) 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 7b6251dfc3..0bfddd5205 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 @@ -30,7 +30,7 @@ async_to_streamed_response_wrapper, ) from ..._base_client import make_request_options -from ...types.access_keys import access_key_create_params +from ...types.access_keys import access_key_create_params, access_key_list_params from ...types.access_keys.access_key_list_response import AccessKeyListResponse from ...types.access_keys.access_key_create_response import AccessKeyCreateResponse @@ -107,6 +107,8 @@ def create( def list( self, *, + page: int | Omit = omit, + page_size: int | Omit = omit, # 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, @@ -114,11 +116,32 @@ def list( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> AccessKeyListResponse: - """List Access Keys""" + """ + List Access Keys + + Args: + page: Page number to retrieve. + + page_size: Number of keys to retrieve per page. + + 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 + """ return self._get( "/apis/auth/v2/access-keys", options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + {"page": page, "page_size": page_size}, access_key_list_params.AccessKeyListParams + ), ), cast_to=AccessKeyListResponse, ) @@ -227,6 +250,8 @@ async def create( async def list( self, *, + page: int | Omit = omit, + page_size: int | Omit = omit, # 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, @@ -234,11 +259,32 @@ async def list( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> AccessKeyListResponse: - """List Access Keys""" + """ + List Access Keys + + Args: + page: Page number to retrieve. + + page_size: Number of keys to retrieve per page. + + 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 + """ return await self._get( "/apis/auth/v2/access-keys", options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + {"page": page, "page_size": page_size}, access_key_list_params.AccessKeyListParams + ), ), cast_to=AccessKeyListResponse, ) 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 b42a03a46b..1c88799790 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 @@ -16,5 +16,5 @@ from nemo_platform.types.access_keys import ( Methods: - client.access_keys.create(\*\*params) -> AccessKeyCreateResponse -- client.access_keys.list() -> AccessKeyListResponse +- client.access_keys.list(\*\*params) -> AccessKeyListResponse - client.access_keys.delete(jti) -> object 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 6bf30f77aa..66fd70cdf5 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 @@ -18,6 +18,7 @@ from __future__ import annotations from .access_key_create_params import AccessKeyCreateParams as AccessKeyCreateParams +from .access_key_list_params import AccessKeyListParams as AccessKeyListParams from .access_key_list_response import AccessKeyListResponse as AccessKeyListResponse from .access_key_create_response import AccessKeyCreateResponse as AccessKeyCreateResponse from .access_key_metadata_response import AccessKeyMetadataResponse as AccessKeyMetadataResponse diff --git a/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_list_params.py b/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_list_params.py new file mode 100644 index 0000000000..f6ebcc8a90 --- /dev/null +++ b/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_list_params.py @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["AccessKeyListParams"] + + +class AccessKeyListParams(TypedDict, total=False): + page: int + + page_size: int 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 18679bed66..f4f39249ad 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 @@ -75,6 +75,12 @@ def test_method_list(self, client: NeMoPlatform) -> None: access_key = client.access_keys.list() assert_matches_type(AccessKeyListResponse, access_key, path=["response"]) + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: NeMoPlatform) -> None: + access_key = client.access_keys.list(page=2, page_size=25) + assert_matches_type(AccessKeyListResponse, access_key, path=["response"]) + @pytest.mark.skip(reason="Mock server tests are disabled") @parametrize def test_raw_response_list(self, client: NeMoPlatform) -> None: @@ -188,6 +194,12 @@ async def test_method_list(self, async_client: AsyncNeMoPlatform) -> None: access_key = await async_client.access_keys.list() assert_matches_type(AccessKeyListResponse, access_key, path=["response"]) + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncNeMoPlatform) -> None: + access_key = await async_client.access_keys.list(page=2, page_size=25) + assert_matches_type(AccessKeyListResponse, access_key, path=["response"]) + @pytest.mark.skip(reason="Mock server tests are disabled") @parametrize async def test_raw_response_list(self, async_client: AsyncNeMoPlatform) -> None: 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 fd90019b4f..6b8da3e30a 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 @@ -14,7 +14,12 @@ from nemo_platform.auth.helpers import decode_jwt_claims, generate_unsigned_jwt from nemo_platform.cli.app import app from nemo_platform_plugin.auth.access_keys.issuer import AccessKeyFeatureDisabledError -from nemo_platform_plugin.auth.access_keys.types import AccessKeyCreateRequest, AccessKeyCreateResponse +from nemo_platform_plugin.auth.access_keys.types import ( + AccessKeyCreateRequest, + AccessKeyCreateResponse, + AccessKeyListResponse, + AccessKeyMetadataResponse, +) from nemo_platform_plugin.client.constants import WORKLOAD_IDENTITY_TOKEN_FILE_ENVVAR from typer.testing import CliRunner @@ -66,7 +71,10 @@ def _decode_jwt_noop(token: str) -> dict: return {} -def _created_access_key(name: str | None = None) -> AccessKeyCreateResponse: +def _created_access_key( + name: str | None = None, + description: str | None = None, +) -> AccessKeyCreateResponse: return AccessKeyCreateResponse( jti="ak_example", name=name, @@ -75,6 +83,10 @@ def _created_access_key(name: str | None = None) -> AccessKeyCreateResponse: principal="alice@example.com", created_at=datetime(2026, 7, 28, 12, 0, tzinfo=UTC), expires_at=None, + description=description, + status="ACTIVE", + issuer="https://platform.example.com/apis/auth", + audiences=["nemo-platform-access-key"], ) @@ -368,21 +380,40 @@ def test_auth_access_keys_create_prints_token(monkeypatch: pytest.MonkeyPatch): assert "expires_in_seconds" not in body.model_fields_set -def test_auth_access_keys_create_sends_optional_name_and_expiration(monkeypatch: pytest.MonkeyPatch): +def test_auth_access_keys_create_sends_optional_metadata_and_expiration(monkeypatch: pytest.MonkeyPatch): fake_platform_client = MagicMock() fake_access_keys_client = MagicMock() - fake_access_keys_client.create_access_key.return_value.data.return_value = _created_access_key("short-lived") + fake_access_keys_client.create_access_key.return_value.data.return_value = _created_access_key( + "short-lived", "CI automation" + ) monkeypatch.setattr("nemo_platform.cli.core.context.CLIContext.get_client", lambda self: fake_platform_client) 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", "create", "--name", "short-lived", "--expires-in", "3600"]) + result = runner.invoke( + app, + [ + "auth", + "access-keys", + "create", + "--name", + "short-lived", + "--description", + "CI automation", + "--expires-in", + "3600", + ], + ) assert_exit_code(result, 0) fake_access_keys_client.create_access_key.assert_called_once_with( - body=AccessKeyCreateRequest(name="short-lived", expires_in_seconds=3600), + body=AccessKeyCreateRequest( + name="short-lived", + description="CI automation", + expires_in_seconds=3600, + ), ) @@ -439,27 +470,104 @@ def test_auth_access_keys_create_reports_disabled_feature(monkeypatch: pytest.Mo assert "Scoped Access Keys are not enabled" in result.output -def test_auth_access_keys_help_hides_unimplemented_lifecycle_commands() -> None: +def test_auth_access_keys_list_outputs_owned_keys(monkeypatch: pytest.MonkeyPatch) -> None: + fake_access_keys_client = MagicMock() + fake_access_keys_client.list_access_keys.return_value.data.return_value = AccessKeyListResponse( + data=[ + AccessKeyMetadataResponse( + jti="ak_example", + name="ci-build", + principal="alice@example.com", + created_at=datetime(2026, 7, 28, 12, 0, tzinfo=UTC), + description="CI automation", + status="ACTIVE", + issuer="https://platform.example.com/apis/auth", + audiences=["nemo-platform-access-key"], + ) + ] + ) + 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, ["--output-format", "json", "auth", "access-keys", "list"]) + + assert_exit_code(result, 0) + assert '"jti": "ak_example"' in result.output + assert '"name": "ci-build"' in result.output + assert '"description": "CI automation"' in result.output + assert '"status": "ACTIVE"' in result.output + fake_access_keys_client.list_access_keys.assert_called_once_with(query_params={"page": 1, "page_size": 100}) + + +def test_auth_access_keys_list_table_includes_key_name(monkeypatch: pytest.MonkeyPatch) -> None: + fake_access_keys_client = MagicMock() + fake_access_keys_client.list_access_keys.return_value.data.return_value = AccessKeyListResponse( + data=[ + AccessKeyMetadataResponse( + jti="ak_example", + name="ci-build", + principal="alice@example.com", + created_at=datetime(2026, 7, 28, 12, 0, tzinfo=UTC), + description="CI automation", + status="ACTIVE", + issuer="https://platform.example.com/apis/auth", + audiences=["nemo-platform-access-key"], + ) + ] + ) + 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", "list"]) + + assert_exit_code(result, 0) + assert "ak_example" in result.output + assert "ci-build" in result.output + assert "CI automation" in result.output + assert "ACTIVE" in result.output + fake_access_keys_client.list_access_keys.assert_called_once_with(query_params={"page": 1, "page_size": 100}) + + +def test_auth_access_keys_list_points_to_next_page(monkeypatch: pytest.MonkeyPatch) -> None: + fake_access_keys_client = MagicMock() + fake_access_keys_client.list_access_keys.return_value.data.return_value = AccessKeyListResponse( + data=[], + has_more=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, + ) + + result = runner.invoke( + app, + ["--output-format", "json", "auth", "access-keys", "list", "--page", "3", "--page-size", "25"], + ) + + assert_exit_code(result, 0) + assert result.stdout.strip() == "[]" + assert "use --page 4" in result.stderr + fake_access_keys_client.list_access_keys.assert_called_once_with(query_params={"page": 3, "page_size": 25}) + + +def test_auth_access_keys_help_exposes_lifecycle_commands() -> None: result = runner.invoke(app, ["auth", "access-keys", "--help"]) assert_exit_code(result, 0) assert "create" in result.output - assert "list" not in result.output - assert "revoke" not in result.output + assert "list" in result.output create_help = runner.invoke(app, ["auth", "access-keys", "create", "--help"]) assert_exit_code(create_help, 0) assert "Use 'none' to request no expiration" in " ".join(create_help.output.split()) - list_result = runner.invoke(app, ["auth", "access-keys", "list"]) - revoke_result = runner.invoke(app, ["auth", "access-keys", "revoke", "ak_example"]) - - assert list_result.exit_code != 0 - assert revoke_result.exit_code != 0 - assert "No such command" in list_result.output - assert "No such command" in revoke_result.output - - def test_auth_tokens_group_is_not_exposed() -> None: result = runner.invoke(app, ["auth", "tokens", "create"]) diff --git a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/core/test_errors.py b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/core/test_errors.py index 02af2c4741..f455682c3b 100644 --- a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/core/test_errors.py +++ b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/core/test_errors.py @@ -60,6 +60,17 @@ def test_format_api_error_fallback_to_str(): assert _format_api_error(error) == "String representation of error" +def test_format_api_error_ignores_non_string_message(): + class APIErrorLike: + body = None + message = 404 + + def __str__(self) -> str: + return "String representation of error" + + assert _format_api_error(APIErrorLike()) == "String representation of error" + + @pytest.mark.parametrize( "error_class,status_code,expected_prefix,expected_hint", [ 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 a4e6a8cfe6..a6f6ffb933 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 @@ -5,15 +5,18 @@ from typing import Any -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from nemo_platform_plugin.auth.access_keys.issuer import ( AccessKeyFeatureDisabledError, - AccessKeyIssuer, AccessKeyOperationNotImplementedError, ) from nmp.common.auth import AuthClient, get_auth_client -from nmp.common.auth.access_keys import AccessKeyIssuerService from nmp.common.config import get_auth_config +from nmp.core.auth.app.access_keys import ( + AccessKeyRegistry, + PersistentAccessKeyIssuer, + get_access_key_registry, +) from . import schemas @@ -41,8 +44,11 @@ } -def get_access_key_issuer(auth_client: AuthClient = Depends(get_auth_client)) -> AccessKeyIssuerService: - return AccessKeyIssuerService(config=get_auth_config(), principal=auth_client.principal) +def get_access_key_issuer( + auth_client: AuthClient = Depends(get_auth_client), + registry: AccessKeyRegistry = Depends(get_access_key_registry), +) -> PersistentAccessKeyIssuer: + return PersistentAccessKeyIssuer(get_auth_config(), auth_client.principal, registry) def _not_implemented(exc: AccessKeyOperationNotImplementedError) -> HTTPException: @@ -60,7 +66,7 @@ def _disabled(exc: AccessKeyFeatureDisabledError) -> HTTPException: ) async def create_access_key( request: schemas.AccessKeyCreateRequest, - issuer: AccessKeyIssuerService = Depends(get_access_key_issuer), + issuer: PersistentAccessKeyIssuer = Depends(get_access_key_issuer), ) -> schemas.AccessKeyCreateResponse: try: return await issuer.create_async(request) @@ -77,9 +83,13 @@ async def create_access_key( response_model=schemas.AccessKeyListResponse, responses=_ACCESS_KEY_LIFECYCLE_ERROR_RESPONSES, ) -async def list_access_keys(issuer: AccessKeyIssuer = Depends(get_access_key_issuer)) -> schemas.AccessKeyListResponse: +async def list_access_keys( + page: int = Query(default=1, ge=1), + page_size: int = Query(default=100, ge=1, le=100), + issuer: PersistentAccessKeyIssuer = Depends(get_access_key_issuer), +) -> schemas.AccessKeyListResponse: try: - return issuer.list() + return await issuer.list_async(page=page, page_size=page_size) except AccessKeyFeatureDisabledError as exc: raise _disabled(exc) from exc except AccessKeyOperationNotImplementedError as exc: @@ -87,7 +97,7 @@ async def list_access_keys(issuer: AccessKeyIssuer = Depends(get_access_key_issu @router.delete("/v2/access-keys/{jti}", responses=_ACCESS_KEY_LIFECYCLE_ERROR_RESPONSES) -async def revoke_access_key(jti: str, issuer: AccessKeyIssuer = Depends(get_access_key_issuer)) -> None: +async def revoke_access_key(jti: str, issuer: PersistentAccessKeyIssuer = Depends(get_access_key_issuer)) -> None: try: issuer.revoke(jti) except AccessKeyFeatureDisabledError as exc: 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 new file mode 100644 index 0000000000..a8825828ca --- /dev/null +++ b/services/core/auth/src/nmp/core/auth/app/access_keys.py @@ -0,0 +1,125 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Persistence and issuance services for Scoped Access Keys.""" + +from __future__ import annotations + +import logging +from datetime import UTC, datetime + +from fastapi import Depends +from nemo_platform_plugin.auth.access_keys.issuer import AccessKeyFeatureDisabledError +from nemo_platform_plugin.auth.access_keys.types import ( + AccessKeyCreateRequest, + AccessKeyCreateResponse, + AccessKeyListResponse, + AccessKeyMetadataResponse, + AccessKeyStatus, +) +from nmp.common.auth.access_keys import AccessKeyIssuerService +from nmp.common.auth.models import Principal +from nmp.common.config import AuthConfig +from nmp.common.entities import EntityClient +from nmp.common.service.dependencies import get_entity_client +from nmp.core.auth.entities import AccessKeyEntity + +ACCESS_KEY_WORKSPACE = "system" +logger = logging.getLogger(__name__) + + +class AccessKeyRegistry: + """Durable access-key lifecycle records stored by the entities service.""" + + def __init__(self, entity_client: EntityClient) -> None: + self._entity_client = entity_client + + async def add(self, key: AccessKeyCreateResponse) -> None: + await self._entity_client.create( + AccessKeyEntity( + name=key.jti, + workspace=ACCESS_KEY_WORKSPACE, + key_name=key.name, + description=key.description, + principal=key.principal, + issuer=key.issuer, + audiences=key.audiences, + issued_at=key.created_at, + expires_at=key.expires_at, + ) + ) + + async def list_for_principal(self, principal: str, *, page: int, page_size: int) -> AccessKeyListResponse: + result = await self._entity_client.list( + AccessKeyEntity, + workspace=ACCESS_KEY_WORKSPACE, + filter_obj={"principal": principal}, + sort="-issued_at", + page=page, + page_size=page_size, + ) + return AccessKeyListResponse( + data=[self._metadata(record) for record in result.data], + has_more=page < result.pagination.total_pages, + ) + + @staticmethod + def _metadata(record: AccessKeyEntity) -> AccessKeyMetadataResponse: + return AccessKeyMetadataResponse( + jti=record.name, + name=record.key_name, + description=record.description, + principal=record.principal, + status=AccessKeyRegistry._status(record), + issuer=record.issuer, + audiences=record.audiences, + created_at=record.issued_at, + expires_at=record.expires_at, + ) + + @staticmethod + def _status(record: AccessKeyEntity) -> AccessKeyStatus: + if record.revoked_at is not None: + return "REVOKED" + if record.expires_at is not None and record.expires_at <= datetime.now(tz=UTC): + return "EXPIRED" + return "ACTIVE" + +def get_access_key_registry(entity_client: EntityClient = Depends(get_entity_client)) -> AccessKeyRegistry: + return AccessKeyRegistry(entity_client.as_service("auth", internal=True)) + + +class PersistentAccessKeyIssuer: + """Signs access keys and records their lifecycle before returning them.""" + + def __init__(self, config: AuthConfig, principal: Principal, registry: AccessKeyRegistry) -> None: + self._issuer = AccessKeyIssuerService(config=config, principal=principal) + self._config = config + self._registry = registry + self.principal = principal.id + + async def create_async(self, request: AccessKeyCreateRequest) -> AccessKeyCreateResponse: + self._ensure_enabled() + key = await self._issuer.create_async(request) + await self._registry.add(key) + logger.info( + "Scoped Access Key created", + extra={ + "audit_event": "access_key.created", + "actor_principal": self.principal, + "access_key_jti": key.jti, + }, + ) + return key + + async def list_async(self, *, page: int = 1, page_size: int = 100) -> AccessKeyListResponse: + self._ensure_enabled() + return await self._registry.list_for_principal(self.principal, page=page, page_size=page_size) + + def revoke(self, jti: str) -> None: + """Preserve the pre-lifecycle not-implemented response until revocation is added.""" + self._issuer.revoke(jti) + + 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/entities/__init__.py b/services/core/auth/src/nmp/core/auth/entities/__init__.py index 8951c05549..6d48848f45 100644 --- a/services/core/auth/src/nmp/core/auth/entities/__init__.py +++ b/services/core/auth/src/nmp/core/auth/entities/__init__.py @@ -3,6 +3,6 @@ """Auth service entities.""" -from .entities import RoleBindingEntity +from .entities import AccessKeyEntity, RoleBindingEntity -__all__ = ["RoleBindingEntity"] +__all__ = ["AccessKeyEntity", "RoleBindingEntity"] diff --git a/services/core/auth/src/nmp/core/auth/entities/entities.py b/services/core/auth/src/nmp/core/auth/entities/entities.py index e7853dfb02..b91801f35b 100644 --- a/services/core/auth/src/nmp/core/auth/entities/entities.py +++ b/services/core/auth/src/nmp/core/auth/entities/entities.py @@ -30,3 +30,18 @@ class RoleBindingEntity(EntityBase): granted_by: str granted_at: datetime revoked_at: Optional[datetime] = None + + +class AccessKeyEntity(EntityBase): + """Persistent lifecycle record for a Scoped Access Key.""" + + __entity_type__ = "access_key" + + key_name: str | None = None + description: str | None = None + principal: str + issuer: str + audiences: list[str] + issued_at: datetime + expires_at: datetime | None = None + revoked_at: datetime | None = None diff --git a/services/core/auth/tests/test_access_key_registry.py b/services/core/auth/tests/test_access_key_registry.py new file mode 100644 index 0000000000..c29357cbdc --- /dev/null +++ b/services/core/auth/tests/test_access_key_registry.py @@ -0,0 +1,108 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from nemo_platform_plugin.auth.access_keys.types import AccessKeyCreateResponse +from nmp.core.auth.app.access_keys import AccessKeyRegistry +from nmp.core.auth.entities import AccessKeyEntity + +NOW = datetime(2026, 8, 4, 18, 0, tzinfo=UTC) + + +def _record(*, jti: str = "ak_example", principal: str = "alice@example.com", revoked: bool = False): + return AccessKeyEntity( + name=jti, + workspace="system", + key_name="ci-build", + description="CI build automation", + principal=principal, + issued_at=NOW, + expires_at=datetime(2030, 1, 1, tzinfo=UTC), + revoked_at=NOW if revoked else None, + issuer="https://platform.example.com/apis/auth", + audiences=["nemo-platform-access-key"], + ) + + +def _expired_record() -> AccessKeyEntity: + return _record(jti="ak_expired").model_copy(update={"expires_at": datetime(2026, 8, 3, 18, 0, tzinfo=UTC)}) + + +@pytest.mark.asyncio +async def test_registry_persists_created_key_metadata() -> None: + entity_client = AsyncMock() + registry = AccessKeyRegistry(entity_client) + key = AccessKeyCreateResponse( + jti="ak_example", + name="ci-build", + principal="alice@example.com", + created_at=NOW, + expires_at=NOW + timedelta(hours=1), + description="CI build automation", + status="ACTIVE", + issuer="https://platform.example.com/apis/auth", + audiences=["nemo-platform-access-key"], + token="secret-token", + token_type="Bearer", + ) + + await registry.add(key) + + saved = entity_client.create.await_args.args[0] + assert isinstance(saved, AccessKeyEntity) + assert saved.name == "ak_example" + assert saved.key_name == "ci-build" + assert saved.description == "CI build automation" + assert "secret-token" not in saved.model_dump_json() + + +@pytest.mark.asyncio +async def test_registry_lists_principals_keys_with_status_across_pages() -> None: + entity_client = AsyncMock() + entity_client.list.return_value = SimpleNamespace( + data=[_record(), _record(jti="ak_revoked", revoked=True)], pagination=SimpleNamespace(total_pages=2) + ) + registry = AccessKeyRegistry(entity_client) + + result = await registry.list_for_principal("alice@example.com", page=1, page_size=2) + + assert [key.jti for key in result.data] == ["ak_example", "ak_revoked"] + assert [key.status for key in result.data] == ["ACTIVE", "REVOKED"] + assert result.has_more + entity_client.list.assert_awaited_once() + assert entity_client.list.await_args.kwargs["page"] == 1 + assert entity_client.list.await_args.kwargs["page_size"] == 2 + assert entity_client.list.await_args.kwargs["filter_obj"] == {"principal": "alice@example.com"} + + +@pytest.mark.asyncio +async def test_registry_can_retrieve_later_list_page() -> None: + entity_client = AsyncMock() + entity_client.list.return_value = SimpleNamespace( + data=[_record(jti="ak_later")], pagination=SimpleNamespace(total_pages=12) + ) + registry = AccessKeyRegistry(entity_client) + + result = await registry.list_for_principal("alice@example.com", page=11, page_size=25) + + assert [key.jti for key in result.data] == ["ak_later"] + assert result.has_more + assert entity_client.list.await_args.kwargs["page"] == 11 + assert entity_client.list.await_args.kwargs["page_size"] == 25 + + +@pytest.mark.asyncio +async def test_registry_reports_expired_status() -> None: + entity_client = AsyncMock() + entity_client.list.return_value = SimpleNamespace( + data=[_expired_record()], pagination=SimpleNamespace(total_pages=1) + ) + registry = AccessKeyRegistry(entity_client) + + result = await registry.list_for_principal("alice@example.com", page=1, page_size=100) + + assert result.data[0].status == "EXPIRED" diff --git a/services/core/auth/tests/test_access_keys.py b/services/core/auth/tests/test_access_keys.py index 2dd7096543..2ddbe900bb 100644 --- a/services/core/auth/tests/test_access_keys.py +++ b/services/core/auth/tests/test_access_keys.py @@ -1,19 +1,50 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 +from datetime import UTC, datetime, timedelta from unittest.mock import patch import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from nemo_platform_plugin.auth.access_keys.issuer import AccessKeyOperationNotImplementedError +from nemo_platform_plugin.auth.access_keys.types import AccessKeyCreateResponse from nmp.common.auth.client import AuthClient from nmp.common.auth.dependencies import auth_client_context from nmp.common.auth.models import Principal 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 get_access_key_registry + + +class InMemoryAccessKeyRegistry: + def __init__(self): + self.keys = {} + + async def add(self, key): + self.keys[key.jti] = key + + def _status(self, key): + if key.expires_at is not None and key.expires_at <= datetime.now(tz=UTC): + return "EXPIRED" + return key.status + + async def list_for_principal(self, principal, *, page, page_size): + from nemo_platform_plugin.auth.access_keys.types import AccessKeyListResponse, AccessKeyMetadataResponse + + owned = [(jti, key) for jti, key in self.keys.items() if key.principal == principal] + start = (page - 1) * page_size + selected = owned[start : start + page_size] + return AccessKeyListResponse( + data=[ + AccessKeyMetadataResponse.model_validate( + key.model_dump(exclude={"token", "token_type"}) | {"status": self._status(key)} + ) + for jti, key in selected + ], + has_more=start + page_size < len(owned), + ) @pytest.fixture def client(tmp_path): @@ -44,6 +75,8 @@ def client(tmp_path): app = FastAPI() app.include_router(router) + registry = InMemoryAccessKeyRegistry() + app.dependency_overrides[get_access_key_registry] = lambda: registry token = auth_client_context.set( AuthClient( @@ -61,6 +94,7 @@ def disabled_client(): config = AuthConfig(enabled=True, access_keys=AccessKeyConfig()) app = FastAPI() app.include_router(router) + app.dependency_overrides[get_access_key_registry] = lambda: InMemoryAccessKeyRegistry() token = auth_client_context.set( AuthClient( principal=Principal(id="alice@example.com", email="alice@example.com", groups=["team-ml"]), @@ -73,18 +107,50 @@ def disabled_client(): def test_create_access_key_returns_token_for_current_principal(client): - response = client.post("/v2/access-keys", json={"name": "gtc-intake", "expires_in_seconds": 3600}) + response = client.post( + "/v2/access-keys", + json={ + "name": "gtc-intake", + "description": "GTC intake automation", + "expires_in_seconds": 3600, + }, + ) assert response.status_code == 200 body = response.json() assert body["jti"].startswith("ak_") assert body["name"] == "gtc-intake" + assert body["description"] == "GTC intake automation" assert body["token_type"] == "Bearer" assert body["principal"] == "alice@example.com" assert body["expires_at"] is not None assert body["token"].count(".") == 2 +@pytest.mark.asyncio +async def test_in_memory_access_key_registry_reports_expired_status() -> None: + registry = InMemoryAccessKeyRegistry() + await registry.add( + AccessKeyCreateResponse( + jti="ak_expired", + name="expired-key", + token="signed.jwt.token", + token_type="Bearer", + principal="alice@example.com", + created_at=datetime.now(tz=UTC) - timedelta(hours=2), + expires_at=datetime.now(tz=UTC) - timedelta(hours=1), + description=None, + status="ACTIVE", + issuer="http://testserver/apis/auth", + audiences=["nemo-platform-access-key"], + ) + ) + + result = await registry.list_for_principal("alice@example.com", page=1, page_size=100) + + assert result.data[0].status == "EXPIRED" + + def test_create_access_key_allows_unnamed_tokens(client): response = client.post("/v2/access-keys", json={"expires_in_seconds": 3600}) @@ -189,8 +255,31 @@ def test_access_key_lifecycle_openapi_documents_error_responses(client): assert create_responses["501"]["content"]["application/json"]["schema"] == { "$ref": "#/components/schemas/AccessKeyNotImplementedErrorResponse" } - - list_responses = openapi["paths"]["/v2/access-keys"]["get"]["responses"] + request_schema = openapi["components"]["schemas"]["AccessKeyCreateRequest"] + assert request_schema["properties"]["name"]["nullable"] is True + assert request_schema["properties"]["description"]["nullable"] is True + assert request_schema["properties"]["expires_in_seconds"]["nullable"] is True + metadata_schema = openapi["components"]["schemas"]["AccessKeyMetadataResponse"] + assert metadata_schema["properties"]["name"]["nullable"] is True + assert metadata_schema["properties"]["description"]["nullable"] is True + assert metadata_schema["properties"]["audiences"]["uniqueItems"] is True + assert metadata_schema["properties"]["expires_at"]["nullable"] is True + create_response_schema = openapi["components"]["schemas"]["AccessKeyCreateResponse"] + assert create_response_schema["properties"]["audiences"]["uniqueItems"] is True + list_schema = openapi["components"]["schemas"]["AccessKeyListResponse"] + assert list_schema["properties"]["has_more"]["default"] is False + + list_operation = openapi["paths"]["/v2/access-keys"]["get"] + list_parameters = {parameter["name"]: parameter for parameter in list_operation["parameters"]} + assert list_parameters["page"]["schema"] == {"type": "integer", "minimum": 1, "default": 1, "title": "Page"} + assert list_parameters["page_size"]["schema"] == { + "type": "integer", + "maximum": 100, + "minimum": 1, + "default": 100, + "title": "Page Size", + } + list_responses = list_operation["responses"] assert list_responses["200"]["content"]["application/json"]["schema"] == { "$ref": "#/components/schemas/AccessKeyListResponse" } @@ -215,15 +304,41 @@ def test_access_key_lifecycle_openapi_documents_error_responses(client): } -def test_list_access_keys_is_explicitly_not_implemented(client): - response = client.get("/v2/access-keys") - - assert response.status_code == 501 - assert response.json()["detail"] == "Scoped Access Key listing is not implemented." - +def test_list_access_keys_returns_current_principals_persisted_keys(client): + created = client.post( + "/v2/access-keys", + json={"name": "gtc-intake", "description": "GTC intake automation"}, + ).json() -def test_revoke_access_key_is_explicitly_not_implemented(client): - response = client.delete("/v2/access-keys/ak_example") + response = client.get("/v2/access-keys") - assert response.status_code == 501 - assert response.json()["detail"] == "Scoped Access Key revocation for ak_example is not implemented." + assert response.status_code == 200 + assert response.json()["data"] == [ + { + "jti": created["jti"], + "name": "gtc-intake", + "principal": "alice@example.com", + "created_at": created["created_at"], + "expires_at": created["expires_at"], + "description": "GTC intake automation", + "status": "ACTIVE", + "issuer": "http://testserver/apis/auth", + "audiences": ["nemo-platform-access-key"], + } + ] + assert response.json()["has_more"] is False + + +def test_list_access_keys_supports_pagination(client): + first = client.post("/v2/access-keys", json={"name": "first"}).json() + second = client.post("/v2/access-keys", json={"name": "second"}).json() + + first_page = client.get("/v2/access-keys", params={"page": 1, "page_size": 1}) + second_page = client.get("/v2/access-keys", params={"page": 2, "page_size": 1}) + + assert first_page.status_code == 200 + assert [key["jti"] for key in first_page.json()["data"]] == [first["jti"]] + assert first_page.json()["has_more"] is True + assert second_page.status_code == 200 + assert [key["jti"] for key in second_page.json()["data"]] == [second["jti"]] + assert second_page.json()["has_more"] is False From fb2b0078f216c9bdcc593c34856ccb9568e4cb86 Mon Sep 17 00:00:00 2001 From: anastasia-nesterenko Date: Wed, 5 Aug 2026 12:30:50 -0600 Subject: [PATCH 2/8] feat(auth): revoke access keys and enforce lifecycle Signed-off-by: anastasia-nesterenko --- .../authentication/using-authentication.mdx | 10 +- docs/auth/deployment/configuration.mdx | 4 +- docs/cli/reference.mdx | 21 ++ openapi/ga/individual/platform.openapi.yaml | 21 +- openapi/ga/openapi.yaml | 21 +- openapi/openapi.yaml | 21 +- .../nemo_platform_ext/cli/commands/auth.py | 19 ++ .../tests/cli/commands/test_auth.py | 72 +++++ .../auth/access_keys/client.py | 5 +- .../auth/access_keys/endpoints.py | 3 +- .../auth/access_keys/issuer.py | 3 +- .../auth/access_keys/types.py | 13 +- .../tests/auth/access_keys/test_client.py | 13 + .../src/nmp/common/auth/access_keys.py | 3 +- .../src/nmp/common/auth/middleware.py | 181 +++++++++++- .../nmp_common/src/nmp/common/config/base.py | 21 ++ .../src/nmp/common/entities/client.py | 9 +- .../nmp_common/tests/auth/test_middleware.py | 278 +++++++++++++++++- .../nmp_common/tests/entities/test_client.py | 11 +- .../nemo-platform/.nmpcontext/openapi.yaml | 85 +++++- .../nemo-platform/.nmpcontext/stainless.yaml | 1 + .../src/nemo_platform/cli/commands/auth.py | 19 ++ .../resources/access_keys/access_keys.py | 27 +- .../resources/access_keys/api.md | 3 +- .../types/access_keys/__init__.py | 1 + .../access_keys/access_key_create_params.py | 8 +- .../access_keys/access_key_create_response.py | 13 +- .../access_keys/access_key_list_response.py | 8 +- .../access_key_metadata_response.py | 14 +- .../access_keys/access_key_revoke_response.py | 30 ++ .../tests/api_resources/test_access_keys.py | 20 +- .../cli/commands/test_auth.py | 72 +++++ sdk/stainless.yaml | 1 + .../core/auth/api/v2/access_keys/endpoints.py | 25 +- .../core/auth/api/v2/access_keys/schemas.py | 2 +- .../src/nmp/core/auth/api/v2/authenticate.py | 67 ++++- .../auth/src/nmp/core/auth/app/access_keys.py | 140 ++++++++- .../integration/test_scoped_access_keys.py | 51 +++- .../auth/tests/test_access_key_registry.py | 105 ++++++- services/core/auth/tests/test_access_keys.py | 81 ++++- services/core/auth/tests/test_authenticate.py | 85 +++++- .../auth/tests/test_embedded_pdp_stress.py | 12 +- .../tests/test_workload_token_exchange.py | 17 ++ 43 files changed, 1514 insertions(+), 102 deletions(-) create mode 100644 sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_revoke_response.py diff --git a/docs/auth/authentication/using-authentication.mdx b/docs/auth/authentication/using-authentication.mdx index 08ae4a3d9b..589038cff9 100644 --- a/docs/auth/authentication/using-authentication.mdx +++ b/docs/auth/authentication/using-authentication.mdx @@ -109,7 +109,7 @@ the SDK's OIDC refresh flow: ```bash # Create a Scoped Access Key with the platform default expiry and print the token once. -nemo auth access-keys create --name ci-build +nemo auth access-keys create --name ci-build --description "CI build automation" ``` Scoped Access Key management commands live under the `auth` namespace as @@ -131,15 +131,17 @@ 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 with optional pagination: +List keys or 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 revoke ak_example ``` -The list includes each key's status, description, issuer, audiences, creation -time, and expiration time. Revocation and rotation are not implemented. +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. ### Token Inspection diff --git a/docs/auth/deployment/configuration.mdx b/docs/auth/deployment/configuration.mdx index 8a2eaca2bd..63cce44a26 100644 --- a/docs/auth/deployment/configuration.mdx +++ b/docs/auth/deployment/configuration.mdx @@ -89,8 +89,8 @@ Nested auth keys use a double underscore after `NMP_AUTH_`: for example, Scoped Access Keys let an authenticated user create a scoped bearer token for non-SDK clients and automation. The implementation creates user-scoped signed -JWT access keys and rejects service principals. Revocation and rotation are not -implemented. +JWT access keys, persists their lifecycle metadata, and rejects service principals. +Users can list and revoke their own keys. Rotation is not implemented. Scoped Access Keys are an auth-service feature exposed under the auth CLI namespace (`nemo auth access-keys ...`) and the `/apis/auth/v2/access-keys` API routes. diff --git a/docs/cli/reference.mdx b/docs/cli/reference.mdx index f08ae887b6..dac831e94d 100644 --- a/docs/cli/reference.mdx +++ b/docs/cli/reference.mdx @@ -277,6 +277,8 @@ nemo auth access-keys [OPTIONS] COMMAND [ARGS]... **Commands:** * `create`: Create a Scoped Access Key for the current authenticated... +* `list`: List Scoped Access Keys owned by the current... +* `revoke`: Revoke a Scoped Access Key owned by the current... ##### nemo auth access-keys create @@ -291,6 +293,7 @@ nemo auth access-keys create [OPTIONS] **Options:** * `--name, -n`: Optional human-readable label for the Scoped Access Key. +* `--description, -d`: Optional description for the Scoped Access Key. * `--expires-in`: Scoped Access Key lifetime in seconds. Use 'none' to request no expiration. **Help:** @@ -316,6 +319,24 @@ nemo auth access-keys list [OPTIONS] * `--help, -h`: Show this message and exit. +##### nemo auth access-keys revoke + +Revoke a Scoped Access Key owned by the current authenticated user. + +**Usage:** + +```shell +nemo auth access-keys revoke [OPTIONS] JTI +``` + +**Arguments:** + +* ``: Stable ID of the Scoped Access Key to revoke. + +**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 156dc2e7dd..0f318277ae 100644 --- a/openapi/ga/individual/platform.openapi.yaml +++ b/openapi/ga/individual/platform.openapi.yaml @@ -275,9 +275,10 @@ paths: description: Successful Response content: application/json: - schema: {} + schema: + $ref: '#/components/schemas/AccessKeyRevokeResponse' '404': - description: Scoped Access Keys are not enabled + description: Scoped Access Keys are not enabled or the key was not found content: application/json: schema: @@ -8137,6 +8138,22 @@ components: - detail title: AccessKeyNotImplementedErrorResponse description: Response returned by unsupported Scoped Access Key lifecycle endpoints. + AccessKeyRevokeResponse: + properties: + jti: + type: string + title: Jti + description: Stable JWT ID for this Scoped Access Key. + revoked: + type: boolean + title: Revoked + description: True when this request changed the key from active to revoked. + type: object + required: + - jti + - revoked + title: AccessKeyRevokeResponse + description: Response returned after a Scoped Access Key revoke request. ActionRails: properties: instant_actions: diff --git a/openapi/ga/openapi.yaml b/openapi/ga/openapi.yaml index 156dc2e7dd..0f318277ae 100644 --- a/openapi/ga/openapi.yaml +++ b/openapi/ga/openapi.yaml @@ -275,9 +275,10 @@ paths: description: Successful Response content: application/json: - schema: {} + schema: + $ref: '#/components/schemas/AccessKeyRevokeResponse' '404': - description: Scoped Access Keys are not enabled + description: Scoped Access Keys are not enabled or the key was not found content: application/json: schema: @@ -8137,6 +8138,22 @@ components: - detail title: AccessKeyNotImplementedErrorResponse description: Response returned by unsupported Scoped Access Key lifecycle endpoints. + AccessKeyRevokeResponse: + properties: + jti: + type: string + title: Jti + description: Stable JWT ID for this Scoped Access Key. + revoked: + type: boolean + title: Revoked + description: True when this request changed the key from active to revoked. + type: object + required: + - jti + - revoked + title: AccessKeyRevokeResponse + description: Response returned after a Scoped Access Key revoke request. ActionRails: properties: instant_actions: diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 156dc2e7dd..0f318277ae 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -275,9 +275,10 @@ paths: description: Successful Response content: application/json: - schema: {} + schema: + $ref: '#/components/schemas/AccessKeyRevokeResponse' '404': - description: Scoped Access Keys are not enabled + description: Scoped Access Keys are not enabled or the key was not found content: application/json: schema: @@ -8137,6 +8138,22 @@ components: - detail title: AccessKeyNotImplementedErrorResponse description: Response returned by unsupported Scoped Access Key lifecycle endpoints. + AccessKeyRevokeResponse: + properties: + jti: + type: string + title: Jti + description: Stable JWT ID for this Scoped Access Key. + revoked: + type: boolean + title: Revoked + description: True when this request changed the key from active to revoked. + type: object + required: + - jti + - revoked + title: AccessKeyRevokeResponse + description: Response returned after a Scoped Access Key revoke request. 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 b4107a489e..a0f6520e23 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 @@ -906,6 +906,25 @@ def list_access_keys( typer.echo(f"More Scoped Access Keys are available; use --page {page + 1} to retrieve them.", err=True) +@access_keys_app.command("revoke") +@handle_errors +def revoke_access_key( + ctx: typer.Context, + jti: Annotated[str, typer.Argument(help="Stable ID of the Scoped Access Key to revoke.")], +) -> None: + """Revoke a Scoped Access Key owned by the current authenticated user.""" + try: + result = _access_key_issuer(ctx).revoke(jti) + except AccessKeyFeatureDisabledError as exc: + _raise_access_key_disabled(exc) + except AccessKeyOperationNotImplementedError as exc: + _raise_access_key_not_implemented(exc) + if result.revoked: + typer.echo(f"Revoked Scoped Access Key {jti}.") + else: + typer.echo(f"Scoped Access Key {jti} was already revoked.") + + @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 b66b4eb4b1..f977274a6c 100644 --- a/packages/nemo_platform_ext/tests/cli/commands/test_auth.py +++ b/packages/nemo_platform_ext/tests/cli/commands/test_auth.py @@ -9,6 +9,7 @@ from types import SimpleNamespace from unittest.mock import MagicMock, patch +import httpx import pytest import yaml from nemo_platform_ext.auth.helpers import decode_jwt_claims, generate_unsigned_jwt @@ -19,8 +20,10 @@ AccessKeyCreateResponse, AccessKeyListResponse, AccessKeyMetadataResponse, + AccessKeyRevokeResponse, ) from nemo_platform_plugin.client.constants import WORKLOAD_IDENTITY_TOKEN_FILE_ENVVAR +from nemo_platform_plugin.client.errors import NotFoundError from typer.testing import CliRunner from ..utils import assert_exit_code @@ -90,6 +93,16 @@ def _created_access_key( ) +def _access_key_not_found_error(jti: str) -> NotFoundError: + return NotFoundError( + httpx.Response( + 404, + json={"detail": f"Scoped Access Key {jti} was not found"}, + request=httpx.Request("DELETE", f"https://platform.example.com/apis/auth/v2/access-keys/{jti}"), + ) + ) + + # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @@ -557,17 +570,76 @@ def test_auth_access_keys_list_points_to_next_page(monkeypatch: pytest.MonkeyPat fake_access_keys_client.list_access_keys.assert_called_once_with(query_params={"page": 3, "page_size": 25}) +def test_auth_access_keys_revoke_sends_jti(monkeypatch: pytest.MonkeyPatch) -> None: + fake_access_keys_client = MagicMock() + fake_access_keys_client.revoke_access_key.return_value.data.return_value = AccessKeyRevokeResponse( + jti="ak_example", + revoked=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, + ) + + result = runner.invoke(app, ["auth", "access-keys", "revoke", "ak_example"]) + + assert_exit_code(result, 0) + assert "Revoked Scoped Access Key ak_example." in result.output + fake_access_keys_client.revoke_access_key.assert_called_once_with(jti="ak_example") + + +def test_auth_access_keys_revoke_reports_already_revoked(monkeypatch: pytest.MonkeyPatch) -> None: + fake_access_keys_client = MagicMock() + fake_access_keys_client.revoke_access_key.return_value.data.return_value = AccessKeyRevokeResponse( + jti="ak_example", + revoked=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", "revoke", "ak_example"]) + + assert_exit_code(result, 0) + assert "Scoped Access Key ak_example was already revoked." in result.output + + +def test_auth_access_keys_revoke_reports_missing_key(monkeypatch: pytest.MonkeyPatch) -> None: + fake_access_keys_client = MagicMock() + fake_access_keys_client.revoke_access_key.side_effect = _access_key_not_found_error("ak_unknown") + 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", "revoke", "ak_unknown"]) + + assert_exit_code(result, 1) + assert "Not found: (404) Scoped Access Key ak_unknown was not found" in result.output + fake_access_keys_client.revoke_access_key.assert_called_once_with(jti="ak_unknown") + + def test_auth_access_keys_help_exposes_lifecycle_commands() -> None: result = runner.invoke(app, ["auth", "access-keys", "--help"]) assert_exit_code(result, 0) assert "create" in result.output assert "list" in result.output + assert "revoke" in result.output create_help = runner.invoke(app, ["auth", "access-keys", "create", "--help"]) assert_exit_code(create_help, 0) assert "Use 'none' to request no expiration" in " ".join(create_help.output.split()) + revoke_help = runner.invoke(app, ["auth", "access-keys", "revoke", "--help"]) + assert_exit_code(revoke_help, 0) + assert "Stable ID of the Scoped Access Key" in " ".join(revoke_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 2551f1f193..0c91676b6e 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 @@ -13,6 +13,7 @@ AccessKeyCreateRequest, AccessKeyCreateResponse, AccessKeyListResponse, + AccessKeyRevokeResponse, ) from nemo_platform_plugin.client.client import AsyncNemoClient, NemoClient from nemo_platform_plugin.client.errors import NemoHTTPError @@ -53,9 +54,9 @@ def list(self, *, page: int = 1, page_size: int = 100) -> AccessKeyListResponse: _raise_domain_error_from_http(exc) raise - def revoke(self, jti: str) -> None: + def revoke(self, jti: str) -> AccessKeyRevokeResponse: try: - self._client.revoke_access_key(jti=jti).data() + return self._client.revoke_access_key(jti=jti).data() except NemoHTTPError as exc: _raise_domain_error_from_http(exc) raise 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 b9b8fdc508..7bed60e380 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 @@ -10,6 +10,7 @@ AccessKeyCreateResponse, AccessKeyListQueryParams, AccessKeyListResponse, + AccessKeyRevokeResponse, ) from nemo_platform_plugin.client.endpoint import delete, get, post @@ -26,4 +27,4 @@ def list_access_keys(*, query_params: AccessKeyListQueryParams | None = None) -> @delete("/apis/auth/v2/access-keys/{jti}") @abstractmethod -def revoke_access_key(*, jti: str) -> None: ... +def revoke_access_key(*, jti: str) -> AccessKeyRevokeResponse: ... 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 c06d1e11de..724ab1bfc1 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 @@ -9,6 +9,7 @@ AccessKeyCreateRequest, AccessKeyCreateResponse, AccessKeyListResponse, + AccessKeyRevokeResponse, ) @@ -27,4 +28,4 @@ def create(self, request: AccessKeyCreateRequest) -> AccessKeyCreateResponse: .. def list(self, *, page: int = 1, page_size: int = 100) -> AccessKeyListResponse: ... - def revoke(self, jti: str) -> None: ... + def revoke(self, jti: str) -> AccessKeyRevokeResponse: ... 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 e245550c8d..fcaffb15fd 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 @@ -89,14 +89,11 @@ class AccessKeyListResponse(BaseModel): ) -class AccessKeyAuthenticateResponse(BaseModel): - """Successful Scoped Access Key authentication response for gateway callouts.""" - - jti: str - principal: str - email: str | None = None - groups: list[str] = Field(default_factory=list) - scopes: list[str] = Field(default_factory=list) +class AccessKeyRevokeResponse(BaseModel): + """Response returned after a Scoped Access Key revoke request.""" + + jti: str = Field(description="Stable JWT ID for this Scoped Access Key.") + revoked: bool = Field(description="True when this request changed the key from active to revoked.") class AccessKeyNotImplementedErrorResponse(BaseModel): 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 9e62d5a048..b48f9c43d8 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 @@ -15,6 +15,7 @@ from nemo_platform_plugin.auth.access_keys.types import ( AccessKeyCreateRequest, AccessKeyCreateResponse, + AccessKeyRevokeResponse, ) from nemo_platform_plugin.client.errors import NemoHTTPError @@ -53,6 +54,18 @@ def test_access_key_issuer_client_delegates_create_to_client() -> None: client.create_access_key.assert_called_once_with(body=AccessKeyCreateRequest()) +def test_access_key_issuer_client_revokes_by_jti() -> None: + client = _AccessKeysClientStub() + revoked = AccessKeyRevokeResponse(jti="ak_example", revoked=True) + client.revoke_access_key.return_value.data.return_value = revoked + + issuer = AccessKeyIssuerClient(client.as_client()) + result = issuer.revoke("ak_example") + + assert result == revoked + client.revoke_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/nmp_common/src/nmp/common/auth/access_keys.py b/packages/nmp_common/src/nmp/common/auth/access_keys.py index 8003de5260..c88a511265 100644 --- a/packages/nmp_common/src/nmp/common/auth/access_keys.py +++ b/packages/nmp_common/src/nmp/common/auth/access_keys.py @@ -21,6 +21,7 @@ AccessKeyCreateRequest, AccessKeyCreateResponse, AccessKeyListResponse, + AccessKeyRevokeResponse, ) from nmp.common.config import AuthConfig, get_platform_config @@ -226,7 +227,7 @@ def list(self, *, page: int = 1, page_size: int = 100) -> AccessKeyListResponse: self._ensure_enabled() raise AccessKeyOperationNotImplementedError("Scoped Access Key listing is not implemented.") - def revoke(self, jti: str) -> None: + def revoke(self, jti: str) -> AccessKeyRevokeResponse: self._ensure_enabled() raise AccessKeyOperationNotImplementedError(f"Scoped Access Key revocation for {jti} is not implemented.") diff --git a/packages/nmp_common/src/nmp/common/auth/middleware.py b/packages/nmp_common/src/nmp/common/auth/middleware.py index 58a688a1f9..02366acc36 100644 --- a/packages/nmp_common/src/nmp/common/auth/middleware.py +++ b/packages/nmp_common/src/nmp/common/auth/middleware.py @@ -4,11 +4,14 @@ """Authorization middleware for NeMo Platform services.""" import logging +import math +import time +from functools import cached_property from typing import Any, Callable, Optional import httpx from fastapi import Request, Response -from nmp.common.config import AuthConfig, get_auth_config +from nmp.common.config import AuthConfig, get_auth_config, get_platform_config from nmp.common.observability.context import get_app_ctx from nmp.common.platform_endpoint import parse_platform_endpoint from starlette.middleware.base import BaseHTTPMiddleware @@ -19,10 +22,13 @@ from .client import AuthClient from .dependencies import auth_client_context from .exceptions import InvalidPrincipalHeader, InvalidScopeFormatError +from .jwt import TokenClaims from .models import Principal from .token_resolver import ResolvedBearerToken, resolve_bearer_token logger = logging.getLogger(__name__) +_ACCESS_KEY_LIFECYCLE_CIRCUIT_FAILURE_THRESHOLD = 3 +_ACCESS_KEY_LIFECYCLE_CIRCUIT_OPEN_SECONDS = 5.0 class PrincipalExtractionError(RuntimeError): @@ -116,6 +122,7 @@ def __init__( app: ASGIApp, service_name: Optional[str] = None, http_client: Optional[httpx.AsyncClient] = None, + access_key_lifecycle_http_client: Optional[httpx.AsyncClient] = None, ): """Initialize the authorization middleware. @@ -125,18 +132,33 @@ def __init__( http_client: Optional HTTP client for PDP calls. If not provided, one will be created lazily on first use. This is used for testing with ASGI transport - see architecture/docs/http-client-injection.md. + access_key_lifecycle_http_client: Optional HTTP client for access-key + lifecycle calls. If not provided, a client using the platform + endpoint transport is created lazily. """ super().__init__(app) self.config: AuthConfig = get_auth_config() self.service_name = service_name self._client: Optional[httpx.AsyncClient] = http_client + self._access_key_lifecycle_client: Optional[httpx.AsyncClient] = access_key_lifecycle_http_client self._jwt_validator: Optional[Any] = None + # Soft circuit-breaker state is intentionally local to this middleware + # instance (and therefore to one worker process); failures still fail + # closed independently in every worker. + self._access_key_lifecycle_failure_count = 0 + self._access_key_lifecycle_circuit_open_until = 0.0 if self.config.allow_unsigned_jwt: logger.warning( "auth.allow_unsigned_jwt is enabled. Unsigned JWTs (`alg=none`) are accepted; use only for local/testing." ) + @cached_property + def _access_key_lifecycle_url(self) -> str: + """Resolve the auth-service access-key lifecycle callout URL from current platform config.""" + platform_endpoint = parse_platform_endpoint(str(get_platform_config().base_url)) + return f"{platform_endpoint.connect_base_url}/apis/auth/authenticate" + @staticmethod def _principal_from_headers(headers_dict: dict) -> tuple[Principal, None] | tuple[None, JSONResponse]: """Extract and validate a Principal from request headers. @@ -177,6 +199,15 @@ def _get_client(self, request: Request) -> httpx.AsyncClient: self._client = endpoint.async_http_client(timeout=self.config.policy_decision_point_request_timeout_seconds) return self._client + def _get_access_key_lifecycle_client(self) -> httpx.AsyncClient: + """Get a client bound to the platform endpoint transport.""" + if self._access_key_lifecycle_client is None: + endpoint = parse_platform_endpoint(str(get_platform_config().base_url)) + self._access_key_lifecycle_client = endpoint.async_http_client( + timeout=self.config.policy_decision_point_request_timeout_seconds + ) + return self._access_key_lifecycle_client + def _update_auth_context(self, principal: Principal) -> None: """Update the observability AuthContext with principal info. @@ -459,6 +490,15 @@ async def _handle_principal_headers_request( async def _handle_bearer_token_request(self, request: Request, call_next: Callable, token: str) -> Response: """Handle requests with Authorization: Bearer tokens through the shared resolver.""" + if self.config.access_keys.enabled: + from .access_keys import is_access_key_token_candidate + + if is_access_key_token_candidate(token): + resolved_or_error = await self._authenticate_access_key_lifecycle(request, token) + if isinstance(resolved_or_error, Response): + return resolved_or_error + return await self._handle_resolved_bearer_token(request, call_next, resolved_or_error) + jwt_validator = self._get_jwt_validator() if jwt_validator is None and not self.config.access_keys.enabled: logger.warning("Bearer token provided but bearer token authentication is not configured") @@ -485,6 +525,145 @@ async def _handle_bearer_token_request(self, request: Request, call_next: Callab return await self._handle_resolved_bearer_token(request, call_next, resolved) + def _access_key_lifecycle_retry_after(self) -> int: + retry_after = max(1, math.ceil(self._access_key_lifecycle_circuit_open_until - time.monotonic())) + return retry_after + + def _access_key_lifecycle_error_response( + self, + status_code: int, + detail: str, + *, + retry_after: int | None = None, + ) -> JSONResponse: + headers = {"Retry-After": str(retry_after)} if retry_after is not None else None + return JSONResponse(status_code=status_code, content={"detail": detail}, headers=headers) + + def _access_key_lifecycle_circuit_response(self) -> JSONResponse | None: + if time.monotonic() < self._access_key_lifecycle_circuit_open_until: + retry_after = self._access_key_lifecycle_retry_after() + logger.warning("Access-key lifecycle validation circuit is open for %s more seconds", retry_after) + return self._access_key_lifecycle_error_response( + 503, + "Access-key lifecycle validation unavailable", + retry_after=retry_after, + ) + return None + + def _record_access_key_lifecycle_success(self) -> None: + self._access_key_lifecycle_failure_count = 0 + self._access_key_lifecycle_circuit_open_until = 0.0 + + def _record_access_key_lifecycle_failure(self) -> int | None: + self._access_key_lifecycle_failure_count += 1 + if self._access_key_lifecycle_failure_count >= _ACCESS_KEY_LIFECYCLE_CIRCUIT_FAILURE_THRESHOLD: + self._access_key_lifecycle_circuit_open_until = ( + time.monotonic() + _ACCESS_KEY_LIFECYCLE_CIRCUIT_OPEN_SECONDS + ) + return self._access_key_lifecycle_retry_after() + return None + + async def _call_access_key_lifecycle(self, request: Request, token: str) -> httpx.Response | JSONResponse: + """Call the auth-service access-key validator and map transport failures. + + The callout uses the platform endpoint transport and the configured PDP + request timeout. + """ + circuit_response = self._access_key_lifecycle_circuit_response() + if circuit_response is not None: + return circuit_response + + url = self._access_key_lifecycle_url + try: + response = await self._get_access_key_lifecycle_client().get( + url, + headers={"Authorization": f"Bearer {token}"}, + ) + except httpx.ConnectError as exc: + logger.error("Cannot connect to access-key lifecycle validator at %s: %s", url, exc) + return self._access_key_lifecycle_error_response( + 503, + "Access-key lifecycle validation unavailable", + retry_after=self._record_access_key_lifecycle_failure(), + ) + except httpx.TimeoutException as exc: + logger.error("Access-key lifecycle validation timed out at %s: %s", url, exc) + return self._access_key_lifecycle_error_response( + 504, + "Access-key lifecycle validation timeout", + retry_after=self._record_access_key_lifecycle_failure(), + ) + except httpx.HTTPError as exc: + logger.error("Access-key lifecycle validation failed at %s: %s", url, exc) + return self._access_key_lifecycle_error_response( + 502, + "Access-key lifecycle validation error", + retry_after=self._record_access_key_lifecycle_failure(), + ) + + if response.status_code == 200: + self._record_access_key_lifecycle_success() + return response + if response.status_code == 401: + self._record_access_key_lifecycle_success() + return response + + logger.error( + "Access-key lifecycle validator returned HTTP %s from %s", + response.status_code, + url, + ) + return self._access_key_lifecycle_error_response( + 503, + "Access-key lifecycle validation unavailable", + retry_after=self._record_access_key_lifecycle_failure(), + ) + + def _resolved_access_key_from_lifecycle_response(self, response: httpx.Response) -> ResolvedBearerToken | None: + try: + body = response.json() + except ValueError: + return None + if not isinstance(body, dict) or body.get("token_kind") != "access_key": + return None + principal = body.get("principal") + if not isinstance(principal, str) or not principal: + return None + email = body.get("email") + groups = body.get("groups") + scopes = body.get("scopes") + jti = body.get("jti") + raw_claims: dict[str, object] = {"nmp_token_type": "access_key"} + if isinstance(jti, str) and jti: + raw_claims["jti"] = jti + return ResolvedBearerToken( + claims=TokenClaims( + subject=principal, + email=email if isinstance(email, str) else None, + groups=[group for group in groups if isinstance(group, str)] if isinstance(groups, list) else [], + scopes=[scope for scope in scopes if isinstance(scope, str)] if isinstance(scopes, list) else [], + raw_claims=raw_claims, + ), + token_kind="access_key", + ) + + async def _authenticate_access_key_lifecycle( + self, + request: Request, + token: str, + ) -> ResolvedBearerToken | Response: + response_or_error = await self._call_access_key_lifecycle(request, token) + if isinstance(response_or_error, Response): + return response_or_error + if response_or_error.status_code == 401: + return JSONResponse(status_code=401, content={"detail": "Invalid or expired token"}) + + resolved = self._resolved_access_key_from_lifecycle_response(response_or_error) + if resolved is None: + logger.error("Access-key lifecycle validator returned an invalid success response") + return self._access_key_lifecycle_error_response(503, "Access-key lifecycle validation unavailable") + return resolved + async def _handle_resolved_bearer_token( self, request: Request, diff --git a/packages/nmp_common/src/nmp/common/config/base.py b/packages/nmp_common/src/nmp/common/config/base.py index b11ca561ab..9364e54ae9 100644 --- a/packages/nmp_common/src/nmp/common/config/base.py +++ b/packages/nmp_common/src/nmp/common/config/base.py @@ -10,6 +10,7 @@ from __future__ import annotations +from pathlib import Path from typing import Annotated, Any, Literal, Self from nemo_platform_plugin.config import LOOPBACK_ADDRESSES as LOOPBACK_ADDRESSES @@ -446,8 +447,28 @@ def validate_workload_token_signing_key_id(self) -> Self: "auth.oidc.workload_token_key_id or auth.token_signing.key_id must be configured " "when auth.oidc.workload_token_exchange_enabled is true" ) + workload_private_key_file = self._normalized_private_key_file(self.oidc.workload_token_private_key_file) + access_key_private_key_file = self._normalized_private_key_file(self.token_signing.private_key_file) + if ( + self.access_keys.enabled + and workload_private_key_file + and access_key_private_key_file + and workload_private_key_file != access_key_private_key_file + and key_id.strip() == self.token_signing.key_id.strip() + ): + raise ValueError( + "auth.oidc.workload_token_key_id must be distinct from auth.token_signing.key_id " + "when auth.oidc.workload_token_private_key_file differs from " + "auth.token_signing.private_key_file and Scoped Access Keys are enabled" + ) return self + @staticmethod + def _normalized_private_key_file(value: str | None) -> str | None: + if value is None or not value.strip(): + return None + return str(Path(value).expanduser().resolve(strict=False)) + def get_pdp_url(self, entrypoint: str) -> str: # Import lazily to avoid a module cycle: platform_endpoint imports # PlatformConfig from nmp.common.config, which is defined in this file. diff --git a/packages/nmp_common/src/nmp/common/entities/client.py b/packages/nmp_common/src/nmp/common/entities/client.py index ae02fccf17..e3913dddfc 100644 --- a/packages/nmp_common/src/nmp/common/entities/client.py +++ b/packages/nmp_common/src/nmp/common/entities/client.py @@ -47,7 +47,14 @@ def as_service(self, service_name: str, *, internal: bool = False) -> "EntityCli """ from nmp.common.observability import MARK_INTERNAL_REQUEST_HEADERS - headers: dict[str, str] = {"X-NMP-Principal-Id": f"service:{service_name}"} + headers: dict[str, str] = { + "X-NMP-Principal-Id": f"service:{service_name}", + # ``with_options`` merges defaults. Explicitly clear any delegation + # inherited from a request-scoped client so this is true elevation. + "X-NMP-Principal-On-Behalf-Of": "", + "X-NMP-Principal-On-Behalf-Of-Email": "", + "X-NMP-Principal-On-Behalf-Of-Groups": "", + } if internal: headers.update(MARK_INTERNAL_REQUEST_HEADERS) # with_options merges headers into the client's defaults and shares the diff --git a/packages/nmp_common/tests/auth/test_middleware.py b/packages/nmp_common/tests/auth/test_middleware.py index 6a83f4cc81..cefd846270 100644 --- a/packages/nmp_common/tests/auth/test_middleware.py +++ b/packages/nmp_common/tests/auth/test_middleware.py @@ -4,8 +4,11 @@ """Unit tests for authorization middleware.""" import time +from contextlib import asynccontextmanager +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch +import httpx import jwt import pytest from fastapi import Depends, FastAPI @@ -13,11 +16,18 @@ from nmp.common.auth.client import AuthClient from nmp.common.auth.dependencies import get_auth_client from nmp.common.auth.jwt import TokenClaims, UnsignedJWTRejectedError -from nmp.common.auth.middleware import BYPASS_PREFIXES, HEALTH_ENDPOINTS, PUBLIC_GET_PATHS, AuthorizationMiddleware +from nmp.common.auth.middleware import ( + _ACCESS_KEY_LIFECYCLE_CIRCUIT_FAILURE_THRESHOLD, + BYPASS_PREFIXES, + HEALTH_ENDPOINTS, + PUBLIC_GET_PATHS, + AuthorizationMiddleware, +) from nmp.common.auth.models import Principal from nmp.common.auth.token_resolver import ResolvedBearerToken from nmp.common.config import AuthConfig, Configuration from nmp.common.config.base import OIDCConfig +from starlette.responses import Response @pytest.fixture(autouse=True) @@ -66,6 +76,30 @@ def auth_config_oidc_disabled(): ) +@pytest.fixture +def access_key_lifecycle_middleware(auth_config_oidc_disabled): + @asynccontextmanager + async def make(handler, base_url: str = "http://platform.example.com"): + config = auth_config_oidc_disabled.model_copy( + update={"access_keys": auth_config_oidc_disabled.access_keys.model_copy(update={"enabled": True})} + ) + Configuration.set_override(config) + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client: + middleware = AuthorizationMiddleware( + FastAPI(), + service_name="test-service", + http_client=http_client, + access_key_lifecycle_http_client=http_client, + ) + with patch( + "nmp.common.auth.middleware.get_platform_config", + return_value=SimpleNamespace(base_url=base_url), + ): + yield middleware + + return make + + def create_test_app(auth_config: AuthConfig) -> FastAPI: """Create a test FastAPI app with auth middleware.""" app = FastAPI() @@ -526,6 +560,159 @@ def test_scoped_access_key_middleware_mapping_is_skipped_when_access_keys_are_di assert response.json()["detail"] == "Bearer token authentication not configured" mock_validate.assert_not_called() + def test_access_key_lifecycle_url_reads_platform_config_lazily_once(self, auth_config_oidc_disabled): + Configuration.set_override(auth_config_oidc_disabled) + app = FastAPI() + + with patch( + "nmp.common.auth.middleware.get_platform_config", + side_effect=AssertionError("platform config read too early"), + ): + middleware = AuthorizationMiddleware(app, service_name="test-service") + + with patch( + "nmp.common.auth.middleware.get_platform_config", + side_effect=[ + SimpleNamespace(base_url="http://platform-one:8080"), + ], + ) as get_platform: + assert middleware._access_key_lifecycle_url == "http://platform-one:8080/apis/auth/authenticate" + assert middleware._access_key_lifecycle_url == "http://platform-one:8080/apis/auth/authenticate" + + assert get_platform.call_count == 1 + + @pytest.mark.asyncio + async def test_access_key_lifecycle_callout_allows_active_token(self, access_key_lifecycle_middleware): + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 200, + json={ + "principal": "alice@example.com", + "email": "alice@example.com", + "groups": ["team-ml"], + "scopes": [], + "jti": "ak_example", + "token_kind": "access_key", + }, + ) + + async with access_key_lifecycle_middleware(handler) as middleware: + response = await middleware._authenticate_access_key_lifecycle(MagicMock(), "scoped-access-key") + + assert isinstance(response, ResolvedBearerToken) + assert response.claims.subject == "alice@example.com" + assert requests[0].url == httpx.URL("http://platform.example.com/apis/auth/authenticate") + assert requests[0].headers["authorization"] == "Bearer scoped-access-key" + + @pytest.mark.asyncio + async def test_access_key_lifecycle_callout_does_not_use_pdp_transport(self, auth_config_oidc_disabled): + config = auth_config_oidc_disabled.model_copy( + update={"access_keys": auth_config_oidc_disabled.access_keys.model_copy(update={"enabled": True})} + ) + Configuration.set_override(config) + + def reject_pdp_request(request: httpx.Request) -> httpx.Response: + raise AssertionError(f"lifecycle request used PDP transport: {request.url}") + + def authenticate_access_key(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={ + "principal": "alice@example.com", + "groups": [], + "scopes": [], + "jti": "ak_example", + "token_kind": "access_key", + }, + ) + + async with ( + httpx.AsyncClient(transport=httpx.MockTransport(reject_pdp_request)) as pdp_client, + httpx.AsyncClient(transport=httpx.MockTransport(authenticate_access_key)) as lifecycle_client, + ): + middleware = AuthorizationMiddleware( + FastAPI(), + service_name="test-service", + http_client=pdp_client, + access_key_lifecycle_http_client=lifecycle_client, + ) + with patch( + "nmp.common.auth.middleware.get_platform_config", + return_value=SimpleNamespace(base_url="unix:///tmp/nemo-platform.sock"), + ): + response = await middleware._authenticate_access_key_lifecycle(MagicMock(), "scoped-access-key") + + assert isinstance(response, ResolvedBearerToken) + assert response.claims.subject == "alice@example.com" + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("callout_status", "expected_status", "expected_detail"), + [ + (401, 401, "Invalid or expired token"), + (500, 503, "Access-key lifecycle validation unavailable"), + ], + ) + async def test_access_key_lifecycle_callout_rejects_token_or_unexpected_status( + self, + access_key_lifecycle_middleware, + callout_status, + expected_status, + expected_detail, + ): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(callout_status) + + async with access_key_lifecycle_middleware(handler) as middleware: + response = await middleware._authenticate_access_key_lifecycle(MagicMock(), "scoped-access-key") + + assert isinstance(response, Response) + assert response.status_code == expected_status + assert response.body == f'{{"detail":"{expected_detail}"}}'.encode() + + @pytest.mark.asyncio + async def test_access_key_lifecycle_callout_timeout_returns_504(self, access_key_lifecycle_middleware): + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ReadTimeout("timed out", request=request) + + async with access_key_lifecycle_middleware(handler) as middleware: + response = await middleware._authenticate_access_key_lifecycle(MagicMock(), "scoped-access-key") + + assert isinstance(response, Response) + assert response.status_code == 504 + assert response.body == b'{"detail":"Access-key lifecycle validation timeout"}' + + @pytest.mark.asyncio + async def test_access_key_lifecycle_callout_opens_circuit_after_repeated_failures( + self, + access_key_lifecycle_middleware, + ): + calls = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + raise httpx.ConnectError("down", request=request) + + async with access_key_lifecycle_middleware(handler) as middleware: + response = None + for _ in range(_ACCESS_KEY_LIFECYCLE_CIRCUIT_FAILURE_THRESHOLD): + response = await middleware._authenticate_access_key_lifecycle(MagicMock(), "scoped-access-key") + + assert isinstance(response, Response) + assert response.status_code == 503 + assert "retry-after" in response.headers + + circuit_response = await middleware._authenticate_access_key_lifecycle(MagicMock(), "scoped-access-key") + + assert calls == _ACCESS_KEY_LIFECYCLE_CIRCUIT_FAILURE_THRESHOLD + assert circuit_response is not None + assert circuit_response.status_code == 503 + assert "retry-after" in circuit_response.headers + def test_bearer_token_request_uses_shared_resolver(self, auth_config_enabled): app = create_test_app(auth_config_enabled) client = TestClient(app, raise_server_exceptions=False) @@ -550,6 +737,79 @@ def test_bearer_token_request_uses_shared_resolver(self, auth_config_enabled): resolver.assert_awaited_once() mock_authorize.assert_called_once() + def test_access_key_bearer_uses_authenticate_callout_without_local_resolver(self, auth_config_oidc_disabled): + app = FastAPI() + + @app.get("/whoami") + async def whoami(auth_client: AuthClient = Depends(get_auth_client)): + return { + "principal": auth_client.principal.id, + "email": auth_client.principal.email, + "groups": auth_client.principal.groups, + } + + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 200, + json={ + "principal": "alice@example.com", + "email": "alice@example.com", + "groups": ["team-ml"], + "scopes": ["models:read"], + "jti": "ak_example", + "token_kind": "access_key", + }, + ) + + config = auth_config_oidc_disabled.model_copy( + update={"access_keys": auth_config_oidc_disabled.access_keys.model_copy(update={"enabled": True})} + ) + Configuration.set_override(config) + http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + app.add_middleware( + AuthorizationMiddleware, + service_name="test-service", + http_client=http_client, + access_key_lifecycle_http_client=http_client, + ) + client = TestClient(app, raise_server_exceptions=False) + token = jwt.encode( + { + "sub": "alice@example.com", + "iat": int(time.time()), + "nbf": int(time.time()), + "jti": "ak_example", + "nmp_token_type": "access_key", + }, + key="", + algorithm="none", + ) + + with ( + patch( + "nmp.common.auth.middleware.get_platform_config", + return_value=SimpleNamespace(base_url="http://platform.example.com"), + ), + patch("nmp.common.auth.middleware.resolve_bearer_token", new=AsyncMock()) as resolver, + patch.object(AuthClient, "authorize_request", autospec=True) as mock_authorize, + ): + mock_authorize.return_value = MagicMock(allowed=True) + response = client.get("/whoami", headers={"Authorization": f"Bearer {token}"}) + + assert response.status_code == 200 + assert response.json() == { + "principal": "alice@example.com", + "email": "alice@example.com", + "groups": ["team-ml"], + } + assert requests[0].url == httpx.URL("http://platform.example.com/apis/auth/authenticate") + assert requests[0].headers["authorization"] == f"Bearer {token}" + resolver.assert_not_awaited() + mock_authorize.assert_called_once() + def test_bearer_token_sets_auth_client_context_for_service_handler(self, auth_config_enabled): app = FastAPI() @@ -574,13 +834,15 @@ async def whoami(auth_client: AuthClient = Depends(get_auth_client)): ) resolved = ResolvedBearerToken(claims=claims, token_kind="access_key") - with patch( - "nmp.common.auth.middleware.resolve_bearer_token", - new=AsyncMock(return_value=resolved), - ) as resolver: - with patch.object(AuthClient, "authorize_request", autospec=True) as mock_authorize: - mock_authorize.return_value = MagicMock(allowed=True) - response = client.get("/whoami", headers={"Authorization": "Bearer scoped-access-key"}) + with ( + patch( + "nmp.common.auth.middleware.resolve_bearer_token", + new=AsyncMock(return_value=resolved), + ) as resolver, + patch.object(AuthClient, "authorize_request", autospec=True) as mock_authorize, + ): + mock_authorize.return_value = MagicMock(allowed=True) + response = client.get("/whoami", headers={"Authorization": "Bearer scoped-access-key"}) assert response.status_code == 200 assert response.json() == { diff --git a/packages/nmp_common/tests/entities/test_client.py b/packages/nmp_common/tests/entities/test_client.py index 0f2ca4d3f2..6e7ca96204 100644 --- a/packages/nmp_common/tests/entities/test_client.py +++ b/packages/nmp_common/tests/entities/test_client.py @@ -1451,7 +1451,12 @@ def _service_entities_client(url_resolver=None) -> tuple[EntityClient, AsyncMock typed = AsyncEntitiesClient( base_url="http://platform", workspace="default", - default_headers={"X-NMP-Principal-Id": "alice@example.com"}, + default_headers={ + "X-NMP-Principal-Id": "service:platform", + "X-NMP-Principal-On-Behalf-Of": "alice@example.com", + "X-NMP-Principal-On-Behalf-Of-Email": "alice@example.com", + "X-NMP-Principal-On-Behalf-Of-Groups": "team-ml", + }, http_client=mock_http, url_resolver=url_resolver, ) @@ -1466,8 +1471,9 @@ def test_as_service_returns_new_client_without_mutating_the_original(): assert elevated is not client assert isinstance(elevated, EntityClient) # The caller-scoped client must keep its original principal. - assert client._client._default_headers["X-NMP-Principal-Id"] == "alice@example.com" + assert client._client._default_headers["X-NMP-Principal-On-Behalf-Of"] == "alice@example.com" assert elevated._client._default_headers["X-NMP-Principal-Id"] == "service:models" + assert elevated._client._default_headers["X-NMP-Principal-On-Behalf-Of"] == "" def test_as_service_preserves_the_platform_url_resolver(): @@ -1506,6 +1512,7 @@ class TestEntity(EntityBase): sent_headers = mock_http.request.call_args.kwargs["headers"] assert sent_headers["X-NMP-Principal-Id"] == "service:models" + assert sent_headers["X-NMP-Principal-On-Behalf-Of"] == "" @pytest.mark.asyncio diff --git a/sdk/python/nemo-platform/.nmpcontext/openapi.yaml b/sdk/python/nemo-platform/.nmpcontext/openapi.yaml index b21d803a6a..7915830ddb 100644 --- a/sdk/python/nemo-platform/.nmpcontext/openapi.yaml +++ b/sdk/python/nemo-platform/.nmpcontext/openapi.yaml @@ -269,9 +269,10 @@ paths: description: Successful Response content: application/json: - schema: {} + schema: + $ref: '#/components/schemas/AccessKeyRevokeResponse' '404': - description: Scoped Access Keys are not enabled + description: Scoped Access Keys are not enabled or the key was not found content: application/json: schema: @@ -7950,14 +7951,22 @@ components: title: Name description: Optional human-readable Scoped Access Key label. The token jti remains the stable identifier. + nullable: true type: string maxLength: 128 minLength: 1 + description: + title: Description + description: Optional human-readable description of the Scoped Access Key. + nullable: true + type: string + maxLength: 1024 expires_in_seconds: title: Expires In Seconds description: Scoped Access Key lifetime in seconds. Omit to use auth.access_keys.default_expires_in_seconds. Send explicit null to request a non-time-delimited key, which requires auth.access_keys.max_expires_in_seconds to be disabled. + nullable: true type: integer minimum: 1.0 type: object @@ -7972,17 +7981,42 @@ components: name: title: Name description: Optional human-readable Scoped Access Key label. + nullable: true + type: string + description: + title: Description + description: Human-readable description of the Scoped Access Key. + nullable: true type: string principal: type: string title: Principal description: Principal ID stamped into the token. + status: + type: string + enum: + - ACTIVE + - EXPIRED + - REVOKED + title: Status + issuer: + type: string + title: Issuer + description: Issuer stamped into the Scoped Access Key JWT. + audiences: + items: + type: string + type: array + uniqueItems: true + title: Audiences + description: Audiences accepted for the Scoped Access Key JWT. created_at: type: string format: date-time title: Created At expires_at: title: Expires At + nullable: true type: string format: date-time token: @@ -7996,6 +8030,9 @@ components: required: - jti - principal + - status + - issuer + - audiences - created_at - token - token_type @@ -8037,23 +8074,51 @@ components: name: title: Name description: Optional human-readable Scoped Access Key label. + nullable: true + type: string + description: + title: Description + description: Human-readable description of the Scoped Access Key. + nullable: true type: string principal: type: string title: Principal description: Principal ID stamped into the token. + status: + type: string + enum: + - ACTIVE + - EXPIRED + - REVOKED + title: Status + issuer: + type: string + title: Issuer + description: Issuer stamped into the Scoped Access Key JWT. + audiences: + items: + type: string + type: array + uniqueItems: true + title: Audiences + description: Audiences accepted for the Scoped Access Key JWT. created_at: type: string format: date-time title: Created At expires_at: title: Expires At + nullable: true type: string format: date-time type: object required: - jti - principal + - status + - issuer + - audiences - created_at title: AccessKeyMetadataResponse description: Metadata for a Scoped Access Key. @@ -8067,6 +8132,22 @@ components: - detail title: AccessKeyNotImplementedErrorResponse description: Response returned by unsupported Scoped Access Key lifecycle endpoints. + AccessKeyRevokeResponse: + properties: + jti: + type: string + title: Jti + description: Stable JWT ID for this Scoped Access Key. + revoked: + type: boolean + title: Revoked + description: True when this request changed the key from active to revoked. + type: object + required: + - jti + - revoked + title: AccessKeyRevokeResponse + description: Response returned after a Scoped Access Key revoke request. ActionRails: properties: instant_actions: diff --git a/sdk/python/nemo-platform/.nmpcontext/stainless.yaml b/sdk/python/nemo-platform/.nmpcontext/stainless.yaml index 1293767ddb..3a51bbdb87 100644 --- a/sdk/python/nemo-platform/.nmpcontext/stainless.yaml +++ b/sdk/python/nemo-platform/.nmpcontext/stainless.yaml @@ -960,6 +960,7 @@ resources: access_key_list_response: AccessKeyListResponse access_key_metadata_response: AccessKeyMetadataResponse access_key_not_implemented_error_response: AccessKeyNotImplementedErrorResponse + access_key_revoke_response: AccessKeyRevokeResponse methods: list: get /apis/auth/v2/access-keys create: post /apis/auth/v2/access-keys 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 e9e1f7a9c1..5b15cd2076 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 @@ -906,6 +906,25 @@ def list_access_keys( typer.echo(f"More Scoped Access Keys are available; use --page {page + 1} to retrieve them.", err=True) +@access_keys_app.command("revoke") +@handle_errors +def revoke_access_key( + ctx: typer.Context, + jti: Annotated[str, typer.Argument(help="Stable ID of the Scoped Access Key to revoke.")], +) -> None: + """Revoke a Scoped Access Key owned by the current authenticated user.""" + try: + result = _access_key_issuer(ctx).revoke(jti) + except AccessKeyFeatureDisabledError as exc: + _raise_access_key_disabled(exc) + except AccessKeyOperationNotImplementedError as exc: + _raise_access_key_not_implemented(exc) + if result.revoked: + typer.echo(f"Revoked Scoped Access Key {jti}.") + else: + typer.echo(f"Scoped Access Key {jti} was already revoked.") + + @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 0bfddd5205..ec0ac295cc 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 @@ -17,6 +17,8 @@ from __future__ import annotations +from typing import Optional + import httpx from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given @@ -33,6 +35,7 @@ from ...types.access_keys import access_key_create_params, access_key_list_params 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 __all__ = ["AccessKeysResource", "AsyncAccessKeysResource"] @@ -60,8 +63,9 @@ def with_streaming_response(self) -> AccessKeysResourceWithStreamingResponse: def create( self, *, - expires_in_seconds: int | Omit = omit, - name: str | Omit = omit, + description: Optional[str] | Omit = omit, + expires_in_seconds: Optional[int] | Omit = omit, + name: Optional[str] | Omit = omit, # 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, @@ -73,6 +77,8 @@ def create( Create Access Key Args: + description: Optional human-readable description of the Scoped Access Key. + expires_in_seconds: Scoped Access Key lifetime in seconds. Omit to use auth.access_keys.default_expires_in_seconds. Send explicit null to request a non-time-delimited key, which requires auth.access_keys.max_expires_in_seconds @@ -93,6 +99,7 @@ def create( "/apis/auth/v2/access-keys", body=maybe_transform( { + "description": description, "expires_in_seconds": expires_in_seconds, "name": name, }, @@ -156,7 +163,7 @@ def delete( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> object: + ) -> AccessKeyRevokeResponse: """ Revoke Access Key @@ -176,7 +183,7 @@ def delete( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=object, + cast_to=AccessKeyRevokeResponse, ) @@ -203,8 +210,9 @@ def with_streaming_response(self) -> AsyncAccessKeysResourceWithStreamingRespons async def create( self, *, - expires_in_seconds: int | Omit = omit, - name: str | Omit = omit, + description: Optional[str] | Omit = omit, + expires_in_seconds: Optional[int] | Omit = omit, + name: Optional[str] | Omit = omit, # 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, @@ -216,6 +224,8 @@ async def create( Create Access Key Args: + description: Optional human-readable description of the Scoped Access Key. + expires_in_seconds: Scoped Access Key lifetime in seconds. Omit to use auth.access_keys.default_expires_in_seconds. Send explicit null to request a non-time-delimited key, which requires auth.access_keys.max_expires_in_seconds @@ -236,6 +246,7 @@ async def create( "/apis/auth/v2/access-keys", body=await async_maybe_transform( { + "description": description, "expires_in_seconds": expires_in_seconds, "name": name, }, @@ -299,7 +310,7 @@ async def delete( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> object: + ) -> AccessKeyRevokeResponse: """ Revoke Access Key @@ -319,7 +330,7 @@ async def delete( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=object, + cast_to=AccessKeyRevokeResponse, ) 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 1c88799790..87b5e755ed 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 @@ -10,6 +10,7 @@ from nemo_platform.types.access_keys import ( AccessKeyListResponse, AccessKeyMetadataResponse, AccessKeyNotImplementedErrorResponse, + AccessKeyRevokeResponse, ) ``` @@ -17,4 +18,4 @@ Methods: - client.access_keys.create(\*\*params) -> AccessKeyCreateResponse - client.access_keys.list(\*\*params) -> AccessKeyListResponse -- client.access_keys.delete(jti) -> object +- client.access_keys.delete(jti) -> AccessKeyRevokeResponse 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 66fd70cdf5..af8c4a9f34 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 @@ -21,4 +21,5 @@ from .access_key_list_params import AccessKeyListParams as AccessKeyListParams from .access_key_list_response import AccessKeyListResponse as AccessKeyListResponse 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 diff --git a/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_create_params.py b/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_create_params.py index e85eeb17c2..c3afc09a3d 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_create_params.py +++ b/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_create_params.py @@ -17,13 +17,17 @@ from __future__ import annotations +from typing import Optional from typing_extensions import TypedDict __all__ = ["AccessKeyCreateParams"] class AccessKeyCreateParams(TypedDict, total=False): - expires_in_seconds: int + description: Optional[str] + """Optional human-readable description of the Scoped Access Key.""" + + expires_in_seconds: Optional[int] """Scoped Access Key lifetime in seconds. Omit to use auth.access_keys.default_expires_in_seconds. Send explicit null to @@ -31,7 +35,7 @@ class AccessKeyCreateParams(TypedDict, total=False): auth.access_keys.max_expires_in_seconds to be disabled. """ - name: str + name: Optional[str] """Optional human-readable Scoped Access Key label. The token jti remains the stable identifier. 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 5efb4f7c07..14c5d71ab3 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 @@ -15,7 +15,7 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import Optional +from typing import List, Optional from datetime import datetime from typing_extensions import Literal @@ -29,16 +29,27 @@ class AccessKeyCreateResponse(BaseModel): token: str + audiences: List[str] + """Audiences accepted for the Scoped Access Key JWT.""" + created_at: datetime + issuer: str + """Issuer stamped into the Scoped Access Key JWT.""" + jti: str """Stable JWT ID for this Scoped Access Key.""" principal: str """Principal ID stamped into the token.""" + status: Literal["ACTIVE", "EXPIRED", "REVOKED"] + token_type: Literal["Bearer"] + description: Optional[str] = None + """Human-readable description of the Scoped Access Key.""" + expires_at: Optional[datetime] = None name: Optional[str] = None diff --git a/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_list_response.py b/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_list_response.py index 12375871c6..0dfa906ad5 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_list_response.py +++ b/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_list_response.py @@ -15,7 +15,7 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import List +from typing import List, Optional from ..._models import BaseModel from .access_key_metadata_response import AccessKeyMetadataResponse @@ -27,3 +27,9 @@ class AccessKeyListResponse(BaseModel): """List response for Scoped Access Key metadata.""" data: List[AccessKeyMetadataResponse] + + has_more: Optional[bool] = None + """ + True when the response was capped and more keys are available than this response + includes. + """ 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 969979365d..d2e327655a 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 @@ -15,8 +15,9 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import Optional +from typing import List, Optional from datetime import datetime +from typing_extensions import Literal from ..._models import BaseModel @@ -26,14 +27,25 @@ class AccessKeyMetadataResponse(BaseModel): """Metadata for a Scoped Access Key.""" + audiences: List[str] + """Audiences accepted for the Scoped Access Key JWT.""" + created_at: datetime + issuer: str + """Issuer stamped into the Scoped Access Key JWT.""" + jti: str """Stable JWT ID for this Scoped Access Key.""" principal: str """Principal ID stamped into the token.""" + status: Literal["ACTIVE", "EXPIRED", "REVOKED"] + + description: Optional[str] = None + """Human-readable description of the Scoped Access Key.""" + expires_at: Optional[datetime] = None name: Optional[str] = None diff --git a/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_revoke_response.py b/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_revoke_response.py new file mode 100644 index 0000000000..19906df6b6 --- /dev/null +++ b/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_revoke_response.py @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from ..._models import BaseModel + +__all__ = ["AccessKeyRevokeResponse"] + + +class AccessKeyRevokeResponse(BaseModel): + """Response returned after a Scoped Access Key revoke request.""" + + jti: str + """Stable JWT ID for this Scoped Access Key.""" + + revoked: bool + """True when this request changed the key from active to revoked.""" 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 f4f39249ad..9cfa0e4da3 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 @@ -24,7 +24,11 @@ from tests.utils import assert_matches_type from nemo_platform import NeMoPlatform, AsyncNeMoPlatform -from nemo_platform.types.access_keys import AccessKeyListResponse, AccessKeyCreateResponse +from nemo_platform.types.access_keys import ( + AccessKeyListResponse, + AccessKeyCreateResponse, + AccessKeyRevokeResponse, +) base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -42,6 +46,7 @@ def test_method_create(self, client: NeMoPlatform) -> None: @parametrize def test_method_create_with_all_params(self, client: NeMoPlatform) -> None: access_key = client.access_keys.create( + description="description", expires_in_seconds=1, name="x", ) @@ -109,7 +114,7 @@ def test_method_delete(self, client: NeMoPlatform) -> None: access_key = client.access_keys.delete( "jti", ) - assert_matches_type(object, access_key, path=["response"]) + assert_matches_type(AccessKeyRevokeResponse, access_key, path=["response"]) @pytest.mark.skip(reason="Mock server tests are disabled") @parametrize @@ -121,7 +126,7 @@ def test_raw_response_delete(self, client: NeMoPlatform) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" access_key = response.parse() - assert_matches_type(object, access_key, path=["response"]) + assert_matches_type(AccessKeyRevokeResponse, access_key, path=["response"]) @pytest.mark.skip(reason="Mock server tests are disabled") @parametrize @@ -133,7 +138,7 @@ def test_streaming_response_delete(self, client: NeMoPlatform) -> None: assert response.http_request.headers.get("X-Stainless-Lang") == "python" access_key = response.parse() - assert_matches_type(object, access_key, path=["response"]) + assert_matches_type(AccessKeyRevokeResponse, access_key, path=["response"]) assert cast(Any, response.is_closed) is True @@ -161,6 +166,7 @@ async def test_method_create(self, async_client: AsyncNeMoPlatform) -> None: @parametrize async def test_method_create_with_all_params(self, async_client: AsyncNeMoPlatform) -> None: access_key = await async_client.access_keys.create( + description="description", expires_in_seconds=1, name="x", ) @@ -228,7 +234,7 @@ async def test_method_delete(self, async_client: AsyncNeMoPlatform) -> None: access_key = await async_client.access_keys.delete( "jti", ) - assert_matches_type(object, access_key, path=["response"]) + assert_matches_type(AccessKeyRevokeResponse, access_key, path=["response"]) @pytest.mark.skip(reason="Mock server tests are disabled") @parametrize @@ -240,7 +246,7 @@ async def test_raw_response_delete(self, async_client: AsyncNeMoPlatform) -> Non assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" access_key = await response.parse() - assert_matches_type(object, access_key, path=["response"]) + assert_matches_type(AccessKeyRevokeResponse, access_key, path=["response"]) @pytest.mark.skip(reason="Mock server tests are disabled") @parametrize @@ -252,7 +258,7 @@ async def test_streaming_response_delete(self, async_client: AsyncNeMoPlatform) assert response.http_request.headers.get("X-Stainless-Lang") == "python" access_key = await response.parse() - assert_matches_type(object, access_key, path=["response"]) + assert_matches_type(AccessKeyRevokeResponse, access_key, path=["response"]) assert cast(Any, response.is_closed) is True 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 6b8da3e30a..b2930da113 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 @@ -9,6 +9,7 @@ from types import SimpleNamespace from unittest.mock import MagicMock, patch +import httpx import pytest import yaml from nemo_platform.auth.helpers import decode_jwt_claims, generate_unsigned_jwt @@ -19,8 +20,10 @@ AccessKeyCreateResponse, AccessKeyListResponse, AccessKeyMetadataResponse, + AccessKeyRevokeResponse, ) from nemo_platform_plugin.client.constants import WORKLOAD_IDENTITY_TOKEN_FILE_ENVVAR +from nemo_platform_plugin.client.errors import NotFoundError from typer.testing import CliRunner from ..utils import assert_exit_code @@ -90,6 +93,16 @@ def _created_access_key( ) +def _access_key_not_found_error(jti: str) -> NotFoundError: + return NotFoundError( + httpx.Response( + 404, + json={"detail": f"Scoped Access Key {jti} was not found"}, + request=httpx.Request("DELETE", f"https://platform.example.com/apis/auth/v2/access-keys/{jti}"), + ) + ) + + # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @@ -557,17 +570,76 @@ def test_auth_access_keys_list_points_to_next_page(monkeypatch: pytest.MonkeyPat fake_access_keys_client.list_access_keys.assert_called_once_with(query_params={"page": 3, "page_size": 25}) +def test_auth_access_keys_revoke_sends_jti(monkeypatch: pytest.MonkeyPatch) -> None: + fake_access_keys_client = MagicMock() + fake_access_keys_client.revoke_access_key.return_value.data.return_value = AccessKeyRevokeResponse( + jti="ak_example", + revoked=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, + ) + + result = runner.invoke(app, ["auth", "access-keys", "revoke", "ak_example"]) + + assert_exit_code(result, 0) + assert "Revoked Scoped Access Key ak_example." in result.output + fake_access_keys_client.revoke_access_key.assert_called_once_with(jti="ak_example") + + +def test_auth_access_keys_revoke_reports_already_revoked(monkeypatch: pytest.MonkeyPatch) -> None: + fake_access_keys_client = MagicMock() + fake_access_keys_client.revoke_access_key.return_value.data.return_value = AccessKeyRevokeResponse( + jti="ak_example", + revoked=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", "revoke", "ak_example"]) + + assert_exit_code(result, 0) + assert "Scoped Access Key ak_example was already revoked." in result.output + + +def test_auth_access_keys_revoke_reports_missing_key(monkeypatch: pytest.MonkeyPatch) -> None: + fake_access_keys_client = MagicMock() + fake_access_keys_client.revoke_access_key.side_effect = _access_key_not_found_error("ak_unknown") + 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", "revoke", "ak_unknown"]) + + assert_exit_code(result, 1) + assert "Not found: (404) Scoped Access Key ak_unknown was not found" in result.output + fake_access_keys_client.revoke_access_key.assert_called_once_with(jti="ak_unknown") + + def test_auth_access_keys_help_exposes_lifecycle_commands() -> None: result = runner.invoke(app, ["auth", "access-keys", "--help"]) assert_exit_code(result, 0) assert "create" in result.output assert "list" in result.output + assert "revoke" in result.output create_help = runner.invoke(app, ["auth", "access-keys", "create", "--help"]) assert_exit_code(create_help, 0) assert "Use 'none' to request no expiration" in " ".join(create_help.output.split()) + revoke_help = runner.invoke(app, ["auth", "access-keys", "revoke", "--help"]) + assert_exit_code(revoke_help, 0) + assert "Stable ID of the Scoped Access Key" in " ".join(revoke_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 1293767ddb..3a51bbdb87 100644 --- a/sdk/stainless.yaml +++ b/sdk/stainless.yaml @@ -960,6 +960,7 @@ resources: access_key_list_response: AccessKeyListResponse access_key_metadata_response: AccessKeyMetadataResponse access_key_not_implemented_error_response: AccessKeyNotImplementedErrorResponse + access_key_revoke_response: AccessKeyRevokeResponse methods: list: get /apis/auth/v2/access-keys create: post /apis/auth/v2/access-keys 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 a6f6ffb933..ca0d02c6d9 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 @@ -13,6 +13,7 @@ from nmp.common.auth import AuthClient, get_auth_client from nmp.common.config import get_auth_config from nmp.core.auth.app.access_keys import ( + AccessKeyNotFoundError, AccessKeyRegistry, PersistentAccessKeyIssuer, get_access_key_registry, @@ -26,6 +27,10 @@ "description": "Scoped Access Keys are not enabled", "model": schemas.AccessKeyErrorResponse, } +_ACCESS_KEY_DISABLED_OR_NOT_FOUND_ERROR_RESPONSE: dict[str, Any] = { + "description": "Scoped Access Keys are not enabled or the key was not found", + "model": schemas.AccessKeyErrorResponse, +} _ACCESS_KEY_NOT_IMPLEMENTED_ERROR_RESPONSE: dict[str, Any] = { "description": "Not Implemented", "model": schemas.AccessKeyNotImplementedErrorResponse, @@ -42,6 +47,10 @@ 404: _ACCESS_KEY_DISABLED_ERROR_RESPONSE, 501: _ACCESS_KEY_NOT_IMPLEMENTED_ERROR_RESPONSE, } +_ACCESS_KEY_REVOKE_ERROR_RESPONSES: dict[int | str, dict[str, Any]] = { + 404: _ACCESS_KEY_DISABLED_OR_NOT_FOUND_ERROR_RESPONSE, + 501: _ACCESS_KEY_NOT_IMPLEMENTED_ERROR_RESPONSE, +} def get_access_key_issuer( @@ -96,11 +105,21 @@ async def list_access_keys( raise _not_implemented(exc) from exc -@router.delete("/v2/access-keys/{jti}", responses=_ACCESS_KEY_LIFECYCLE_ERROR_RESPONSES) -async def revoke_access_key(jti: str, issuer: PersistentAccessKeyIssuer = Depends(get_access_key_issuer)) -> None: +@router.delete( + "/v2/access-keys/{jti}", + response_model=schemas.AccessKeyRevokeResponse, + responses=_ACCESS_KEY_REVOKE_ERROR_RESPONSES, +) +async def revoke_access_key( + jti: str, + issuer: PersistentAccessKeyIssuer = Depends(get_access_key_issuer), +) -> schemas.AccessKeyRevokeResponse: try: - issuer.revoke(jti) + revoked = await issuer.revoke_async(jti) except AccessKeyFeatureDisabledError as exc: raise _disabled(exc) from exc 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 + return schemas.AccessKeyRevokeResponse(jti=jti, revoked=revoked) 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 b8476099d0..4858d0ccb4 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 @@ -3,13 +3,13 @@ from __future__ import annotations -from nemo_platform_plugin.auth.access_keys.types import AccessKeyAuthenticateResponse as AccessKeyAuthenticateResponse from nemo_platform_plugin.auth.access_keys.types import AccessKeyCreateRequest as AccessKeyCreateRequest from nemo_platform_plugin.auth.access_keys.types import AccessKeyCreateResponse as AccessKeyCreateResponse from nemo_platform_plugin.auth.access_keys.types import AccessKeyListResponse as AccessKeyListResponse from nemo_platform_plugin.auth.access_keys.types import ( AccessKeyNotImplementedErrorResponse as AccessKeyNotImplementedErrorResponse, ) +from nemo_platform_plugin.auth.access_keys.types import AccessKeyRevokeResponse as AccessKeyRevokeResponse from pydantic import BaseModel diff --git a/services/core/auth/src/nmp/core/auth/api/v2/authenticate.py b/services/core/auth/src/nmp/core/auth/api/v2/authenticate.py index 97fad73e93..b68c34c765 100644 --- a/services/core/auth/src/nmp/core/auth/api/v2/authenticate.py +++ b/services/core/auth/src/nmp/core/auth/api/v2/authenticate.py @@ -4,7 +4,8 @@ from __future__ import annotations import logging -from typing import Any +from dataclasses import dataclass +from typing import Annotated, Any import jwt from fastapi import APIRouter, Depends, HTTPException, Request, Response, status @@ -18,6 +19,7 @@ _workload_token_issuer, get_workload_token_exchange_service, ) +from nmp.core.auth.app.access_keys import AccessKeyRegistry, get_access_key_registry from pydantic import BaseModel, Field router = APIRouter(tags=["Authentication"]) @@ -53,6 +55,25 @@ class AuthenticateResponse(BaseModel): } +@dataclass(frozen=True) +class AuthenticateDependencies: + workload_token_exchange_service: WorkloadTokenExchangeService + access_key_registry: AccessKeyRegistry + + +def get_authenticate_dependencies( + workload_token_exchange_service: WorkloadTokenExchangeService = Depends(get_workload_token_exchange_service), + access_key_registry: AccessKeyRegistry = Depends(get_access_key_registry), +) -> AuthenticateDependencies: + return AuthenticateDependencies( + workload_token_exchange_service=workload_token_exchange_service, + access_key_registry=access_key_registry, + ) + + +AuthenticateDependency = Annotated[AuthenticateDependencies, Depends(get_authenticate_dependencies)] + + def _bearer_token_from_request(request: Request) -> str: try: token = parse_bearer_authorization_header(request.headers.get("authorization")) @@ -187,6 +208,7 @@ async def _authenticate_bearer_token( request: Request, response: Response, workload_token_exchange_service: WorkloadTokenExchangeService, + access_key_registry: AccessKeyRegistry, ) -> AuthenticateResponse: token = _bearer_token_from_request(request) config = get_auth_config() @@ -205,6 +227,13 @@ async def resolve_workload_subject(candidate: str) -> ResolvedBearerToken | None if resolved is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid bearer token") + if resolved.token_kind == "access_key": + jti = resolved.claims.raw_claims.get("jti") + if not isinstance(jti, str) or not jti: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid bearer token") + if not await access_key_registry.is_active(jti, resolved.claims.subject, claims=resolved.claims): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid bearer token") + _stamp_principal_headers(response, resolved) return _response_from_claims(resolved.claims, resolved.token_kind) @@ -218,9 +247,14 @@ async def resolve_workload_subject(candidate: str) -> ResolvedBearerToken | None async def authenticate_bearer_token_get( request: Request, response: Response, - workload_token_exchange_service: WorkloadTokenExchangeService = Depends(get_workload_token_exchange_service), + dependencies: AuthenticateDependency, ) -> AuthenticateResponse: - return await _authenticate_bearer_token(request, response, workload_token_exchange_service) + return await _authenticate_bearer_token( + request, + response, + dependencies.workload_token_exchange_service, + dependencies.access_key_registry, + ) @router.post( @@ -232,9 +266,14 @@ async def authenticate_bearer_token_get( async def authenticate_bearer_token_post( request: Request, response: Response, - workload_token_exchange_service: WorkloadTokenExchangeService = Depends(get_workload_token_exchange_service), + dependencies: AuthenticateDependency, ) -> AuthenticateResponse: - return await _authenticate_bearer_token(request, response, workload_token_exchange_service) + return await _authenticate_bearer_token( + request, + response, + dependencies.workload_token_exchange_service, + dependencies.access_key_registry, + ) @router.api_route( @@ -246,9 +285,14 @@ async def authenticate_bearer_token_post( async def authenticate_bearer_token_callout_methods( request: Request, response: Response, - workload_token_exchange_service: WorkloadTokenExchangeService = Depends(get_workload_token_exchange_service), + dependencies: AuthenticateDependency, ) -> AuthenticateResponse: - return await _authenticate_bearer_token(request, response, workload_token_exchange_service) + return await _authenticate_bearer_token( + request, + response, + dependencies.workload_token_exchange_service, + dependencies.access_key_registry, + ) @router.api_route( @@ -261,7 +305,12 @@ async def authenticate_bearer_token_prefixed_callout( request: Request, response: Response, original_path: str, - workload_token_exchange_service: WorkloadTokenExchangeService = Depends(get_workload_token_exchange_service), + dependencies: AuthenticateDependency, ) -> AuthenticateResponse: _ = original_path - return await _authenticate_bearer_token(request, response, workload_token_exchange_service) + return await _authenticate_bearer_token( + request, + response, + dependencies.workload_token_exchange_service, + dependencies.access_key_registry, + ) 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 a8825828ca..c58a8f305c 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 @@ -17,10 +17,11 @@ AccessKeyMetadataResponse, AccessKeyStatus, ) -from nmp.common.auth.access_keys import AccessKeyIssuerService +from nmp.common.auth.access_keys import LEGACY_ACCESS_KEY_METADATA_VERSION, AccessKeyIssuerService +from nmp.common.auth.jwt import TokenClaims from nmp.common.auth.models import Principal from nmp.common.config import AuthConfig -from nmp.common.entities import EntityClient +from nmp.common.entities import EntityClient, EntityConflictError, EntityNotFoundError from nmp.common.service.dependencies import get_entity_client from nmp.core.auth.entities import AccessKeyEntity @@ -28,6 +29,10 @@ logger = logging.getLogger(__name__) +class AccessKeyNotFoundError(Exception): + """Raised when a key does not exist or is not owned by the caller.""" + + class AccessKeyRegistry: """Durable access-key lifecycle records stored by the entities service.""" @@ -63,6 +68,40 @@ async def list_for_principal(self, principal: str, *, page: int, page_size: int) has_more=page < result.pagination.total_pages, ) + async def revoke(self, jti: str, principal: str) -> bool: + record = await self._get_owned(jti, principal) + if record.revoked_at is not None: + return False + # Entity storage does not expose compare-and-swap here. Concurrent revokes may both write a timestamp, + # but revocation is idempotent and the final lifecycle state is still correct. + updated = record.model_copy(update={"revoked_at": datetime.now(tz=UTC)}) + await self._entity_client.update(updated) + return True + + async def is_active(self, jti: str, principal: str, *, claims: TokenClaims | None = None) -> bool: + try: + record = await self._get_owned(jti, principal) + except AccessKeyNotFoundError: + if claims is None: + return False + record = await self._backfill_legacy_record(jti, principal, claims) + if record is None: + return False + return self._status(record) == "ACTIVE" + + async def _get_owned(self, jti: str, principal: str) -> AccessKeyEntity: + try: + record = await self._entity_client.get( + AccessKeyEntity, + name=jti, + workspace=ACCESS_KEY_WORKSPACE, + ) + except EntityNotFoundError as exc: + raise AccessKeyNotFoundError(f"Scoped Access Key {jti} was not found") from exc + if record.principal != principal: + raise AccessKeyNotFoundError(f"Scoped Access Key {jti} was not found") + return record + @staticmethod def _metadata(record: AccessKeyEntity) -> AccessKeyMetadataResponse: return AccessKeyMetadataResponse( @@ -85,6 +124,86 @@ def _status(record: AccessKeyEntity) -> AccessKeyStatus: return "EXPIRED" return "ACTIVE" + async def _backfill_legacy_record( + self, + jti: str, + principal: str, + claims: TokenClaims, + ) -> AccessKeyEntity | None: + record = self._record_from_validated_claims(jti, principal, claims) + if record is None: + return None + try: + await self._entity_client.create(record) + except EntityConflictError: + try: + return await self._get_owned(jti, principal) + except AccessKeyNotFoundError: + return None + logger.info( + "Backfilled legacy Scoped Access Key lifecycle record", + extra={ + "audit_event": "access_key.backfilled", + "actor_principal": principal, + "access_key_jti": jti, + }, + ) + return record + + @classmethod + def _record_from_validated_claims( + cls, + jti: str, + principal: str, + claims: TokenClaims, + ) -> AccessKeyEntity | None: + raw_claims = claims.raw_claims + # Keep the registry boundary defensive even though the authenticate + # endpoint currently derives ``jti`` and ``principal`` from these claims. + if raw_claims.get("jti") != jti or claims.subject != principal: + return None + issuer = raw_claims.get("iss") + issued_at = cls._datetime_from_claim(raw_claims.get("iat")) + if not isinstance(issuer, str) or issued_at is None: + return None + audiences = cls._audiences_from_claim(raw_claims.get("aud")) + if not audiences: + return None + + metadata = raw_claims.get("nmp_access_key") + if not isinstance(metadata, dict) or metadata.get("version") != LEGACY_ACCESS_KEY_METADATA_VERSION: + return None + key_name = metadata.get("name") + description = metadata.get("description") + return AccessKeyEntity( + name=jti, + workspace=ACCESS_KEY_WORKSPACE, + key_name=key_name if isinstance(key_name, str) else None, + description=description if isinstance(description, str) else None, + principal=principal, + issuer=issuer, + audiences=audiences, + issued_at=issued_at, + expires_at=cls._datetime_from_claim(raw_claims.get("exp")), + ) + + @staticmethod + def _audiences_from_claim(value: object) -> list[str]: + if isinstance(value, str): + return [value] + if isinstance(value, list): + return [audience for audience in value if isinstance(audience, str)] + return [] + + @staticmethod + def _datetime_from_claim(value: object) -> datetime | None: + if isinstance(value, (int, float)): + return datetime.fromtimestamp(value, tz=UTC) + if isinstance(value, datetime): + return value.astimezone(UTC) + return None + + def get_access_key_registry(entity_client: EntityClient = Depends(get_entity_client)) -> AccessKeyRegistry: return AccessKeyRegistry(entity_client.as_service("auth", internal=True)) @@ -116,9 +235,20 @@ async def list_async(self, *, page: int = 1, page_size: int = 100) -> AccessKeyL self._ensure_enabled() return await self._registry.list_for_principal(self.principal, page=page, page_size=page_size) - def revoke(self, jti: str) -> None: - """Preserve the pre-lifecycle not-implemented response until revocation is added.""" - self._issuer.revoke(jti) + async def revoke_async(self, jti: str) -> bool: + self._ensure_enabled() + revoked = await self._registry.revoke(jti, self.principal) + audit_event = "access_key.revoked" if revoked else "access_key.revoke_noop" + logger.info( + "Scoped Access Key revoked" if revoked else "Scoped Access Key revoke requested for already-revoked key", + extra={ + "audit_event": audit_event, + "actor_principal": self.principal, + "access_key_jti": jti, + "access_key_already_revoked": not revoked, + }, + ) + return revoked def _ensure_enabled(self) -> None: if not self._config.access_keys.enabled: 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 b063cfdde2..de3c26ab1a 100644 --- a/services/core/auth/tests/integration/test_scoped_access_keys.py +++ b/services/core/auth/tests/integration/test_scoped_access_keys.py @@ -5,6 +5,7 @@ from pathlib import Path from unittest.mock import patch +import pytest from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa from fastapi.testclient import TestClient @@ -15,6 +16,11 @@ from nmp.core.auth.config import AuthServiceConfig from nmp.testing.client import create_test_client +# Run on a dedicated worker so the inline create_test_client call doesn't share +# the module-level wasmtime singleton with the module-scoped test_client fixture +# used by sibling integration tests (which would violate wasmtime thread affinity). +pytestmark = pytest.mark.xdist_group("auth_scoped_access_keys") + ACCESS_KEYS_PATH = "/apis/auth/v2/access-keys" IAM_ROLE_BINDINGS_PATH = "/apis/auth/v2/iam/role-bindings" WORKSPACES_PATH = "/apis/entities/v2/workspaces" @@ -57,7 +63,7 @@ def _auth_configs(private_key_file: str) -> tuple[AuthConfig, AuthServiceConfig] ) service_config = AuthServiceConfig( **shared_config.model_dump(), - policy_data_refresh_interval=0.05, + policy_data_refresh_interval=0.2, bundle_cache_seconds=0, admin_email="admin@example.com", ) @@ -101,7 +107,13 @@ def test_scoped_access_key_created_by_auth_service_authenticates_platform_reques headers=user_headers, ) assert create_key.status_code == 200, create_key.text - access_key = create_key.json()["token"] + access_key_body = create_key.json() + access_key = access_key_body["token"] + access_key_jti = access_key_body["jti"] + + list_keys = client.get(ACCESS_KEYS_PATH, headers=user_headers) + assert list_keys.status_code == 200, list_keys.text + assert [key["jti"] for key in list_keys.json()["data"]] == [access_key_jti] role_binding = client.post( IAM_ROLE_BINDINGS_PATH, @@ -120,11 +132,9 @@ async def validate_with_local_jwks(config: AuthConfig, token: str) -> TokenClaim f"{WORKSPACES_PATH}/{workspace}", headers={"Authorization": f"Bearer {access_key}"}, ) + assert response.status_code == 200, response.text + assert response.json()["name"] == workspace - assert response.status_code == 200, response.text - assert response.json()["name"] == workspace - - with patch("nmp.common.auth.access_keys.validate_access_key_token", validate_with_local_jwks): authenticate_response = client.get( "/apis/auth/authenticate", headers={"Authorization": f"Bearer {access_key}"}, @@ -133,18 +143,33 @@ async def validate_with_local_jwks(config: AuthConfig, token: str) -> TokenClaim "/apis/auth/authenticate", headers={"Authorization": f"Bearer {_tamper_jwt(access_key)}"}, ) + assert authenticate_response.status_code == 200, authenticate_response.text + assert authenticate_response.json()["principal"] == user + assert authenticate_response.json()["token_kind"] == "access_key" + assert invalid_authenticate_response.status_code == 401, invalid_authenticate_response.text - assert authenticate_response.status_code == 200, authenticate_response.text - assert authenticate_response.json()["principal"] == user - assert authenticate_response.json()["token_kind"] == "access_key" - assert invalid_authenticate_response.status_code == 401, invalid_authenticate_response.text - - with patch("nmp.common.auth.access_keys.validate_access_key_token", validate_with_local_jwks): invalid_workspace_response = client.get( f"{WORKSPACES_PATH}/{workspace}", headers={"Authorization": f"Bearer {_tamper_jwt(access_key)}"}, ) + assert invalid_workspace_response.status_code == 401, invalid_workspace_response.text + + revoke_key = client.delete(f"{ACCESS_KEYS_PATH}/{access_key_jti}", headers=user_headers) + assert revoke_key.status_code == 200, revoke_key.text + revoked_keys = client.get(ACCESS_KEYS_PATH, headers=user_headers).json()["data"] + assert len(revoked_keys) == 1 + assert revoked_keys[0]["status"] == "REVOKED" + + revoked_authenticate_response = client.get( + "/apis/auth/authenticate", + headers={"Authorization": f"Bearer {access_key}"}, + ) + assert revoked_authenticate_response.status_code == 401, revoked_authenticate_response.text - assert invalid_workspace_response.status_code == 401, invalid_workspace_response.text + revoked_workspace_response = client.get( + f"{WORKSPACES_PATH}/{workspace}", + headers={"Authorization": f"Bearer {access_key}"}, + ) + assert revoked_workspace_response.status_code == 401, revoked_workspace_response.text finally: client.delete(f"{WORKSPACES_PATH}/{workspace}", headers=SERVICE_HEADERS) diff --git a/services/core/auth/tests/test_access_key_registry.py b/services/core/auth/tests/test_access_key_registry.py index c29357cbdc..4ec7c98f46 100644 --- a/services/core/auth/tests/test_access_key_registry.py +++ b/services/core/auth/tests/test_access_key_registry.py @@ -7,7 +7,9 @@ import pytest from nemo_platform_plugin.auth.access_keys.types import AccessKeyCreateResponse -from nmp.core.auth.app.access_keys import AccessKeyRegistry +from nmp.common.auth.jwt import TokenClaims +from nmp.common.entities import EntityNotFoundError +from nmp.core.auth.app.access_keys import AccessKeyNotFoundError, AccessKeyRegistry from nmp.core.auth.entities import AccessKeyEntity NOW = datetime(2026, 8, 4, 18, 0, tzinfo=UTC) @@ -106,3 +108,104 @@ async def test_registry_reports_expired_status() -> None: result = await registry.list_for_principal("alice@example.com", page=1, page_size=100) assert result.data[0].status == "EXPIRED" + + +@pytest.mark.asyncio +async def test_registry_revokes_owned_key_without_deleting_audit_record() -> None: + entity_client = AsyncMock() + original = _record() + entity_client.get.return_value = original + registry = AccessKeyRegistry(entity_client) + + assert await registry.revoke("ak_example", "alice@example.com") + + updated = entity_client.update.await_args.args[0] + assert updated is not original + assert original.revoked_at is None + assert updated.revoked_at is not None + entity_client.delete.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_registry_reports_revoked_key_as_inactive() -> None: + entity_client = AsyncMock() + entity_client.get.return_value = _record(revoked=True) + registry = AccessKeyRegistry(entity_client) + + assert not await registry.is_active("ak_example", "alice@example.com") + + +@pytest.mark.asyncio +async def test_registry_hides_missing_and_other_principals_keys() -> None: + entity_client = AsyncMock() + entity_client.get.return_value = _record(principal="bob@example.com") + registry = AccessKeyRegistry(entity_client) + + with pytest.raises(AccessKeyNotFoundError): + await registry.revoke("ak_example", "alice@example.com") + + entity_client.get.side_effect = EntityNotFoundError("missing") + assert not await registry.is_active("ak_missing", "alice@example.com") + + +@pytest.mark.asyncio +async def test_registry_backfills_missing_legacy_access_key_from_validated_claims() -> None: + entity_client = AsyncMock() + entity_client.get.side_effect = EntityNotFoundError("missing") + registry = AccessKeyRegistry(entity_client) + claims = TokenClaims( + subject="alice@example.com", + email=None, + groups=[], + scopes=[], + raw_claims={ + "iss": "https://platform.example.com/apis/auth", + "aud": ["nemo-platform-access-key"], + "sub": "alice@example.com", + "iat": 1_785_280_000, + "nbf": 1_785_280_000, + "exp": 1_893_456_000, + "jti": "ak_legacy", + "nmp_token_type": "access_key", + "nmp_access_key": {"version": 1, "name": "legacy-key"}, + }, + ) + + assert await registry.is_active("ak_legacy", "alice@example.com", claims=claims) + + saved = entity_client.create.await_args.args[0] + assert saved.name == "ak_legacy" + assert saved.key_name == "legacy-key" + assert saved.description is None + assert saved.principal == "alice@example.com" + assert saved.issuer == "https://platform.example.com/apis/auth" + assert saved.audiences == ["nemo-platform-access-key"] + assert saved.issued_at == datetime.fromtimestamp(1_785_280_000, tz=UTC) + assert saved.expires_at == datetime.fromtimestamp(1_893_456_000, tz=UTC) + + +@pytest.mark.asyncio +async def test_registry_rejects_missing_current_access_key_record() -> None: + entity_client = AsyncMock() + entity_client.get.side_effect = EntityNotFoundError("missing") + registry = AccessKeyRegistry(entity_client) + claims = TokenClaims( + subject="alice@example.com", + email=None, + groups=[], + scopes=[], + raw_claims={ + "iss": "https://platform.example.com/apis/auth", + "aud": ["nemo-platform-access-key"], + "sub": "alice@example.com", + "iat": 1_785_280_000, + "nbf": 1_785_280_000, + "exp": 1_893_456_000, + "jti": "ak_current", + "nmp_token_type": "access_key", + "nmp_access_key": {"version": 2, "name": "current-key"}, + }, + ) + + assert not await registry.is_active("ak_current", "alice@example.com", claims=claims) + entity_client.create.assert_not_awaited() diff --git a/services/core/auth/tests/test_access_keys.py b/services/core/auth/tests/test_access_keys.py index 2ddbe900bb..cb12398e75 100644 --- a/services/core/auth/tests/test_access_keys.py +++ b/services/core/auth/tests/test_access_keys.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 +import logging from datetime import UTC, datetime, timedelta from unittest.mock import patch @@ -15,17 +16,20 @@ 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 get_access_key_registry +from nmp.core.auth.app.access_keys import AccessKeyNotFoundError, get_access_key_registry class InMemoryAccessKeyRegistry: def __init__(self): self.keys = {} + self.revoked = set() async def add(self, key): self.keys[key.jti] = key - def _status(self, key): + def _status(self, jti, key): + if jti in self.revoked: + return "REVOKED" if key.expires_at is not None and key.expires_at <= datetime.now(tz=UTC): return "EXPIRED" return key.status @@ -39,13 +43,26 @@ async def list_for_principal(self, principal, *, page, page_size): return AccessKeyListResponse( data=[ AccessKeyMetadataResponse.model_validate( - key.model_dump(exclude={"token", "token_type"}) | {"status": self._status(key)} + key.model_dump(exclude={"token", "token_type"}) | {"status": self._status(jti, key)} ) for jti, key in selected ], has_more=start + page_size < len(owned), ) + async def revoke(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") + revoked = jti not in self.revoked + self.revoked.add(jti) + return revoked + + 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" + + @pytest.fixture def client(tmp_path): config = AuthConfig( @@ -127,6 +144,30 @@ def test_create_access_key_returns_token_for_current_principal(client): assert body["token"].count(".") == 2 +def test_create_and_revoke_emit_actor_aware_audit_logs(client, caplog): + with caplog.at_level(logging.INFO, logger="nmp.core.auth.app.access_keys"): + created = client.post( + "/v2/access-keys", + json={"name": "gtc-intake", "description": "GTC intake automation"}, + ).json() + response = client.delete(f"/v2/access-keys/{created['jti']}") + repeat_response = client.delete(f"/v2/access-keys/{created['jti']}") + + assert response.status_code == 200 + assert repeat_response.status_code == 200 + events = {record.audit_event: record for record in caplog.records if hasattr(record, "audit_event")} + assert events["access_key.created"].actor_principal == "alice@example.com" + assert events["access_key.created"].access_key_jti == created["jti"] + assert events["access_key.revoked"].actor_principal == "alice@example.com" + assert events["access_key.revoked"].access_key_jti == created["jti"] + assert not events["access_key.revoked"].access_key_already_revoked + assert events["access_key.revoke_noop"].actor_principal == "alice@example.com" + assert events["access_key.revoke_noop"].access_key_jti == created["jti"] + assert events["access_key.revoke_noop"].access_key_already_revoked + assert created["token"] not in caplog.text + assert "GTC intake automation" not in caplog.text + + @pytest.mark.asyncio async def test_in_memory_access_key_registry_reports_expired_status() -> None: registry = InMemoryAccessKeyRegistry() @@ -149,6 +190,7 @@ async def test_in_memory_access_key_registry_reports_expired_status() -> None: result = await registry.list_for_principal("alice@example.com", page=1, page_size=100) assert result.data[0].status == "EXPIRED" + assert not await registry.is_active("ak_expired", "alice@example.com") def test_create_access_key_allows_unnamed_tokens(client): @@ -268,6 +310,8 @@ def test_access_key_lifecycle_openapi_documents_error_responses(client): assert create_response_schema["properties"]["audiences"]["uniqueItems"] is True list_schema = openapi["components"]["schemas"]["AccessKeyListResponse"] assert list_schema["properties"]["has_more"]["default"] is False + revoke_schema = openapi["components"]["schemas"]["AccessKeyRevokeResponse"] + assert set(revoke_schema["required"]) == {"jti", "revoked"} list_operation = openapi["paths"]["/v2/access-keys"]["get"] list_parameters = {parameter["name"]: parameter for parameter in list_operation["parameters"]} @@ -293,8 +337,10 @@ def test_access_key_lifecycle_openapi_documents_error_responses(client): } revoke_responses = openapi["paths"]["/v2/access-keys/{jti}"]["delete"]["responses"] - assert revoke_responses["200"]["content"]["application/json"]["schema"] == {} - assert revoke_responses["404"]["description"] == "Scoped Access Keys are not enabled" + assert revoke_responses["200"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/AccessKeyRevokeResponse" + } + assert revoke_responses["404"]["description"] == "Scoped Access Keys are not enabled or the key was not found" assert revoke_responses["404"]["content"]["application/json"]["schema"] == { "$ref": "#/components/schemas/AccessKeyErrorResponse" } @@ -342,3 +388,28 @@ def test_list_access_keys_supports_pagination(client): assert second_page.status_code == 200 assert [key["jti"] for key in second_page.json()["data"]] == [second["jti"]] assert second_page.json()["has_more"] is False + + +def test_revoke_access_key_marks_key_revoked_in_listing(client): + created = client.post("/v2/access-keys", json={"name": "gtc-intake"}).json() + + response = client.delete(f"/v2/access-keys/{created['jti']}") + + assert response.status_code == 200 + assert response.json() == {"jti": created["jti"], "revoked": True} + listed = client.get("/v2/access-keys").json()["data"] + assert len(listed) == 1 + assert listed[0]["jti"] == created["jti"] + assert listed[0]["status"] == "REVOKED" + + repeat_response = client.delete(f"/v2/access-keys/{created['jti']}") + + assert repeat_response.status_code == 200 + assert repeat_response.json() == {"jti": created["jti"], "revoked": False} + + +def test_revoke_access_key_returns_not_found_for_unknown_key(client): + response = client.delete("/v2/access-keys/ak_unknown") + + assert response.status_code == 404 + assert response.json()["detail"] == "Scoped Access Key ak_unknown was not found" diff --git a/services/core/auth/tests/test_authenticate.py b/services/core/auth/tests/test_authenticate.py index 493307ac74..91932e317a 100644 --- a/services/core/auth/tests/test_authenticate.py +++ b/services/core/auth/tests/test_authenticate.py @@ -22,6 +22,7 @@ WorkloadTokenExchangeService, get_workload_token_exchange_service, ) +from nmp.core.auth.app.access_keys import get_access_key_registry def _private_key_pem() -> bytes: @@ -33,6 +34,25 @@ def _private_key_pem() -> bytes: ) +class AlwaysActiveAccessKeyRegistry: + async def is_active(self, jti: str, principal: str, **kwargs) -> bool: + return True + + +class RevokedAccessKeyRegistry: + async def is_active(self, jti: str, principal: str, **kwargs) -> bool: + return False + + +class ClaimAwareAccessKeyRegistry: + def __init__(self) -> None: + self.claims = None + + async def is_active(self, jti: str, principal: str, **kwargs) -> bool: + self.claims = kwargs.get("claims") + return True + + @contextmanager def _test_client( config: AuthConfig, @@ -41,6 +61,7 @@ def _test_client( ) -> Iterator[TestClient]: app = FastAPI() app.include_router(router) + app.dependency_overrides[get_access_key_registry] = lambda: AlwaysActiveAccessKeyRegistry() if workload_token_exchange_service is not None: app.dependency_overrides[get_workload_token_exchange_service] = lambda: workload_token_exchange_service with patch("nmp.core.auth.api.v2.authenticate.get_auth_config", return_value=config): @@ -97,6 +118,68 @@ def test_authenticate_access_key_returns_principal_headers(tmp_path): assert len(resolver_call.kwargs["extra_resolvers"]) == 2 +def test_authenticate_passes_access_key_claims_for_legacy_record_backfill(tmp_path): + config = AuthConfig( + enabled=True, + token_signing=TokenSigningConfig(private_key_file=str(tmp_path / "private.pem")), + access_keys=AccessKeyConfig(enabled=True), + ) + (tmp_path / "private.pem").write_bytes(_private_key_pem()) + claims = TokenClaims( + subject="alice@example.com", + email=None, + groups=[], + scopes=[], + raw_claims={ + "iss": "http://testserver/apis/auth", + "aud": ["nemo-platform-access-key"], + "sub": "alice@example.com", + "iat": 1_785_280_000, + "nbf": 1_785_280_000, + "jti": "ak_legacy", + "nmp_token_type": "access_key", + "nmp_access_key": {"version": 1, "name": "legacy"}, + }, + ) + resolved = ResolvedBearerToken(claims=claims, token_kind="access_key") + registry = ClaimAwareAccessKeyRegistry() + with ( + _test_client(config) as client, + patch("nmp.core.auth.api.v2.authenticate.resolve_bearer_token", new=AsyncMock(return_value=resolved)), + ): + client.app.dependency_overrides[get_access_key_registry] = lambda: registry + response = client.get("/authenticate", headers={"Authorization": "Bearer signed.jwt.token"}) + + assert response.status_code == 200 + assert registry.claims is claims + + +def test_authenticate_rejects_revoked_access_key(tmp_path): + config = AuthConfig( + enabled=True, + token_signing=TokenSigningConfig(private_key_file=str(tmp_path / "private.pem")), + access_keys=AccessKeyConfig(enabled=True), + ) + (tmp_path / "private.pem").write_bytes(_private_key_pem()) + claims = TokenClaims( + subject="alice@example.com", + email=None, + groups=[], + scopes=[], + raw_claims={"jti": "ak_revoked", "nmp_token_type": "access_key"}, + ) + resolved = ResolvedBearerToken(claims=claims, token_kind="access_key") + with ( + _test_client(config) as client, + patch("nmp.core.auth.api.v2.authenticate.resolve_bearer_token", new=AsyncMock(return_value=resolved)), + ): + client.app.dependency_overrides[get_access_key_registry] = lambda: RevokedAccessKeyRegistry() + response = client.get("/authenticate", headers={"Authorization": "Bearer signed.jwt.token"}) + + assert response.status_code == 401 + assert response.json()["detail"] == "Invalid bearer token" + + def test_authenticate_callout_accepts_original_request_methods(tmp_path): config = AuthConfig( enabled=True, @@ -109,7 +192,7 @@ def test_authenticate_callout_accepts_original_request_methods(tmp_path): email=None, groups=[], scopes=["models:write"], - raw_claims={"nmp_token_type": "access_key"}, + raw_claims={"jti": "ak_writer", "nmp_token_type": "access_key"}, ) resolved = ResolvedBearerToken(claims=claims, token_kind="access_key") with ( diff --git a/services/core/auth/tests/test_embedded_pdp_stress.py b/services/core/auth/tests/test_embedded_pdp_stress.py index 330599a45a..b4b8be31b3 100644 --- a/services/core/auth/tests/test_embedded_pdp_stress.py +++ b/services/core/auth/tests/test_embedded_pdp_stress.py @@ -332,12 +332,16 @@ def large_scale_policy_data(): reload_policy() set_policy_data(data) - yield { + ctx = { "endpoints": endpoints, + "post_endpoints": [path for path in endpoints if "post" in data["authz"]["endpoints"][path]], "workspace_ids": [f"workspace-{i:05d}" for i in range(NUM_WORKSPACES)], "user_ids": [f"user{i:05d}@example.com" for i in range(NUM_USERS)], "rng": random.Random(SEED + 1), # Separate RNG for test execution } + assert ctx["post_endpoints"], "expected at least one POST endpoint in static-authz" + + yield ctx reload_policy() @@ -523,14 +527,14 @@ def endpoint_allows_platform_admin(pattern: str) -> bool: result = evaluate("allow", {"principal_id": "admin@example.com", "method": "DELETE", "path": path}) assert result["allowed"] is True, f"Platform admin should be allowed on {path!r}" - # Test 2: Service principals should always be allowed + # Test 2: Service principals should be allowed on registered POST routes. for _ in range(50): workspace_id = rng.choice(ctx["workspace_ids"]) - endpoint = rng.choice(ctx["endpoints"]) + endpoint = rng.choice(ctx["post_endpoints"]) path = _generate_random_path(endpoint, workspace_id, rng) result = evaluate("allow", {"principal_id": "service:test-svc", "method": "POST", "path": path}) - assert result["allowed"] is True, "Service principal should always be allowed" + assert result["allowed"] is True, f"Service principal should be allowed on {path!r}" # Test 3: Health/status endpoints should always be allowed for path in ["/health/live", "/health/ready", "/status"]: diff --git a/services/core/auth/tests/test_workload_token_exchange.py b/services/core/auth/tests/test_workload_token_exchange.py index 93d2663809..8cb060a5a9 100644 --- a/services/core/auth/tests/test_workload_token_exchange.py +++ b/services/core/auth/tests/test_workload_token_exchange.py @@ -415,6 +415,23 @@ def test_workload_exchange_accepts_workload_key_id_when_shared_key_id_unset() -> assert config.oidc.workload_token_key_id == "workload-signing" +def test_workload_exchange_requires_distinct_key_id_for_distinct_access_key_jwks_file(tmp_path: Path) -> None: + with pytest.raises(ValidationError, match="workload_token_key_id must be distinct"): + AuthConfig( + enabled=True, + token_signing=TokenSigningConfig( + key_id="nemo-platform-signing", + private_key_file=str(tmp_path / "access-key.pem"), + ), + oidc=OIDCConfig( + enabled=True, + workload_token_exchange_enabled=True, + workload_token_private_key_file=str(tmp_path / "workload-token.pem"), + ), + access_keys=AccessKeyConfig(enabled=True), + ) + + def test_workload_signing_key_specific_override_wins_over_shared_token_signing( workload_signing_key: rsa.RSAPrivateKey, tmp_path, From 56681f2889b58063558bb300280631df2327ba5c Mon Sep 17 00:00:00 2001 From: anastasia-nesterenko Date: Wed, 5 Aug 2026 22:26:57 -0600 Subject: [PATCH 3/8] fix(auth): route access key validation through injected client Signed-off-by: anastasia-nesterenko --- .../authentication/using-authentication.mdx | 2 +- docs/cli/reference.mdx | 12 +-- openapi/ga/individual/platform.openapi.yaml | 12 ++- openapi/ga/openapi.yaml | 12 ++- openapi/openapi.yaml | 12 ++- .../nemo_platform_ext/cli/commands/auth.py | 6 +- .../src/nemo_platform_ext/cli/core/errors.py | 65 +++++--------- .../tests/cli/commands/test_auth.py | 15 +++- .../tests/cli/core/test_errors.py | 57 ++++++++++++ .../auth/access_keys/client.py | 7 +- .../auth/access_keys/types.py | 2 +- .../tests/auth/access_keys/test_client.py | 30 ++++++- .../src/nmp/common/auth/access_keys.py | 49 +++++----- .../nmp_common/src/nmp/common/auth/jwt.py | 42 +++++---- .../src/nmp/common/auth/middleware.py | 51 +++++++---- .../nmp_common/tests/auth/test_access_keys.py | 47 +++++++++- packages/nmp_common/tests/auth/test_jwt.py | 32 ++++++- .../nmp_common/tests/auth/test_middleware.py | 87 ++++++++++++++---- .../src/nmp/platform_runner/server.py | 7 +- .../nmp_platform_runner/tests/test_server.py | 14 +++ .../nemo-platform/.nmpcontext/openapi.yaml | 90 +++++++++++-------- .../src/nemo_platform/cli/commands/auth.py | 6 +- .../src/nemo_platform/cli/core/errors.py | 65 +++++--------- .../resources/access_keys/access_keys.py | 4 + .../access_keys/access_key_list_response.py | 5 +- .../access_keys/access_key_revoke_response.py | 2 +- .../cli/commands/test_auth.py | 15 +++- .../nemo_platform_ext/cli/core/test_errors.py | 57 ++++++++++++ .../core/auth/api/v2/access_keys/endpoints.py | 45 ++++++---- .../core/auth/api/v2/access_keys/schemas.py | 9 +- .../src/nmp/core/auth/api/v2/authenticate.py | 29 ++---- .../auth/api/v2/workload_token_exchange.py | 8 +- .../auth/src/nmp/core/auth/app/access_keys.py | 43 ++++++--- .../src/nmp/core/auth/entities/entities.py | 9 +- .../auth/tests/test_access_key_registry.py | 51 ++++++++++- services/core/auth/tests/test_access_keys.py | 72 ++++++++++++--- 36 files changed, 767 insertions(+), 304 deletions(-) diff --git a/docs/auth/authentication/using-authentication.mdx b/docs/auth/authentication/using-authentication.mdx index 589038cff9..7c14d36c82 100644 --- a/docs/auth/authentication/using-authentication.mdx +++ b/docs/auth/authentication/using-authentication.mdx @@ -136,7 +136,7 @@ List keys or 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 revoke ak_example +nemo auth access-keys revoke ak_0123456789abcdef0123456789abcdef ``` The list includes each key's `ACTIVE`, `EXPIRED`, or `REVOKED` status plus its diff --git a/docs/cli/reference.mdx b/docs/cli/reference.mdx index dac831e94d..4c66485f61 100644 --- a/docs/cli/reference.mdx +++ b/docs/cli/reference.mdx @@ -276,13 +276,13 @@ nemo auth access-keys [OPTIONS] COMMAND [ARGS]... **Commands:** -* `create`: Create a Scoped Access Key for the current authenticated... -* `list`: List Scoped Access Keys owned by the current... -* `revoke`: Revoke a Scoped Access Key owned by the current... +* `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... ##### nemo auth access-keys create -Create a Scoped Access Key for the current authenticated user. +Create a Scoped Access Key for the currently authenticated user. **Usage:** @@ -302,7 +302,7 @@ nemo auth access-keys create [OPTIONS] ##### nemo auth access-keys list -List Scoped Access Keys owned by the current authenticated user. +List Scoped Access Keys owned by the currently authenticated user. **Usage:** @@ -321,7 +321,7 @@ nemo auth access-keys list [OPTIONS] ##### nemo auth access-keys revoke -Revoke a Scoped Access Key owned by the current authenticated user. +Revoke a Scoped Access Key owned by the currently authenticated user. **Usage:** diff --git a/openapi/ga/individual/platform.openapi.yaml b/openapi/ga/individual/platform.openapi.yaml index 0f318277ae..9c6f48a1cc 100644 --- a/openapi/ga/individual/platform.openapi.yaml +++ b/openapi/ga/individual/platform.openapi.yaml @@ -269,7 +269,10 @@ paths: required: true schema: type: string + pattern: ^ak_[0-9a-f]{32}$ + description: Stable JWT ID of the Scoped Access Key to revoke. title: Jti + description: Stable JWT ID of the Scoped Access Key to revoke. responses: '200': description: Successful Response @@ -8049,6 +8052,13 @@ components: detail: type: string title: Detail + code: + title: Code + description: Set to access_keys_disabled when the Scoped Access Key feature + is disabled. + nullable: true + type: string + const: access_keys_disabled type: object required: - detail @@ -8147,7 +8157,7 @@ components: revoked: type: boolean title: Revoked - description: True when this request changed the key from active to revoked. + description: True when this request newly recorded the key's revocation. type: object required: - jti diff --git a/openapi/ga/openapi.yaml b/openapi/ga/openapi.yaml index 0f318277ae..9c6f48a1cc 100644 --- a/openapi/ga/openapi.yaml +++ b/openapi/ga/openapi.yaml @@ -269,7 +269,10 @@ paths: required: true schema: type: string + pattern: ^ak_[0-9a-f]{32}$ + description: Stable JWT ID of the Scoped Access Key to revoke. title: Jti + description: Stable JWT ID of the Scoped Access Key to revoke. responses: '200': description: Successful Response @@ -8049,6 +8052,13 @@ components: detail: type: string title: Detail + code: + title: Code + description: Set to access_keys_disabled when the Scoped Access Key feature + is disabled. + nullable: true + type: string + const: access_keys_disabled type: object required: - detail @@ -8147,7 +8157,7 @@ components: revoked: type: boolean title: Revoked - description: True when this request changed the key from active to revoked. + description: True when this request newly recorded the key's revocation. type: object required: - jti diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 0f318277ae..9c6f48a1cc 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -269,7 +269,10 @@ paths: required: true schema: type: string + pattern: ^ak_[0-9a-f]{32}$ + description: Stable JWT ID of the Scoped Access Key to revoke. title: Jti + description: Stable JWT ID of the Scoped Access Key to revoke. responses: '200': description: Successful Response @@ -8049,6 +8052,13 @@ components: detail: type: string title: Detail + code: + title: Code + description: Set to access_keys_disabled when the Scoped Access Key feature + is disabled. + nullable: true + type: string + const: access_keys_disabled type: object required: - detail @@ -8147,7 +8157,7 @@ components: revoked: type: boolean title: Revoked - description: True when this request changed the key from active to revoked. + description: True when this request newly recorded the key's revocation. type: object required: - jti 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 a0f6520e23..64a049186c 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 @@ -851,7 +851,7 @@ def create_access_key( ), ] = None, ) -> None: - """Create a Scoped Access Key for the current authenticated user.""" + """Create a Scoped Access Key for the currently authenticated user.""" expires_in_was_set, parsed_expires_in = _parse_access_key_expires_in(expires_in) request = AccessKeyCreateRequest( name=name, @@ -877,7 +877,7 @@ def list_access_keys( typer.Option("--page-size", min=1, max=100, help="Number of keys to retrieve per page."), ] = 100, ) -> None: - """List Scoped Access Keys owned by the current authenticated user.""" + """List Scoped Access Keys owned by the currently authenticated user.""" try: listed = _access_key_issuer(ctx).list(page=page, page_size=page_size) except AccessKeyFeatureDisabledError as exc: @@ -912,7 +912,7 @@ def revoke_access_key( ctx: typer.Context, jti: Annotated[str, typer.Argument(help="Stable ID of the Scoped Access Key to revoke.")], ) -> None: - """Revoke a Scoped Access Key owned by the current authenticated user.""" + """Revoke a Scoped Access Key owned by the currently authenticated user.""" try: result = _access_key_issuer(ctx).revoke(jti) except AccessKeyFeatureDisabledError as exc: diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/core/errors.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/core/errors.py index 1e12bf0da7..285dcdc3a0 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/core/errors.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/core/errors.py @@ -13,11 +13,9 @@ import httpx import typer -if typing.TYPE_CHECKING: - from nemo_platform import APIError - REMOTE_ERROR_EXIT_CODE = 3 + class MissingRequiredFieldsError(Exception): """Raised when required fields are missing from CLI input.""" @@ -177,30 +175,7 @@ def handle_exception(error: Exception, ctx: click.Context | None = None) -> None PermissionDeniedError, RateLimitError, ) - from nemo_platform_plugin.client.errors import ( - AuthenticationError as PluginAuthenticationError, - ) - from nemo_platform_plugin.client.errors import ( - BadRequestError as PluginBadRequestError, - ) - from nemo_platform_plugin.client.errors import ( - ConflictError as PluginConflictError, - ) - from nemo_platform_plugin.client.errors import ( - InternalServerError as PluginInternalServerError, - ) - from nemo_platform_plugin.client.errors import ( - NemoHTTPError, - ) - from nemo_platform_plugin.client.errors import ( - NotFoundError as PluginNotFoundError, - ) - from nemo_platform_plugin.client.errors import ( - PermissionDeniedError as PluginPermissionDeniedError, - ) - from nemo_platform_plugin.client.errors import ( - RateLimitError as PluginRateLimitError, - ) + from nemo_platform_plugin.client import errors as plugin_errors prog = "nemo" @@ -265,40 +240,45 @@ def handle_exception(error: Exception, ctx: click.Context | None = None) -> None if isinstance(error, typer.Exit): # Re-raise typer.Exit with its original exit code (don't treat Exit(0) as error) raise error - if isinstance(error, (AuthenticationError, PluginAuthenticationError)): + if isinstance(error, (AuthenticationError, plugin_errors.AuthenticationError)): console.print(f"[bold red]Authentication error:[/] ({error.status_code}) {_format_api_error(error)}") console.print( "[yellow]Hint:[/] Run [cyan]'nemo auth login'[/] or set the token manually " "with [cyan]nemo config set --access-token [/], or use [cyan]NMP_ACCESS_TOKEN[/]." ) raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) - elif isinstance(error, (PermissionDeniedError, PluginPermissionDeniedError)): + elif isinstance(error, (PermissionDeniedError, plugin_errors.PermissionDeniedError)): console.print(f"[bold red]Permission denied:[/] ({error.status_code}) {_format_api_error(error)}") console.print( "[yellow]Hint:[/] Your current credentials do not have access to perform this operation. " "Contact your administrator to request access." ) raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) - elif isinstance(error, (NotFoundError, PluginNotFoundError)): + elif isinstance(error, (NotFoundError, plugin_errors.NotFoundError)): console.print(f"[bold red]Not found:[/] ({error.status_code}) {_format_api_error(error)}") _print_api_request_context(console, error) console.print(f"[yellow]Hint:[/] {_format_not_found_hint(ctx, prog)}") raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) - elif isinstance(error, (BadRequestError, PluginBadRequestError)): + elif isinstance(error, (BadRequestError, plugin_errors.BadRequestError)): console.print(f"[bold red]Bad request:[/] ({error.status_code}) {_format_api_error(error)}") console.print("[yellow]Hint:[/] Check your input values. Run with [cyan]--help[/] to see required options.") raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) - elif isinstance(error, (ConflictError, PluginConflictError)): + elif isinstance(error, (ConflictError, plugin_errors.ConflictError)): console.print(f"[bold red]Conflict:[/] ({error.status_code}) {_format_api_error(error)}") console.print( - "[yellow]Hint:[/] A resource with this name already exists. Try a different name or delete the existing one." + "[yellow]Hint:[/] This can mean a resource with that name already exists (try a different name or delete the existing one), " + "or a concurrent update conflicted (retry the operation)." ) raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) - elif isinstance(error, (RateLimitError, PluginRateLimitError)): + elif isinstance(error, plugin_errors.UnprocessableEntityError): + console.print(f"[bold red]Invalid input:[/] ({error.status_code}) {_format_api_error(error)}") + console.print("[yellow]Hint:[/] Check your input values. Run with [cyan]--help[/] to see required options.") + raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) + elif isinstance(error, (RateLimitError, plugin_errors.RateLimitError)): console.print(f"[bold red]Rate limit exceeded:[/] ({error.status_code}) {_format_api_error(error)}") console.print("[yellow]Hint:[/] Too many requests. Wait a moment and try again.") raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) - elif isinstance(error, (InternalServerError, PluginInternalServerError)): + elif isinstance(error, (InternalServerError, plugin_errors.InternalServerError)): formatted = _format_api_error(error) console.print(f"[bold red]Server error:[/] ({error.status_code}) {formatted}") list_cmd = _build_list_cmd(ctx, prog) if ("404" in formatted or "not found" in formatted.lower()) else None @@ -307,25 +287,26 @@ def handle_exception(error: Exception, ctx: click.Context | None = None) -> None else: console.print("[yellow]Hint:[/] This is a server-side issue. Try again later or contact support.") raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) - elif isinstance(error, APITimeoutError): + elif isinstance(error, APITimeoutError) or ( + isinstance(error, plugin_errors.NemoTransportError) and isinstance(error.error, httpx.TimeoutException) + ): console.print(f"[bold red]Timeout error:[/] {_format_api_error(error)}") _print_api_request_context(console, error) console.print("[yellow]Hint:[/] The request timed out. The server may be busy - try again later.") raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) - elif isinstance(error, APIConnectionError): + elif isinstance(error, (APIConnectionError, plugin_errors.NemoTransportError)): console.print(f"[bold red]Connection error:[/] {_format_api_error(error)}") _print_api_request_context(console, error) console.print( "[yellow]Hint:[/] Check your network connection and verify that [cyan]base-url[/] you configured is correct." ) raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) - elif isinstance(error, APITimeoutError): - console.print(f"[bold red]Timeout error:[/] {_format_api_error(error)}") + elif isinstance(error, (APIStatusError, plugin_errors.NemoHTTPError)): + console.print(f"[bold red]API error:[/] ({error.status_code}) {_format_api_error(error)}") _print_api_request_context(console, error) - console.print("[yellow]Hint:[/] The request timed out. The server may be busy - try again later.") raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) - elif isinstance(error, (APIStatusError, NemoHTTPError)): - console.print(f"[bold red]API error:[/] ({error.status_code}) {_format_api_error(error)}") + elif isinstance(error, plugin_errors.NemoResponseValidationError): + console.print(f"[bold red]API response error:[/] {_format_api_error(error)}") _print_api_request_context(console, error) raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) elif isinstance(error, APIError): 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 f977274a6c..5a8404b3e7 100644 --- a/packages/nemo_platform_ext/tests/cli/commands/test_auth.py +++ b/packages/nemo_platform_ext/tests/cli/commands/test_auth.py @@ -14,7 +14,6 @@ import yaml from nemo_platform_ext.auth.helpers import decode_jwt_claims, generate_unsigned_jwt from nemo_platform_ext.cli.app import app -from nemo_platform_plugin.auth.access_keys.issuer import AccessKeyFeatureDisabledError from nemo_platform_plugin.auth.access_keys.types import ( AccessKeyCreateRequest, AccessKeyCreateResponse, @@ -466,10 +465,18 @@ def test_auth_access_keys_create_rejects_invalid_expiration(monkeypatch: pytest. def test_auth_access_keys_create_reports_disabled_feature(monkeypatch: pytest.MonkeyPatch): + from nemo_platform_plugin.client.errors import NemoHTTPError + fake_platform_client = MagicMock() fake_access_keys_client = MagicMock() - fake_access_keys_client.create_access_key.side_effect = AccessKeyFeatureDisabledError( - "Scoped Access Keys are not enabled" + # Simulate the real 404 response the server sends when the feature is disabled, + # to exercise the NemoHTTPError → AccessKeyFeatureDisabledError translation path. + fake_access_keys_client.create_access_key.side_effect = NemoHTTPError( + httpx.Response( + 404, + json={"detail": "Scoped Access Keys are not enabled", "code": "access_keys_disabled"}, + request=httpx.Request("POST", "https://platform.example.com/apis/auth/v2/access-keys"), + ) ) monkeypatch.setattr("nemo_platform_ext.cli.core.context.CLIContext.get_client", lambda self: fake_platform_client) monkeypatch.setattr( @@ -618,7 +625,7 @@ def test_auth_access_keys_revoke_reports_missing_key(monkeypatch: pytest.MonkeyP result = runner.invoke(app, ["auth", "access-keys", "revoke", "ak_unknown"]) - assert_exit_code(result, 1) + assert_exit_code(result, 3) assert "Not found: (404) Scoped Access Key ak_unknown was not found" in result.output fake_access_keys_client.revoke_access_key.assert_called_once_with(jti="ak_unknown") diff --git a/packages/nemo_platform_ext/tests/cli/core/test_errors.py b/packages/nemo_platform_ext/tests/cli/core/test_errors.py index 2d450dfff4..29fab81738 100644 --- a/packages/nemo_platform_ext/tests/cli/core/test_errors.py +++ b/packages/nemo_platform_ext/tests/cli/core/test_errors.py @@ -28,6 +28,7 @@ _format_api_error, handle_exception, ) +from nemo_platform_plugin.client import errors as plugin_errors from typer.testing import CliRunner DOCUMENTED_REMOTE_ERROR_EXIT_CODE = 3 @@ -111,6 +112,62 @@ def test_handle_api_connection_error(capsys): assert "base-url" in captured.err +def test_handle_plugin_transport_error_as_connection_error(capsys): + request = httpx.Request("GET", "http://test/apis/auth/v2/access-keys") + error = plugin_errors.NemoTransportError(httpx.ConnectError("Could not connect", request=request)) + + with pytest.raises(typer.Exit) as exc_info: + handle_exception(error) + + assert exc_info.value.exit_code == DOCUMENTED_REMOTE_ERROR_EXIT_CODE + captured = capsys.readouterr() + assert "Connection error:" in captured.err + assert "Request: GET http://test/apis/auth/v2/access-keys" in captured.err + assert "base-url" in captured.err + + +def test_handle_plugin_timeout_as_timeout_error(capsys): + request = httpx.Request("GET", "http://test/apis/auth/v2/access-keys") + error = plugin_errors.NemoTransportError(httpx.ReadTimeout("timed out", request=request)) + + with pytest.raises(typer.Exit) as exc_info: + handle_exception(error) + + assert exc_info.value.exit_code == DOCUMENTED_REMOTE_ERROR_EXIT_CODE + captured = capsys.readouterr() + assert "Timeout error:" in captured.err + assert "Request: GET http://test/apis/auth/v2/access-keys" in captured.err + assert "request timed out" in captured.err + + +def test_handle_plugin_response_validation_error(capsys): + request = httpx.Request("GET", "http://test/apis/auth/v2/access-keys") + response = httpx.Response(200, request=request, json={"unexpected": True}) + error = plugin_errors.NemoResponseValidationError(response, ValueError("invalid response")) + + with pytest.raises(typer.Exit) as exc_info: + handle_exception(error) + + assert exc_info.value.exit_code == DOCUMENTED_REMOTE_ERROR_EXIT_CODE + captured = capsys.readouterr() + assert "API response error:" in captured.err + assert "Request: GET http://test/apis/auth/v2/access-keys" in captured.err + + +def test_handle_plugin_unprocessable_entity_error(capsys): + request = httpx.Request("POST", "http://test/apis/auth/v2/access-keys") + response = httpx.Response(422, request=request, json={"detail": "Invalid key description"}) + error = plugin_errors.UnprocessableEntityError(response) + + with pytest.raises(typer.Exit) as exc_info: + handle_exception(error) + + assert exc_info.value.exit_code == DOCUMENTED_REMOTE_ERROR_EXIT_CODE + captured = capsys.readouterr() + assert "Invalid input: (422) Invalid key description" in captured.err + assert "Check your input values" in captured.err + + def test_handle_api_timeout_error(capsys): request = Mock() error = APITimeoutError(request=request) 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 0c91676b6e..36c61a4850 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 @@ -65,5 +65,8 @@ def revoke(self, jti: str) -> AccessKeyRevokeResponse: def _raise_domain_error_from_http(exc: NemoHTTPError) -> None: if exc.status_code == 501: raise AccessKeyOperationNotImplementedError(exc.detail) from exc - if exc.status_code == 404 and "not enabled" in exc.detail and "Scoped Access Keys" in exc.detail: - raise AccessKeyFeatureDisabledError(exc.detail) from exc + if exc.status_code == 404: + disabled_code = isinstance(exc.body, dict) and exc.body.get("code") == "access_keys_disabled" + legacy_disabled_detail = exc.detail == "Scoped Access Keys are not enabled" + if disabled_code or legacy_disabled_detail: + raise AccessKeyFeatureDisabledError(exc.detail) from exc 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 fcaffb15fd..785e640b27 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 @@ -93,7 +93,7 @@ class AccessKeyRevokeResponse(BaseModel): """Response returned after a Scoped Access Key revoke request.""" jti: str = Field(description="Stable JWT ID for this Scoped Access Key.") - revoked: bool = Field(description="True when this request changed the key from active to revoked.") + revoked: bool = Field(description="True when this request newly recorded the key's revocation.") class AccessKeyNotImplementedErrorResponse(BaseModel): 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 b48f9c43d8..c36040ce3e 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 @@ -90,10 +90,18 @@ def test_access_key_issuer_client_translates_http_501_to_domain_error() -> None: issuer.list() -def test_access_key_issuer_client_translates_disabled_feature_to_domain_error() -> None: +@pytest.mark.parametrize( + "body", + [ + {"detail": "Scoped Access Keys are not enabled", "code": "access_keys_disabled"}, + {"detail": "Scoped Access Keys are not enabled"}, + ], + ids=["structured-code", "legacy-detail"], +) +def test_access_key_issuer_client_translates_disabled_feature_to_domain_error(body: dict[str, str]) -> None: response = httpx.Response( 404, - json={"detail": "Scoped Access Keys are not enabled"}, + json=body, request=httpx.Request("POST", "https://cluster.example.com/apis/auth/v2/access-keys"), ) client = _AccessKeysClientStub() @@ -103,3 +111,21 @@ def test_access_key_issuer_client_translates_disabled_feature_to_domain_error() with pytest.raises(AccessKeyFeatureDisabledError, match="not enabled"): issuer.create(AccessKeyCreateRequest()) + + +def test_access_key_issuer_client_propagates_not_found_as_http_error() -> None: + """Plain 404 (key not found) propagates as NemoHTTPError so @handle_errors renders it correctly.""" + response = httpx.Response( + 404, + json={"detail": "Scoped Access Key ak_" + "0" * 32 + " was not found"}, + request=httpx.Request("DELETE", "https://cluster.example.com/apis/auth/v2/access-keys/ak_" + "0" * 32), + ) + client = _AccessKeysClientStub() + client.revoke_access_key.side_effect = NemoHTTPError(response) + + issuer = AccessKeyIssuerClient(client.as_client()) + + with pytest.raises(NemoHTTPError) as exc_info: + issuer.revoke("ak_" + "0" * 32) + assert exc_info.value.status_code == 404 + assert "was not found" in exc_info.value.detail 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 c88a511265..9bfbfb1bce 100644 --- a/packages/nmp_common/src/nmp/common/auth/access_keys.py +++ b/packages/nmp_common/src/nmp/common/auth/access_keys.py @@ -3,6 +3,7 @@ from __future__ import annotations +import logging import time import uuid from dataclasses import dataclass @@ -26,7 +27,7 @@ from nmp.common.config import AuthConfig, get_platform_config from .jwks import DEFAULT_JWKS_CACHE_LIFESPAN, AsyncJWKSClient, signing_jwk_from_jwks -from .jwt import TokenClaims +from .jwt import TokenClaims, groups_from_claim, scopes_from_claim from .models import Principal from .signing_keys import RSASigningKey, RSASigningKeyCache @@ -35,6 +36,8 @@ ACCESS_KEY_METADATA_VERSION = 2 LEGACY_ACCESS_KEY_METADATA_VERSION = 1 +logger = logging.getLogger(__name__) + def platform_token_issuer(config: AuthConfig) -> str: if config.token_signing.issuer: @@ -59,6 +62,10 @@ def access_key_jwks_uri(config: AuthConfig) -> str: _EXPIRES_IN_SECONDS_FIELD = "expires_in_seconds" +class AccessKeyValidationError(ValueError): + """Raised when a Scoped Access Key request conflicts with platform policy.""" + + @dataclass(frozen=True) class _AccessKeyTokenPayload: claims: dict[str, Any] @@ -82,14 +89,6 @@ def _groups_claim_for_gateway_header(groups: list[str]) -> str | None: return groups_claim or None -def _groups_from_claim(groups_claim: Any) -> list[str]: - if isinstance(groups_claim, str): - return [group.strip() for group in groups_claim.split(",") if group.strip()] - if isinstance(groups_claim, list): - return [group for group in groups_claim if isinstance(group, str)] - return [] - - def is_access_key_token_candidate(token: str) -> bool: """Return True when an untrusted token claims to be a Scoped Access Key.""" try: @@ -163,17 +162,17 @@ def _resolve_expires_in_seconds(config: AuthConfig, request: AccessKeyCreateRequ if expires_in_seconds is None: if max_expires_in_seconds is not None: if expires_in_seconds_was_set: - raise RuntimeError( + raise AccessKeyValidationError( "expires_in_seconds=null requires auth.access_keys.max_expires_in_seconds to be disabled" ) - raise RuntimeError( + raise AccessKeyValidationError( "expires_in_seconds is required when auth.access_keys.default_expires_in_seconds is null " "and auth.access_keys.max_expires_in_seconds is finite" ) return None if max_expires_in_seconds is not None and expires_in_seconds > max_expires_in_seconds: - raise RuntimeError( + raise AccessKeyValidationError( "expires_in_seconds must be less than or equal to " f"auth.access_keys.max_expires_in_seconds ({max_expires_in_seconds})" ) @@ -222,8 +221,7 @@ async def create_async(self, request: AccessKeyCreateRequest) -> AccessKeyCreate now=self._now(), ) - def list(self, *, page: int = 1, page_size: int = 100) -> AccessKeyListResponse: - _ = page, page_size + def list(self, *, page: int = 1, page_size: int = 100) -> AccessKeyListResponse: # noqa: ARG002 self._ensure_enabled() raise AccessKeyOperationNotImplementedError("Scoped Access Key listing is not implemented.") @@ -285,7 +283,7 @@ def _build_access_key_token_payload( if not config.access_keys.enabled: raise AccessKeyFeatureDisabledError("Scoped Access Keys are not enabled") if principal.id.startswith("service:"): - raise RuntimeError("Scoped Access Keys cannot be created for service principals") + raise AccessKeyValidationError("Scoped Access Keys cannot be created for service principals") issued_at = now jti = f"ak_{uuid.uuid4().hex}" @@ -367,7 +365,16 @@ async def validate_access_key_token( return None try: - unverified = jwt.decode(token, options={"verify_signature": False}) + unverified = jwt.decode( + token, + options={ + "verify_signature": False, + "verify_exp": False, + "verify_iat": False, + "verify_nbf": False, + "verify_aud": False, + }, + ) if unverified.get("nmp_token_type") != ACCESS_KEY_TOKEN_TYPE: return None @@ -392,17 +399,17 @@ async def validate_access_key_token( if not isinstance(subject, str) or not subject or subject.startswith("service:"): return None - groups = _groups_from_claim(claims.get("groups", [])) + groups = groups_from_claim(claims.get("groups", [])) scope_claim = claims.get("scope") or claims.get("scp") - scopes = scope_claim.split() if isinstance(scope_claim, str) else [] return TokenClaims( subject=subject, email=claims.get("email") if isinstance(claims.get("email"), str) else None, - groups=[group for group in groups if isinstance(group, str)], - scopes=scopes, + groups=groups, + scopes=scopes_from_claim(scope_claim), raw_claims=claims, ) except httpx.HTTPError: raise - except Exception: + except Exception as exc: + logger.warning("Access key token validation failed: %s", exc, exc_info=True) return None diff --git a/packages/nmp_common/src/nmp/common/auth/jwt.py b/packages/nmp_common/src/nmp/common/auth/jwt.py index 4f13b95cd7..8d8d49b2d5 100644 --- a/packages/nmp_common/src/nmp/common/auth/jwt.py +++ b/packages/nmp_common/src/nmp/common/auth/jwt.py @@ -38,6 +38,24 @@ class TokenClaims: raw_claims: dict +def groups_from_claim(value: object) -> list[str]: + """Parse a groups JWT claim that may be a comma-separated string or a list.""" + if isinstance(value, str): + return [g.strip() for g in value.split(",") if g.strip()] + if isinstance(value, list): + return [item.strip() for item in value if isinstance(item, str) and item.strip()] + return [] + + +def scopes_from_claim(value: object) -> list[str]: + """Parse a scopes JWT claim that may be whitespace-delimited or a list.""" + if isinstance(value, str): + return value.split() + if isinstance(value, list): + return [item.strip() for item in value if isinstance(item, str) and item.strip()] + return [] + + class UnsignedJWTRejectedError(Exception): """Raised when an unsigned JWT is rejected by configuration.""" @@ -101,28 +119,14 @@ def _extract_token_claims(self, claims: dict) -> Optional[TokenClaims]: groups: list[str] = [] for claim_name in [self.config.oidc.groups_claim, "cognito:groups"]: if claim_name in claims: - groups_value = claims[claim_name] - if isinstance(groups_value, str): - groups = [g.strip() for g in groups_value.split(",")] - elif isinstance(groups_value, list): - groups = groups_value + groups = groups_from_claim(claims[claim_name]) break - scopes: list[str] = [] scope_value = claims.get("scope") or claims.get("scp") - if scope_value: - if isinstance(scope_value, str): - raw_scopes = scope_value.split() - elif isinstance(scope_value, list): - raw_scopes = scope_value - else: - raw_scopes = [] - - prefix = self.config.oidc.scope_prefix - if prefix: - scopes = [s[len(prefix) :] if s.startswith(prefix) else s for s in raw_scopes] - else: - scopes = raw_scopes + scopes = scopes_from_claim(scope_value) + prefix = self.config.oidc.scope_prefix + if prefix: + scopes = [scope.removeprefix(prefix) for scope in scopes] return TokenClaims( subject=subject, diff --git a/packages/nmp_common/src/nmp/common/auth/middleware.py b/packages/nmp_common/src/nmp/common/auth/middleware.py index 02366acc36..9fe5990c97 100644 --- a/packages/nmp_common/src/nmp/common/auth/middleware.py +++ b/packages/nmp_common/src/nmp/common/auth/middleware.py @@ -13,7 +13,7 @@ from fastapi import Request, Response from nmp.common.config import AuthConfig, get_auth_config, get_platform_config from nmp.common.observability.context import get_app_ctx -from nmp.common.platform_endpoint import parse_platform_endpoint +from nmp.common.platform_endpoint import PlatformEndpoint, parse_platform_endpoint, resolve_service_endpoint from starlette.middleware.base import BaseHTTPMiddleware from starlette.responses import JSONResponse from starlette.types import ASGIApp @@ -153,11 +153,14 @@ def __init__( "auth.allow_unsigned_jwt is enabled. Unsigned JWTs (`alg=none`) are accepted; use only for local/testing." ) + @cached_property + def _access_key_lifecycle_endpoint(self) -> PlatformEndpoint: + """Resolve the auth-service lifecycle endpoint through service discovery.""" + return resolve_service_endpoint("auth", get_platform_config()) + @cached_property def _access_key_lifecycle_url(self) -> str: - """Resolve the auth-service access-key lifecycle callout URL from current platform config.""" - platform_endpoint = parse_platform_endpoint(str(get_platform_config().base_url)) - return f"{platform_endpoint.connect_base_url}/apis/auth/authenticate" + return f"{self._access_key_lifecycle_endpoint.connect_base_url}/apis/auth/authenticate" @staticmethod def _principal_from_headers(headers_dict: dict) -> tuple[Principal, None] | tuple[None, JSONResponse]: @@ -200,10 +203,12 @@ def _get_client(self, request: Request) -> httpx.AsyncClient: return self._client def _get_access_key_lifecycle_client(self) -> httpx.AsyncClient: - """Get a client bound to the platform endpoint transport.""" + """Get a client bound to the discovered auth-service transport. + + Reuses policy_decision_point_request_timeout_seconds as the callout timeout. + """ if self._access_key_lifecycle_client is None: - endpoint = parse_platform_endpoint(str(get_platform_config().base_url)) - self._access_key_lifecycle_client = endpoint.async_http_client( + self._access_key_lifecycle_client = self._access_key_lifecycle_endpoint.async_http_client( timeout=self.config.policy_decision_point_request_timeout_seconds ) return self._access_key_lifecycle_client @@ -494,7 +499,7 @@ async def _handle_bearer_token_request(self, request: Request, call_next: Callab from .access_keys import is_access_key_token_candidate if is_access_key_token_candidate(token): - resolved_or_error = await self._authenticate_access_key_lifecycle(request, token) + resolved_or_error = await self._authenticate_access_key_lifecycle(token) if isinstance(resolved_or_error, Response): return resolved_or_error return await self._handle_resolved_bearer_token(request, call_next, resolved_or_error) @@ -518,6 +523,12 @@ async def _handle_bearer_token_request(self, request: Request, call_next: Callab ) if resolved is None: + if jwt_validator is None: + logger.warning( + "Bearer token rejected: access keys enabled but token was not a valid Scoped Access Key " + "(service: %s)", + self.service_name or "unknown", + ) return JSONResponse( status_code=401, content={"detail": "Invalid or expired token"}, @@ -563,7 +574,7 @@ def _record_access_key_lifecycle_failure(self) -> int | None: return self._access_key_lifecycle_retry_after() return None - async def _call_access_key_lifecycle(self, request: Request, token: str) -> httpx.Response | JSONResponse: + async def _call_access_key_lifecycle(self, token: str) -> httpx.Response | JSONResponse: """Call the auth-service access-key validator and map transport failures. The callout uses the platform endpoint transport and the configured PDP @@ -602,10 +613,8 @@ async def _call_access_key_lifecycle(self, request: Request, token: str) -> http ) if response.status_code == 200: - self._record_access_key_lifecycle_success() return response if response.status_code == 401: - self._record_access_key_lifecycle_success() return response logger.error( @@ -633,9 +642,12 @@ def _resolved_access_key_from_lifecycle_response(self, response: httpx.Response) groups = body.get("groups") scopes = body.get("scopes") jti = body.get("jti") - raw_claims: dict[str, object] = {"nmp_token_type": "access_key"} - if isinstance(jti, str) and jti: - raw_claims["jti"] = jti + if not isinstance(jti, str) or not jti: + return None + raw_claims: dict[str, object] = { + "nmp_token_type": "access_key", + "jti": jti, + } return ResolvedBearerToken( claims=TokenClaims( subject=principal, @@ -649,19 +661,24 @@ def _resolved_access_key_from_lifecycle_response(self, response: httpx.Response) async def _authenticate_access_key_lifecycle( self, - request: Request, token: str, ) -> ResolvedBearerToken | Response: - response_or_error = await self._call_access_key_lifecycle(request, token) + response_or_error = await self._call_access_key_lifecycle(token) if isinstance(response_or_error, Response): return response_or_error if response_or_error.status_code == 401: + self._record_access_key_lifecycle_success() return JSONResponse(status_code=401, content={"detail": "Invalid or expired token"}) resolved = self._resolved_access_key_from_lifecycle_response(response_or_error) if resolved is None: logger.error("Access-key lifecycle validator returned an invalid success response") - return self._access_key_lifecycle_error_response(503, "Access-key lifecycle validation unavailable") + return self._access_key_lifecycle_error_response( + 503, + "Access-key lifecycle validation unavailable", + retry_after=self._record_access_key_lifecycle_failure(), + ) + self._record_access_key_lifecycle_success() return resolved async def _handle_resolved_bearer_token( diff --git a/packages/nmp_common/tests/auth/test_access_keys.py b/packages/nmp_common/tests/auth/test_access_keys.py index b39835420b..4c09489e0a 100644 --- a/packages/nmp_common/tests/auth/test_access_keys.py +++ b/packages/nmp_common/tests/auth/test_access_keys.py @@ -18,6 +18,7 @@ from nmp.common.auth.access_keys import ( ACCESS_KEY_TOKEN_TYPE, AccessKeyIssuerService, + AccessKeyValidationError, access_key_jwks_uri, clear_access_key_signing_key_cache, public_jwk_from_private_key_pem, @@ -354,7 +355,7 @@ def test_access_key_issuer_service_rejects_expiration_above_configured_max(tmp_p now=lambda: 1785280000, ) - with pytest.raises(RuntimeError, match="max_expires_in_seconds"): + with pytest.raises(AccessKeyValidationError, match="max_expires_in_seconds"): issuer.create(AccessKeyCreateRequest(name="too-long", expires_in_seconds=61)) @@ -382,7 +383,7 @@ def test_access_key_issuer_service_rejects_explicit_null_expiration_when_max_con now=lambda: 1785280000, ) - with pytest.raises(RuntimeError, match="expires_in_seconds=null requires"): + with pytest.raises(AccessKeyValidationError, match="expires_in_seconds=null requires"): issuer.create(AccessKeyCreateRequest(name="unlimited", expires_in_seconds=None)) @@ -438,7 +439,7 @@ def test_access_key_issuer_service_requires_expiration_when_default_disabled_and now=lambda: 1785280000, ) - with pytest.raises(RuntimeError, match="expires_in_seconds is required"): + with pytest.raises(AccessKeyValidationError, match="expires_in_seconds is required"): issuer.create(AccessKeyCreateRequest(name="must-set-expiry")) created = issuer.create(AccessKeyCreateRequest(name="finite-expiry", expires_in_seconds=60)) @@ -612,7 +613,7 @@ async def test_validate_access_key_token_rejects_service_principal_subject(tmp_p config = _access_key_config(tmp_path) issuer = AccessKeyIssuerService(config=config, principal=Principal(id="service:jobs"), now=lambda: 1785280000) - with pytest.raises(RuntimeError, match="service principals"): + with pytest.raises(AccessKeyValidationError, match="service principals"): await issuer.create_async(AccessKeyCreateRequest(name="bad-service-key", expires_in_seconds=600)) @@ -628,3 +629,41 @@ async def test_validate_access_key_token_rejects_expired_key(tmp_path): assert created.expires_at == datetime.fromtimestamp(1785280060, tz=UTC) assert await validate_access_key_token(config, created.token, jwks_override=jwks, now=1785280061) is None + + +@pytest.mark.asyncio +async def test_validate_access_key_token_returns_none_when_feature_disabled() -> None: + disabled_config = AuthConfig(access_keys=AccessKeyConfig(enabled=False)) + + result = await validate_access_key_token(disabled_config, "not-a-token") + + assert result is None + + +@pytest.mark.asyncio +async def test_validate_access_key_token_parses_list_scp_claim(tmp_path) -> None: + config = _access_key_config(tmp_path, max_expires_in_seconds=None) + now = 1_785_280_000 + signing_key = await access_keys_mod._access_key_signing_key_async(config) + jwks = {"keys": [await public_jwk_from_private_key_pem_async(config)]} + token = jwt.encode( + { + "iss": access_keys_mod.access_key_issuer(config), + "aud": config.access_keys.audience, + "sub": "alice@example.com", + "iat": now, + "nbf": now, + "jti": "ak_" + "a" * 32, + "nmp_token_type": ACCESS_KEY_TOKEN_TYPE, + "nmp_access_key": {"version": 2}, + "scp": ["read", "write"], + }, + signing_key.private_key, + algorithm="RS256", + headers={"kid": config.token_signing.key_id}, + ) + + claims = await validate_access_key_token(config, token, jwks_override=jwks) + + assert claims is not None + assert claims.scopes == ["read", "write"] diff --git a/packages/nmp_common/tests/auth/test_jwt.py b/packages/nmp_common/tests/auth/test_jwt.py index e31f91f014..8b5b231381 100644 --- a/packages/nmp_common/tests/auth/test_jwt.py +++ b/packages/nmp_common/tests/auth/test_jwt.py @@ -12,7 +12,13 @@ from cryptography.hazmat.primitives.asymmetric import rsa from jwt.algorithms import RSAAlgorithm from nmp.common import http_clients -from nmp.common.auth.jwt import JWTValidator, TokenClaims, UnsignedJWTRejectedError +from nmp.common.auth.jwt import ( + JWTValidator, + TokenClaims, + UnsignedJWTRejectedError, + groups_from_claim, + scopes_from_claim, +) from nmp.common.config import AuthConfig from nmp.common.config.base import OIDCConfig @@ -80,6 +86,30 @@ def test_token_claims_with_none_email(self): assert claims.email is None +@pytest.mark.parametrize( + ("value", "expected"), + [ + (" admins, developers ,, ", ["admins", "developers"]), + ([" admins ", "", 42, "developers"], ["admins", "developers"]), + (None, []), + ], +) +def test_groups_from_claim_normalizes_supported_claim_shapes(value: object, expected: list[str]) -> None: + assert groups_from_claim(value) == expected + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("read write", ["read", "write"]), + ([" read ", "", 42, "write"], ["read", "write"]), + (None, []), + ], +) +def test_scopes_from_claim_normalizes_supported_claim_shapes(value: object, expected: list[str]) -> None: + assert scopes_from_claim(value) == expected + + class TestOIDCConfigClaimDefaults: """Tests for issuer-based claim defaults.""" diff --git a/packages/nmp_common/tests/auth/test_middleware.py b/packages/nmp_common/tests/auth/test_middleware.py index cefd846270..aa0ad29cc8 100644 --- a/packages/nmp_common/tests/auth/test_middleware.py +++ b/packages/nmp_common/tests/auth/test_middleware.py @@ -5,7 +5,6 @@ import time from contextlib import asynccontextmanager -from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import httpx @@ -25,7 +24,7 @@ ) from nmp.common.auth.models import Principal from nmp.common.auth.token_resolver import ResolvedBearerToken -from nmp.common.config import AuthConfig, Configuration +from nmp.common.config import AuthConfig, Configuration, PlatformConfig from nmp.common.config.base import OIDCConfig from starlette.responses import Response @@ -77,7 +76,9 @@ def auth_config_oidc_disabled(): @pytest.fixture -def access_key_lifecycle_middleware(auth_config_oidc_disabled): +def access_key_lifecycle_middleware(auth_config_oidc_disabled, monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("NMP_AUTH_URL", raising=False) + @asynccontextmanager async def make(handler, base_url: str = "http://platform.example.com"): config = auth_config_oidc_disabled.model_copy( @@ -93,7 +94,7 @@ async def make(handler, base_url: str = "http://platform.example.com"): ) with patch( "nmp.common.auth.middleware.get_platform_config", - return_value=SimpleNamespace(base_url=base_url), + return_value=PlatformConfig(base_url=base_url, services=""), ): yield middleware @@ -560,9 +561,15 @@ def test_scoped_access_key_middleware_mapping_is_skipped_when_access_keys_are_di assert response.json()["detail"] == "Bearer token authentication not configured" mock_validate.assert_not_called() - def test_access_key_lifecycle_url_reads_platform_config_lazily_once(self, auth_config_oidc_disabled): + def test_access_key_lifecycle_url_reads_platform_config_lazily_once( + self, + auth_config_oidc_disabled, + monkeypatch: pytest.MonkeyPatch, + ): + monkeypatch.delenv("NMP_AUTH_URL", raising=False) Configuration.set_override(auth_config_oidc_disabled) app = FastAPI() + platform_config = PlatformConfig(base_url="http://platform-one:8080", services="") with patch( "nmp.common.auth.middleware.get_platform_config", @@ -572,15 +579,30 @@ def test_access_key_lifecycle_url_reads_platform_config_lazily_once(self, auth_c with patch( "nmp.common.auth.middleware.get_platform_config", - side_effect=[ - SimpleNamespace(base_url="http://platform-one:8080"), - ], + side_effect=[platform_config], ) as get_platform: assert middleware._access_key_lifecycle_url == "http://platform-one:8080/apis/auth/authenticate" assert middleware._access_key_lifecycle_url == "http://platform-one:8080/apis/auth/authenticate" assert get_platform.call_count == 1 + def test_access_key_lifecycle_url_uses_auth_service_discovery( + self, + auth_config_oidc_disabled, + monkeypatch: pytest.MonkeyPatch, + ): + monkeypatch.delenv("NMP_AUTH_URL", raising=False) + Configuration.set_override(auth_config_oidc_disabled) + platform_config = PlatformConfig( + base_url="http://platform.example.com", + service_discovery={"auth": "http://auth.internal:8080"}, + services="", + ) + middleware = AuthorizationMiddleware(FastAPI(), service_name="test-service") + + with patch("nmp.common.auth.middleware.get_platform_config", return_value=platform_config): + assert middleware._access_key_lifecycle_url == "http://auth.internal:8080/apis/auth/authenticate" + @pytest.mark.asyncio async def test_access_key_lifecycle_callout_allows_active_token(self, access_key_lifecycle_middleware): requests: list[httpx.Request] = [] @@ -600,7 +622,7 @@ def handler(request: httpx.Request) -> httpx.Response: ) async with access_key_lifecycle_middleware(handler) as middleware: - response = await middleware._authenticate_access_key_lifecycle(MagicMock(), "scoped-access-key") + response = await middleware._authenticate_access_key_lifecycle("scoped-access-key") assert isinstance(response, ResolvedBearerToken) assert response.claims.subject == "alice@example.com" @@ -641,9 +663,9 @@ def authenticate_access_key(request: httpx.Request) -> httpx.Response: ) with patch( "nmp.common.auth.middleware.get_platform_config", - return_value=SimpleNamespace(base_url="unix:///tmp/nemo-platform.sock"), + return_value=PlatformConfig(base_url="unix:///tmp/nemo-platform.sock", services=""), ): - response = await middleware._authenticate_access_key_lifecycle(MagicMock(), "scoped-access-key") + response = await middleware._authenticate_access_key_lifecycle("scoped-access-key") assert isinstance(response, ResolvedBearerToken) assert response.claims.subject == "alice@example.com" @@ -667,7 +689,7 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(callout_status) async with access_key_lifecycle_middleware(handler) as middleware: - response = await middleware._authenticate_access_key_lifecycle(MagicMock(), "scoped-access-key") + response = await middleware._authenticate_access_key_lifecycle("scoped-access-key") assert isinstance(response, Response) assert response.status_code == expected_status @@ -679,7 +701,7 @@ def handler(request: httpx.Request) -> httpx.Response: raise httpx.ReadTimeout("timed out", request=request) async with access_key_lifecycle_middleware(handler) as middleware: - response = await middleware._authenticate_access_key_lifecycle(MagicMock(), "scoped-access-key") + response = await middleware._authenticate_access_key_lifecycle("scoped-access-key") assert isinstance(response, Response) assert response.status_code == 504 @@ -700,19 +722,52 @@ def handler(request: httpx.Request) -> httpx.Response: async with access_key_lifecycle_middleware(handler) as middleware: response = None for _ in range(_ACCESS_KEY_LIFECYCLE_CIRCUIT_FAILURE_THRESHOLD): - response = await middleware._authenticate_access_key_lifecycle(MagicMock(), "scoped-access-key") + response = await middleware._authenticate_access_key_lifecycle("scoped-access-key") assert isinstance(response, Response) assert response.status_code == 503 assert "retry-after" in response.headers - circuit_response = await middleware._authenticate_access_key_lifecycle(MagicMock(), "scoped-access-key") + circuit_response = await middleware._authenticate_access_key_lifecycle("scoped-access-key") assert calls == _ACCESS_KEY_LIFECYCLE_CIRCUIT_FAILURE_THRESHOLD assert circuit_response is not None assert circuit_response.status_code == 503 assert "retry-after" in circuit_response.headers + @pytest.mark.asyncio + async def test_access_key_lifecycle_malformed_success_opens_circuit( + self, + access_key_lifecycle_middleware, + ): + calls = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + return httpx.Response( + 200, + json={ + "token_kind": "access_key", + "principal": "alice@example.com", + }, + ) + + async with access_key_lifecycle_middleware(handler) as middleware: + response = None + for _ in range(_ACCESS_KEY_LIFECYCLE_CIRCUIT_FAILURE_THRESHOLD): + response = await middleware._authenticate_access_key_lifecycle("scoped-access-key") + + assert isinstance(response, Response) + assert response.status_code == 503 + assert "retry-after" in response.headers + + circuit_response = await middleware._authenticate_access_key_lifecycle("scoped-access-key") + + assert calls == _ACCESS_KEY_LIFECYCLE_CIRCUIT_FAILURE_THRESHOLD + assert circuit_response.status_code == 503 + assert "retry-after" in circuit_response.headers + def test_bearer_token_request_uses_shared_resolver(self, auth_config_enabled): app = create_test_app(auth_config_enabled) client = TestClient(app, raise_server_exceptions=False) @@ -791,7 +846,7 @@ def handler(request: httpx.Request) -> httpx.Response: with ( patch( "nmp.common.auth.middleware.get_platform_config", - return_value=SimpleNamespace(base_url="http://platform.example.com"), + return_value=PlatformConfig(base_url="http://platform.example.com", services=""), ), patch("nmp.common.auth.middleware.resolve_bearer_token", new=AsyncMock()) as resolver, patch.object(AuthClient, "authorize_request", autospec=True) as mock_authorize, diff --git a/packages/nmp_platform_runner/src/nmp/platform_runner/server.py b/packages/nmp_platform_runner/src/nmp/platform_runner/server.py index d1cfca8eb2..fc5b38e086 100644 --- a/packages/nmp_platform_runner/src/nmp/platform_runner/server.py +++ b/packages/nmp_platform_runner/src/nmp/platform_runner/server.py @@ -233,7 +233,12 @@ async def run_platform_seed_and_update_readiness() -> None: auth_config = get_auth_config() logger.info("Adding AuthorizationMiddleware", extra={"auth_enabled": auth_config.enabled}) - app.add_middleware(AuthorizationMiddleware, service_name="platform", http_client=http_client) + app.add_middleware( + AuthorizationMiddleware, + service_name="platform", + http_client=http_client, + access_key_lifecycle_http_client=http_client, + ) app.add_middleware( CORSMiddleware, allow_origins=["*"], diff --git a/packages/nmp_platform_runner/tests/test_server.py b/packages/nmp_platform_runner/tests/test_server.py index 8903e26342..c9e3f5589e 100644 --- a/packages/nmp_platform_runner/tests/test_server.py +++ b/packages/nmp_platform_runner/tests/test_server.py @@ -221,6 +221,20 @@ def test_create_app_openapi_registers_rebased_query_param_schemas(monkeypatch): clear_query_param_schemas() +def test_create_app_injects_http_client_for_auth_callouts(monkeypatch): + platform_cfg = _patch_platform_app_config(monkeypatch, seed_on_startup=False) + platform_cfg.services = "" + http_client = MagicMock() + + app = server.create_app(services=[PluginService()], http_client=http_client) + + auth_middleware = next( + middleware for middleware in app.user_middleware if middleware.cls is server.AuthorizationMiddleware + ) + assert auth_middleware.kwargs["http_client"] is http_client + assert auth_middleware.kwargs["access_key_lifecycle_http_client"] is http_client + + def test_create_app_mounted_services_drive_sdk_local_routing_without_services_env(monkeypatch): monkeypatch.delenv("NMP_SERVICES", raising=False) monkeypatch.setenv("NMP_BASE_URL", "https://nemo-gateway:8080") diff --git a/sdk/python/nemo-platform/.nmpcontext/openapi.yaml b/sdk/python/nemo-platform/.nmpcontext/openapi.yaml index 7915830ddb..9d469f6da4 100644 --- a/sdk/python/nemo-platform/.nmpcontext/openapi.yaml +++ b/sdk/python/nemo-platform/.nmpcontext/openapi.yaml @@ -167,6 +167,48 @@ paths: schema: $ref: '#/components/schemas/WorkloadTokenExchangeErrorResponse' /apis/auth/v2/access-keys: + post: + tags: + - Scoped Access Keys + summary: Create Access Key + operationId: create_access_key_apis_auth_v2_access_keys_post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyCreateRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyCreateResponse' + '400': + description: Scoped Access Key creation error + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' + '404': + description: Scoped Access Keys are not enabled + 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' get: tags: - Scoped Access Keys @@ -209,42 +251,6 @@ paths: application/json: schema: $ref: '#/components/schemas/AccessKeyNotImplementedErrorResponse' - post: - tags: - - Scoped Access Keys - summary: Create Access Key - operationId: create_access_key_apis_auth_v2_access_keys_post - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/AccessKeyCreateRequest' - required: true - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/AccessKeyCreateResponse' - '400': - description: Scoped Access Key creation error - content: - application/json: - schema: - $ref: '#/components/schemas/AccessKeyErrorResponse' - '404': - description: Scoped Access Keys are not enabled - 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: @@ -261,8 +267,11 @@ paths: - name: jti in: path required: true + description: Stable JWT ID of the Scoped Access Key to revoke. schema: type: string + pattern: ^ak_[0-9a-f]{32}$ + description: Stable JWT ID of the Scoped Access Key to revoke. title: Jti responses: '200': @@ -8043,6 +8052,13 @@ components: detail: type: string title: Detail + code: + title: Code + description: Set to access_keys_disabled when the Scoped Access Key feature + is disabled. + nullable: true + type: string + const: access_keys_disabled type: object required: - detail @@ -8141,7 +8157,7 @@ components: revoked: type: boolean title: Revoked - description: True when this request changed the key from active to revoked. + description: True when this request newly recorded the key's revocation. type: object required: - jti 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 5b15cd2076..326dfa1d2d 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 @@ -851,7 +851,7 @@ def create_access_key( ), ] = None, ) -> None: - """Create a Scoped Access Key for the current authenticated user.""" + """Create a Scoped Access Key for the currently authenticated user.""" expires_in_was_set, parsed_expires_in = _parse_access_key_expires_in(expires_in) request = AccessKeyCreateRequest( name=name, @@ -877,7 +877,7 @@ def list_access_keys( typer.Option("--page-size", min=1, max=100, help="Number of keys to retrieve per page."), ] = 100, ) -> None: - """List Scoped Access Keys owned by the current authenticated user.""" + """List Scoped Access Keys owned by the currently authenticated user.""" try: listed = _access_key_issuer(ctx).list(page=page, page_size=page_size) except AccessKeyFeatureDisabledError as exc: @@ -912,7 +912,7 @@ def revoke_access_key( ctx: typer.Context, jti: Annotated[str, typer.Argument(help="Stable ID of the Scoped Access Key to revoke.")], ) -> None: - """Revoke a Scoped Access Key owned by the current authenticated user.""" + """Revoke a Scoped Access Key owned by the currently authenticated user.""" try: result = _access_key_issuer(ctx).revoke(jti) except AccessKeyFeatureDisabledError as exc: diff --git a/sdk/python/nemo-platform/src/nemo_platform/cli/core/errors.py b/sdk/python/nemo-platform/src/nemo_platform/cli/core/errors.py index 29ff24ea91..ee4fa8e29f 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/cli/core/errors.py +++ b/sdk/python/nemo-platform/src/nemo_platform/cli/core/errors.py @@ -13,11 +13,9 @@ import httpx import typer -if typing.TYPE_CHECKING: - from nemo_platform import APIError - REMOTE_ERROR_EXIT_CODE = 3 + class MissingRequiredFieldsError(Exception): """Raised when required fields are missing from CLI input.""" @@ -177,30 +175,7 @@ def handle_exception(error: Exception, ctx: click.Context | None = None) -> None PermissionDeniedError, RateLimitError, ) - from nemo_platform_plugin.client.errors import ( - AuthenticationError as PluginAuthenticationError, - ) - from nemo_platform_plugin.client.errors import ( - BadRequestError as PluginBadRequestError, - ) - from nemo_platform_plugin.client.errors import ( - ConflictError as PluginConflictError, - ) - from nemo_platform_plugin.client.errors import ( - InternalServerError as PluginInternalServerError, - ) - from nemo_platform_plugin.client.errors import ( - NemoHTTPError, - ) - from nemo_platform_plugin.client.errors import ( - NotFoundError as PluginNotFoundError, - ) - from nemo_platform_plugin.client.errors import ( - PermissionDeniedError as PluginPermissionDeniedError, - ) - from nemo_platform_plugin.client.errors import ( - RateLimitError as PluginRateLimitError, - ) + from nemo_platform_plugin.client import errors as plugin_errors prog = "nemo" @@ -265,40 +240,45 @@ def handle_exception(error: Exception, ctx: click.Context | None = None) -> None if isinstance(error, typer.Exit): # Re-raise typer.Exit with its original exit code (don't treat Exit(0) as error) raise error - if isinstance(error, (AuthenticationError, PluginAuthenticationError)): + if isinstance(error, (AuthenticationError, plugin_errors.AuthenticationError)): console.print(f"[bold red]Authentication error:[/] ({error.status_code}) {_format_api_error(error)}") console.print( "[yellow]Hint:[/] Run [cyan]'nemo auth login'[/] or set the token manually " "with [cyan]nemo config set --access-token [/], or use [cyan]NMP_ACCESS_TOKEN[/]." ) raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) - elif isinstance(error, (PermissionDeniedError, PluginPermissionDeniedError)): + elif isinstance(error, (PermissionDeniedError, plugin_errors.PermissionDeniedError)): console.print(f"[bold red]Permission denied:[/] ({error.status_code}) {_format_api_error(error)}") console.print( "[yellow]Hint:[/] Your current credentials do not have access to perform this operation. " "Contact your administrator to request access." ) raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) - elif isinstance(error, (NotFoundError, PluginNotFoundError)): + elif isinstance(error, (NotFoundError, plugin_errors.NotFoundError)): console.print(f"[bold red]Not found:[/] ({error.status_code}) {_format_api_error(error)}") _print_api_request_context(console, error) console.print(f"[yellow]Hint:[/] {_format_not_found_hint(ctx, prog)}") raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) - elif isinstance(error, (BadRequestError, PluginBadRequestError)): + elif isinstance(error, (BadRequestError, plugin_errors.BadRequestError)): console.print(f"[bold red]Bad request:[/] ({error.status_code}) {_format_api_error(error)}") console.print("[yellow]Hint:[/] Check your input values. Run with [cyan]--help[/] to see required options.") raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) - elif isinstance(error, (ConflictError, PluginConflictError)): + elif isinstance(error, (ConflictError, plugin_errors.ConflictError)): console.print(f"[bold red]Conflict:[/] ({error.status_code}) {_format_api_error(error)}") console.print( - "[yellow]Hint:[/] A resource with this name already exists. Try a different name or delete the existing one." + "[yellow]Hint:[/] This can mean a resource with that name already exists (try a different name or delete the existing one), " + "or a concurrent update conflicted (retry the operation)." ) raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) - elif isinstance(error, (RateLimitError, PluginRateLimitError)): + elif isinstance(error, plugin_errors.UnprocessableEntityError): + console.print(f"[bold red]Invalid input:[/] ({error.status_code}) {_format_api_error(error)}") + console.print("[yellow]Hint:[/] Check your input values. Run with [cyan]--help[/] to see required options.") + raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) + elif isinstance(error, (RateLimitError, plugin_errors.RateLimitError)): console.print(f"[bold red]Rate limit exceeded:[/] ({error.status_code}) {_format_api_error(error)}") console.print("[yellow]Hint:[/] Too many requests. Wait a moment and try again.") raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) - elif isinstance(error, (InternalServerError, PluginInternalServerError)): + elif isinstance(error, (InternalServerError, plugin_errors.InternalServerError)): formatted = _format_api_error(error) console.print(f"[bold red]Server error:[/] ({error.status_code}) {formatted}") list_cmd = _build_list_cmd(ctx, prog) if ("404" in formatted or "not found" in formatted.lower()) else None @@ -307,25 +287,26 @@ def handle_exception(error: Exception, ctx: click.Context | None = None) -> None else: console.print("[yellow]Hint:[/] This is a server-side issue. Try again later or contact support.") raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) - elif isinstance(error, APITimeoutError): + elif isinstance(error, APITimeoutError) or ( + isinstance(error, plugin_errors.NemoTransportError) and isinstance(error.error, httpx.TimeoutException) + ): console.print(f"[bold red]Timeout error:[/] {_format_api_error(error)}") _print_api_request_context(console, error) console.print("[yellow]Hint:[/] The request timed out. The server may be busy - try again later.") raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) - elif isinstance(error, APIConnectionError): + elif isinstance(error, (APIConnectionError, plugin_errors.NemoTransportError)): console.print(f"[bold red]Connection error:[/] {_format_api_error(error)}") _print_api_request_context(console, error) console.print( "[yellow]Hint:[/] Check your network connection and verify that [cyan]base-url[/] you configured is correct." ) raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) - elif isinstance(error, APITimeoutError): - console.print(f"[bold red]Timeout error:[/] {_format_api_error(error)}") + elif isinstance(error, (APIStatusError, plugin_errors.NemoHTTPError)): + console.print(f"[bold red]API error:[/] ({error.status_code}) {_format_api_error(error)}") _print_api_request_context(console, error) - console.print("[yellow]Hint:[/] The request timed out. The server may be busy - try again later.") raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) - elif isinstance(error, (APIStatusError, NemoHTTPError)): - console.print(f"[bold red]API error:[/] ({error.status_code}) {_format_api_error(error)}") + elif isinstance(error, plugin_errors.NemoResponseValidationError): + console.print(f"[bold red]API response error:[/] {_format_api_error(error)}") _print_api_request_context(console, error) raise typer.Exit(code=REMOTE_ERROR_EXIT_CODE) elif isinstance(error, APIError): 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 ec0ac295cc..3d5147f68a 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 @@ -168,6 +168,8 @@ def delete( Revoke Access Key Args: + jti: Stable JWT ID of the Scoped Access Key to revoke. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -315,6 +317,8 @@ async def delete( Revoke Access Key Args: + jti: Stable JWT ID of the Scoped Access Key to revoke. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request diff --git a/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_list_response.py b/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_list_response.py index 0dfa906ad5..d47c7c31c0 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_list_response.py +++ b/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_list_response.py @@ -29,7 +29,4 @@ class AccessKeyListResponse(BaseModel): data: List[AccessKeyMetadataResponse] has_more: Optional[bool] = None - """ - True when the response was capped and more keys are available than this response - includes. - """ + """True when another page of keys is available.""" diff --git a/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_revoke_response.py b/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_revoke_response.py index 19906df6b6..0adc5bdad4 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_revoke_response.py +++ b/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_revoke_response.py @@ -27,4 +27,4 @@ class AccessKeyRevokeResponse(BaseModel): """Stable JWT ID for this Scoped Access Key.""" revoked: bool - """True when this request changed the key from active to revoked.""" + """True when this request newly recorded the key's revocation.""" 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 b2930da113..eb74c7350b 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 @@ -14,7 +14,6 @@ import yaml from nemo_platform.auth.helpers import decode_jwt_claims, generate_unsigned_jwt from nemo_platform.cli.app import app -from nemo_platform_plugin.auth.access_keys.issuer import AccessKeyFeatureDisabledError from nemo_platform_plugin.auth.access_keys.types import ( AccessKeyCreateRequest, AccessKeyCreateResponse, @@ -466,10 +465,18 @@ def test_auth_access_keys_create_rejects_invalid_expiration(monkeypatch: pytest. def test_auth_access_keys_create_reports_disabled_feature(monkeypatch: pytest.MonkeyPatch): + from nemo_platform_plugin.client.errors import NemoHTTPError + fake_platform_client = MagicMock() fake_access_keys_client = MagicMock() - fake_access_keys_client.create_access_key.side_effect = AccessKeyFeatureDisabledError( - "Scoped Access Keys are not enabled" + # Simulate the real 404 response the server sends when the feature is disabled, + # to exercise the NemoHTTPError → AccessKeyFeatureDisabledError translation path. + fake_access_keys_client.create_access_key.side_effect = NemoHTTPError( + httpx.Response( + 404, + json={"detail": "Scoped Access Keys are not enabled", "code": "access_keys_disabled"}, + request=httpx.Request("POST", "https://platform.example.com/apis/auth/v2/access-keys"), + ) ) monkeypatch.setattr("nemo_platform.cli.core.context.CLIContext.get_client", lambda self: fake_platform_client) monkeypatch.setattr( @@ -618,7 +625,7 @@ def test_auth_access_keys_revoke_reports_missing_key(monkeypatch: pytest.MonkeyP result = runner.invoke(app, ["auth", "access-keys", "revoke", "ak_unknown"]) - assert_exit_code(result, 1) + assert_exit_code(result, 3) assert "Not found: (404) Scoped Access Key ak_unknown was not found" in result.output fake_access_keys_client.revoke_access_key.assert_called_once_with(jti="ak_unknown") diff --git a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/core/test_errors.py b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/core/test_errors.py index f455682c3b..23d4c585ff 100644 --- a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/core/test_errors.py +++ b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/core/test_errors.py @@ -28,6 +28,7 @@ _format_api_error, handle_exception, ) +from nemo_platform_plugin.client import errors as plugin_errors from typer.testing import CliRunner DOCUMENTED_REMOTE_ERROR_EXIT_CODE = 3 @@ -111,6 +112,62 @@ def test_handle_api_connection_error(capsys): assert "base-url" in captured.err +def test_handle_plugin_transport_error_as_connection_error(capsys): + request = httpx.Request("GET", "http://test/apis/auth/v2/access-keys") + error = plugin_errors.NemoTransportError(httpx.ConnectError("Could not connect", request=request)) + + with pytest.raises(typer.Exit) as exc_info: + handle_exception(error) + + assert exc_info.value.exit_code == DOCUMENTED_REMOTE_ERROR_EXIT_CODE + captured = capsys.readouterr() + assert "Connection error:" in captured.err + assert "Request: GET http://test/apis/auth/v2/access-keys" in captured.err + assert "base-url" in captured.err + + +def test_handle_plugin_timeout_as_timeout_error(capsys): + request = httpx.Request("GET", "http://test/apis/auth/v2/access-keys") + error = plugin_errors.NemoTransportError(httpx.ReadTimeout("timed out", request=request)) + + with pytest.raises(typer.Exit) as exc_info: + handle_exception(error) + + assert exc_info.value.exit_code == DOCUMENTED_REMOTE_ERROR_EXIT_CODE + captured = capsys.readouterr() + assert "Timeout error:" in captured.err + assert "Request: GET http://test/apis/auth/v2/access-keys" in captured.err + assert "request timed out" in captured.err + + +def test_handle_plugin_response_validation_error(capsys): + request = httpx.Request("GET", "http://test/apis/auth/v2/access-keys") + response = httpx.Response(200, request=request, json={"unexpected": True}) + error = plugin_errors.NemoResponseValidationError(response, ValueError("invalid response")) + + with pytest.raises(typer.Exit) as exc_info: + handle_exception(error) + + assert exc_info.value.exit_code == DOCUMENTED_REMOTE_ERROR_EXIT_CODE + captured = capsys.readouterr() + assert "API response error:" in captured.err + assert "Request: GET http://test/apis/auth/v2/access-keys" in captured.err + + +def test_handle_plugin_unprocessable_entity_error(capsys): + request = httpx.Request("POST", "http://test/apis/auth/v2/access-keys") + response = httpx.Response(422, request=request, json={"detail": "Invalid key description"}) + error = plugin_errors.UnprocessableEntityError(response) + + with pytest.raises(typer.Exit) as exc_info: + handle_exception(error) + + assert exc_info.value.exit_code == DOCUMENTED_REMOTE_ERROR_EXIT_CODE + captured = capsys.readouterr() + assert "Invalid input: (422) Invalid key description" in captured.err + assert "Check your input values" in captured.err + + def test_handle_api_timeout_error(capsys): request = Mock() error = APITimeoutError(request=request) 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 ca0d02c6d9..5a7d90d70e 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,14 +3,16 @@ from __future__ import annotations -from typing import Any +from typing import Annotated, Any -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, HTTPException, Path, Query, status +from fastapi.responses import JSONResponse from nemo_platform_plugin.auth.access_keys.issuer import ( AccessKeyFeatureDisabledError, AccessKeyOperationNotImplementedError, ) from nmp.common.auth import AuthClient, get_auth_client +from nmp.common.auth.access_keys import AccessKeyValidationError from nmp.common.config import get_auth_config from nmp.core.auth.app.access_keys import ( AccessKeyNotFoundError, @@ -23,6 +25,16 @@ router = APIRouter(tags=["Scoped Access Keys"]) +_ACCESS_KEY_DISABLED_CODE = "access_keys_disabled" +_ACCESS_KEY_DISABLED_DETAIL = "Scoped Access Keys are not enabled" +_AccessKeyJTI = Annotated[ + str, + Path( + pattern=r"^ak_[0-9a-f]{32}$", + description="Stable JWT ID of the Scoped Access Key to revoke.", + ), +] + _ACCESS_KEY_DISABLED_ERROR_RESPONSE: dict[str, Any] = { "description": "Scoped Access Keys are not enabled", "model": schemas.AccessKeyErrorResponse, @@ -64,8 +76,11 @@ def _not_implemented(exc: AccessKeyOperationNotImplementedError) -> HTTPExceptio return HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc)) -def _disabled(exc: AccessKeyFeatureDisabledError) -> HTTPException: - return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) +def _disabled_response() -> JSONResponse: + return JSONResponse( + status_code=status.HTTP_404_NOT_FOUND, + content={"detail": _ACCESS_KEY_DISABLED_DETAIL, "code": _ACCESS_KEY_DISABLED_CODE}, + ) @router.post( @@ -76,14 +91,14 @@ def _disabled(exc: AccessKeyFeatureDisabledError) -> HTTPException: async def create_access_key( request: schemas.AccessKeyCreateRequest, issuer: PersistentAccessKeyIssuer = Depends(get_access_key_issuer), -) -> schemas.AccessKeyCreateResponse: +) -> schemas.AccessKeyCreateResponse | JSONResponse: try: return await issuer.create_async(request) - except AccessKeyFeatureDisabledError as exc: - raise _disabled(exc) from exc + except AccessKeyFeatureDisabledError: + return _disabled_response() except AccessKeyOperationNotImplementedError as exc: raise _not_implemented(exc) from exc - except RuntimeError as exc: + except AccessKeyValidationError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc @@ -96,11 +111,11 @@ async def list_access_keys( page: int = Query(default=1, ge=1), page_size: int = Query(default=100, ge=1, le=100), issuer: PersistentAccessKeyIssuer = Depends(get_access_key_issuer), -) -> schemas.AccessKeyListResponse: +) -> schemas.AccessKeyListResponse | JSONResponse: try: return await issuer.list_async(page=page, page_size=page_size) - except AccessKeyFeatureDisabledError as exc: - raise _disabled(exc) from exc + except AccessKeyFeatureDisabledError: + return _disabled_response() except AccessKeyOperationNotImplementedError as exc: raise _not_implemented(exc) from exc @@ -111,13 +126,13 @@ async def list_access_keys( responses=_ACCESS_KEY_REVOKE_ERROR_RESPONSES, ) async def revoke_access_key( - jti: str, + jti: _AccessKeyJTI, issuer: PersistentAccessKeyIssuer = Depends(get_access_key_issuer), -) -> schemas.AccessKeyRevokeResponse: +) -> schemas.AccessKeyRevokeResponse | JSONResponse: try: revoked = await issuer.revoke_async(jti) - except AccessKeyFeatureDisabledError as exc: - raise _disabled(exc) from exc + except AccessKeyFeatureDisabledError: + return _disabled_response() except AccessKeyOperationNotImplementedError as exc: raise _not_implemented(exc) from exc except AccessKeyNotFoundError as exc: 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 4858d0ccb4..9165bfff6b 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 @@ -3,6 +3,8 @@ from __future__ import annotations +from typing import Literal + from nemo_platform_plugin.auth.access_keys.types import AccessKeyCreateRequest as AccessKeyCreateRequest from nemo_platform_plugin.auth.access_keys.types import AccessKeyCreateResponse as AccessKeyCreateResponse from nemo_platform_plugin.auth.access_keys.types import AccessKeyListResponse as AccessKeyListResponse @@ -10,10 +12,15 @@ AccessKeyNotImplementedErrorResponse as AccessKeyNotImplementedErrorResponse, ) from nemo_platform_plugin.auth.access_keys.types import AccessKeyRevokeResponse as AccessKeyRevokeResponse -from pydantic import BaseModel +from pydantic import BaseModel, Field class AccessKeyErrorResponse(BaseModel): """Scoped Access Key error response.""" detail: str + code: Literal["access_keys_disabled"] | None = Field( + default=None, + json_schema_extra={"nullable": True}, + description="Set to access_keys_disabled when the Scoped Access Key feature is disabled.", + ) diff --git a/services/core/auth/src/nmp/core/auth/api/v2/authenticate.py b/services/core/auth/src/nmp/core/auth/api/v2/authenticate.py index b68c34c765..666b54e635 100644 --- a/services/core/auth/src/nmp/core/auth/api/v2/authenticate.py +++ b/services/core/auth/src/nmp/core/auth/api/v2/authenticate.py @@ -10,14 +10,14 @@ import jwt from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from nmp.common.auth.bearer import MalformedBearerTokenError, parse_bearer_authorization_header -from nmp.common.auth.jwt import TokenClaims +from nmp.common.auth.jwt import TokenClaims, groups_from_claim, scopes_from_claim from nmp.common.auth.token_resolver import ResolvedBearerToken, ResolvedTokenKind, resolve_bearer_token from nmp.common.config import AuthConfig, get_auth_config from nmp.core.auth.api.v2.workload_token_exchange import ( WorkloadTokenExchangeService, - _allowed_audiences, - _workload_token_issuer, + allowed_audiences, get_workload_token_exchange_service, + workload_token_issuer, ) from nmp.core.auth.app.access_keys import AccessKeyRegistry, get_access_key_registry from pydantic import BaseModel, Field @@ -84,21 +84,8 @@ def _bearer_token_from_request(request: Request) -> str: return token -def _groups_from_claim(groups_claim: object) -> list[str]: - if isinstance(groups_claim, str): - return [group.strip() for group in groups_claim.split(",") if group.strip()] - if isinstance(groups_claim, list): - return [group for group in groups_claim if isinstance(group, str)] - return [] - - def _scopes_from_claims(claims: dict[str, object]) -> list[str]: - scope_claim = claims.get("scope") or claims.get("scp") - if isinstance(scope_claim, str): - return scope_claim.split() - if isinstance(scope_claim, list): - return [scope for scope in scope_claim if isinstance(scope, str)] - return [] + return scopes_from_claim(claims.get("scope") or claims.get("scp")) def _stamp_principal_headers(response: Response, resolved: ResolvedBearerToken) -> None: @@ -145,8 +132,8 @@ async def _validate_workload_access_token( token, public_key, algorithms=["RS256"], - audience=list(_allowed_audiences(config)), - issuer=_workload_token_issuer(config, request), + audience=list(allowed_audiences(config)), + issuer=workload_token_issuer(config, request), options={"require": ["sub", "iat", "nbf", "exp"]}, leeway=30, ) @@ -156,7 +143,7 @@ async def _validate_workload_access_token( return TokenClaims( subject=subject, email=claims.get("email") if isinstance(claims.get("email"), str) else None, - groups=_groups_from_claim(claims.get("groups", [])), + groups=groups_from_claim(claims.get("groups", [])), scopes=_scopes_from_claims(claims), raw_claims=claims, ) @@ -197,7 +184,7 @@ async def _resolve_workload_subject_token( token_claims = TokenClaims( subject=subject, email=email if isinstance(email, str) else None, - groups=_groups_from_claim(claims.get(config.oidc.groups_claim, claims.get("groups", []))), + groups=groups_from_claim(claims.get(config.oidc.groups_claim, claims.get("groups", []))), scopes=_scopes_from_claims(claims), raw_claims=claims, ) diff --git a/services/core/auth/src/nmp/core/auth/api/v2/workload_token_exchange.py b/services/core/auth/src/nmp/core/auth/api/v2/workload_token_exchange.py index fa42f8246a..6c8ccb17ed 100644 --- a/services/core/auth/src/nmp/core/auth/api/v2/workload_token_exchange.py +++ b/services/core/auth/src/nmp/core/auth/api/v2/workload_token_exchange.py @@ -179,7 +179,7 @@ def workload_jwks_url(request: Request | None = None) -> str: return f"{_platform_base_url_from_request(request)}{WORKLOAD_JWKS_PATH}" -def _workload_token_issuer(config: AuthConfig, request: Request | None) -> str: +def workload_token_issuer(config: AuthConfig, request: Request | None) -> str: return ( config.oidc.workload_token_issuer or config.token_signing.issuer @@ -404,13 +404,13 @@ def _default_workload_audience(config: AuthConfig) -> str: return config.oidc.workload_audience or config.oidc.audience or DEFAULT_WORKLOAD_AUDIENCE -def _allowed_audiences(config: AuthConfig) -> set[str]: +def allowed_audiences(config: AuthConfig) -> set[str]: return {_default_workload_audience(config), *config.oidc.workload_allowed_audiences} def _validated_audience(config: AuthConfig, requested_audience: Any) -> str: audience = str(requested_audience or _default_workload_audience(config)) - if audience not in _allowed_audiences(config): + if audience not in allowed_audiences(config): raise jwt.InvalidAudienceError(f"unexpected requested audience: {audience!r}") return audience @@ -572,7 +572,7 @@ async def token_exchange( now = int(time.time()) scope = str(form.get("scope") or config.oidc.workload_scope or DEFAULT_WORKLOAD_SCOPE) exchanged_claims: dict[str, Any] = { - "iss": _workload_token_issuer(config, request), + "iss": workload_token_issuer(config, request), "sub": subject, "aud": audience, "iat": now, 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 c58a8f305c..b3445d5fc9 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 @@ -6,7 +6,7 @@ from __future__ import annotations import logging -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from fastapi import Depends from nemo_platform_plugin.auth.access_keys.issuer import AccessKeyFeatureDisabledError @@ -72,10 +72,21 @@ async def revoke(self, jti: str, principal: str) -> bool: record = await self._get_owned(jti, principal) if record.revoked_at is not None: return False - # Entity storage does not expose compare-and-swap here. Concurrent revokes may both write a timestamp, - # but revocation is idempotent and the final lifecycle state is still correct. updated = record.model_copy(update={"revoked_at": datetime.now(tz=UTC)}) - await self._entity_client.update(updated) + try: + await self._entity_client.update(updated) + except EntityConflictError: + # EntityClient.update uses db_version optimistic locking. Re-read to + # determine whether a concurrent revoke won the race. + try: + current = await self._get_owned(jti, principal) + except AccessKeyNotFoundError: + # The key was concurrently hard-deleted between our update and this + # read. Treat as already-revoked (idempotent outcome). + return False + if current.revoked_at is not None: + return False + raise return True async def is_active(self, jti: str, principal: str, *, claims: TokenClaims | None = None) -> bool: @@ -111,7 +122,7 @@ def _metadata(record: AccessKeyEntity) -> AccessKeyMetadataResponse: principal=record.principal, status=AccessKeyRegistry._status(record), issuer=record.issuer, - audiences=record.audiences, + audiences=list(dict.fromkeys(record.audiences)), created_at=record.issued_at, expires_at=record.expires_at, ) @@ -120,7 +131,7 @@ def _metadata(record: AccessKeyEntity) -> AccessKeyMetadataResponse: def _status(record: AccessKeyEntity) -> AccessKeyStatus: if record.revoked_at is not None: return "REVOKED" - if record.expires_at is not None and record.expires_at <= datetime.now(tz=UTC): + if record.expires_at is not None and record.expires_at <= datetime.now(tz=UTC) - timedelta(seconds=30): return "EXPIRED" return "ACTIVE" @@ -148,6 +159,10 @@ async def _backfill_legacy_record( "access_key_jti": jti, }, ) + # Return the locally-constructed record rather than re-fetching. The + # immediate caller (is_active) only reads revoked_at and expires_at, + # both of which are set locally. If this method is extended to use + # server-assigned fields (e.g. db_version), re-fetch here instead. return record @classmethod @@ -174,12 +189,12 @@ def _record_from_validated_claims( if not isinstance(metadata, dict) or metadata.get("version") != LEGACY_ACCESS_KEY_METADATA_VERSION: return None key_name = metadata.get("name") - description = metadata.get("description") return AccessKeyEntity( name=jti, workspace=ACCESS_KEY_WORKSPACE, key_name=key_name if isinstance(key_name, str) else None, - description=description if isinstance(description, str) else None, + # description is not embedded in JWT claims for any version; always None on backfill + description=None, principal=principal, issuer=issuer, audiences=audiences, @@ -192,7 +207,7 @@ def _audiences_from_claim(value: object) -> list[str]: if isinstance(value, str): return [value] if isinstance(value, list): - return [audience for audience in value if isinstance(audience, str)] + return list(dict.fromkeys(audience for audience in value if isinstance(audience, str))) return [] @staticmethod @@ -220,7 +235,15 @@ def __init__(self, config: AuthConfig, principal: Principal, registry: AccessKey async def create_async(self, request: AccessKeyCreateRequest) -> AccessKeyCreateResponse: self._ensure_enabled() key = await self._issuer.create_async(request) - await self._registry.add(key) + try: + await self._registry.add(key) + except Exception: + logger.warning( + "Failed to persist Scoped Access Key lifecycle record; the signed JWT will not be returned to the caller", + extra={"access_key_jti": key.jti, "actor_principal": self.principal}, + exc_info=True, + ) + raise logger.info( "Scoped Access Key created", extra={ diff --git a/services/core/auth/src/nmp/core/auth/entities/entities.py b/services/core/auth/src/nmp/core/auth/entities/entities.py index b91801f35b..13e479cd16 100644 --- a/services/core/auth/src/nmp/core/auth/entities/entities.py +++ b/services/core/auth/src/nmp/core/auth/entities/entities.py @@ -4,7 +4,6 @@ """Auth service entities.""" from datetime import datetime -from typing import Optional from nmp.common.entities import EntityBase @@ -29,11 +28,15 @@ class RoleBindingEntity(EntityBase): role: str granted_by: str granted_at: datetime - revoked_at: Optional[datetime] = None + revoked_at: datetime | None = None class AccessKeyEntity(EntityBase): - """Persistent lifecycle record for a Scoped Access Key.""" + """Persistent lifecycle record for a Scoped Access Key. + + The inherited ``name`` field stores the JWT ID (jti) as the stable entity key. + ``key_name`` is the optional human-readable label supplied at creation time. + """ __entity_type__ = "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 4ec7c98f46..72fb13e3df 100644 --- a/services/core/auth/tests/test_access_key_registry.py +++ b/services/core/auth/tests/test_access_key_registry.py @@ -8,7 +8,7 @@ import pytest from nemo_platform_plugin.auth.access_keys.types import AccessKeyCreateResponse from nmp.common.auth.jwt import TokenClaims -from nmp.common.entities import EntityNotFoundError +from nmp.common.entities import EntityConflictError, EntityNotFoundError from nmp.core.auth.app.access_keys import AccessKeyNotFoundError, AccessKeyRegistry from nmp.core.auth.entities import AccessKeyEntity @@ -31,7 +31,10 @@ def _record(*, jti: str = "ak_example", principal: str = "alice@example.com", re def _expired_record() -> AccessKeyEntity: - return _record(jti="ak_expired").model_copy(update={"expires_at": datetime(2026, 8, 3, 18, 0, tzinfo=UTC)}) + # Use a far-past date so the result is clock-independent. + # AccessKeyRegistry._status applies a 30s leeway, so expires_at must be + # well before now to reliably return "EXPIRED". + return _record(jti="ak_expired").model_copy(update={"expires_at": datetime(2000, 1, 1, tzinfo=UTC)}) @pytest.mark.asyncio @@ -65,8 +68,9 @@ async def test_registry_persists_created_key_metadata() -> None: @pytest.mark.asyncio async def test_registry_lists_principals_keys_with_status_across_pages() -> None: entity_client = AsyncMock() + active_record = _record().model_copy(update={"audiences": ["nemo-platform-access-key", "nemo-platform-access-key"]}) entity_client.list.return_value = SimpleNamespace( - data=[_record(), _record(jti="ak_revoked", revoked=True)], pagination=SimpleNamespace(total_pages=2) + data=[active_record, _record(jti="ak_revoked", revoked=True)], pagination=SimpleNamespace(total_pages=2) ) registry = AccessKeyRegistry(entity_client) @@ -74,6 +78,7 @@ async def test_registry_lists_principals_keys_with_status_across_pages() -> None assert [key.jti for key in result.data] == ["ak_example", "ak_revoked"] assert [key.status for key in result.data] == ["ACTIVE", "REVOKED"] + assert result.data[0].audiences == ["nemo-platform-access-key"] assert result.has_more entity_client.list.assert_awaited_once() assert entity_client.list.await_args.kwargs["page"] == 1 @@ -126,6 +131,44 @@ async def test_registry_revokes_owned_key_without_deleting_audit_record() -> Non entity_client.delete.assert_not_awaited() +@pytest.mark.asyncio +async def test_registry_concurrent_revoke_reports_existing_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) + + assert not await registry.revoke("ak_example", "alice@example.com") + + assert entity_client.get.await_count == 2 + entity_client.update.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_registry_concurrent_revoke_with_hard_delete_treats_as_already_revoked() -> 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) + + assert not await registry.revoke("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() + entity_client.get.return_value = _expired_record() + registry = AccessKeyRegistry(entity_client) + + assert await registry.revoke("ak_expired", "alice@example.com") + + updated = entity_client.update.await_args.args[0] + assert updated.revoked_at is not None + + @pytest.mark.asyncio async def test_registry_reports_revoked_key_as_inactive() -> None: entity_client = AsyncMock() @@ -160,7 +203,7 @@ async def test_registry_backfills_missing_legacy_access_key_from_validated_claim scopes=[], raw_claims={ "iss": "https://platform.example.com/apis/auth", - "aud": ["nemo-platform-access-key"], + "aud": ["nemo-platform-access-key", "nemo-platform-access-key"], "sub": "alice@example.com", "iat": 1_785_280_000, "nbf": 1_785_280_000, diff --git a/services/core/auth/tests/test_access_keys.py b/services/core/auth/tests/test_access_keys.py index cb12398e75..61a0ac32a8 100644 --- a/services/core/auth/tests/test_access_keys.py +++ b/services/core/auth/tests/test_access_keys.py @@ -30,14 +30,18 @@ async def add(self, key): def _status(self, jti, key): if jti in self.revoked: return "REVOKED" - if key.expires_at is not None and key.expires_at <= datetime.now(tz=UTC): + if key.expires_at is not None and key.expires_at <= datetime.now(tz=UTC) - timedelta(seconds=30): return "EXPIRED" return key.status async def list_for_principal(self, principal, *, page, page_size): from nemo_platform_plugin.auth.access_keys.types import AccessKeyListResponse, AccessKeyMetadataResponse - owned = [(jti, key) for jti, key in self.keys.items() if key.principal == principal] + # Sort newest-first then by jti to match the real registry's `sort="-issued_at"`. + owned = sorted( + [(jti, key) for jti, key in self.keys.items() if key.principal == principal], + key=lambda item: (-item[1].created_at.timestamp(), item[0]), + ) start = (page - 1) * page_size selected = owned[start : start + page_size] return AccessKeyListResponse( @@ -235,7 +239,9 @@ def test_create_access_key_is_disabled_by_default(disabled_client): response = disabled_client.post("/v2/access-keys", json={}) assert response.status_code == 404 - assert response.json()["detail"] == "Scoped Access Keys are not enabled" + body = response.json() + assert body["detail"] == "Scoped Access Keys are not enabled" + assert body["code"] == "access_keys_disabled" def test_create_access_key_is_explicitly_not_implemented(client): @@ -253,21 +259,38 @@ async def create_async(self, request): assert response.json()["detail"] == "Scoped Access Key creation is not implemented" +def test_create_access_key_does_not_misclassify_unexpected_runtime_error(client): + class BrokenIssuer: + async def create_async(self, request): + raise RuntimeError("entity storage unavailable") + + client.app.dependency_overrides[get_access_key_issuer] = lambda: BrokenIssuer() + try: + with pytest.raises(RuntimeError, match="entity storage unavailable"): + client.post("/v2/access-keys", json={}) + finally: + client.app.dependency_overrides.clear() + + def test_list_access_keys_is_disabled_by_default(disabled_client): response = disabled_client.get("/v2/access-keys") assert response.status_code == 404 - assert response.json()["detail"] == "Scoped Access Keys are not enabled" + body = response.json() + assert body["detail"] == "Scoped Access Keys are not enabled" + assert body["code"] == "access_keys_disabled" def test_revoke_access_key_is_disabled_by_default(disabled_client): - response = disabled_client.delete("/v2/access-keys/ak_example") + response = disabled_client.delete("/v2/access-keys/ak_" + "a" * 32) assert response.status_code == 404 - assert response.json()["detail"] == "Scoped Access Keys are not enabled" + body = response.json() + assert body["detail"] == "Scoped Access Keys are not enabled" + assert body["code"] == "access_keys_disabled" -def test_access_key_specific_jwks_route_is_removed(client): +def test_access_key_specific_jwks_route_does_not_accept_get(client): response = client.get("/v2/access-keys/jwks") assert response.status_code == 405 @@ -312,6 +335,12 @@ 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"} + 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" + assert error_code_schema["description"] == ( + "Set to access_keys_disabled when the Scoped Access Key feature is disabled." + ) list_operation = openapi["paths"]["/v2/access-keys"]["get"] list_parameters = {parameter["name"]: parameter for parameter in list_operation["parameters"]} @@ -336,7 +365,11 @@ def test_access_key_lifecycle_openapi_documents_error_responses(client): "$ref": "#/components/schemas/AccessKeyNotImplementedErrorResponse" } - revoke_responses = openapi["paths"]["/v2/access-keys/{jti}"]["delete"]["responses"] + 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." + revoke_responses = revoke_operation["responses"] assert revoke_responses["200"]["content"]["application/json"]["schema"] == { "$ref": "#/components/schemas/AccessKeyRevokeResponse" } @@ -383,11 +416,16 @@ def test_list_access_keys_supports_pagination(client): second_page = client.get("/v2/access-keys", params={"page": 2, "page_size": 1}) assert first_page.status_code == 200 - assert [key["jti"] for key in first_page.json()["data"]] == [first["jti"]] + assert len(first_page.json()["data"]) == 1 assert first_page.json()["has_more"] is True assert second_page.status_code == 200 - assert [key["jti"] for key in second_page.json()["data"]] == [second["jti"]] + assert len(second_page.json()["data"]) == 1 assert second_page.json()["has_more"] is False + # Both keys appear exactly once across the two pages (order depends on issued_at). + all_jtis = { + key["jti"] for page_data in [first_page.json()["data"], second_page.json()["data"]] for key in page_data + } + assert all_jtis == {first["jti"], second["jti"]} def test_revoke_access_key_marks_key_revoked_in_listing(client): @@ -409,7 +447,17 @@ def test_revoke_access_key_marks_key_revoked_in_listing(client): def test_revoke_access_key_returns_not_found_for_unknown_key(client): - response = client.delete("/v2/access-keys/ak_unknown") + unknown_jti = "ak_" + "0" * 32 + response = client.delete(f"/v2/access-keys/{unknown_jti}") assert response.status_code == 404 - assert response.json()["detail"] == "Scoped Access Key ak_unknown was not found" + assert response.json()["detail"] == f"Scoped Access Key {unknown_jti} was not found" + + +def test_revoke_access_key_rejects_malformed_jti(client): + response = client.delete("/v2/access-keys/ak_tooshort") + + assert response.status_code == 422 + detail = response.json()["detail"] + assert isinstance(detail, list) + assert detail[0]["type"] == "string_pattern_mismatch" From 09ea9545ab91c83204e8d7856af9ef0cdf97a848 Mon Sep 17 00:00:00 2001 From: anastasia-nesterenko Date: Fri, 7 Aug 2026 15:10:51 -0600 Subject: [PATCH 4/8] test(inference-gateway): group auth integration tests Signed-off-by: anastasia-nesterenko --- .../inference-gateway/tests/integration/test_igw_with_auth.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/services/core/inference-gateway/tests/integration/test_igw_with_auth.py b/services/core/inference-gateway/tests/integration/test_igw_with_auth.py index 1b8a03d5a5..e295cd3a21 100644 --- a/services/core/inference-gateway/tests/integration/test_igw_with_auth.py +++ b/services/core/inference-gateway/tests/integration/test_igw_with_auth.py @@ -35,6 +35,8 @@ unique_email, ) +pytestmark = pytest.mark.xdist_group("inference_gateway_auth") + @pytest.fixture(scope="module") def ctx() -> Generator[ClientContext, None, None]: From c8aad776788e2461902a4f7210f1bad738feefba Mon Sep 17 00:00:00 2001 From: anastasia-nesterenko Date: Fri, 7 Aug 2026 15:48:03 -0600 Subject: [PATCH 5/8] test(inference-gateway): serialize Docker middleware tests Signed-off-by: anastasia-nesterenko --- .../inference-gateway/tests/integration/test_igw_with_auth.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/services/core/inference-gateway/tests/integration/test_igw_with_auth.py b/services/core/inference-gateway/tests/integration/test_igw_with_auth.py index e295cd3a21..1b8a03d5a5 100644 --- a/services/core/inference-gateway/tests/integration/test_igw_with_auth.py +++ b/services/core/inference-gateway/tests/integration/test_igw_with_auth.py @@ -35,8 +35,6 @@ unique_email, ) -pytestmark = pytest.mark.xdist_group("inference_gateway_auth") - @pytest.fixture(scope="module") def ctx() -> Generator[ClientContext, None, None]: From 8d5576676ceb947cd15b1dd3541ff45f54fba9a9 Mon Sep 17 00:00:00 2001 From: anastasia-nesterenko Date: Fri, 7 Aug 2026 17:17:56 -0600 Subject: [PATCH 6/8] test(inference-gateway): serialize service fixtures Signed-off-by: anastasia-nesterenko --- .../tests/integration/test_mock_provider_mode.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/services/core/inference-gateway/tests/integration/test_mock_provider_mode.py b/services/core/inference-gateway/tests/integration/test_mock_provider_mode.py index 398aa453e3..167439d9bc 100644 --- a/services/core/inference-gateway/tests/integration/test_mock_provider_mode.py +++ b/services/core/inference-gateway/tests/integration/test_mock_provider_mode.py @@ -7,9 +7,8 @@ IGW service, including all three route types (provider, model, openai) and all supported HTTP methods. -Note: These tests use function-scoped fixtures to avoid parallelism issues. -Each test gets its own unique provider names to prevent conflicts when -running tests in parallel with pytest-xdist. +Note: These tests share a module-scoped service context on one xdist worker. +Each test uses unique provider names to prevent state conflicts. ================================================================================ REAL-WORLD USAGE EXAMPLES @@ -40,6 +39,8 @@ DEFAULT_WORKSPACE = "default" +pytestmark = pytest.mark.xdist_group("inference_gateway_mock_provider") + def _unique_name(prefix: str) -> str: """Generate a unique name with a random suffix for test isolation.""" @@ -66,7 +67,7 @@ def _openai_route(workspace: str, endpoint: str) -> str: # ============================================================================= -@pytest.fixture +@pytest.fixture(scope="module") def mock_provider_test_clients() -> Generator[ClientContext, None, None]: """Create a ClientContext for testing with IGW in mock provider mode. @@ -1376,8 +1377,8 @@ def test_fixture_llm_judge_pattern(mock_provider_test_clients: ClientContext): def test_fixture_isolation(mock_provider_test_clients: ClientContext): """Test that mock_provider_test_clients fixture properly isolates test state. - Each test using mock_provider_test_clients gets a fresh context. Providers added in - one test won't be visible in another test. + Providers added to the shared test context are not visible in a separately + created context. """ from nemo_platform import NotFoundError @@ -1398,7 +1399,7 @@ def test_fixture_isolation(mock_provider_test_clients: ClientContext): ) assert response == {"context": 1} - # Create a new context (simulating another test) + # Create a separate context. with create_test_client( InferenceGatewayService, ModelsService, From 91359d2ae70c8964429ef79fe41166cde510e4a1 Mon Sep 17 00:00:00 2001 From: anastasia-nesterenko Date: Fri, 7 Aug 2026 18:24:56 -0600 Subject: [PATCH 7/8] test(inference-gateway): keep mock providers unique Signed-off-by: anastasia-nesterenko --- .../tests/integration/test_mock_provider_mode.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/core/inference-gateway/tests/integration/test_mock_provider_mode.py b/services/core/inference-gateway/tests/integration/test_mock_provider_mode.py index 167439d9bc..61a77e3e86 100644 --- a/services/core/inference-gateway/tests/integration/test_mock_provider_mode.py +++ b/services/core/inference-gateway/tests/integration/test_mock_provider_mode.py @@ -1225,7 +1225,7 @@ def test_fixture_add_provider_with_error_status(mock_provider_test_clients: Clie provider = add_mock_provider( mock_provider_test_clients.sdk, workspace=DEFAULT_WORKSPACE, - name="error-provider", # Becomes "igw-mock-error-provider" + name="fixture-error-provider", # Becomes "igw-mock-fixture-error-provider" mock_response_body={"error": "rate limited"}, mock_status=429, ) From 02e326bc6046a17001ff102e27380aa644c51fbe Mon Sep 17 00:00:00 2001 From: anastasia-nesterenko Date: Tue, 11 Aug 2026 10:34:43 -0600 Subject: [PATCH 8/8] feat(auth): validate access key lifecycle through auth SDK Signed-off-by: anastasia-nesterenko --- openapi/ga/individual/platform.openapi.yaml | 12 + openapi/ga/openapi.yaml | 12 + openapi/openapi.yaml | 12 + .../tests/cli/integration/test_filesets.py | 2 +- .../tests/auth/access_keys/test_endpoints.py | 2 +- .../nmp/common/auth/access_key_lifecycle.py | 156 ++++++++++++ .../src/nmp/common/auth/access_keys.py | 14 +- .../src/nmp/common/auth/middleware.py | 190 ++------------- .../src/nmp/common/auth/token_resolver.py | 3 +- .../tests/auth/test_access_key_lifecycle.py | 222 ++++++++++++++++++ .../nmp_common/tests/auth/test_access_keys.py | 50 +++- .../nmp_common/tests/auth/test_middleware.py | 174 ++++++++------ .../src/nmp/platform_runner/server.py | 11 +- .../nmp_platform_runner/tests/test_server.py | 36 ++- .../nmp_testing/src/nmp/testing/client.py | 10 +- .../nemo-platform/.nmpcontext/openapi.yaml | 12 + .../cli/integration/test_filesets.py | 2 +- .../core/auth/api/v2/access_keys/endpoints.py | 15 +- .../auth/src/nmp/core/auth/app/access_keys.py | 35 ++- .../src/nmp/core/auth/entities/entities.py | 12 +- .../auth/tests/test_access_key_registry.py | 111 ++++++++- services/core/auth/tests/test_access_keys.py | 34 ++- 22 files changed, 834 insertions(+), 293 deletions(-) create mode 100644 packages/nmp_common/src/nmp/common/auth/access_key_lifecycle.py create mode 100644 packages/nmp_common/tests/auth/test_access_key_lifecycle.py diff --git a/openapi/ga/individual/platform.openapi.yaml b/openapi/ga/individual/platform.openapi.yaml index 9c6f48a1cc..85cc460506 100644 --- a/openapi/ga/individual/platform.openapi.yaml +++ b/openapi/ga/individual/platform.openapi.yaml @@ -197,6 +197,12 @@ paths: application/json: schema: $ref: '#/components/schemas/AccessKeyErrorResponse' + '409': + description: Concurrent access-key update conflict + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' '501': description: Not Implemented content: @@ -286,6 +292,12 @@ paths: application/json: schema: $ref: '#/components/schemas/AccessKeyErrorResponse' + '409': + description: Concurrent access-key update conflict + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' '501': description: Not Implemented content: diff --git a/openapi/ga/openapi.yaml b/openapi/ga/openapi.yaml index 9c6f48a1cc..85cc460506 100644 --- a/openapi/ga/openapi.yaml +++ b/openapi/ga/openapi.yaml @@ -197,6 +197,12 @@ paths: application/json: schema: $ref: '#/components/schemas/AccessKeyErrorResponse' + '409': + description: Concurrent access-key update conflict + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' '501': description: Not Implemented content: @@ -286,6 +292,12 @@ paths: application/json: schema: $ref: '#/components/schemas/AccessKeyErrorResponse' + '409': + description: Concurrent access-key update conflict + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' '501': description: Not Implemented content: diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 9c6f48a1cc..85cc460506 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -197,6 +197,12 @@ paths: application/json: schema: $ref: '#/components/schemas/AccessKeyErrorResponse' + '409': + description: Concurrent access-key update conflict + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' '501': description: Not Implemented content: @@ -286,6 +292,12 @@ paths: application/json: schema: $ref: '#/components/schemas/AccessKeyErrorResponse' + '409': + description: Concurrent access-key update conflict + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' '501': description: Not Implemented content: diff --git a/packages/nemo_platform_ext/tests/cli/integration/test_filesets.py b/packages/nemo_platform_ext/tests/cli/integration/test_filesets.py index a351894d9a..4bb39b4317 100644 --- a/packages/nemo_platform_ext/tests/cli/integration/test_filesets.py +++ b/packages/nemo_platform_ext/tests/cli/integration/test_filesets.py @@ -142,7 +142,7 @@ def test_upload_to_nonexistent_fileset_fails( f"files upload {test_file} nonexistent-fileset-12345 --workspace {random_workspace}", ) - assert_exit_code(result, 1) + assert_exit_code(result, 3) assert "not found" in result.stderr.lower() @pytest.mark.parametrize( 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 83a2222183..3ff6f6deb9 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 @@ -7,7 +7,7 @@ def test_create_access_key_endpoint_uses_gateway_path() -> None: - prepared = endpoints.create_access_key(body=AccessKeyCreateRequest(name="gtc-intake")) + prepared = endpoints.create_access_key(body=AccessKeyCreateRequest(name="ci-intake")) assert isinstance(prepared, PreparedRequest) assert prepared.method == "POST" diff --git a/packages/nmp_common/src/nmp/common/auth/access_key_lifecycle.py b/packages/nmp_common/src/nmp/common/auth/access_key_lifecycle.py new file mode 100644 index 0000000000..aaa6864e6a --- /dev/null +++ b/packages/nmp_common/src/nmp/common/auth/access_key_lifecycle.py @@ -0,0 +1,156 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Auth-service client for validating Scoped Access Key lifecycle state.""" + +import logging +import math +import time + +import httpx +import jwt +from nemo_platform import ( + APIConnectionError, + APIResponseValidationError, + APIStatusError, + APITimeoutError, + AsyncNeMoPlatform, + AuthenticationError, +) +from nmp.common.config import AuthConfig + +from .jwt import TokenClaims +from .token_resolver import ResolvedBearerToken + +logger = logging.getLogger(__name__) +ACCESS_KEY_LIFECYCLE_CIRCUIT_FAILURE_THRESHOLD = 3 +ACCESS_KEY_LIFECYCLE_CIRCUIT_OPEN_SECONDS = 5.0 + + +class AccessKeyLifecycleUnavailableError(RuntimeError): + """Raised when the auth-service lifecycle validator cannot be trusted.""" + + def __init__(self, status_code: int, detail: str, *, retry_after: int | None = None) -> None: + super().__init__(detail) + self.status_code = status_code + self.detail = detail + self.retry_after = retry_after + + +class AccessKeyLifecycleAuthenticator: + """Validate access keys through the generated Auth SDK with fail-closed circuit breaking.""" + + def __init__(self, config: AuthConfig, http_client: httpx.AsyncClient | None = None) -> None: + self._config = config + self._http_client = http_client + self._sdk: AsyncNeMoPlatform | None = None + self._failure_count = 0 + self._circuit_open_until = 0.0 + + def _get_sdk(self) -> AsyncNeMoPlatform: + if self._sdk is None: + # Import lazily to avoid an auth -> SDK factory import cycle. The + # factory attaches PlatformRequestRouter, which owns service + # discovery and transport selection for /apis/auth requests. + from nmp.common.sdk_factory import get_async_platform_sdk, with_options_preserving_request_router + + sdk = get_async_platform_sdk(http_client=self._http_client) + self._sdk = with_options_preserving_request_router( + sdk, + max_retries=0, + _extra_kwargs={"_strict_response_validation": True}, + ) + return self._sdk + + def _retry_after(self) -> int: + return max(1, math.ceil(self._circuit_open_until - time.monotonic())) + + def _record_success(self) -> None: + self._failure_count = 0 + self._circuit_open_until = 0.0 + + def _record_failure(self) -> int | None: + self._failure_count += 1 + if self._failure_count >= ACCESS_KEY_LIFECYCLE_CIRCUIT_FAILURE_THRESHOLD: + self._circuit_open_until = time.monotonic() + ACCESS_KEY_LIFECYCLE_CIRCUIT_OPEN_SECONDS + return self._retry_after() + return None + + def _unavailable(self, status_code: int, detail: str) -> AccessKeyLifecycleUnavailableError: + return AccessKeyLifecycleUnavailableError( + status_code, + detail, + retry_after=self._record_failure(), + ) + + async def authenticate(self, token: str) -> ResolvedBearerToken | None: + """Return trusted access-key claims, or None when auth rejects the token.""" + now = time.monotonic() + if now < self._circuit_open_until: + retry_after = self._retry_after() + logger.warning("Access-key lifecycle validation circuit is open for %s more seconds", retry_after) + raise AccessKeyLifecycleUnavailableError( + 503, + "Access-key lifecycle validation unavailable", + retry_after=retry_after, + ) + if self._circuit_open_until > 0.0: + # Timer just lapsed — reset so the N-failure threshold applies fresh to this probe. + self._failure_count = 0 + self._circuit_open_until = 0.0 + + try: + result = await self._get_sdk().auth.authenticate_get( + extra_headers={"Authorization": f"Bearer {token}"}, + timeout=self._config.policy_decision_point_request_timeout_seconds, + ) + except AuthenticationError: + self._record_success() + return None + except APITimeoutError as exc: + logger.error("Access-key lifecycle validation timed out at %s: %s", exc.request.url, exc) + raise self._unavailable(504, "Access-key lifecycle validation timeout") from exc + except APIConnectionError as exc: + logger.error("Cannot connect to access-key lifecycle validator at %s: %s", exc.request.url, exc) + raise self._unavailable(503, "Access-key lifecycle validation unavailable") from exc + except (APIStatusError, APIResponseValidationError) as exc: + logger.error("Access-key lifecycle validation failed at %s: %s", exc.request.url, exc) + raise self._unavailable(503, "Access-key lifecycle validation unavailable") from exc + + if result.token_kind != "access_key": + self._record_success() + return None + if not result.jti: + logger.error("Access-key lifecycle validator returned an invalid success response") + raise self._unavailable(503, "Access-key lifecycle validation unavailable") + + self._record_success() + try: + unverified_payload = jwt.decode( + token, + options={ + "verify_signature": False, + "verify_exp": False, + "verify_iat": False, + "verify_nbf": False, + "verify_aud": False, + }, + ) + except jwt.PyJWTError: + unverified_payload = {} + return ResolvedBearerToken( + claims=TokenClaims( + subject=result.principal, + email=result.email, + groups=result.groups or [], + scopes=result.scopes or [], + raw_claims={ + **unverified_payload, + "nmp_token_type": "access_key", + "jti": result.jti, + "sub": result.principal, + **({"email": result.email} if result.email is not None else {}), + }, + ), + token_kind="access_key", + ) 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 9bfbfb1bce..06eefdf1fd 100644 --- a/packages/nmp_common/src/nmp/common/auth/access_keys.py +++ b/packages/nmp_common/src/nmp/common/auth/access_keys.py @@ -4,6 +4,7 @@ from __future__ import annotations import logging +import re import time import uuid from dataclasses import dataclass @@ -23,6 +24,7 @@ AccessKeyCreateResponse, AccessKeyListResponse, AccessKeyRevokeResponse, + AccessKeyStatus, ) from nmp.common.config import AuthConfig, get_platform_config @@ -35,6 +37,9 @@ ACCESS_KEY_JWKS_PATH = "/apis/auth/jwks" ACCESS_KEY_METADATA_VERSION = 2 LEGACY_ACCESS_KEY_METADATA_VERSION = 1 +ACCESS_KEY_JTI_PATTERN = r"^ak_[0-9a-f]{32}$" +_ACCESS_KEY_JTI_RE = re.compile(ACCESS_KEY_JTI_PATTERN) +_NEWLY_CREATED_STATUS: AccessKeyStatus = "ACTIVE" logger = logging.getLogger(__name__) @@ -102,9 +107,12 @@ def is_access_key_token_candidate(token: str) -> bool: "verify_aud": False, }, ) - except jwt.DecodeError: + except jwt.PyJWTError: return False - return unverified.get("nmp_token_type") == ACCESS_KEY_TOKEN_TYPE + if unverified.get("nmp_token_type") != ACCESS_KEY_TOKEN_TYPE: + return False + jti = unverified.get("jti", "") + return isinstance(jti, str) and bool(_ACCESS_KEY_JTI_RE.match(jti)) def _access_key_signing_key(config: AuthConfig) -> RSASigningKey: @@ -344,7 +352,7 @@ def _access_key_response_from_payload( token=token, token_type="Bearer", principal=payload.principal, - status="ACTIVE", + status=_NEWLY_CREATED_STATUS, issuer=payload.issuer, audiences=payload.audiences, created_at=payload.created_at, diff --git a/packages/nmp_common/src/nmp/common/auth/middleware.py b/packages/nmp_common/src/nmp/common/auth/middleware.py index 9fe5990c97..ce32436873 100644 --- a/packages/nmp_common/src/nmp/common/auth/middleware.py +++ b/packages/nmp_common/src/nmp/common/auth/middleware.py @@ -4,31 +4,29 @@ """Authorization middleware for NeMo Platform services.""" import logging -import math -import time -from functools import cached_property from typing import Any, Callable, Optional import httpx from fastapi import Request, Response -from nmp.common.config import AuthConfig, get_auth_config, get_platform_config +from nmp.common.config import AuthConfig, get_auth_config from nmp.common.observability.context import get_app_ctx -from nmp.common.platform_endpoint import PlatformEndpoint, parse_platform_endpoint, resolve_service_endpoint +from nmp.common.platform_endpoint import parse_platform_endpoint from starlette.middleware.base import BaseHTTPMiddleware from starlette.responses import JSONResponse from starlette.types import ASGIApp +from .access_key_lifecycle import ( + AccessKeyLifecycleAuthenticator, + AccessKeyLifecycleUnavailableError, +) from .bearer import MalformedBearerTokenError, parse_bearer_authorization_header from .client import AuthClient from .dependencies import auth_client_context from .exceptions import InvalidPrincipalHeader, InvalidScopeFormatError -from .jwt import TokenClaims from .models import Principal from .token_resolver import ResolvedBearerToken, resolve_bearer_token logger = logging.getLogger(__name__) -_ACCESS_KEY_LIFECYCLE_CIRCUIT_FAILURE_THRESHOLD = 3 -_ACCESS_KEY_LIFECYCLE_CIRCUIT_OPEN_SECONDS = 5.0 class PrincipalExtractionError(RuntimeError): @@ -140,28 +138,17 @@ def __init__( self.config: AuthConfig = get_auth_config() self.service_name = service_name self._client: Optional[httpx.AsyncClient] = http_client - self._access_key_lifecycle_client: Optional[httpx.AsyncClient] = access_key_lifecycle_http_client + self._access_key_lifecycle = AccessKeyLifecycleAuthenticator( + self.config, + http_client=access_key_lifecycle_http_client, + ) self._jwt_validator: Optional[Any] = None - # Soft circuit-breaker state is intentionally local to this middleware - # instance (and therefore to one worker process); failures still fail - # closed independently in every worker. - self._access_key_lifecycle_failure_count = 0 - self._access_key_lifecycle_circuit_open_until = 0.0 if self.config.allow_unsigned_jwt: logger.warning( "auth.allow_unsigned_jwt is enabled. Unsigned JWTs (`alg=none`) are accepted; use only for local/testing." ) - @cached_property - def _access_key_lifecycle_endpoint(self) -> PlatformEndpoint: - """Resolve the auth-service lifecycle endpoint through service discovery.""" - return resolve_service_endpoint("auth", get_platform_config()) - - @cached_property - def _access_key_lifecycle_url(self) -> str: - return f"{self._access_key_lifecycle_endpoint.connect_base_url}/apis/auth/authenticate" - @staticmethod def _principal_from_headers(headers_dict: dict) -> tuple[Principal, None] | tuple[None, JSONResponse]: """Extract and validate a Principal from request headers. @@ -202,17 +189,6 @@ def _get_client(self, request: Request) -> httpx.AsyncClient: self._client = endpoint.async_http_client(timeout=self.config.policy_decision_point_request_timeout_seconds) return self._client - def _get_access_key_lifecycle_client(self) -> httpx.AsyncClient: - """Get a client bound to the discovered auth-service transport. - - Reuses policy_decision_point_request_timeout_seconds as the callout timeout. - """ - if self._access_key_lifecycle_client is None: - self._access_key_lifecycle_client = self._access_key_lifecycle_endpoint.async_http_client( - timeout=self.config.policy_decision_point_request_timeout_seconds - ) - return self._access_key_lifecycle_client - def _update_auth_context(self, principal: Principal) -> None: """Update the observability AuthContext with principal info. @@ -515,7 +491,12 @@ async def _handle_bearer_token_request(self, request: Request, call_next: Callab from .jwt import UnsignedJWTRejectedError try: - resolved = await resolve_bearer_token(self.config, token, jwt_validator=jwt_validator) + resolved = await resolve_bearer_token( + self.config, + token, + jwt_validator=jwt_validator, + skip_access_key_check=self.config.access_keys.enabled, + ) except UnsignedJWTRejectedError as exc: return JSONResponse( status_code=401, @@ -525,8 +506,8 @@ async def _handle_bearer_token_request(self, request: Request, call_next: Callab if resolved is None: if jwt_validator is None: logger.warning( - "Bearer token rejected: access keys enabled but token was not a valid Scoped Access Key " - "(service: %s)", + "Bearer token rejected: OIDC is not configured and the token did not pass " + "the Scoped Access Key candidate check (service: %s)", self.service_name or "unknown", ) return JSONResponse( @@ -536,10 +517,6 @@ async def _handle_bearer_token_request(self, request: Request, call_next: Callab return await self._handle_resolved_bearer_token(request, call_next, resolved) - def _access_key_lifecycle_retry_after(self) -> int: - retry_after = max(1, math.ceil(self._access_key_lifecycle_circuit_open_until - time.monotonic())) - return retry_after - def _access_key_lifecycle_error_response( self, status_code: int, @@ -550,135 +527,20 @@ def _access_key_lifecycle_error_response( headers = {"Retry-After": str(retry_after)} if retry_after is not None else None return JSONResponse(status_code=status_code, content={"detail": detail}, headers=headers) - def _access_key_lifecycle_circuit_response(self) -> JSONResponse | None: - if time.monotonic() < self._access_key_lifecycle_circuit_open_until: - retry_after = self._access_key_lifecycle_retry_after() - logger.warning("Access-key lifecycle validation circuit is open for %s more seconds", retry_after) - return self._access_key_lifecycle_error_response( - 503, - "Access-key lifecycle validation unavailable", - retry_after=retry_after, - ) - return None - - def _record_access_key_lifecycle_success(self) -> None: - self._access_key_lifecycle_failure_count = 0 - self._access_key_lifecycle_circuit_open_until = 0.0 - - def _record_access_key_lifecycle_failure(self) -> int | None: - self._access_key_lifecycle_failure_count += 1 - if self._access_key_lifecycle_failure_count >= _ACCESS_KEY_LIFECYCLE_CIRCUIT_FAILURE_THRESHOLD: - self._access_key_lifecycle_circuit_open_until = ( - time.monotonic() + _ACCESS_KEY_LIFECYCLE_CIRCUIT_OPEN_SECONDS - ) - return self._access_key_lifecycle_retry_after() - return None - - async def _call_access_key_lifecycle(self, token: str) -> httpx.Response | JSONResponse: - """Call the auth-service access-key validator and map transport failures. - - The callout uses the platform endpoint transport and the configured PDP - request timeout. - """ - circuit_response = self._access_key_lifecycle_circuit_response() - if circuit_response is not None: - return circuit_response - - url = self._access_key_lifecycle_url - try: - response = await self._get_access_key_lifecycle_client().get( - url, - headers={"Authorization": f"Bearer {token}"}, - ) - except httpx.ConnectError as exc: - logger.error("Cannot connect to access-key lifecycle validator at %s: %s", url, exc) - return self._access_key_lifecycle_error_response( - 503, - "Access-key lifecycle validation unavailable", - retry_after=self._record_access_key_lifecycle_failure(), - ) - except httpx.TimeoutException as exc: - logger.error("Access-key lifecycle validation timed out at %s: %s", url, exc) - return self._access_key_lifecycle_error_response( - 504, - "Access-key lifecycle validation timeout", - retry_after=self._record_access_key_lifecycle_failure(), - ) - except httpx.HTTPError as exc: - logger.error("Access-key lifecycle validation failed at %s: %s", url, exc) - return self._access_key_lifecycle_error_response( - 502, - "Access-key lifecycle validation error", - retry_after=self._record_access_key_lifecycle_failure(), - ) - - if response.status_code == 200: - return response - if response.status_code == 401: - return response - - logger.error( - "Access-key lifecycle validator returned HTTP %s from %s", - response.status_code, - url, - ) - return self._access_key_lifecycle_error_response( - 503, - "Access-key lifecycle validation unavailable", - retry_after=self._record_access_key_lifecycle_failure(), - ) - - def _resolved_access_key_from_lifecycle_response(self, response: httpx.Response) -> ResolvedBearerToken | None: - try: - body = response.json() - except ValueError: - return None - if not isinstance(body, dict) or body.get("token_kind") != "access_key": - return None - principal = body.get("principal") - if not isinstance(principal, str) or not principal: - return None - email = body.get("email") - groups = body.get("groups") - scopes = body.get("scopes") - jti = body.get("jti") - if not isinstance(jti, str) or not jti: - return None - raw_claims: dict[str, object] = { - "nmp_token_type": "access_key", - "jti": jti, - } - return ResolvedBearerToken( - claims=TokenClaims( - subject=principal, - email=email if isinstance(email, str) else None, - groups=[group for group in groups if isinstance(group, str)] if isinstance(groups, list) else [], - scopes=[scope for scope in scopes if isinstance(scope, str)] if isinstance(scopes, list) else [], - raw_claims=raw_claims, - ), - token_kind="access_key", - ) - async def _authenticate_access_key_lifecycle( self, token: str, ) -> ResolvedBearerToken | Response: - response_or_error = await self._call_access_key_lifecycle(token) - if isinstance(response_or_error, Response): - return response_or_error - if response_or_error.status_code == 401: - self._record_access_key_lifecycle_success() - return JSONResponse(status_code=401, content={"detail": "Invalid or expired token"}) - - resolved = self._resolved_access_key_from_lifecycle_response(response_or_error) - if resolved is None: - logger.error("Access-key lifecycle validator returned an invalid success response") + try: + resolved = await self._access_key_lifecycle.authenticate(token) + except AccessKeyLifecycleUnavailableError as exc: return self._access_key_lifecycle_error_response( - 503, - "Access-key lifecycle validation unavailable", - retry_after=self._record_access_key_lifecycle_failure(), + exc.status_code, + exc.detail, + retry_after=exc.retry_after, ) - self._record_access_key_lifecycle_success() + if resolved is None: + return JSONResponse(status_code=401, content={"detail": "Invalid or expired token"}) return resolved async def _handle_resolved_bearer_token( diff --git a/packages/nmp_common/src/nmp/common/auth/token_resolver.py b/packages/nmp_common/src/nmp/common/auth/token_resolver.py index 1dffa0b21b..e07662b016 100644 --- a/packages/nmp_common/src/nmp/common/auth/token_resolver.py +++ b/packages/nmp_common/src/nmp/common/auth/token_resolver.py @@ -48,8 +48,9 @@ async def resolve_bearer_token( *, jwt_validator: JWTValidator | None = None, extra_resolvers: Sequence[ExtraBearerTokenResolver] = (), + skip_access_key_check: bool = False, ) -> ResolvedBearerToken | None: - if config.access_keys.enabled: + if config.access_keys.enabled and not skip_access_key_check: from .access_keys import validate_access_key_token access_key_claims = await validate_access_key_token(config, token) diff --git a/packages/nmp_common/tests/auth/test_access_key_lifecycle.py b/packages/nmp_common/tests/auth/test_access_key_lifecycle.py new file mode 100644 index 0000000000..0bf496c0da --- /dev/null +++ b/packages/nmp_common/tests/auth/test_access_key_lifecycle.py @@ -0,0 +1,222 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for auth-service Scoped Access Key lifecycle validation.""" + +from unittest.mock import patch + +import httpx +import pytest +from nmp.common.auth.access_key_lifecycle import ( + AccessKeyLifecycleAuthenticator, + AccessKeyLifecycleUnavailableError, +) +from nmp.common.auth.token_resolver import ResolvedBearerToken +from nmp.common.config import AuthConfig, Configuration, PlatformConfig + + +def _config() -> AuthConfig: + return AuthConfig(policy_decision_point_request_timeout_seconds=1.0) + + +@pytest.mark.asyncio +async def test_authenticator_uses_sdk_routing_and_returns_trusted_claims() -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 200, + json={ + "principal": "alice@example.com", + "email": "alice@example.com", + "groups": ["team-ml"], + "scopes": ["models:read"], + "jti": "ak_example", + "token_kind": "access_key", + }, + ) + + platform_config = PlatformConfig( + base_url="http://platform.example.com", + service_discovery={"auth": "http://auth.internal:8080"}, + services="", + ) + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client: + authenticator = AccessKeyLifecycleAuthenticator(_config(), http_client=http_client) + with patch.object(Configuration, "get_platform_config", return_value=platform_config): + result = await authenticator.authenticate("scoped-access-key") + + assert isinstance(result, ResolvedBearerToken) + assert result.claims.subject == "alice@example.com" + assert result.claims.groups == ["team-ml"] + assert result.claims.scopes == ["models:read"] + assert requests[0].url == httpx.URL("http://auth.internal:8080/apis/auth/authenticate") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("token_kind", ["oidc_access_token", "workload_access_token", "workload_subject_token"]) +async def test_authenticator_rejects_successful_non_access_key_response(token_kind: str) -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={ + "principal": "alice@example.com", + "groups": [], + "scopes": [], + "token_kind": token_kind, + }, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client: + authenticator = AccessKeyLifecycleAuthenticator(_config(), http_client=http_client) + with patch.object( + Configuration, + "get_platform_config", + return_value=PlatformConfig(base_url="http://platform.example.com", services=""), + ): + assert await authenticator.authenticate("candidate-token") is None + + +@pytest.mark.asyncio +async def test_authenticator_rejects_malformed_sdk_response() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={ + "principal": 123, + "groups": [], + "scopes": [], + "jti": "ak_example", + "token_kind": "access_key", + }, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client: + authenticator = AccessKeyLifecycleAuthenticator(_config(), http_client=http_client) + with ( + patch.object( + Configuration, + "get_platform_config", + return_value=PlatformConfig(base_url="http://platform.example.com", services=""), + ), + pytest.raises(AccessKeyLifecycleUnavailableError) as exc_info, + ): + await authenticator.authenticate("candidate-token") + + assert exc_info.value.status_code == 503 + + +@pytest.mark.asyncio +async def test_authenticator_returns_none_when_auth_service_rejects_token() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(401, request=request, json={"detail": "Invalid bearer token"}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client: + authenticator = AccessKeyLifecycleAuthenticator(_config(), http_client=http_client) + with patch.object( + Configuration, + "get_platform_config", + return_value=PlatformConfig(base_url="http://platform.example.com", services=""), + ): + assert await authenticator.authenticate("rejected-token") is None + + +@pytest.mark.asyncio +async def test_authenticator_rejects_access_key_response_without_jti() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + request=request, + json={ + "principal": "alice@example.com", + "groups": [], + "scopes": [], + "token_kind": "access_key", + }, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client: + authenticator = AccessKeyLifecycleAuthenticator(_config(), http_client=http_client) + with ( + patch.object( + Configuration, + "get_platform_config", + return_value=PlatformConfig(base_url="http://platform.example.com", services=""), + ), + pytest.raises(AccessKeyLifecycleUnavailableError) as exc_info, + ): + await authenticator.authenticate("candidate-token") + + assert exc_info.value.status_code == 503 + + +@pytest.mark.asyncio +async def test_authenticator_maps_transport_timeout_to_gateway_timeout() -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ReadTimeout("timed out", request=request) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client: + authenticator = AccessKeyLifecycleAuthenticator(_config(), http_client=http_client) + with ( + patch.object( + Configuration, + "get_platform_config", + return_value=PlatformConfig(base_url="http://platform.example.com", services=""), + ), + pytest.raises(AccessKeyLifecycleUnavailableError) as exc_info, + ): + await authenticator.authenticate("candidate-token") + + assert exc_info.value.status_code == 504 + + +@pytest.mark.asyncio +async def test_authenticator_opens_circuit_and_recovers_after_window() -> None: + request_count = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal request_count + request_count += 1 + if request_count <= 3: + return httpx.Response(500, request=request, json={"detail": "auth unavailable"}) + return httpx.Response( + 200, + request=request, + json={ + "principal": "alice@example.com", + "groups": [], + "scopes": [], + "jti": "ak_example", + "token_kind": "access_key", + }, + ) + + monotonic_now = 0.0 + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client: + authenticator = AccessKeyLifecycleAuthenticator(_config(), http_client=http_client) + with ( + patch.object( + Configuration, + "get_platform_config", + return_value=PlatformConfig(base_url="http://platform.example.com", services=""), + ), + patch("nmp.common.auth.access_key_lifecycle.time.monotonic", side_effect=lambda: monotonic_now), + ): + for expected_retry_after in (None, None, 5): + with pytest.raises(AccessKeyLifecycleUnavailableError) as exc_info: + await authenticator.authenticate("candidate-token") + assert exc_info.value.retry_after == expected_retry_after + + with pytest.raises(AccessKeyLifecycleUnavailableError) as exc_info: + await authenticator.authenticate("candidate-token") + assert exc_info.value.status_code == 503 + assert exc_info.value.retry_after == 5 + assert request_count == 3 + + monotonic_now = 6.0 + result = await authenticator.authenticate("candidate-token") + + assert isinstance(result, ResolvedBearerToken) + assert request_count == 4 diff --git a/packages/nmp_common/tests/auth/test_access_keys.py b/packages/nmp_common/tests/auth/test_access_keys.py index 4c09489e0a..b753727aeb 100644 --- a/packages/nmp_common/tests/auth/test_access_keys.py +++ b/packages/nmp_common/tests/auth/test_access_keys.py @@ -52,6 +52,38 @@ def test_access_key_jwks_uri_uses_canonical_auth_jwks_path() -> None: assert "/access-keys/" not in jwks_uri +def test_access_key_candidate_rejects_any_pyjwt_error(monkeypatch: pytest.MonkeyPatch) -> None: + def raise_invalid_token(*args: Any, **kwargs: Any) -> dict[str, Any]: + raise jwt.InvalidTokenError("invalid token") + + monkeypatch.setattr(jwt, "decode", raise_invalid_token) + + assert access_keys_mod.is_access_key_token_candidate("invalid") is False + + +@pytest.mark.parametrize( + ("token_type", "jti", "expected"), + [ + (ACCESS_KEY_TOKEN_TYPE, "ak_" + "a" * 32, True), + (ACCESS_KEY_TOKEN_TYPE, None, False), + (ACCESS_KEY_TOKEN_TYPE, "ak_example", False), + (ACCESS_KEY_TOKEN_TYPE, "ak_" + "A" * 32, False), + ("oidc_access_token", "ak_" + "a" * 32, False), + ], +) +def test_access_key_candidate_requires_token_type_and_canonical_jti( + token_type: str, + jti: str | None, + expected: bool, +) -> None: + payload = {"nmp_token_type": token_type} + if jti is not None: + payload["jti"] = jti + token = jwt.encode(payload, key="", algorithm="none") + + assert access_keys_mod.is_access_key_token_candidate(token) is expected + + def test_token_signing_private_key_file_uses_auth_service_env_override(monkeypatch): monkeypatch.setenv( "NMP_AUTH_TOKEN_SIGNING__PRIVATE_KEY_FILE", @@ -278,16 +310,16 @@ def test_access_key_issuer_service_stamps_current_principal(tmp_path): created = issuer.create( AccessKeyCreateRequest( - name="gtc-intake", - description="GTC intake automation", + name="ci-intake", + description="CI intake automation", expires_in_seconds=600, ) ) unverified = jwt.decode(created.token, options={"verify_signature": False}) assert created.jti.startswith("ak_") - assert created.name == "gtc-intake" - assert created.description == "GTC intake automation" + assert created.name == "ci-intake" + assert created.description == "CI intake automation" assert created.principal == "alice@example.com" assert created.expires_at == datetime.fromtimestamp(1785280600, tz=UTC) assert unverified["jti"] == created.jti @@ -298,7 +330,7 @@ def test_access_key_issuer_service_stamps_current_principal(tmp_path): assert unverified["nmp_token_type"] == ACCESS_KEY_TOKEN_TYPE assert unverified["nmp_access_key"] == { "version": 2, - "name": "gtc-intake", + "name": "ci-intake", } assert unverified["exp"] == 1785280600 @@ -483,7 +515,7 @@ async def test_validate_access_key_token_returns_token_claims(tmp_path): config = _access_key_config(tmp_path, max_expires_in_seconds=None) principal = Principal(id="alice@example.com", email="alice@example.com", groups=["team-ml"]) issuer = AccessKeyIssuerService(config=config, principal=principal, now=lambda: 1785280000) - created = await issuer.create_async(AccessKeyCreateRequest(name="gtc-intake", expires_in_seconds=None)) + created = await issuer.create_async(AccessKeyCreateRequest(name="ci-intake", expires_in_seconds=None)) jwks = {"keys": [await public_jwk_from_private_key_pem_async(config)]} claims = await validate_access_key_token(config, created.token, jwks_override=jwks) @@ -527,7 +559,7 @@ async def test_validate_access_key_token_fetches_remote_jwks_once_with_async_cli config = _access_key_config(tmp_path, max_expires_in_seconds=None) principal = Principal(id="alice@example.com", email="alice@example.com", groups=["team-ml"]) issuer = AccessKeyIssuerService(config=config, principal=principal, now=lambda: 1785280000) - created = await issuer.create_async(AccessKeyCreateRequest(name="gtc-intake", expires_in_seconds=None)) + created = await issuer.create_async(AccessKeyCreateRequest(name="ci-intake", expires_in_seconds=None)) jwks = {"keys": [await public_jwk_from_private_key_pem_async(config)]} jwks_uri = f"https://auth.example.test/{id(jwks)}/jwks" @@ -571,7 +603,7 @@ async def test_validate_access_key_token_propagates_remote_jwks_fetch_failure(tm config = _access_key_config(tmp_path, max_expires_in_seconds=None) principal = Principal(id="alice@example.com", email="alice@example.com", groups=["team-ml"]) issuer = AccessKeyIssuerService(config=config, principal=principal, now=lambda: 1785280000) - created = await issuer.create_async(AccessKeyCreateRequest(name="gtc-intake", expires_in_seconds=None)) + created = await issuer.create_async(AccessKeyCreateRequest(name="ci-intake", expires_in_seconds=None)) jwks_uri = "https://auth.example.test/jwks" class FakeResponse: @@ -603,7 +635,7 @@ async def test_validate_access_key_token_rejects_wrong_audience(tmp_path): principal=Principal(id="alice@example.com", email="alice@example.com"), now=lambda: 1785280000, ) - created = await issuer.create_async(AccessKeyCreateRequest(name="gtc-intake", expires_in_seconds=None)) + created = await issuer.create_async(AccessKeyCreateRequest(name="ci-intake", expires_in_seconds=None)) jwks = {"keys": [await public_jwk_from_private_key_pem_async(config)]} assert await validate_access_key_token(wrong_config, created.token, jwks_override=jwks) is None diff --git a/packages/nmp_common/tests/auth/test_middleware.py b/packages/nmp_common/tests/auth/test_middleware.py index aa0ad29cc8..8501f334a7 100644 --- a/packages/nmp_common/tests/auth/test_middleware.py +++ b/packages/nmp_common/tests/auth/test_middleware.py @@ -3,6 +3,7 @@ """Unit tests for authorization middleware.""" +import asyncio import time from contextlib import asynccontextmanager from unittest.mock import AsyncMock, MagicMock, patch @@ -12,11 +13,11 @@ import pytest from fastapi import Depends, FastAPI from fastapi.testclient import TestClient +from nmp.common.auth.access_key_lifecycle import ACCESS_KEY_LIFECYCLE_CIRCUIT_FAILURE_THRESHOLD from nmp.common.auth.client import AuthClient from nmp.common.auth.dependencies import get_auth_client from nmp.common.auth.jwt import TokenClaims, UnsignedJWTRejectedError from nmp.common.auth.middleware import ( - _ACCESS_KEY_LIFECYCLE_CIRCUIT_FAILURE_THRESHOLD, BYPASS_PREFIXES, HEALTH_ENDPOINTS, PUBLIC_GET_PATHS, @@ -93,7 +94,7 @@ async def make(handler, base_url: str = "http://platform.example.com"): access_key_lifecycle_http_client=http_client, ) with patch( - "nmp.common.auth.middleware.get_platform_config", + "nmp.common.sdk_factory.Configuration.get_platform_config", return_value=PlatformConfig(base_url=base_url, services=""), ): yield middleware @@ -508,15 +509,22 @@ def test_bearer_token_scoped_access_key_accepted_without_oidc(self, auth_config_ scopes=[], raw_claims={"nmp_token_type": "access_key"}, ) + resolved = ResolvedBearerToken(claims=valid_claims, token_kind="access_key") + + with patch("nmp.common.auth.access_keys.is_access_key_token_candidate", return_value=True): + with patch.object( + AuthorizationMiddleware, + "_authenticate_access_key_lifecycle", + new_callable=AsyncMock, + return_value=resolved, + ) as mock_lifecycle: + with patch("nmp.common.auth.client.AuthClient.authorize_request") as mock_authorize: + mock_authorize.return_value = MagicMock(allowed=True) - with patch("nmp.common.auth.access_keys.validate_access_key_token") as mock_validate: - mock_validate.return_value = valid_claims - with patch("nmp.common.auth.client.AuthClient.authorize_request") as mock_authorize: - mock_authorize.return_value = MagicMock(allowed=True) - - response = client.get("/test", headers={"Authorization": "Bearer scoped-access-key"}) + response = client.get("/test", headers={"Authorization": "Bearer scoped-access-key"}) assert response.status_code == 200 + mock_lifecycle.assert_called_once() mock_authorize.assert_called_once() def test_bearer_token_scoped_access_key_invalid_falls_back_to_oidc(self, auth_config_enabled): @@ -534,17 +542,16 @@ def test_bearer_token_scoped_access_key_invalid_falls_back_to_oidc(self, auth_co raw_claims={}, ) - with patch("nmp.common.auth.access_keys.validate_access_key_token") as mock_access_key_validate: - mock_access_key_validate.return_value = None - with patch("nmp.common.auth.jwt.JWTValidator.validate_token") as mock_oidc_validate: - mock_oidc_validate.return_value = oidc_claims - with patch("nmp.common.auth.client.AuthClient.authorize_request") as mock_authorize: - mock_authorize.return_value = MagicMock(allowed=True) + # Non-access-key tokens skip validate_access_key_token entirely (is_access_key_token_candidate + # returns False for OIDC tokens) and fall straight through to OIDC validation. + with patch("nmp.common.auth.jwt.JWTValidator.validate_token") as mock_oidc_validate: + mock_oidc_validate.return_value = oidc_claims + with patch("nmp.common.auth.client.AuthClient.authorize_request") as mock_authorize: + mock_authorize.return_value = MagicMock(allowed=True) - response = client.get("/test", headers={"Authorization": "Bearer oidc-token"}) + response = client.get("/test", headers={"Authorization": "Bearer oidc-token"}) assert response.status_code == 200 - mock_access_key_validate.assert_called_once() mock_oidc_validate.assert_called_once() def test_scoped_access_key_middleware_mapping_is_skipped_when_access_keys_are_disabled( @@ -561,32 +568,8 @@ def test_scoped_access_key_middleware_mapping_is_skipped_when_access_keys_are_di assert response.json()["detail"] == "Bearer token authentication not configured" mock_validate.assert_not_called() - def test_access_key_lifecycle_url_reads_platform_config_lazily_once( - self, - auth_config_oidc_disabled, - monkeypatch: pytest.MonkeyPatch, - ): - monkeypatch.delenv("NMP_AUTH_URL", raising=False) - Configuration.set_override(auth_config_oidc_disabled) - app = FastAPI() - platform_config = PlatformConfig(base_url="http://platform-one:8080", services="") - - with patch( - "nmp.common.auth.middleware.get_platform_config", - side_effect=AssertionError("platform config read too early"), - ): - middleware = AuthorizationMiddleware(app, service_name="test-service") - - with patch( - "nmp.common.auth.middleware.get_platform_config", - side_effect=[platform_config], - ) as get_platform: - assert middleware._access_key_lifecycle_url == "http://platform-one:8080/apis/auth/authenticate" - assert middleware._access_key_lifecycle_url == "http://platform-one:8080/apis/auth/authenticate" - - assert get_platform.call_count == 1 - - def test_access_key_lifecycle_url_uses_auth_service_discovery( + @pytest.mark.asyncio + async def test_access_key_lifecycle_sdk_uses_auth_service_discovery( self, auth_config_oidc_disabled, monkeypatch: pytest.MonkeyPatch, @@ -598,10 +581,32 @@ def test_access_key_lifecycle_url_uses_auth_service_discovery( service_discovery={"auth": "http://auth.internal:8080"}, services="", ) - middleware = AuthorizationMiddleware(FastAPI(), service_name="test-service") + requests: list[httpx.Request] = [] - with patch("nmp.common.auth.middleware.get_platform_config", return_value=platform_config): - assert middleware._access_key_lifecycle_url == "http://auth.internal:8080/apis/auth/authenticate" + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 200, + json={ + "principal": "alice@example.com", + "groups": [], + "scopes": [], + "jti": "ak_example", + "token_kind": "access_key", + }, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client: + middleware = AuthorizationMiddleware( + FastAPI(), + service_name="test-service", + access_key_lifecycle_http_client=http_client, + ) + with patch.object(Configuration, "get_platform_config", return_value=platform_config): + response = await middleware._authenticate_access_key_lifecycle("scoped-access-key") + + assert isinstance(response, ResolvedBearerToken) + assert requests[0].url == httpx.URL("http://auth.internal:8080/apis/auth/authenticate") @pytest.mark.asyncio async def test_access_key_lifecycle_callout_allows_active_token(self, access_key_lifecycle_middleware): @@ -662,7 +667,7 @@ def authenticate_access_key(request: httpx.Request) -> httpx.Response: access_key_lifecycle_http_client=lifecycle_client, ) with patch( - "nmp.common.auth.middleware.get_platform_config", + "nmp.common.sdk_factory.Configuration.get_platform_config", return_value=PlatformConfig(base_url="unix:///tmp/nemo-platform.sock", services=""), ): response = await middleware._authenticate_access_key_lifecycle("scoped-access-key") @@ -721,7 +726,7 @@ def handler(request: httpx.Request) -> httpx.Response: async with access_key_lifecycle_middleware(handler) as middleware: response = None - for _ in range(_ACCESS_KEY_LIFECYCLE_CIRCUIT_FAILURE_THRESHOLD): + for _ in range(ACCESS_KEY_LIFECYCLE_CIRCUIT_FAILURE_THRESHOLD): response = await middleware._authenticate_access_key_lifecycle("scoped-access-key") assert isinstance(response, Response) @@ -730,7 +735,7 @@ def handler(request: httpx.Request) -> httpx.Response: circuit_response = await middleware._authenticate_access_key_lifecycle("scoped-access-key") - assert calls == _ACCESS_KEY_LIFECYCLE_CIRCUIT_FAILURE_THRESHOLD + assert calls == ACCESS_KEY_LIFECYCLE_CIRCUIT_FAILURE_THRESHOLD assert circuit_response is not None assert circuit_response.status_code == 503 assert "retry-after" in circuit_response.headers @@ -755,7 +760,7 @@ def handler(request: httpx.Request) -> httpx.Response: async with access_key_lifecycle_middleware(handler) as middleware: response = None - for _ in range(_ACCESS_KEY_LIFECYCLE_CIRCUIT_FAILURE_THRESHOLD): + for _ in range(ACCESS_KEY_LIFECYCLE_CIRCUIT_FAILURE_THRESHOLD): response = await middleware._authenticate_access_key_lifecycle("scoped-access-key") assert isinstance(response, Response) @@ -764,10 +769,34 @@ def handler(request: httpx.Request) -> httpx.Response: circuit_response = await middleware._authenticate_access_key_lifecycle("scoped-access-key") - assert calls == _ACCESS_KEY_LIFECYCLE_CIRCUIT_FAILURE_THRESHOLD + assert calls == ACCESS_KEY_LIFECYCLE_CIRCUIT_FAILURE_THRESHOLD assert circuit_response.status_code == 503 assert "retry-after" in circuit_response.headers + @pytest.mark.asyncio + async def test_access_key_lifecycle_rejects_wrong_typed_sdk_response( + self, + access_key_lifecycle_middleware, + ): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={ + "principal": 123, + "groups": [], + "scopes": [], + "jti": "ak_example", + "token_kind": "access_key", + }, + ) + + async with access_key_lifecycle_middleware(handler) as middleware: + response = await middleware._authenticate_access_key_lifecycle("scoped-access-key") + + assert isinstance(response, Response) + assert response.status_code == 503 + assert response.body == b'{"detail":"Access-key lifecycle validation unavailable"}' + def test_bearer_token_request_uses_shared_resolver(self, auth_config_enabled): app = create_test_app(auth_config_enabled) client = TestClient(app, raise_server_exceptions=False) @@ -836,34 +865,37 @@ def handler(request: httpx.Request) -> httpx.Response: "sub": "alice@example.com", "iat": int(time.time()), "nbf": int(time.time()), - "jti": "ak_example", + "jti": "ak_" + "a" * 32, "nmp_token_type": "access_key", }, key="", algorithm="none", ) - with ( - patch( - "nmp.common.auth.middleware.get_platform_config", - return_value=PlatformConfig(base_url="http://platform.example.com", services=""), - ), - patch("nmp.common.auth.middleware.resolve_bearer_token", new=AsyncMock()) as resolver, - patch.object(AuthClient, "authorize_request", autospec=True) as mock_authorize, - ): - mock_authorize.return_value = MagicMock(allowed=True) - response = client.get("/whoami", headers={"Authorization": f"Bearer {token}"}) + try: + with ( + patch( + "nmp.common.sdk_factory.Configuration.get_platform_config", + return_value=PlatformConfig(base_url="http://platform.example.com", services=""), + ), + patch("nmp.common.auth.middleware.resolve_bearer_token", new=AsyncMock()) as resolver, + patch.object(AuthClient, "authorize_request", autospec=True) as mock_authorize, + ): + mock_authorize.return_value = MagicMock(allowed=True) + response = client.get("/whoami", headers={"Authorization": f"Bearer {token}"}) - assert response.status_code == 200 - assert response.json() == { - "principal": "alice@example.com", - "email": "alice@example.com", - "groups": ["team-ml"], - } - assert requests[0].url == httpx.URL("http://platform.example.com/apis/auth/authenticate") - assert requests[0].headers["authorization"] == f"Bearer {token}" - resolver.assert_not_awaited() - mock_authorize.assert_called_once() + assert response.status_code == 200 + assert response.json() == { + "principal": "alice@example.com", + "email": "alice@example.com", + "groups": ["team-ml"], + } + assert requests[0].url == httpx.URL("http://platform.example.com/apis/auth/authenticate") + assert requests[0].headers["authorization"] == f"Bearer {token}" + resolver.assert_not_awaited() + mock_authorize.assert_called_once() + finally: + asyncio.run(http_client.aclose()) def test_bearer_token_sets_auth_client_context_for_service_handler(self, auth_config_enabled): app = FastAPI() diff --git a/packages/nmp_platform_runner/src/nmp/platform_runner/server.py b/packages/nmp_platform_runner/src/nmp/platform_runner/server.py index fc5b38e086..a2494a7241 100644 --- a/packages/nmp_platform_runner/src/nmp/platform_runner/server.py +++ b/packages/nmp_platform_runner/src/nmp/platform_runner/server.py @@ -140,6 +140,7 @@ def create_app( services: list[Service] | None = None, controller_run_funcs: dict[str, ControllerRunFunc] | None = None, http_client: httpx.AsyncClient | None = None, + access_key_lifecycle_http_client: httpx.AsyncClient | None = None, ) -> FastAPI: """Create the FastAPI app from service instances.""" services = services or [] @@ -237,7 +238,7 @@ async def run_platform_seed_and_update_readiness() -> None: AuthorizationMiddleware, service_name="platform", http_client=http_client, - access_key_lifecycle_http_client=http_client, + access_key_lifecycle_http_client=access_key_lifecycle_http_client, ) app.add_middleware( CORSMiddleware, @@ -304,6 +305,7 @@ def build_platform_app( config: PlatformAppConfig | None = None, *, http_client: httpx.AsyncClient | None = None, + access_key_lifecycle_http_client: httpx.AsyncClient | None = None, env: MutableMapping[str, str] | None = None, ) -> FastAPI: """Build a platform FastAPI app without starting uvicorn. @@ -337,7 +339,12 @@ def build_platform_app( sidecar_run_funcs = _load_run_functions(sorted(resolved.sidecars), AVAILABLE_SIDECARS) controller_run_funcs.update(sidecar_run_funcs) - return create_app(service_instances, controller_run_funcs=controller_run_funcs, http_client=http_client) + return create_app( + service_instances, + controller_run_funcs=controller_run_funcs, + http_client=http_client, + access_key_lifecycle_http_client=access_key_lifecycle_http_client, + ) def run_server( diff --git a/packages/nmp_platform_runner/tests/test_server.py b/packages/nmp_platform_runner/tests/test_server.py index c9e3f5589e..4696194cf4 100644 --- a/packages/nmp_platform_runner/tests/test_server.py +++ b/packages/nmp_platform_runner/tests/test_server.py @@ -221,18 +221,23 @@ def test_create_app_openapi_registers_rebased_query_param_schemas(monkeypatch): clear_query_param_schemas() -def test_create_app_injects_http_client_for_auth_callouts(monkeypatch): +def test_create_app_uses_separate_http_clients_for_auth_callouts(monkeypatch): platform_cfg = _patch_platform_app_config(monkeypatch, seed_on_startup=False) platform_cfg.services = "" http_client = MagicMock() + lifecycle_http_client = MagicMock() - app = server.create_app(services=[PluginService()], http_client=http_client) + app = server.create_app( + services=[PluginService()], + http_client=http_client, + access_key_lifecycle_http_client=lifecycle_http_client, + ) auth_middleware = next( middleware for middleware in app.user_middleware if middleware.cls is server.AuthorizationMiddleware ) assert auth_middleware.kwargs["http_client"] is http_client - assert auth_middleware.kwargs["access_key_lifecycle_http_client"] is http_client + assert auth_middleware.kwargs["access_key_lifecycle_http_client"] is lifecycle_http_client def test_create_app_mounted_services_drive_sdk_local_routing_without_services_env(monkeypatch): @@ -275,20 +280,32 @@ def test_build_platform_app_returns_app_without_running_uvicorn(monkeypatch): monkeypatch.setattr(runner_config, "get_controller_groups", lambda _controllers: {"all": [], "core": []}) monkeypatch.setattr(server, "order_services_by_dependencies", lambda services: services) - def fake_create_app(services, controller_run_funcs=None, http_client=None): + def fake_create_app( + services, + controller_run_funcs=None, + http_client=None, + access_key_lifecycle_http_client=None, + ): captured["services"] = services captured["controller_run_funcs"] = controller_run_funcs captured["http_client"] = http_client + captured["access_key_lifecycle_http_client"] = access_key_lifecycle_http_client return FastAPI() monkeypatch.setattr(server, "create_app", fake_create_app) - app = server.build_platform_app(runner_config.PlatformAppConfig(services=["agents"], controllers=[]), env={}) + lifecycle_http_client = MagicMock() + app = server.build_platform_app( + runner_config.PlatformAppConfig(services=["agents"], controllers=[]), + access_key_lifecycle_http_client=lifecycle_http_client, + env={}, + ) assert isinstance(app, FastAPI) assert captured["services"] == [plugin_service] assert captured["controller_run_funcs"] == {} assert captured["http_client"] is None + assert captured["access_key_lifecycle_http_client"] is lifecycle_http_client def test_build_platform_app_accepts_platform_app_config(monkeypatch): @@ -300,10 +317,16 @@ def test_build_platform_app_accepts_platform_app_config(monkeypatch): monkeypatch.setattr(runner_config, "get_controller_groups", lambda _controllers: {"all": [], "core": []}) monkeypatch.setattr(server, "order_services_by_dependencies", lambda services: services) - def fake_create_app(services, controller_run_funcs=None, http_client=None): + def fake_create_app( + services, + controller_run_funcs=None, + http_client=None, + access_key_lifecycle_http_client=None, + ): captured["services"] = services captured["controller_run_funcs"] = controller_run_funcs captured["http_client"] = http_client + captured["access_key_lifecycle_http_client"] = access_key_lifecycle_http_client return FastAPI() monkeypatch.setattr(server, "create_app", fake_create_app) @@ -317,6 +340,7 @@ def fake_create_app(services, controller_run_funcs=None, http_client=None): assert captured["services"] == [plugin_service] assert captured["controller_run_funcs"] == {} assert captured["http_client"] is None + assert captured["access_key_lifecycle_http_client"] is None def test_embedded_auth_preflight_invokes_policy_wasm_helper(monkeypatch): diff --git a/packages/nmp_testing/src/nmp/testing/client.py b/packages/nmp_testing/src/nmp/testing/client.py index 38a7c227cd..8d7433a846 100644 --- a/packages/nmp_testing/src/nmp/testing/client.py +++ b/packages/nmp_testing/src/nmp/testing/client.py @@ -459,8 +459,14 @@ async def _pending_asgi_app(scope: Scope, receive: Receive, send: Send) -> None: pdp_timeout = Configuration.get_service_config(AuthConfig).policy_decision_point_request_timeout_seconds async_http_client = httpx.AsyncClient(transport=transport, base_url="http://testserver", timeout=pdp_timeout) - # Create the app with http_client for middleware injection - app = create_app(services_to_start, http_client=async_http_client) + # Both callouts target this in-process ASGI app in tests. Pass them + # explicitly so production callers never assume the PDP transport can + # also reach the auth-service lifecycle endpoint. + app = create_app( + services_to_start, + http_client=async_http_client, + access_key_lifecycle_http_client=async_http_client, + ) transport.app = app # Clean up FastAPI app state that tracks model call counts. diff --git a/sdk/python/nemo-platform/.nmpcontext/openapi.yaml b/sdk/python/nemo-platform/.nmpcontext/openapi.yaml index 9d469f6da4..7ba6dcbfdd 100644 --- a/sdk/python/nemo-platform/.nmpcontext/openapi.yaml +++ b/sdk/python/nemo-platform/.nmpcontext/openapi.yaml @@ -197,6 +197,12 @@ paths: application/json: schema: $ref: '#/components/schemas/AccessKeyErrorResponse' + '409': + description: Concurrent access-key update conflict + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' '501': description: Not Implemented content: @@ -286,6 +292,12 @@ paths: application/json: schema: $ref: '#/components/schemas/AccessKeyErrorResponse' + '409': + description: Concurrent access-key update conflict + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' '501': description: Not Implemented content: diff --git a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/integration/test_filesets.py b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/integration/test_filesets.py index 77ba83f8fb..f16e0f4dbc 100644 --- a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/integration/test_filesets.py +++ b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/integration/test_filesets.py @@ -142,7 +142,7 @@ def test_upload_to_nonexistent_fileset_fails( f"files upload {test_file} nonexistent-fileset-12345 --workspace {random_workspace}", ) - assert_exit_code(result, 1) + assert_exit_code(result, 3) assert "not found" in result.stderr.lower() @pytest.mark.parametrize( 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 5a7d90d70e..81616622f6 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 @@ -12,8 +12,9 @@ AccessKeyOperationNotImplementedError, ) from nmp.common.auth import AuthClient, get_auth_client -from nmp.common.auth.access_keys import AccessKeyValidationError +from nmp.common.auth.access_keys import ACCESS_KEY_JTI_PATTERN, AccessKeyValidationError from nmp.common.config import get_auth_config +from nmp.common.entities import EntityConflictError from nmp.core.auth.app.access_keys import ( AccessKeyNotFoundError, AccessKeyRegistry, @@ -30,7 +31,7 @@ _AccessKeyJTI = Annotated[ str, Path( - pattern=r"^ak_[0-9a-f]{32}$", + pattern=ACCESS_KEY_JTI_PATTERN, description="Stable JWT ID of the Scoped Access Key to revoke.", ), ] @@ -47,12 +48,17 @@ "description": "Not Implemented", "model": schemas.AccessKeyNotImplementedErrorResponse, } +_ACCESS_KEY_CONFLICT_ERROR_RESPONSE: dict[str, Any] = { + "description": "Concurrent access-key update conflict", + "model": schemas.AccessKeyErrorResponse, +} _ACCESS_KEY_CREATE_ERROR_RESPONSES: dict[int | str, dict[str, Any]] = { 400: { "description": "Scoped Access Key creation error", "model": schemas.AccessKeyErrorResponse, }, 404: _ACCESS_KEY_DISABLED_ERROR_RESPONSE, + 409: _ACCESS_KEY_CONFLICT_ERROR_RESPONSE, 501: _ACCESS_KEY_NOT_IMPLEMENTED_ERROR_RESPONSE, } _ACCESS_KEY_LIFECYCLE_ERROR_RESPONSES: dict[int | str, dict[str, Any]] = { @@ -61,6 +67,7 @@ } _ACCESS_KEY_REVOKE_ERROR_RESPONSES: dict[int | str, dict[str, Any]] = { 404: _ACCESS_KEY_DISABLED_OR_NOT_FOUND_ERROR_RESPONSE, + 409: _ACCESS_KEY_CONFLICT_ERROR_RESPONSE, 501: _ACCESS_KEY_NOT_IMPLEMENTED_ERROR_RESPONSE, } @@ -100,6 +107,8 @@ async def create_access_key( raise _not_implemented(exc) from exc except AccessKeyValidationError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + except EntityConflictError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Concurrent conflict; retry.") from exc @router.get( @@ -137,4 +146,6 @@ async def revoke_access_key( 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 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) 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 b3445d5fc9..56bb1eed14 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 @@ -69,10 +69,13 @@ async def list_for_principal(self, principal: str, *, page: int, page_size: int) ) async def revoke(self, jti: str, principal: str) -> bool: + # Note: unlike is_active, revoke has no backfill path for legacy v1 keys that have + # never authenticated after migration. Without the original JWT claims we cannot + # construct a valid entity, so callers receive 404 until the key authenticates once. record = await self._get_owned(jti, principal) - if record.revoked_at is not None: + if record.status == "REVOKED": return False - updated = record.model_copy(update={"revoked_at": datetime.now(tz=UTC)}) + updated = record.model_copy(update={"status": "REVOKED"}) try: await self._entity_client.update(updated) except EntityConflictError: @@ -84,7 +87,7 @@ async def revoke(self, jti: str, principal: str) -> bool: # The key was concurrently hard-deleted between our update and this # read. Treat as already-revoked (idempotent outcome). return False - if current.revoked_at is not None: + if current.status == "REVOKED": return False raise return True @@ -98,7 +101,7 @@ async def is_active(self, jti: str, principal: str, *, claims: TokenClaims | Non record = await self._backfill_legacy_record(jti, principal, claims) if record is None: return False - return self._status(record) == "ACTIVE" + return self._status(record, leeway_seconds=30) == "ACTIVE" async def _get_owned(self, jti: str, principal: str) -> AccessKeyEntity: try: @@ -120,6 +123,8 @@ def _metadata(record: AccessKeyEntity) -> AccessKeyMetadataResponse: name=record.key_name, description=record.description, principal=record.principal, + # Report lifecycle status against the published expiration instant. + # Clock-skew leeway applies only while authenticating the JWT. status=AccessKeyRegistry._status(record), issuer=record.issuer, audiences=list(dict.fromkeys(record.audiences)), @@ -128,10 +133,14 @@ def _metadata(record: AccessKeyEntity) -> AccessKeyMetadataResponse: ) @staticmethod - def _status(record: AccessKeyEntity) -> AccessKeyStatus: - if record.revoked_at is not None: + def _status(record: AccessKeyEntity, *, leeway_seconds: int = 0) -> AccessKeyStatus: + if record.status == "REVOKED": return "REVOKED" - if record.expires_at is not None and record.expires_at <= datetime.now(tz=UTC) - timedelta(seconds=30): + 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" return "ACTIVE" @@ -160,7 +169,7 @@ async def _backfill_legacy_record( }, ) # Return the locally-constructed record rather than re-fetching. The - # immediate caller (is_active) only reads revoked_at and expires_at, + # immediate caller (is_active) only reads status and expires_at, # both of which are set locally. If this method is extended to use # server-assigned fields (e.g. db_version), re-fetch here instead. return record @@ -186,7 +195,15 @@ def _record_from_validated_claims( return None metadata = raw_claims.get("nmp_access_key") - if not isinstance(metadata, dict) or metadata.get("version") != LEGACY_ACCESS_KEY_METADATA_VERSION: + if not isinstance(metadata, dict): + return None + if metadata.get("version") != LEGACY_ACCESS_KEY_METADATA_VERSION: + logger.warning( + "Access key %s (version=%s) has no registry record and cannot be backfilled; " + "this key will be rejected until its record is restored", + jti, + metadata.get("version"), + ) return None key_name = metadata.get("name") return AccessKeyEntity( diff --git a/services/core/auth/src/nmp/core/auth/entities/entities.py b/services/core/auth/src/nmp/core/auth/entities/entities.py index 13e479cd16..3bf4627831 100644 --- a/services/core/auth/src/nmp/core/auth/entities/entities.py +++ b/services/core/auth/src/nmp/core/auth/entities/entities.py @@ -4,8 +4,10 @@ """Auth service entities.""" from datetime import datetime +from typing import Any, Literal from nmp.common.entities import EntityBase +from pydantic import model_validator class RoleBindingEntity(EntityBase): @@ -47,4 +49,12 @@ class AccessKeyEntity(EntityBase): audiences: list[str] issued_at: datetime expires_at: datetime | None = None - revoked_at: datetime | None = None + status: Literal["ACTIVE", "REVOKED", "SUSPENDED"] = "ACTIVE" + + @model_validator(mode="before") + @classmethod + def _migrate_revoked_at(cls, data: Any) -> Any: + if isinstance(data, dict) and data.get("revoked_at") is not None: + data = dict(data) + data["status"] = "REVOKED" + return data diff --git a/services/core/auth/tests/test_access_key_registry.py b/services/core/auth/tests/test_access_key_registry.py index 72fb13e3df..387ec37b70 100644 --- a/services/core/auth/tests/test_access_key_registry.py +++ b/services/core/auth/tests/test_access_key_registry.py @@ -24,19 +24,54 @@ def _record(*, jti: str = "ak_example", principal: str = "alice@example.com", re principal=principal, issued_at=NOW, expires_at=datetime(2030, 1, 1, tzinfo=UTC), - revoked_at=NOW if revoked else None, + status="REVOKED" if revoked else "ACTIVE", issuer="https://platform.example.com/apis/auth", audiences=["nemo-platform-access-key"], ) +def _suspended_record() -> AccessKeyEntity: + return _record(jti="ak_suspended").model_copy(update={"status": "SUSPENDED"}) + + def _expired_record() -> AccessKeyEntity: - # Use a far-past date so the result is clock-independent. - # AccessKeyRegistry._status applies a 30s leeway, so expires_at must be - # well before now to reliably return "EXPIRED". return _record(jti="ak_expired").model_copy(update={"expires_at": datetime(2000, 1, 1, tzinfo=UTC)}) +def test_access_key_entity_migrates_legacy_revoked_at_to_status() -> None: + record = AccessKeyEntity.model_validate( + { + "name": "ak_legacy_revoked", + "workspace": "system", + "principal": "alice@example.com", + "issued_at": NOW, + "expires_at": datetime(2030, 1, 1, tzinfo=UTC), + "issuer": "https://platform.example.com/apis/auth", + "audiences": ["nemo-platform-access-key"], + "revoked_at": NOW, + } + ) + + assert record.status == "REVOKED" + + +def test_access_key_entity_defaults_unrevoked_legacy_record_to_active() -> None: + record = AccessKeyEntity.model_validate( + { + "name": "ak_legacy_active", + "workspace": "system", + "principal": "alice@example.com", + "issued_at": NOW, + "expires_at": datetime(2030, 1, 1, tzinfo=UTC), + "issuer": "https://platform.example.com/apis/auth", + "audiences": ["nemo-platform-access-key"], + "revoked_at": None, + } + ) + + assert record.status == "ACTIVE" + + @pytest.mark.asyncio async def test_registry_persists_created_key_metadata() -> None: entity_client = AsyncMock() @@ -115,6 +150,20 @@ async def test_registry_reports_expired_status() -> None: assert result.data[0].status == "EXPIRED" +@pytest.mark.asyncio +async def test_registry_reports_expired_at_timestamp_while_authentication_allows_clock_skew() -> None: + record = _record().model_copy(update={"expires_at": datetime.now(tz=UTC) - timedelta(seconds=1)}) + entity_client = AsyncMock() + entity_client.list.return_value = SimpleNamespace(data=[record], pagination=SimpleNamespace(total_pages=1)) + entity_client.get.return_value = record + registry = AccessKeyRegistry(entity_client) + + result = await registry.list_for_principal("alice@example.com", page=1, page_size=100) + + assert result.data[0].status == "EXPIRED" + assert await registry.is_active(record.name, record.principal) + + @pytest.mark.asyncio async def test_registry_revokes_owned_key_without_deleting_audit_record() -> None: entity_client = AsyncMock() @@ -126,8 +175,8 @@ async def test_registry_revokes_owned_key_without_deleting_audit_record() -> Non updated = entity_client.update.await_args.args[0] assert updated is not original - assert original.revoked_at is None - assert updated.revoked_at is not None + assert original.status == "ACTIVE" + assert updated.status == "REVOKED" entity_client.delete.assert_not_awaited() @@ -166,7 +215,7 @@ async def test_registry_can_newly_revoke_expired_key() -> None: assert await registry.revoke("ak_expired", "alice@example.com") updated = entity_client.update.await_args.args[0] - assert updated.revoked_at is not None + assert updated.status == "REVOKED" @pytest.mark.asyncio @@ -227,6 +276,54 @@ async def test_registry_backfills_missing_legacy_access_key_from_validated_claim assert saved.expires_at == datetime.fromtimestamp(1_893_456_000, tz=UTC) +@pytest.mark.asyncio +async def test_registry_reports_suspended_key_as_revoked_in_list() -> None: + entity_client = AsyncMock() + entity_client.list.return_value = SimpleNamespace( + data=[_suspended_record()], pagination=SimpleNamespace(total_pages=1) + ) + registry = AccessKeyRegistry(entity_client) + + result = await registry.list_for_principal("alice@example.com", page=1, page_size=100) + + assert result.data[0].status == "REVOKED" + + +@pytest.mark.asyncio +async def test_registry_revoke_transitions_suspended_key_to_revoked() -> None: + entity_client = AsyncMock() + entity_client.get.return_value = _suspended_record() + registry = AccessKeyRegistry(entity_client) + + assert await registry.revoke("ak_suspended", "alice@example.com") + + updated = entity_client.update.await_args.args[0] + assert updated.status == "REVOKED" + + +@pytest.mark.asyncio +async def test_registry_concurrent_revoke_retries_when_key_is_only_suspended() -> None: + entity_client = AsyncMock() + entity_client.get.side_effect = [_record(), _suspended_record()] + entity_client.update.side_effect = EntityConflictError("entity version changed") + registry = AccessKeyRegistry(entity_client) + + with pytest.raises(EntityConflictError, match="entity version changed"): + await registry.revoke("ak_suspended", "alice@example.com") + + assert entity_client.get.await_count == 2 + entity_client.update.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_registry_reports_suspended_key_as_inactive() -> None: + entity_client = AsyncMock() + entity_client.get.return_value = _suspended_record() + registry = AccessKeyRegistry(entity_client) + + assert not await registry.is_active("ak_suspended", "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 61a0ac32a8..d3611c33af 100644 --- a/services/core/auth/tests/test_access_keys.py +++ b/services/core/auth/tests/test_access_keys.py @@ -131,8 +131,8 @@ def test_create_access_key_returns_token_for_current_principal(client): response = client.post( "/v2/access-keys", json={ - "name": "gtc-intake", - "description": "GTC intake automation", + "name": "ci-intake", + "description": "CI intake automation", "expires_in_seconds": 3600, }, ) @@ -140,8 +140,8 @@ def test_create_access_key_returns_token_for_current_principal(client): assert response.status_code == 200 body = response.json() assert body["jti"].startswith("ak_") - assert body["name"] == "gtc-intake" - assert body["description"] == "GTC intake automation" + assert body["name"] == "ci-intake" + assert body["description"] == "CI intake automation" assert body["token_type"] == "Bearer" assert body["principal"] == "alice@example.com" assert body["expires_at"] is not None @@ -152,7 +152,7 @@ def test_create_and_revoke_emit_actor_aware_audit_logs(client, caplog): with caplog.at_level(logging.INFO, logger="nmp.core.auth.app.access_keys"): created = client.post( "/v2/access-keys", - json={"name": "gtc-intake", "description": "GTC intake automation"}, + json={"name": "ci-intake", "description": "CI intake automation"}, ).json() response = client.delete(f"/v2/access-keys/{created['jti']}") repeat_response = client.delete(f"/v2/access-keys/{created['jti']}") @@ -169,7 +169,7 @@ def test_create_and_revoke_emit_actor_aware_audit_logs(client, caplog): assert events["access_key.revoke_noop"].access_key_jti == created["jti"] assert events["access_key.revoke_noop"].access_key_already_revoked assert created["token"] not in caplog.text - assert "GTC intake automation" not in caplog.text + assert "CI intake automation" not in caplog.text @pytest.mark.asyncio @@ -208,12 +208,12 @@ def test_create_access_key_allows_unnamed_tokens(client): def test_create_access_key_defaults_expiration_when_omitted(client): - response = client.post("/v2/access-keys", json={"name": "gtc-intake"}) + response = client.post("/v2/access-keys", json={"name": "ci-intake"}) assert response.status_code == 200 body = response.json() assert body["jti"].startswith("ak_") - assert body["name"] == "gtc-intake" + assert body["name"] == "ci-intake" assert body["expires_at"] is not None assert body["token"].count(".") == 2 @@ -251,7 +251,7 @@ async def create_async(self, request): client.app.dependency_overrides[get_access_key_issuer] = lambda: NotImplementedIssuer() try: - response = client.post("/v2/access-keys", json={"name": "gtc-intake"}) + response = client.post("/v2/access-keys", json={"name": "ci-intake"}) finally: client.app.dependency_overrides.clear() @@ -316,6 +316,10 @@ def test_access_key_lifecycle_openapi_documents_error_responses(client): assert create_responses["404"]["content"]["application/json"]["schema"] == { "$ref": "#/components/schemas/AccessKeyErrorResponse" } + assert create_responses["409"]["description"] == "Concurrent access-key update conflict" + assert create_responses["409"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/AccessKeyErrorResponse" + } assert create_responses["501"]["description"] == "Not Implemented" assert create_responses["501"]["content"]["application/json"]["schema"] == { "$ref": "#/components/schemas/AccessKeyNotImplementedErrorResponse" @@ -377,6 +381,10 @@ def test_access_key_lifecycle_openapi_documents_error_responses(client): assert revoke_responses["404"]["content"]["application/json"]["schema"] == { "$ref": "#/components/schemas/AccessKeyErrorResponse" } + assert revoke_responses["409"]["description"] == "Concurrent access-key update conflict" + assert revoke_responses["409"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/AccessKeyErrorResponse" + } assert revoke_responses["501"]["description"] == "Not Implemented" assert revoke_responses["501"]["content"]["application/json"]["schema"] == { "$ref": "#/components/schemas/AccessKeyNotImplementedErrorResponse" @@ -386,7 +394,7 @@ def test_access_key_lifecycle_openapi_documents_error_responses(client): def test_list_access_keys_returns_current_principals_persisted_keys(client): created = client.post( "/v2/access-keys", - json={"name": "gtc-intake", "description": "GTC intake automation"}, + json={"name": "ci-intake", "description": "CI intake automation"}, ).json() response = client.get("/v2/access-keys") @@ -395,11 +403,11 @@ def test_list_access_keys_returns_current_principals_persisted_keys(client): assert response.json()["data"] == [ { "jti": created["jti"], - "name": "gtc-intake", + "name": "ci-intake", "principal": "alice@example.com", "created_at": created["created_at"], "expires_at": created["expires_at"], - "description": "GTC intake automation", + "description": "CI intake automation", "status": "ACTIVE", "issuer": "http://testserver/apis/auth", "audiences": ["nemo-platform-access-key"], @@ -429,7 +437,7 @@ def test_list_access_keys_supports_pagination(client): def test_revoke_access_key_marks_key_revoked_in_listing(client): - created = client.post("/v2/access-keys", json={"name": "gtc-intake"}).json() + created = client.post("/v2/access-keys", json={"name": "ci-intake"}).json() response = client.delete(f"/v2/access-keys/{created['jti']}")