Skip to content

Commit 181dfd4

Browse files
committed
fix: authenticate the log-stream reconciliation as the service principal
1 parent 9e7c2bf commit 181dfd4

5 files changed

Lines changed: 96 additions & 5 deletions

File tree

app/api/deps.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -173,10 +173,9 @@ async def require_admin_for_unsafe_methods(request: Request) -> None:
173173
:raises HTTPUnauthorizedException: When the method is unsafe and the request
174174
carries no Bearer credential, or the credential does not validate.
175175
:raises HTTPForbiddenException: When the resolved user is not an admin, or is
176-
inactive. Resolving a user at all is new on routes that previously met
177-
only a header check, so both refusals are new there.
176+
inactive.
178177
:raises BaseAuthProviderException: When the auth provider errors while
179-
validating the credential, for the same reason.
178+
validating the credential.
180179
"""
181180
if request.method in SAFE_HTTP_METHODS:
182181
return

app/sep/routes/stream_logs.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from fastapi import APIRouter, Depends, HTTPException, Request
2626
from starlette.responses import StreamingResponse
2727

28+
from app.core.security import require_internal_token
2829
from app.sep.deps import (
2930
ApiCurrentUser,
3031
get_task_history,
@@ -159,12 +160,20 @@ async def task_history_logs_event_stream(
159160
Streams log lines for a given task history ID from the Tasks API and yields them
160161
formatted as server-sent events.
161162
163+
The log read carries ``access_token`` so it is attributed to the viewing user.
164+
The reconciliation that follows it is a mutating Tasks API call, which the
165+
unsafe-method admin gate admits only for an admin or the service principal —
166+
and this stream is open to any authenticated user, so that call carries the
167+
internal token instead.
168+
162169
:param tasks_client: The TaskAPI client for interacting with the Tasks service.
163170
:type tasks_client: RemoteAPI
164171
:param task_history_id: The ID of the task history whose logs to stream.
165172
:type task_history_id: int
166173
:param request: The FastAPI request object, used to access query parameters.
167174
:type request: Request
175+
:param access_token: Bearer token authenticating the log read as the viewing
176+
user.
168177
:yield: Log entries formatted as server-sent events.
169178
:rtype: str
170179
"""
@@ -179,7 +188,8 @@ async def task_history_logs_event_stream(
179188
):
180189
if log_entry:
181190
yield f"data: {log_entry.decode()}\n\n"
182-
task_history = await tasks_api.post(f"/history/{task_history_id}/sync/")
191+
with tasks_client.auth(require_internal_token()) as sync_api:
192+
task_history = await sync_api.post(f"/history/{task_history_id}/sync/")
183193
yield f"event: finish\ndata: {json.dumps({'status': task_history['status']})}\n\n"
184194
except TimeoutError as exc:
185195
logger.warning(

tests/app/api/test_admin_gate.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ async def test_exactly_one_route_is_exempt_from_the_gate() -> None:
100100
raises for every other, which also pins that ``allow_non_admin_mutation``
101101
registered the object FastAPI stores as ``APIRoute.endpoint``.
102102
"""
103-
exempt = set()
103+
exempt: set[Callable[..., Any]] = set()
104104
for app in (sep_app, inventory_app, tasks_app):
105105
for route in _api_routes(app):
106106
request = make_request("POST", endpoint=route.endpoint)

tests/app/sep/routes/test_stream_logs.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,49 @@ def test_archives_logs_event_stream(
102102
)
103103

104104

105+
def test_sync_hop_is_authenticated_as_the_service_principal(
106+
test_client, mock_tasks_client, task_history_response, mocker
107+
):
108+
"""Send the end-of-stream reconciliation under the internal token.
109+
110+
The log stream is reachable by any authenticated user, and the ``sync`` hop
111+
it issues at stream end is a mutating request on the Tasks API — which is
112+
admin-gated. Carrying the viewing user's own bearer there ends a non-admin's
113+
stream in an error frame instead of the finish frame, so the hop is
114+
authenticated as the service principal the gate admits by identity.
115+
116+
Evidence stops at the credential the request carries; the gate's own
117+
treatment of that identity is covered in ``tests/app/tasks/test_admin_gate.py``.
118+
"""
119+
mocker.patch(
120+
"app.sep.routes.stream_logs.require_internal_token",
121+
return_value="internal-token",
122+
)
123+
active_tokens = []
124+
sync_token = {}
125+
126+
@contextmanager
127+
def recording_auth(token: str):
128+
active_tokens.append(token)
129+
try:
130+
yield mock_tasks_client
131+
finally:
132+
active_tokens.pop()
133+
134+
async def recording_post(_path, **_kwargs):
135+
sync_token["value"] = active_tokens[-1]
136+
return task_history_response.model_dump()
137+
138+
mock_tasks_client.auth = recording_auth
139+
mock_tasks_client.post.side_effect = recording_post
140+
141+
response = test_client.get(f"/stream-logs/{task_history_response.id}")
142+
143+
assert response.status_code == HTTP_200_OK
144+
assert "event: finish" in response.text
145+
assert sync_token["value"] == "internal-token"
146+
147+
105148
def test_logs_event_stream_emits_sep_error_on_upstream_error(
106149
test_client, mock_tasks_client, task_history_response
107150
):

tests/app/tasks/test_admin_gate.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,45 @@ async def fake_dispatch_queue_item(queue_item, passed_session):
166166
assert response.json()["status"] == TaskHistoryStatusEnum.RUNNING.value
167167

168168

169+
def test_the_log_stream_reconciliation_is_refused_for_a_non_admin(
170+
bearer_client: TestClient, created_task_with_history
171+
) -> None:
172+
"""Refuse the task-history reconciliation for a non-admin.
173+
174+
It is a genuine write — it persists ``status``, ``started_at`` and
175+
``finished_at`` — so it stays gated rather than joining the exemption
176+
allowlist. The SEP log stream that triggers it is open to any authenticated
177+
user, which is why that caller sends the internal token instead.
178+
"""
179+
response = bearer_client.post(
180+
f"/history/{created_task_with_history.id}/sync/", headers=BEARER_HEADERS
181+
)
182+
183+
assert response.status_code == status.HTTP_403_FORBIDDEN
184+
185+
186+
def test_the_log_stream_reconciliation_is_accepted_for_the_service_principal(
187+
bearer_client: TestClient,
188+
created_task_with_history,
189+
mock_executor: AsyncMock,
190+
mocker: MockerFixture,
191+
) -> None:
192+
"""Accept the same reconciliation when it carries the internal token.
193+
194+
This is the identity the SEP log stream sends, so a non-admin's stream still
195+
reaches its finish frame.
196+
"""
197+
mocker.patch.object(settings, "SEP_INTERNAL_TOKEN", SecretStr(SERVICE_TOKEN))
198+
mock_executor.sync_task_history.return_value = created_task_with_history
199+
200+
response = bearer_client.post(
201+
f"/history/{created_task_with_history.id}/sync/",
202+
headers={"Authorization": f"Bearer {SERVICE_TOKEN}"},
203+
)
204+
205+
assert response.status_code == status.HTTP_200_OK
206+
207+
169208
def test_the_batch_read_stays_reachable_for_a_non_admin(
170209
bearer_client: TestClient,
171210
) -> None:

0 commit comments

Comments
 (0)