feat(api): add Server-Sent Events (SSE) infrastructure#11556
Conversation
📝 WalkthroughWalkthroughAdds Server-Sent Events (SSE) platform: ASGI/Gunicorn runtime changes, django-eventstream dependency and settings, SSE authentication fallback, channel-name utilities, BaseSSEViewSet and tenant-aware SSEChannelManager, tests, developer guide, and changelog entry. ChangesServer-Sent Events Platform
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
|
✅ All necessary |
|
✅ Conflict Markers Resolved All conflict markers have been successfully resolved in this pull request. |
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
🔒 Container Security ScanImage: 📊 Vulnerability Summary
16 package(s) affected
|
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #11556 +/- ##
==========================================
+ Coverage 94.02% 94.04% +0.01%
==========================================
Files 241 247 +6
Lines 35705 35927 +222
==========================================
+ Hits 33573 33787 +214
- Misses 2132 2140 +8
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@api/src/backend/api/sse/utils.py`:
- Around line 34-36: Change the channel-shape check to require exactly three
segments and fail otherwise: replace the current len(segments) < 3 check with
len(segments) != 3 so channels with more than three segments are rejected;
additionally, after splitting, validate the first segment equals the expected
prefix constant (e.g., CHANNEL_PREFIX or the literal used elsewhere) and
validate the tenant_id segment is a UUID (use uuid.UUID(...) in a try/except) to
prevent separator-injection and non-canonical names when extracting tenant_id
from segments.
In `@api/src/backend/api/tests/test_authentication.py`:
- Around line 413-427: Add a test case in test_authentication.py that verifies
SSEAuthentication.authenticate raises an authentication error when an
access_token query param is present but invalid: create a MagicMock request with
request.query_params = {"access_token": "bad-token"}, patch
"api.authentication.JWTAuthentication" to return a jwt_instance whose
get_validated_token raises rest_framework.exceptions.AuthenticationFailed, call
SSEAuthentication().authenticate(request) and assert that AuthenticationFailed
is raised, and also assert get_validated_token was called with "bad-token" to
ensure the query-token path is exercised.
In `@docs/developer-guide/server-sent-events.mdx`:
- Around line 60-66: Standardize the prose to use "tenant ID" (uppercase)
everywhere while keeping code identifiers like `<tenant_id>` and CHANNEL_PREFIX
(and the example channel format `<prefix>:<tenant_id>:<resource_id>`) unchanged;
update occurrences such as "The tenant id is baked into every channel name" and
"parsing the tenant id embedded in the channel name" to "The tenant ID..." so
all narrative references use the uppercase form, but do not modify code snippets
or symbol names like make_channel_name, CHANNEL_PREFIX, or `<tenant_id>`.
- Line 60: Reword the phrase "owned by your feature" to remove the second-person
possessive in the sentence describing channel format; for example change it to
"provided by the feature", "controlled by the feature", or "assigned by the
feature" so the sentence reads like "The prefix is provided by the feature and
may contain hyphens but never colons (the parser splits on `:`)"; update the doc
text that describes channels and the `make_channel_name` usage accordingly.
- Around line 1-3: Add a Version Badge immediately after the section header
"Server-Sent Events (SSE)" to indicate this is new in Prowler API v1.32.0;
update docs/developer-guide/server-sent-events.mdx by inserting the standard
Version Badge element (matching project badge style) right below the top-level
title line so the page clearly shows "Prowler API v1.32.0" for the new SSE
infrastructure feature.
🪄 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: ASSERTIVE
Plan: Pro Plus
Run ID: 3e28d44a-07ed-482b-80d9-7f77bd3f1cc7
⛔ Files ignored due to path filters (1)
api/uv.lockis excluded by!**/*.lock,!**/uv.lock
📒 Files selected for processing (15)
api/CHANGELOG.mdapi/docker-entrypoint.shapi/pyproject.tomlapi/src/backend/api/authentication.pyapi/src/backend/api/sse/__init__.pyapi/src/backend/api/sse/base_views.pyapi/src/backend/api/sse/channelmanager.pyapi/src/backend/api/sse/utils.pyapi/src/backend/api/tests/test_authentication.pyapi/src/backend/api/tests/test_sse.pyapi/src/backend/config/django/base.pyapi/src/backend/config/guniconf.pyapi/src/backend/config/settings/eventstream.pydocs/developer-guide/server-sent-events.mdxdocs/docs.json
Add the django-eventstream dependency that backs Server-Sent Events and bump gunicorn to a release that ships the native asgi worker class, so SSE streams can run on the event loop.
Run gunicorn with the native asgi worker against config.asgi so SSE streams are parked on the event loop instead of holding a sync worker per open connection; sync CRUD views keep running in the thread-sensitive executor. Disable preload under DEBUG so dev reload picks up edited code, and point the dev and prod entrypoints at the ASGI application.
Browser EventSource cannot set the Authorization header, so add an SSEAuthentication class that extends the standard JWT/API-key stack with an ?access_token=<jwt> query-parameter fallback (RFC 6750 section 2.3), consulted only when no Authorization header is present. The query path accepts a JWT only; API keys remain header-only.
Add the platform SSE layer that wires django-eventstream into the API: - BaseSSEViewSet: a base viewset features subclass to expose an SSE endpoint, reusing the regular DRF stack (auth, RBAC permissions, tenant transaction) and delegating the stream to django-eventstream. - SSEChannelManager: resolves the channel set off the request and enforces a tenant gate by parsing the tenant id embedded in the channel name. - make_channel_name/tenant_id_from_channel: the single source of truth for the <prefix>:<tenant_id>:<resource_id> channel format. - eventstream settings: Valkey Pub/Sub backend on a dedicated DB, the channel manager, and allowed headers; registered in Django settings. No endpoint streams over SSE yet; this is the reusable base.
Document the SSE infrastructure for backend developers: when to use SSE, the architecture and ASGI transport, a step-by-step worked example for adding an endpoint to a feature, the resource.verb event-naming convention, authentication, the tenant-isolation model, and reconnect/ state-recovery. Register the page in the Developer Guide navigation.
Enforce the canonical <prefix>:<tenant_id>:<resource_id> contract: make_channel_name now raises ValueError when any segment contains the ':' separator, and tenant_id_from_channel requires exactly three segments so a crafted name cannot slip a valid tenant UUID into position 1 while carrying extra segments.
Add an error-path test asserting SSEAuthentication.authenticate raises AuthenticationFailed when the access_token query param is present but invalid, complementing the existing valid-token fallback test.
Drive a real DRF request through the full viewset stack (auth, RLS, content negotiation, channel manager) and assert events() returns an SSE StreamingHttpResponse, guarding the DRF-request-into-django-eventstream path from silent regressions.
Mark the Server-Sent Events guide as new in Prowler API v1.32.0 with the standard VersionBadge component.
64d6255 to
7820d68
Compare
There was a problem hiding this comment.
Actionable comments posted: 6
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@api/src/backend/api/authentication.py`:
- Around line 120-123: The code creates a new JWTAuthentication instance instead
of using the shared backend, causing divergence from
CombinedJWTOrAPIKeyAuthentication.jwt_auth; replace the direct instantiation
(JWTAuthentication()) with a reference to the shared backend
(CombinedJWTOrAPIKeyAuthentication.jwt_auth) when validating the raw_token and
retrieving the user so SSE fallback uses the same configured JWT backend; update
calls to get_validated_token and get_user to use that shared jwt_auth instance.
In `@api/src/backend/api/sse/channelmanager.py`:
- Around line 13-29: get_channels_for_request currently returns
request.sse_channels without checking the active JWT tenant; change it to
filter/validate every channel in request.sse_channels against request.tenant_id
(the tenant set by BaseRLSViewSet / request.auth["tenant_id"]) by parsing
tenant_id_from_channel(channel) and only returning channels whose embedded
tenant equals request.tenant_id (fail-closed by excluding mismatches or
malformed channels); keep can_read_channel as the secondary membership backstop
but ensure the primary authorization uses request.tenant_id before handing
channels to django-eventstream.
In `@api/src/backend/api/tests/test_authentication.py`:
- Around line 392-400: Add a new assertion to the existing
test_header_present_delegates_to_super that sets both an Authorization header
and a query access_token (e.g., request.query_params = {"access_token":
"query-token"}) and verifies SSEAuthentication().authenticate(request) delegates
to the superclass authenticate (patch
SSEAuthentication.__bases__[0].authenticate as in the test) and returns the
super result; this ensures the header takes precedence over the ?access_token=
fallback.
In `@api/src/backend/config/guniconf.py`:
- Around line 28-35: The preload/reload and logging decisions are using the
config variable DEBUG instead of the actual runtime Django settings; update the
module to read settings.DEBUG and settings.LOGGING from the active settings
module (via django.conf.settings or by loading DJANGO_SETTINGS_MODULE) and use
those values when computing preload_app, reload and any logging configuration;
specifically change references that set preload_app = not DEBUG and any LOGGING
usage to use settings.DEBUG and settings.LOGGING (ensure settings is
imported/initialized before use) so Gunicorn behavior matches the active
settings module.
In `@docs/developer-guide/server-sent-events.mdx`:
- Around line 27-35: Update the documentation to use consistent
repository-relative paths (prefer the existing repo convention like
api/src/backend/api/...) for all referenced files: replace instances of
api/sse/base_views.py, api/sse/channelmanager.py, api/authentication.py,
api/sse/utils.py, config/settings/eventstream.py and api/tests/test_sse.py with
their repository-relative equivalents (e.g.,
api/src/backend/api/sse/base_views.py,
api/src/backend/api/sse/channelmanager.py,
api/src/backend/api/authentication.py, api/src/backend/api/sse/utils.py,
api/src/backend/config/settings/eventstream.py,
api/src/backend/api/tests/test_sse.py) and ensure all other mentions on the page
use the same convention so paths are uniform throughout the guide.
- Line 15: The documentation uses sentence case for several section headers;
update each listed header to Title Case to match the docs standard — e.g.,
change "When to use SSE", "How it works", "Local development" and the other
occurrences (lines referenced: the headers with texts at 25, 37, 41, 56, 164,
181, 200, 209, 218, 235) to Title Case (e.g., "When to Use SSE", "How It Works",
"Local Development"); ensure every header in
docs/developer-guide/server-sent-events.mdx follows Title-Case capitalization
consistently.
🪄 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: ASSERTIVE
Plan: Pro Plus
Run ID: 3e3c1b2a-d789-4931-ac69-b50dae15e9b6
⛔ Files ignored due to path filters (1)
api/uv.lockis excluded by!**/*.lock,!**/uv.lock
📒 Files selected for processing (15)
api/CHANGELOG.mdapi/docker-entrypoint.shapi/pyproject.tomlapi/src/backend/api/authentication.pyapi/src/backend/api/sse/__init__.pyapi/src/backend/api/sse/base_views.pyapi/src/backend/api/sse/channelmanager.pyapi/src/backend/api/sse/utils.pyapi/src/backend/api/tests/test_authentication.pyapi/src/backend/api/tests/test_sse.pyapi/src/backend/config/django/base.pyapi/src/backend/config/guniconf.pyapi/src/backend/config/settings/eventstream.pydocs/developer-guide/server-sent-events.mdxdocs/docs.json
The ?access_token= fallback in SSEAuthentication created a fresh JWTAuthentication() instead of the shared CombinedJWTOrAPIKeyAuthentication.jwt_auth instance used by the header path. Reuse self.jwt_auth so the query-token fallback stays on the same configured backend if the parent auth stack is ever customized. Patch the shared instance instead of the constructor in the SSE auth tests.
SSEChannelManager.get_channels_for_request returned every channel stashed on the request, leaving can_read_channel (a membership check) as the only tenant gate. A user belonging to multiple tenants could then read another tenant's stream if a viewset ever returned the wrong channel set. Filter request.sse_channels against the active JWT tenant (request.tenant_id, set by BaseRLSViewSet) before handing channels to django-eventstream, keeping can_read_channel as the membership backstop. Fail-closed: a missing or unparseable request tenant, or a malformed channel, yields no channels. Add type hints and docstrings to the manager methods and cover the cross-tenant, malformed, and missing-tenant cases in the tests.
e9101c6
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
api/src/backend/api/sse/channelmanager.py (1)
64-64:⚠️ Potential issue | 🟠 Major | ⚡ Quick winWrap the membership lookup in an RLS transaction.
user.is_member_of_tenant(tenant_id)queries memberships, but this channel-manager path is outside a ViewSet and currently runs withoutrls_transaction(tenant_id). That bypasses the project’s RLS enforcement contract for tenant-scoped reads.Proposed fix
- return user.is_member_of_tenant(tenant_id) + with rls_transaction(tenant_id): + return user.is_member_of_tenant(tenant_id)Also import
rls_transactionfrom the project’s existing helper module.As per coding guidelines, “Any query against tenant-scoped models outside a ViewSet ... MUST be wrapped in
rls_transaction(tenant_id).”🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@api/src/backend/api/sse/channelmanager.py` at line 64, The call to `user.is_member_of_tenant(tenant_id)` on line 64 queries tenant-scoped data outside a ViewSet without RLS protection, violating the project's RLS enforcement contract. Wrap this membership lookup call in an `rls_transaction(tenant_id)` context manager to ensure proper tenant-scoped access control. Additionally, import `rls_transaction` from the project's existing helper module at the top of the file to make it available for use in this code path.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@api/src/backend/api/sse/channelmanager.py`:
- Line 64: The call to `user.is_member_of_tenant(tenant_id)` on line 64 queries
tenant-scoped data outside a ViewSet without RLS protection, violating the
project's RLS enforcement contract. Wrap this membership lookup call in an
`rls_transaction(tenant_id)` context manager to ensure proper tenant-scoped
access control. Additionally, import `rls_transaction` from the project's
existing helper module at the top of the file to make it available for use in
this code path.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 6df24f96-9f96-40c7-89f6-664f09dd4537
�� Files selected for processing (1)
api/src/backend/api/sse/channelmanager.py
Context
The API needs a reusable foundation for Server-Sent Events (SSE) so endpoints can push a one-way stream of events to clients over a single long-lived HTTP connection (live progress, token-by-token LLM output, cross-client sync). This work lands the shared SSE infrastructure and developer documentation so feature endpoints can adopt it. No existing endpoint is converted to SSE in this PR — the goal is to provide the basis.
Description
Adds the platform SSE layer that wires
django-eventstreaminto the API, plus the runtime and auth changes needed to serve streams:django-eventstreamand bumpgunicornto the release that ships the nativeasgiworker.asgiworker againstconfig.asgiso SSE streams are parked on the event loop instead of holding a sync worker per connection (sync CRUD views keep running in the thread-sensitive executor).preload_appis disabled under DEBUG so dev reload works; dev and prod entrypoints point at the ASGI app.SSEAuthentication: extends the standard JWT/API-key stack with an?access_token=<jwt>query-parameter fallback (RFC 6750 §2.3), since browserEventSourcecannot set theAuthorizationheader. The query path accepts a JWT only; API keys remain header-only.api/sse/):BaseSSEViewSet(subclass + implementget_channels, reuses the regular DRF auth/RBAC/tenant-transaction stack),SSEChannelManager(tenant gate via the tenant id embedded in the channel name), andmake_channel_name/tenant_id_from_channel(single source of truth for the<prefix>:<tenant_id>:<resource_id>channel format). Valkey Pub/Sub backend configured on a dedicated DB.resource.verbevent-naming convention, auth, the tenant-isolation model, and reconnect/state recovery.New dependencies:
django-eventstream==5.3.3,gunicorn==26.0.0(was23.0.0).Steps to review
docs/developer-guide/server-sent-events.mdx) for the intended architecture and the worked example.api/src/backend/api/sse/): the base viewset, the channel manager's two-layer authorization (resource lookup inget_channels+ tenant gate incan_read_channel), and the channel-name helpers.SSEAuthenticationinapi/src/backend/api/authentication.pyand the header-wins / query-fallback precedence.config/guniconf.py,docker-entrypoint.sh, and the settings wiring inconfig/django/base.py+config/settings/eventstream.py.Checklist
Community Checklist
SDK/CLI
API
uv.lockupdated[1.32.0] (Prowler UNRELEASED)License
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
Summary by CodeRabbit