Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
20d92a2
[FEAT] Allow unpublishing an exported tool; derive API-key target fro…
hari-kuriakose Jul 25, 2026
cae9099
[FIX] Restrict registry tool deletion to the project's owner
hari-kuriakose Jul 24, 2026
facf9c7
[TEST] Pin the authorization and in-use guards on registry tool deletion
hari-kuriakose Jul 24, 2026
bfde081
[FIX] Close the API-key create IDOR; make the registry 409 actionable
hari-kuriakose Jul 31, 2026
4666d4b
[FIX] Close the API-key IDOR on every route, not just the path one
hari-kuriakose Jul 31, 2026
bad1ded
[FIX] A malformed identifier is a 404, not a 500
hari-kuriakose Jul 31, 2026
3b06040
[TEST] Exercise the malformed-id catch instead of grepping for it
hari-kuriakose Jul 31, 2026
00165fc
[REFACTOR] Share the test source-extraction helper
hari-kuriakose Jul 31, 2026
9e255db
[TEST] Keep extracted-body tracebacks pointing at real lines
hari-kuriakose Jul 31, 2026
d017039
[FIX] Pin route/permission wiring; guard get_active_pipeline (F1, F2)
hari-kuriakose Aug 3, 2026
655881b
[FIX] Refuse same-field target mismatch; correct test-technique claim…
hari-kuriakose Aug 3, 2026
1e6b96b
[TEST] Harden the wiring assertions against unrelated edits
hari-kuriakose Aug 3, 2026
6afb438
[TEST] Pin that registry deletion is organization-scoped (G1, G3)
hari-kuriakose Aug 4, 2026
8476b0c
[FIX] Unbreak pre-commit.ci: attribute docstring reads as a second mo…
hari-kuriakose Aug 4, 2026
ec7277b
Merge branch 'main' into feat/prompt-studio-ergonomics
muhammad-ali-e Aug 6, 2026
2eeef07
Merge branch 'main' into feat/prompt-studio-ergonomics
muhammad-ali-e Aug 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 97 additions & 1 deletion backend/api_v2/api_key_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from permissions.permission import IsOwnerOrSharedUser, IsParentDeploymentOwner
from pipeline_v2.exceptions import PipelineNotFound
from pipeline_v2.pipeline_processor import PipelineProcessor
from rest_framework import serializers, viewsets
from rest_framework import serializers, status, viewsets
from rest_framework.decorators import action
from rest_framework.request import Request
from rest_framework.response import Response
Expand Down Expand Up @@ -33,6 +33,102 @@
return APIKeyListSerializer
return APIKeySerializer

def create(self, request: Request, *args: Any, **kwargs: Any) -> Response:

Check failure on line 36 in backend/api_v2/api_key_views.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 17 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=Zipstack_unstract&issues=AZ_HvqUOvDhZrzQ7Bv_H&open=AZ_HvqUOvDhZrzQ7Bv_H&pullRequest=2206
"""Create an API key for the deployment or pipeline being targeted.

`POST keys/api/<api_id>/` and `POST keys/pipeline/<pipeline_id>/`
already name the resource in the path, so callers need not repeat it
in the body. The body-only routes (`keys/api/`, `keys/pipeline/`) name
it in `api` / `pipeline` instead.

Whichever route is used, the target is resolved and **ownership is
checked here**, because `create` is collection-level: DRF resolves
`IsParentDeploymentOwner` for it but never calls `get_object()`, so
`has_object_permission` never runs on its own. Without this, any org
member could mint a live key for a deployment they do not own. The
check must cover the body-only routes too — otherwise the same hole is
simply reachable by moving the identifier from the path into the body.

The path target is authoritative: a body naming the *other* target is
a contradiction, not an override, and is refused rather than silently
creating a key for whichever one wins. A body repeating the *same*
target with the *same* value is accepted and overwritten -- it agrees
with the path, so there is nothing to refuse. A body naming the same
field with a *different* value is refused for the same reason as the
cross-type case: silently minting a key for the path's resource while
the caller named another one is a wrong-resource credential, not a
harmless override.
"""
# A JSON array (or scalar) body has no `.copy()` returning a mapping;
# reject it as a 400 rather than letting `AttributeError` become a 500.
if not isinstance(request.data, dict):
raise serializers.ValidationError(
{"non_field_errors": "Request body must be a JSON object."}
)
request_data = request.data.copy()

api_id = kwargs.get("api_id")
pipeline_id = kwargs.get("pipeline_id")

if api_id and request_data.get("pipeline"):
raise serializers.ValidationError(
{
"pipeline": "This endpoint creates a key for the API "
"deployment named in the URL; remove `pipeline` from the body."
}
)
if pipeline_id and request_data.get("api"):
raise serializers.ValidationError(
{
"api": "This endpoint creates a key for the pipeline named "
"in the URL; remove `api` from the body."
}
)

# Same field, different value: the caller named one resource in the
# path and another in the body. Overwriting silently would mint a live
# key for a resource they did not ask for -- refuse, as for the
# cross-type contradictions above. An empty body value is not a
# disagreement; it simply does not name anything.
for field, path_value in (("api", api_id), ("pipeline", pipeline_id)):
body_value = request_data.get(field)
if path_value and body_value and str(body_value) != str(path_value):
raise serializers.ValidationError(
{
field: f"`{field}` in the body names a different resource "
"than the URL; remove it or make the two agree."
}
)

# The path wins where it names a target; otherwise fall back to the
# body, so the body-only routes resolve to the same guarded path.
api_id = api_id or request_data.get("api")
pipeline_id = pipeline_id or request_data.get("pipeline")

if api_id:
api = DeploymentHelper.get_api_by_id(api_id=api_id)
if not api:
raise APINotFound()
self.check_object_permissions(request, api)
request_data["api"] = api_id
elif pipeline_id:
# `check_active=False`: minting a key does not require a running
# pipeline, and `get_active_pipeline` would both 422 on a paused
# one and disclose its state before the ownership check below.
pipeline = PipelineProcessor.get_pipeline_by_id(pipeline_id=pipeline_id)
if not pipeline:
raise PipelineNotFound()
self.check_object_permissions(request, pipeline)
request_data["pipeline"] = pipeline_id
# Neither named: let the serializer raise its "one of api/pipeline"
# error rather than inventing a second wording for the same condition.

serializer = self.get_serializer(data=request_data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)

@action(detail=True, methods=["get"])
def api_keys(
self,
Expand Down
Loading
Loading