Skip to content

UN-2407 [FEAT] Add global API deployment keys for grouped API deployments#1881

Open
Deepak-Kesavan wants to merge 13 commits into
mainfrom
UN-2407-unstract-api-need-to-generate-common-api-key-for-group-of-api-deployments
Open

UN-2407 [FEAT] Add global API deployment keys for grouped API deployments#1881
Deepak-Kesavan wants to merge 13 commits into
mainfrom
UN-2407-unstract-api-need-to-generate-common-api-key-for-group-of-api-deployments

Conversation

@Deepak-Kesavan

@Deepak-Kesavan Deepak-Kesavan commented Mar 31, 2026

Copy link
Copy Markdown
Contributor

What

  • Add Global API Deployment Keys feature that allows generating a single API key to authenticate against multiple API deployments within an organization.

Why

  • Currently, each API deployment requires its own API key. Users managing multiple deployments need a way to generate a common key that works across a group (or all) deployments, simplifying API key management and reducing operational overhead.

How

Backend:

  • New Django app global_api_deployment_key with model, views, serializers, URLs, and permissions
  • GlobalApiDeploymentKey model supports allow_all_deployments (org-wide access) or explicit M2M assignment to specific APIDeployment instances
  • CRUD + rotate endpoints under /api/v1/unstract/<org_id>/global-api-deployment/
  • Key validation in KeyHelper.validate_global_api_deployment_key() checks key existence, active status, org match, and deployment access
  • Middleware update to suppress verbose socket logs

Frontend:

  • New GlobalApiDeploymentKeys component with full CRUD UI (create, edit, delete, rotate, copy key)
  • Settings page and route at /:orgName/settings/global-api-deployment-keys
  • Side nav bar updated with new menu item under admin settings

Can this PR break any existing features. If yes, please list possible items. If no, please explain why. (PS: Admins do not merge the PR without this section filled)

  • No. This is a new additive feature:
    • New Django app with its own model, URLs, and views — no existing models or views are modified
    • KeyHelper only gets a new static method; existing key validation methods are untouched
    • Frontend adds a new route and nav item; no existing components are changed
    • The middleware socket log suppression is isolated to /api/v1/socket paths

Database Migrations

  • Yes — 0001_initial.py creates:
    • global_api_deployment_key table with UUID PK, name, description, key, is_active, allow_all_deployments, org FK, created_by FK, modified_by FK
    • M2M join table for api_deployments relationship
    • Unique constraint on (name, organization)

Env Config

  • None

Relevant Docs

  • N/A

Related Issues or PRs

  • Resolves UN-2407

Dependencies Versions

  • No new dependencies

Notes on Testing

  • Create a global API deployment key with "Allow All Deployments" enabled — verify it authenticates against any deployment in the org
  • Create a key with specific deployments assigned — verify it only works for those deployments
  • Rotate a key — verify old key stops working, new key is returned
  • Deactivate a key — verify it can no longer authenticate
  • Verify admin-only access — non-admin users should not see the settings page or access the API

Screenshots

image image

Checklist

I have read and understood the Contribution Guidelines.

…ents

Add ability to generate a common API key that works across a group of
API deployments, allowing users to manage shared access without
creating individual keys per deployment.
@coderabbitai

coderabbitai Bot commented Mar 31, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds global API deployment keys end to end: backend auth now distinguishes global keys, the API exposes CRUD and rotation for them, and the frontend adds an admin settings page to manage keys. Safe-text validation is moved into a shared utility.

Changes

Global API deployment key feature

Layer / File(s) Summary
Auth execution and serializer context
backend/api_v2/deployment_helper.py, backend/api_v2/dto.py, backend/api_v2/api_deployment_views.py, backend/api_v2/serializers.py
Authentication now returns whether a global key was used, stores that flag on the deployment execution DTO, passes it into request serializer context, and uses it to branch profile validation.
Global key app and API surface
backend/backend/settings/base.py, backend/global_api_deployment_key/apps.py, backend/global_api_deployment_key/migrations/*, backend/global_api_deployment_key/models.py, backend/global_api_deployment_key/permissions.py, backend/global_api_deployment_key/serializers.py, backend/global_api_deployment_key/views.py, backend/global_api_deployment_key/urls.py, backend/backend/urls_v2.py
Adds the Django app, model, permission, serializers, viewset actions, migrations, and routes for global API deployment keys and their allowed deployments.
Frontend management UI
frontend/src/components/navigations/side-nav-bar/SideNavBar.jsx, frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.css, frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.jsx, frontend/src/pages/GlobalApiDeploymentKeysPage.jsx, frontend/src/routes/useMainAppRoutes.js
Introduces the settings page, list/table actions, create/edit modals, navigation entry, route, and styling for managing global API deployment keys.
Shared text sanitizer
backend/utils/input_sanitizer.py, backend/platform_api/serializers.py
Moves safe-text validation into a shared utility and updates platform serializers to import it.

Estimated code review effort: 4 (Complex) | ~45 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 35.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding global API deployment keys for grouped deployments.
Description check ✅ Passed The PR description follows the repository template and includes all required sections with relevant details.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch UN-2407-unstract-api-need-to-generate-common-api-key-for-group-of-api-deployments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
13.6% Duplication on New Code (required ≤ 3%)

See analysis details on SonarQube Cloud

@greptile-apps

greptile-apps Bot commented Mar 31, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds organization-wide API deployment keys.

  • Introduces the global-key model, migration, admin-only CRUD and rotation endpoints, and deployment scope validation.
  • Extends deployment authentication and LLM profile validation to support global keys while preserving organization boundaries.
  • Adds the global-key management page, protected route, navigation entry, and shared API-key UI behavior.
  • Adds backend tests covering authorization boundaries, fallback authentication, profile scoping, and partial scope updates.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failures remain within the scope of the previous review threads.

Important Files Changed

Filename Overview
backend/global_api_deployment_key/serializers.py The organization-scoped deployment field resolves the earlier cross-organization assignment issue, and partial updates now leave untouched scopes editable and deactivatable.
backend/api_v2/serializers.py Global-key execution now validates profile organization through its Prompt Studio tool and intentionally fails closed when no organization can be derived.
backend/api_v2/deployment_helper.py Deployment authentication now falls back from a deployment-specific key to a global key and carries the resolved authorization identity downstream.
frontend/src/routes/useMainAppRoutes.js The global API deployment key settings page is registered within the existing administrator-only route boundary.
frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.jsx Adds management workflows for creating, editing, rotating, deleting, activating, and copying global deployment keys.

Reviews (11): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile

Comment thread backend/utils/log_events.py
Comment thread backend/global_api_deployment_key/serializers.py Outdated
Comment thread frontend/src/routes/useMainAppRoutes.js Outdated
Comment thread backend/middleware/request_id.py Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🧹 Nitpick comments (5)
backend/global_api_deployment_key/permissions.py (1)

1-3: Use a feature-specific permission class message.

Current re-export will return the upstream message mentioning “platform API keys,” which is misleading for this endpoint.

♻️ Suggested adjustment
-from platform_api.permissions import IsOrganizationAdmin
+from platform_api.permissions import IsOrganizationAdmin as PlatformIsOrganizationAdmin
+
+
+class IsOrganizationAdmin(PlatformIsOrganizationAdmin):
+    message = "Only organization admins can manage global API deployment keys."

 __all__ = ["IsOrganizationAdmin"]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/global_api_deployment_key/permissions.py` around lines 1 - 3, The
re-export of IsOrganizationAdmin exposes the upstream error message mentioning
“platform API keys”; create a small feature-specific subclass (e.g.,
GlobalAPIDeploymentKeyIsOrganizationAdmin) that inherits from
IsOrganizationAdmin and overrides the permission error message/attribute to
mention global API deployment keys (or this endpoint) and then export that
subclass in __all__ instead of the upstream name; update any references to use
the new subclass name so the endpoint shows the correct permission message.
backend/api_v2/key_helper.py (1)

96-97: Add explicit exception chaining in except blocks raising UnauthorizedKey.

Use raise UnauthorizedKey() from None (or from err) to make exception intent explicit and satisfy Ruff B904. This applies to lines 39, 97, and 104.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/api_v2/key_helper.py` around lines 96 - 97, Update the except blocks
that currently do "except (ValueError, AttributeError): raise UnauthorizedKey()"
to use explicit exception chaining; replace the raise with "raise
UnauthorizedKey() from None" (or "from err" if you capture the exception as
"except (ValueError, AttributeError) as err:") for each place that raises
UnauthorizedKey (the three except blocks that currently raise UnauthorizedKey).
frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.jsx (2)

352-382: Move DeploymentScopeFields outside the parent component.

Defining DeploymentScopeFields inside GlobalApiDeploymentKeys causes it to be re-created on every render, which can lead to:

  • Loss of component state and focus when parent re-renders
  • Unnecessary re-mounts affecting form validation state

Since DeploymentScopeFields uses deployments from the parent scope, pass it as a prop.

♻️ Proposed refactor

Define DeploymentScopeFields outside the component (above or in a separate file):

// Outside GlobalApiDeploymentKeys function
function DeploymentScopeFields({ form, deployments }) {
  const allowAll = Form.useWatch("allow_all_deployments", form);
  return (
    <>
      <Form.Item
        name="allow_all_deployments"
        valuePropName="checked"
        initialValue={true}
      >
        <Checkbox>Allow all API deployments</Checkbox>
      </Form.Item>
      <Form.Item name="api_deployments" label="Select API Deployments">
        <Select
          mode="multiple"
          placeholder="Search and select deployments"
          optionFilterProp="children"
          className="gadk__deployment-select"
          showSearch
          disabled={allowAll !== false}
        >
          {deployments?.map((d) => (
            <Select.Option key={d?.id} value={d?.id}>
              {d?.display_name || d?.api_name}
              {!d?.is_active && " (inactive)"}
            </Select.Option>
          ))}
        </Select>
      </Form.Item>
    </>
  );
}

Then update usages:

-          <DeploymentScopeFields form={createForm} />
+          <DeploymentScopeFields form={createForm} deployments={deployments} />
-          <DeploymentScopeFields form={editForm} />
+          <DeploymentScopeFields form={editForm} deployments={deployments} />
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.jsx`
around lines 352 - 382, DeploymentScopeFields is currently defined inside
GlobalApiDeploymentKeys causing it to be recreated on every render; move the
DeploymentScopeFields function out of the GlobalApiDeploymentKeys component
(define it above or in a separate file) and accept deployments as a prop (i.e.,
change signature to DeploymentScopeFields({ form, deployments })) so it uses
Form.useWatch("allow_all_deployments", form) but no longer closes over parent
scope; update all usages inside GlobalApiDeploymentKeys to pass the form and
deployments props accordingly to preserve state, focus and avoid unnecessary
remounts.

161-175: Consider disabling the switch during status update to prevent double-clicks.

handleToggleStatus doesn't prevent concurrent updates. Rapid toggling could cause race conditions or confusing UI states.

♻️ Optional: Track loading state per row
const [updatingIds, setUpdatingIds] = useState(new Set());

const handleToggleStatus = (record) => {
  if (updatingIds.has(record?.id)) return;
  setUpdatingIds((prev) => new Set(prev).add(record?.id));
  axiosPrivate({
    // ... existing code
  })
    .then(() => fetchKeys())
    .catch((err) =>
      setAlertDetails(handleException(err, "Failed to update key status"))
    )
    .finally(() => {
      setUpdatingIds((prev) => {
        const next = new Set(prev);
        next.delete(record?.id);
        return next;
      });
    });
};

// In render:
<Switch
  size="small"
  checked={record?.is_active}
  loading={updatingIds.has(record?.id)}
  onChange={() => handleToggleStatus(record)}
/>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.jsx`
around lines 161 - 175, handleToggleStatus currently allows rapid concurrent
requests which can cause race conditions; add a per-row loading guard using a
state like updatingIds (useState(new Set())) to ignore toggles when the id is
present, set the id into updatingIds before calling axiosPrivate and remove it
in finally, and pass updatingIds.has(record?.id) to the Switch component's
loading prop while keeping onChange tied to handleToggleStatus so the switch is
disabled/indicates loading during the update; ensure you still call fetchKeys()
on success and handleException on error.
backend/global_api_deployment_key/views.py (1)

75-81: Consider pagination for the deployments endpoint.

For organizations with many API deployments, returning all records without pagination could impact performance. This is acceptable for admin-only usage with small datasets, but may need pagination if deployment counts grow.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/global_api_deployment_key/views.py` around lines 75 - 81, The
deployments action currently returns all APIDeployment objects unpaginated;
update the deployments method to use DRF pagination by calling
self.paginate_queryset(APIDeployment.objects.filter(organization=organization))
and, if a page is returned, serialize that page with
ApiDeploymentMinimalSerializer and return
self.get_paginated_response(serializer.data); otherwise serialize the full
queryset and return Response(serializer.data). Keep references to the existing
method name deployments, model APIDeployment and serializer
ApiDeploymentMinimalSerializer so the change is localized.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@backend/api_v2/serializers.py`:
- Around line 395-397: The code currently returns value when is_global_key is
true without checking the profile's org; instead, fetch the profile referenced
by llm_profile_id and compare its organization/tenant to the caller's
organization (obtainable from the serializer context request or passed tenant
id), and if they don't match raise a generic validation error; keep the
early-return for valid matches but replace the unconditional return in the
is_global_key branch with an org equality check that raises a
serializers.ValidationError (or the existing error type) on mismatch.

In `@backend/global_api_deployment_key/migrations/0001_initial.py`:
- Around line 71-81: The organization FK on this model (the "organization"
ForeignKey added in the migration, inherited via DefaultOrganizationMixin) is
nullable which allows duplicate (name, NULL) rows because SQL treats NULLs as
distinct; to fix, make organization non-nullable at the model/migration level
(set null=False, blank=False and remove default=None on the organization
ForeignKey) so the existing UniqueConstraint on (name, organization) enforces
per-organization uniqueness, and update the model and migration for
GlobalAPIDeploymentKey (or the model defined in this migration) accordingly; if
you intentionally need nullable orgs instead, instead add a partial
UniqueConstraint that applies only when organization IS NOT NULL or document why
nullability is required.

In `@backend/global_api_deployment_key/models.py`:
- Around line 16-18: The BooleanField allow_all_deployments is set to
default=True which makes new keys org-wide by default; change the field
declaration to use default=False on the models.BooleanField for
allow_all_deployments (preserving db_comment), add the corresponding schema
migration to update the default, and adjust any factory/tests that relied on the
permissive default (or explicitly set allow_all_deployments=True where intended)
so behavior remains explicit.

In `@backend/global_api_deployment_key/serializers.py`:
- Around line 72-74: The api_deployments PrimaryKeyRelatedField on
GlobalApiDeploymentKeyCreateSerializer is using APIDeployment.objects.all(),
allowing cross-organization assignment; override the serializer's __init__ to
accept the request/user/org context and set
self.fields['api_deployments'].queryset =
APIDeployment.objects.filter(organization=...) (use the org from
self.context['request'].user or passed context) so only same-organization
deployments are selectable; apply the identical change to
GlobalApiDeploymentKeyUpdateSerializer and ensure both serializers still allow
required=False and many=True.

In `@backend/global_api_deployment_key/views.py`:
- Around line 67-73: In the rotate method, update_fields is preventing Django's
auto_now on modified_at from running; change the save call in
GlobalApiDeploymentKeyView.rotate (currently instance.save(update_fields=["key",
"modified_by"])) to include "modified_at" in the update_fields list
(["key","modified_by","modified_at"]) or simply call instance.save() without
update_fields so modified_at is updated automatically after rotating the key.

In `@backend/middleware/request_id.py`:
- Around line 14-17: The middleware currently forces response.status_code = 200
for any request where "/api/v1/socket" appears in request.path and thus masks
real errors and is too broad; remove the status code rewrite and tighten the
match to an explicit path or prefix check (e.g., use request.path ==
"/api/v1/socket" or request.path.startswith("/api/v1/socket/") as appropriate)
so the code simply returns the response without mutating response.status_code;
update the condition in request_id.py to use the narrowed match and delete the
assignment to response.status_code to preserve real error semantics.

In `@backend/utils/log_events.py`:
- Around line 136-138: The start_server function currently returns the raw
Django WSGIHandler which bypasses Engine.IO/Socket.IO; restore the Socket.IO
wrapper by replacing the commented line with a call that wraps django_app in
socketio.WSGIApp(sio, django_app, socketio_path=namespace) and return that
wrapper (keep WSGIHandler typing intact), removing the commented-out line so
socket routes are handled by socketio rather than Django.

In `@frontend/src/routes/useMainAppRoutes.js`:
- Around line 218-221: The new route rendering GlobalApiDeploymentKeysPage is
not protected by admin-only gating; wrap this Route (the one with path
"settings/global-api-deployment-keys" and element <GlobalApiDeploymentKeysPage
/>) with the RequireAdmin protection (or move it inside the existing
admin-protected block that uses RequireAdmin) so only admins can access it;
ensure you reference the same Route and component names and keep any existing
Route props intact while nesting it under RequireAdmin.

---

Nitpick comments:
In `@backend/api_v2/key_helper.py`:
- Around line 96-97: Update the except blocks that currently do "except
(ValueError, AttributeError): raise UnauthorizedKey()" to use explicit exception
chaining; replace the raise with "raise UnauthorizedKey() from None" (or "from
err" if you capture the exception as "except (ValueError, AttributeError) as
err:") for each place that raises UnauthorizedKey (the three except blocks that
currently raise UnauthorizedKey).

In `@backend/global_api_deployment_key/permissions.py`:
- Around line 1-3: The re-export of IsOrganizationAdmin exposes the upstream
error message mentioning “platform API keys”; create a small feature-specific
subclass (e.g., GlobalAPIDeploymentKeyIsOrganizationAdmin) that inherits from
IsOrganizationAdmin and overrides the permission error message/attribute to
mention global API deployment keys (or this endpoint) and then export that
subclass in __all__ instead of the upstream name; update any references to use
the new subclass name so the endpoint shows the correct permission message.

In `@backend/global_api_deployment_key/views.py`:
- Around line 75-81: The deployments action currently returns all APIDeployment
objects unpaginated; update the deployments method to use DRF pagination by
calling
self.paginate_queryset(APIDeployment.objects.filter(organization=organization))
and, if a page is returned, serialize that page with
ApiDeploymentMinimalSerializer and return
self.get_paginated_response(serializer.data); otherwise serialize the full
queryset and return Response(serializer.data). Keep references to the existing
method name deployments, model APIDeployment and serializer
ApiDeploymentMinimalSerializer so the change is localized.

In
`@frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.jsx`:
- Around line 352-382: DeploymentScopeFields is currently defined inside
GlobalApiDeploymentKeys causing it to be recreated on every render; move the
DeploymentScopeFields function out of the GlobalApiDeploymentKeys component
(define it above or in a separate file) and accept deployments as a prop (i.e.,
change signature to DeploymentScopeFields({ form, deployments })) so it uses
Form.useWatch("allow_all_deployments", form) but no longer closes over parent
scope; update all usages inside GlobalApiDeploymentKeys to pass the form and
deployments props accordingly to preserve state, focus and avoid unnecessary
remounts.
- Around line 161-175: handleToggleStatus currently allows rapid concurrent
requests which can cause race conditions; add a per-row loading guard using a
state like updatingIds (useState(new Set())) to ignore toggles when the id is
present, set the id into updatingIds before calling axiosPrivate and remove it
in finally, and pass updatingIds.has(record?.id) to the Switch component's
loading prop while keeping onChange tied to handleToggleStatus so the switch is
disabled/indicates loading during the update; ensure you still call fetchKeys()
on success and handleException on error.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 52eb7baa-a489-4324-99a6-11e09058028a

📥 Commits

Reviewing files that changed from the base of the PR and between 70bbd61 and 4ee988e.

⛔ Files ignored due to path filters (1)
  • backend/uv.lock is excluded by !**/*.lock
📒 Files selected for processing (24)
  • backend/api_v2/api_deployment_views.py
  • backend/api_v2/deployment_helper.py
  • backend/api_v2/dto.py
  • backend/api_v2/key_helper.py
  • backend/api_v2/serializers.py
  • backend/backend/settings/base.py
  • backend/backend/urls_v2.py
  • backend/global_api_deployment_key/__init__.py
  • backend/global_api_deployment_key/apps.py
  • backend/global_api_deployment_key/migrations/0001_initial.py
  • backend/global_api_deployment_key/migrations/__init__.py
  • backend/global_api_deployment_key/models.py
  • backend/global_api_deployment_key/permissions.py
  • backend/global_api_deployment_key/serializers.py
  • backend/global_api_deployment_key/urls.py
  • backend/global_api_deployment_key/views.py
  • backend/middleware/request_id.py
  • backend/pyproject.toml
  • backend/utils/log_events.py
  • frontend/src/components/navigations/side-nav-bar/SideNavBar.jsx
  • frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.css
  • frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.jsx
  • frontend/src/pages/GlobalApiDeploymentKeysPage.jsx
  • frontend/src/routes/useMainAppRoutes.js

Comment thread backend/api_v2/serializers.py Outdated
Comment thread backend/global_api_deployment_key/migrations/0001_initial.py
Comment thread backend/global_api_deployment_key/models.py
Comment thread backend/global_api_deployment_key/serializers.py Outdated
Comment thread backend/global_api_deployment_key/views.py
Comment thread backend/middleware/request_id.py Outdated
Comment thread backend/utils/log_events.py
Comment thread frontend/src/routes/useMainAppRoutes.js Outdated
- Revert unrelated changes: restore socketio WSGI wrapper in
  log_events.py and remove socket path middleware override
- Security: scope api_deployments queryset to user's organization
  in both Create and Update serializers to prevent cross-org assignment
- Move global-api-deployment-keys route inside RequireAdmin guard
- Change allow_all_deployments default to False (least privilege)
- Fix retrieve endpoint to return full key via DetailSerializer
  so copy-to-clipboard works with the actual key value
- Include modified_at in rotate update_fields so timestamp updates
Comment thread backend/global_api_deployment_key/models.py
Comment thread backend/global_api_deployment_key/serializers.py Outdated
Comment thread backend/pyproject.toml Outdated

@chandrasekharan-zipstack chandrasekharan-zipstack left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add screenshots for the FE changes. Should we include a link or a hook in the existing API deployment's creation flow to easily create or use an existing global API key?
With the current changes, we'll have to hope the user stumbles upon it or we need to explicitly educate a few users that won't scale well. I suggest adding a tool tip and link to this new page from the existing API deployment creation modal

@chandrasekharan-zipstack

Copy link
Copy Markdown
Contributor

Two concerns with the current approach:

1. Separate table for what could be an extension of APIKey

  • The existing APIKey already handles both deployments and pipelines through a single table with polymorphic access checks (has_access does an isinstance switch)
  • This PR introduces an entirely parallel system — model, serializers, views, UI, and a try/except fallback in the auth path — for what is conceptually the same entity (an API key) with broader scope (M2M instead of 1:1)
  • We now have two key management systems to maintain
  • I understand extending APIKey directly is a bigger refactor, but the trade-off should be a conscious one

2. Not extensible beyond deployments

  • Model name, field names (api_deployments, allow_all_deployments), validation method, and the auth hook are all deployment-specific
  • If we need global keys for pipelines later, we'd need to bolt on another M2M + validation path or create yet another table
  • The existing APIKey pattern already shows how to handle both resource types — this new model doesn't follow that pattern

At minimum, could we either generalize this model to support both resource types from the start, or scope this as a first step with a clear path to pipeline support?

…ields

- permissions.py: subclass IsOrganizationAdmin with a clearer denial message
- GlobalApiDeploymentKeys.jsx: move DeploymentScopeFields to module scope and add PropTypes
# Conflicts:
#	backend/uv.lock
#	frontend/src/routes/useMainAppRoutes.js
…ope global-key profiles

- Dedupe validate_safe_text/SAFE_TEXT_PATTERN into utils.input_sanitizer
  (was duplicated in platform_api and global_api_deployment_key serializers);
  add a docstring explaining the safe-char allow-list and why (reviewer ask)
- api_v2: for global API keys, verify the llm_profile_id belongs to the
  deployment's organization before use (ProfileManager.objects is not
  org-scoped, so a caller could otherwise reference another org's profile).
  Generic 'Profile not found' avoids cross-org info disclosure (CodeRabbit)
- Drop unrelated django-celery-beat 2.5.0->2.6.0 bump (reviewer ask)
The global_api_deployment_key app was branched before commit 64b06a7
marked DefaultOrganizationMixin.organization as editable=False (server-
managed, never client input). After merging main, makemigrations detected
model drift ('changes not yet reflected in a migration'). Add the matching
AlterField migration, mirroring the same change already applied to api_v2,
connector_v2, etc.

@chandrasekharan-zipstack chandrasekharan-zipstack left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the backend auth path, the new app, and the settings page in depth, and traced the import graph, the DTO consumers, and the validate_safe_text move.

The security core holds up

Saying this first, because the rest is a list of problems:

  • Cross-org isolation is sound. GlobalApiDeploymentKey.objects.get(key=...) is deliberately not org-scoped, which makes has_access_to_deployment()'s organization_id check the entire tenant boundary — and it's there, and it fires before allow_all_deployments is consulted.
  • organization can't be client-injectededitable=False, set server-side in DefaultOrganizationMixin.save().
  • is_global_key is derived server-side, never read from the request.
  • The validate_llm_profile_id swap is correct. ProfileManager.objects genuinely is not org-scoped, so the new org check really is load-bearing, and the generic "Profile not found" correctly avoids confirming another org's profile exists.
  • Pipelines are correctly excludedpipeline_v2 has its own same-named DeploymentHelper calling KeyHelper.validate_api_key directly. Easy to misread; worth knowing it was checked.
  • The fallback doesn't mask real errors. validate_api_key only ever raises UnauthorizedKey, so a DB OperationalError still surfaces as a 500 rather than being swallowed into a 401.
  • Moving validate_safe_text into utils/input_sanitizer.py did not change the exception type — that module imports ValidationError from rest_framework.serializers, so platform_api still returns 400, not 500.

Blockers

  1. No tests. This PR introduces four independent cross-tenant boundaries — has_access_to_deployment()'s org check, get_queryset()'s org filter, the api_deployments queryset scoping, and validate_llm_profile_id's org equality — and not one is pinned by a test. Also unverified: rotate-invalidates-old-key, is_active=False rejection, non-admin → 403, explicit-M2M rejecting unlisted deployments, and — highest traffic of all — that a valid deployment-scoped key still authenticates after the validate_api rewrite. The pattern to copy is pluggable_apps/manual_review_v2/tests/test_manual_review_views.py (APITestCase + force_authenticate + patch("utils.user_context.UserContext.get_organization")). Suggested homes: global_api_deployment_key/tests/{test_models,test_views,test_serializers}.py and a new api_v2/tests/test_deployment_auth.py.

  2. The UI mints org-wide keys by default — the model defaults allow_all_deployments=False, the create form defaults it to True. Inline comment on the checkbox.

  3. ruff fails, so CI will be red:

    I001  global_api_deployment_key/serializers.py:1  Import block is un-sorted
    I001  platform_api/serializers.py:1               Import block is un-sorted
    

    Both auto-fixable with ruff check --fix.

Themes in the inline comments

  • Guarantee logging on every auth-rejection path. Three distinct rejection reasons currently collapse to a bare raise UnauthorizedKey() with no log line and no exception chaining. Funnel them so no path can silently reject; keep the client response generic.
  • Let the ORM do the UUID coercion. key is a UUIDField, so the hand-rolled uuid.UUID() parse duplicates to_python — and mirroring validate_api_key's except (DoesNotExist, ValidationError) removes the parse, the aliased import, and a latent TypeError-on-None hole at once.
  • Don't throw away the authenticated key's identity. validate_global_api_deployment_key returns the key object and validate_api collapses it to a bool, so nothing can answer "which global key ran this execution?".
  • Incoherent scopes are representable and unvalidated in both serializers.
  • Squash the two migrations — new app, neither shipped, and 0002 emits no SQL.
  • Plus: the "shown once" docstring is false, a mislabelled "non-critical" silent catch, hardcoded colours instead of theme tokens, and a TextField(max_length=...) that Postgres ignores.

I've also left a note on the model suggesting we eventually cover ETL and TASK pipelines for consistency, with the one privilege-escalation trap that decision carries.

Two things I checked that are not bugs

Flagging these so nobody re-derives them:

  • The one-time key is not lost if the clipboard write fails. handleCopyKey re-fetches the full plaintext key from retrieve(). Annoying, not destructive.
  • initialValue={true} does not clobber the edit form. setFieldsValue runs synchronously before the Modal mounts its children, and rc-field-form skips initialValue when the store already holds a value. A scoped key is not silently widened on edit. (The create default in blocker 2 is a separate, real problem.)

Nice work overall — the hard part (the tenant boundary) is right. The gaps are around it.

Comment thread backend/api_v2/key_helper.py Outdated
Comment thread backend/api_v2/key_helper.py Outdated
Comment thread backend/api_v2/key_helper.py Outdated
Comment thread backend/api_v2/deployment_helper.py Outdated
Comment thread backend/api_v2/dto.py Outdated
- serializers.py: assigning specific deployments to a global key
  (allow_all_deployments=False) failed with 'Invalid pk ... does not exist'
  for valid same-org deployments. DRF validates a many=True relation against
  child_relation.queryset; the code set .queryset on the ManyRelatedField
  wrapper (a no-op), leaving validation at APIDeployment.objects.none(). Set
  child_relation.queryset in both Create and Update serializers. Found via
  live API testing.
- GlobalApiDeploymentKeys.jsx: restore apostrophe in SAFE_TEXT_REGEX/message to
  match backend allow-list; the copy dropped it, rejecting valid names client-side.
- views.py get_serializer_class: remove unreachable create/partial_update branches.
…lidation, cleanups

Frontend (GlobalApiDeploymentKeys.jsx):
- BLOCKER: default 'Allow all API deployments' to unchecked and coerce to false
  (was checked / ?? true) so Create no longer silently mints an org-wide key;
  require >=1 deployment when not allow-all. Add .catch() to validateFields.

Backend:
- serializers: cross-field validate() rejecting incoherent scopes; fix
  DetailSerializer docstring (full key retrievable for lifetime, not once).
- key_helper: hoist import (no circular dep), drop uuid alias, rely on
  UUIDField coercion instead of manual parse.
- deployment_helper: log the deployment->global key auth fallback + which named
  global key authorized (audit visibility).
- models: description TextField->CharField(512); type-annotate + note
  load-bearing org check on has_access_to_deployment.
- migrations: squash 0002 into 0001 (app unreleased).
…urface picker load error

- dto/deployment_helper: carry the resolved GlobalApiDeploymentKey through
  DeploymentExecutionDTO instead of a bool; is_global_key becomes a property.
  Preserves 'which named key authorized this execution?' for audit.
- key_helper: log the reason on every global-key rejection while keeping the
  client 401 generic; drop the unneeded select_related(organization).
- frontend: surface a failed deployments fetch (was silently swallowed).
Comment thread backend/api_v2/serializers.py
…lobal key screens

Extract the common key-management screen into a shared ApiKeyManager (table,
create/edit/rotate/delete/copy, name/description + safe-text validation); the
platform and global key screens become thin wrappers injecting only their
feature-specific column/fields/payload shaping. Shared CSS uses antd theme
tokens instead of hardcoded rgba (dark-mode aware). Kills the SonarCloud
new-code duplication and the CSS theming comment.
ruff-format had collapsed the two-line error message into two adjacent string
literals on one line, which reads like a forgotten comma. Merge into a single
literal (same resulting message).
@jaseemjaskp
jaseemjaskp self-requested a review July 13, 2026 07:16

@jaseemjaskp jaseemjaskp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review Toolkit — consolidated findings

Ran the full toolkit (code-reviewer, silent-failure-hunter, type-design-analyzer, pr-test-analyzer, comment-analyzer, code-simplifier) over the global-API-deployment-key feature. Findings that duplicate already-posted threads (retrieve-returns-full-key, dto audit identity, cross-field scope validation, least-privilege default, serializers.py:428 profile authz, the RequireAdmin route thread, modified_at on rotate) were dropped.

The implementation itself is sound — the org-scoping guard, the UnauthorizedKey-only fallback, UUID coercion, and per-branch logging were all independently verified as correct. The one blocking item is not a defect in the code but its complete absence of tests on a security-critical, cross-tenant authorization path — shipping that untested is the merge risk. Everything else is low / nit.

Summary

  • 🔴 1 blocking: no tests for the global-key auth path (cross-org rejection, authz matrix, fallback ordering, malformed key, profile org check)
  • 🟡 4 low: model-layer scope invariant unenforced; partial-PATCH scope traps; DTO leaks bearer token via repr; inaccurate sanitizer comment + missing validate_safe_text tests
  • ⚪ 5 nits: serializer duplication, removable destroy override, validateFields catch, static-rules hoist / column widths, frontend transform merge

Verdict: not approving solely on the untested auth path — add the auth tests and this is good to go.

Comment thread backend/global_api_deployment_key/models.py
Comment thread backend/global_api_deployment_key/models.py
Comment thread backend/global_api_deployment_key/serializers.py
Comment thread backend/api_v2/dto.py Outdated
Comment thread backend/utils/input_sanitizer.py Outdated
Comment thread backend/global_api_deployment_key/serializers.py
Comment thread backend/global_api_deployment_key/views.py Outdated
Comment thread frontend/src/components/settings/api-key-manager/ApiKeyManager.jsx Outdated
Comment thread frontend/src/components/settings/api-key-manager/ApiKeyManager.jsx Outdated
…B query

Addresses the outstanding review round.

Backend
- Add the missing test coverage for the security-critical global-key auth
  path: cross-org rejection, the full allow_all/scoped/inactive matrix,
  deployment-key -> global-key fallback ordering, malformed-key handling,
  and the org-scoped llm_profile_id check. No DB needed.
- Fix an import-time DB query: the class-level
  `queryset=APIDeployment.objects.none()` ran the org-scoped manager's
  get_queryset (and therefore Organization.objects.get) at module import.
  The field is now built in __init__, which also drops the child_relation
  workaround.
- Fix two partial-PATCH traps: flipping a scoped key to allow-all in one
  field now works (and clears the M2M), and a key stranded by a deleted
  deployment stays editable/deactivatable.
- Extract _GlobalApiDeploymentKeyWriteSerializer to de-duplicate the
  api_deployments field, __init__ and validate_description.
- DeploymentExecutionDTO: api_key gets repr=False so the raw bearer token
  can't reach logs via the dataclass repr; mark the DTO frozen.
- Drop the destroy() override, which reproduced ModelViewSet.destroy.
- Correct the "no quotes reach storage" claim in input_sanitizer (the
  apostrophe is allowed) and cover validate_safe_text with tests.
- Document why an unattached LLM profile fails closed for global keys, and
  why allow_all_deployments intentionally wins over api_deployments.

Frontend
- Discriminate the antd validation reject from real errors in the
  create/edit catch, so a non-validation throw no longer leaves the modal
  stuck in confirmLoading.
- Hoist the static form rules out of render; trim a column width so base +
  one injected column totals 100%.
- Merge the two payload transforms into one.
@Deepak-Kesavan

Copy link
Copy Markdown
Contributor Author

Review round addressed in fa93485dd. Replies are on each thread; summary here.

Fixed

Thread Change
[blocking] zero tests on the global-key auth path New global_api_deployment_key/tests/test_global_key_auth.py — 21 cases, no DB required
Two partial-PATCH traps in scope validation validate() now keys off what the caller sent; allow-all auto-clears the M2M, an untouched scope is not re-validated
DeploymentExecutionDTO.__repr__ leaks the bearer token api_key: str = field(repr=False), DTO marked frozen=True
input_sanitizer comment claims quotes are rejected Reworded (the apostrophe is allowed); TestValidateSafeText added
~35 duplicated lines across the two write serializers Extracted _GlobalApiDeploymentKeyWriteSerializer
destroy() reproduces ModelViewSet.destroy Removed
Bare .catch swallows non-validation errors Discriminated on err?.errorFields; releases isSaving and surfaces the rest
nameAndDescriptionRules rebuilt per render; column widths total 102% Hoisted to a module constant; Description 15% → 13% so base + one injected column = 100%
Two near-identical payload transforms Merged into one

Found while writing the tests

queryset=APIDeployment.objects.none() was declared as a class attribute on both write serializers. APIDeployment.objects is the org-scoped manager, so its get_queryset runs UserContext.get_organization()Organization.objects.get(...). That means importing global_api_deployment_key.serializers issued a database query at import time — survivable in a running server, but it breaks any import without a DB, which is how it surfaced (test collection failed with OperationalError: could not translate host name "backend-db-1").

The field is now built in __init__, which also removes the child_relation.queryset workaround entirely.

Deliberately not changed

  • Model-level clean() for the scope invariant — nothing calls it (no admin.py, no ModelForm), so it would be dead code that reads like a guarantee. Documented on has_access_to_deployment instead, and the serializer now clears rather than tolerates a stale list, so the incoherent pair is no longer persistable through the API.
  • Profiles without a prompt_studio_tool failing the global-key org check — a profile with no tool has no derivable organization, so an org-scoped key must fail closed. prompt_studio_tool is null=True but on_delete=CASCADE, and Prompt Studio always creates profiles under a tool, so this is not a live path. Comment + test added.
  • Nullable organization FK — inherited from DefaultOrganizationMixin, which every org-scoped model uses. Worth fixing at the mixin, not forking the pattern for one new table.

Stale bot findings on log_events.py, middleware/request_id.py and pyproject.toml are moot — those were unrelated debugging noise, reverted in an earlier round and no longer in the diff.

Tests: 67 passed (global_api_deployment_key/tests/, utils/tests/test_input_sanitizer.py). ruff, ruff-format and biome check clean.

@github-actions

Copy link
Copy Markdown
Contributor

Frontend Lint Report (Biome)

All checks passed! No linting or formatting issues found.

@sonarqubecloud

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown
Contributor

Unstract test results

Per-group results

Status Group Tier Passed Failed Errors Skipped Duration (s)
e2e-api-deployment e2e 3 0 0 0 16.9
e2e-coowners e2e 1 0 0 0 1.4
e2e-etl e2e 1 0 0 0 8.3
e2e-login e2e 2 0 0 0 1.2
e2e-prompt-studio e2e 1 0 0 0 4.5
e2e-smoke e2e 2 0 0 0 1.3
e2e-workflow e2e 1 0 0 0 16.7
integration-backend integration 162 0 0 26 40.4
integration-connectors integration 1 0 0 7 7.8
integration-workers integration 0 0 0 141 98.5
unit-backend unit 308 0 0 1 38.4
unit-connectors unit 63 0 0 0 10.3
unit-core unit 27 0 0 0 1.4
unit-platform-service unit 15 0 0 0 2.7
unit-rig unit 86 0 0 0 4.6
unit-sdk1 unit 480 0 0 0 24.3
unit-workers unit 1312 0 0 0 99.8
TOTAL 2465 0 0 175 378.5

Critical paths

⚠️ Critical paths not yet covered

  • workflow-execution-fan-out — Multi-file workflow execution fans out to file-processing workers and rejoins. (declared coverage: no groups declared)
✅ Covered critical paths
  • auth-login — covered by e2e-login
  • adapter-register-llm — covered by integration-backend
  • workflow-author — covered by integration-backend
  • co-owner-manage — covered by integration-backend, e2e-coowners
  • workflow-create-execute — covered by e2e-workflow
  • api-deployment-provision — covered by integration-backend
  • api-deployment-auth — covered by integration-backend
  • api-deployment-run — covered by e2e-api-deployment
  • prompt-studio-author — covered by integration-backend
  • prompt-studio-fetch-response — covered by e2e-prompt-studio
  • connector-register-test — covered by integration-backend
  • pipeline-etl-execute — covered by e2e-etl
  • usage-aggregate-read — covered by integration-backend
  • usage-token-tracking — covered by e2e-api-deployment
  • callback-result-delivery — covered by e2e-api-deployment

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants