Skip to content

Commit 23f4c70

Browse files
authored
fix(security): require auth on printer detail GET and job retry (#145)
* fix(security): require auth on printer detail GET and job retry GET /api/printers/{id} and POST /api/jobs/{id}/retry shipped with no auth dependency at all, while every sibling endpoint in the same router file required one: - printers.py: list_printers, get_printer_status, get_printer_tape, get_printer_queue, pause_printer, resume_printer and clear_printer_queue all required ReadAuthDep/PrintAuthDep — the single-printer detail GET did not. It returned full printer metadata (including the raw `connection` dict: host/port/SNMP config) to any caller that could reach the backend, no credentials required. - jobs.py: list_jobs, get_job and cancel_job all required read/print scope — retry_job (which clones a terminal job into a fresh QUEUED job the worker actually dispatches to the physical printer) did not. The pause/resume 501 stubs had the same gap, fixed for consistency even though they currently have no side effect beyond a 404/501 existence check. Both were silent omissions, not documented decisions (compare to GET /api/events, which documents its network-layer-only auth model right in its own module docstring). Adds a structural guardrail test (test_route_auth_coverage_guardrail.py) that enumerates every route the real app mounts and fails the build the moment a new route ships without a recognised auth dependency and without a conscious, commented addition to an explicit public-by-design allowlist — verified to catch this exact regression by reverting the fix locally and re-running it. Adds concrete 401/200 regression coverage for both endpoints to test_auth_wiring.py, the existing house-style auth-wiring suite. Full backend suite (1042 tests), ruff and mypy (CI scope: app/) pass. * fix(security): enforce per-printer ACL on GET /api/printers/{id} Review finding on PR #145: get_printer required auth (fixed in the previous commit) but did not call check_printer_access(), unlike its siblings get_printer_status/pause_printer/resume_printer/ clear_printer_queue. A read-scoped key restricted via allowed_printer_ids to printer A still got 200 (including the raw connection dict — host/port/SNMP config) for GET /api/printers/{B}. This meant the endpoint's own declared leak was only half closed. Adds check_printer_access(_auth, printer_id) after the 404 check, matching the exact sibling pattern (guarded by `if _auth is not None` for consistency with the rest of the file). Adds a regression test seeding its own printer (not relying on the frequently-empty shared fixture data other ACL tests in this file skip on) so it actually runs rather than skipping — verified to fail (200 instead of 403) with the fix reverted and pass with it restored. The identical pre-existing gap on list_printers/get_printer_tape/ get_printer_queue is deliberately out of scope here — tracked as follow-up issue #146. Full backend suite (1043 tests) still green; ruff and mypy clean on touched files.
1 parent aec51f0 commit 23f4c70

6 files changed

Lines changed: 353 additions & 1 deletion

File tree

‎backend/app/api/routes/jobs.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ async def cancel_job(
207207
async def pause_job(
208208
job_id: UUID,
209209
session: SessionDep,
210+
_auth: PrintAuthDep,
210211
) -> ProblemDetail:
211212
"""Return 501 — pause is not yet implemented."""
212213
# Verify the job exists so we return 404 rather than 501 for unknown jobs
@@ -241,6 +242,7 @@ async def pause_job(
241242
async def resume_job(
242243
job_id: UUID,
243244
session: SessionDep,
245+
_auth: PrintAuthDep,
244246
) -> ProblemDetail:
245247
"""Return 501 — resume is not yet implemented."""
246248
# Verify the job exists so we return 404 rather than 501 for unknown jobs
@@ -276,6 +278,7 @@ async def resume_job(
276278
async def retry_job(
277279
job_id: UUID,
278280
session: SessionDep,
281+
_auth: PrintAuthDep,
279282
) -> JobRead:
280283
"""Clone a terminal job into a fresh QUEUED job."""
281284
job = await _get_job_or_404(session, job_id)

‎backend/app/api/routes/printers.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,9 +143,12 @@ async def list_printers(
143143
async def get_printer(
144144
printer_id: UUID,
145145
session: SessionDep,
146+
_auth: ReadAuthDep,
146147
) -> PrinterRead:
147148
"""Return full printer metadata for a single printer."""
148149
printer = await _get_printer_or_404(session, printer_id)
150+
if _auth is not None:
151+
check_printer_access(_auth, printer_id)
149152
state = await printer_state_repo.get(session, printer_id)
150153
return PrinterRead(
151154
id=printer.id,

‎backend/tests/integration/api/test_auth_wiring.py‎

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@
66
77
Phase 1k.1a (Task 25): Removed TemplateLoader and /api/templates tests.
88
Templates are deleted in Phase 1k.1a.
9+
10+
2026-08-14 security audit: added coverage for GET /api/printers/{id} and
11+
POST /api/jobs/{id}/retry, which shipped with NO auth dependency at all
12+
while their sibling endpoints in the same router file all required one
13+
(see tests/unit/api/test_route_auth_coverage_guardrail.py for the
14+
structural guardrail that now catches this class of gap for every route,
15+
not just these two).
916
"""
1017

1118
from __future__ import annotations
@@ -119,3 +126,118 @@ async def test_pangolin_sso_header_grants_read(api_client_with_seed):
119126
headers={"X-Pangolin-User": "testuser@example.com"},
120127
)
121128
assert resp.status_code == 200, f"SSO should grant read: {resp.status_code}"
129+
130+
131+
# ---------------------------------------------------------------------------
132+
# 2026-08-14 security audit regressions:
133+
# GET /api/printers/{id} and POST /api/jobs/{id}/retry had NO auth
134+
# dependency at all — any caller that could reach the backend got full
135+
# printer connection metadata / could trigger a real reprint with zero
136+
# credentials. See app/api/routes/printers.py::get_printer and
137+
# app/api/routes/jobs.py::retry_job.
138+
# ---------------------------------------------------------------------------
139+
140+
141+
async def _seed_printer(factory):
142+
from app.models.printer import Printer
143+
144+
async with factory() as s:
145+
printer = Printer(
146+
name="audit-seed-printer",
147+
slug="audit-seed-printer",
148+
model="PT-P750W",
149+
backend="ptouch",
150+
connection={"host": "192.0.2.10", "port": 9100},
151+
)
152+
s.add(printer)
153+
await s.commit()
154+
await s.refresh(printer)
155+
return printer
156+
157+
158+
async def _seed_failed_job(factory, printer_id):
159+
from app.models.job import Job, JobState
160+
161+
async with factory() as s:
162+
job = Job(
163+
printer_id=printer_id,
164+
template_key="audit-seed-template",
165+
state=JobState.FAILED.value,
166+
payload={"text": "audit"},
167+
)
168+
s.add(job)
169+
await s.commit()
170+
await s.refresh(job)
171+
return job
172+
173+
174+
@pytest.mark.asyncio
175+
async def test_get_printer_detail_without_auth_returns_401(api_client_with_seed):
176+
"""GET /api/printers/{id} must reject unauthenticated requests.
177+
178+
Regression test: this endpoint previously had no auth dependency at
179+
all, unlike GET /api/printers (list) and every other single-printer
180+
endpoint (status/tape/queue/pause/resume) in the same file.
181+
"""
182+
import app.db.engine as _engine_module
183+
184+
printer = await _seed_printer(_engine_module.async_session)
185+
186+
resp = await api_client_with_seed.get(f"/api/printers/{printer.id}")
187+
assert resp.status_code == 401, f"Expected 401, got {resp.status_code}: {resp.text}"
188+
189+
190+
@pytest.mark.asyncio
191+
async def test_get_printer_detail_with_read_key_returns_200(api_client_with_seed):
192+
"""GET /api/printers/{id} succeeds for a caller with a valid read-scope key."""
193+
import app.db.engine as _engine_module
194+
195+
factory = _engine_module.async_session
196+
printer = await _seed_printer(factory)
197+
read_key = await _make_read_key(factory)
198+
199+
resp = await api_client_with_seed.get(
200+
f"/api/printers/{printer.id}",
201+
headers={"X-Label-Hub-Key": read_key},
202+
)
203+
assert resp.status_code == 200, f"Expected 200, got {resp.status_code}: {resp.text}"
204+
assert resp.json()["id"] == str(printer.id)
205+
206+
207+
@pytest.mark.asyncio
208+
async def test_retry_job_without_auth_returns_401(api_client_with_seed):
209+
"""POST /api/jobs/{id}/retry must reject unauthenticated requests.
210+
211+
Regression test: this endpoint previously had no auth dependency at
212+
all, unlike list_jobs/get_job (read scope) and cancel_job (print scope)
213+
in the same file — despite retry_job creating a new QUEUED job that the
214+
worker actually dispatches to the physical printer.
215+
"""
216+
import app.db.engine as _engine_module
217+
218+
factory = _engine_module.async_session
219+
printer = await _seed_printer(factory)
220+
job = await _seed_failed_job(factory, printer.id)
221+
222+
resp = await api_client_with_seed.post(f"/api/jobs/{job.id}/retry")
223+
assert resp.status_code == 401, f"Expected 401, got {resp.status_code}: {resp.text}"
224+
225+
226+
@pytest.mark.asyncio
227+
async def test_retry_job_with_print_key_returns_201(api_client_with_seed):
228+
"""POST /api/jobs/{id}/retry succeeds for a caller with a valid print-scope key."""
229+
import app.db.engine as _engine_module
230+
231+
factory = _engine_module.async_session
232+
printer = await _seed_printer(factory)
233+
job = await _seed_failed_job(factory, printer.id)
234+
print_key = await _make_print_key(factory)
235+
236+
resp = await api_client_with_seed.post(
237+
f"/api/jobs/{job.id}/retry",
238+
headers={"X-Label-Hub-Key": print_key},
239+
)
240+
assert resp.status_code == 201, f"Expected 201, got {resp.status_code}: {resp.text}"
241+
body = resp.json()
242+
assert body["id"] != str(job.id)
243+
assert body["state"] == "queued"

‎backend/tests/integration/api/test_printer_acl.py‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,54 @@ async def test_key_restricted_to_printer_a_allowed_on_printer_a(api_client_with_
117117
assert resp.status_code in (204, 404), (
118118
f"Expected 204 or 404, got {resp.status_code}: {resp.text}"
119119
)
120+
121+
122+
@pytest.mark.asyncio
123+
async def test_get_printer_detail_restricted_to_printer_a_blocked_on_printer_b(
124+
api_client_with_seed,
125+
):
126+
"""2026-08-14 follow-up: GET /api/printers/{id} (single-printer detail)
127+
now requires auth but was missing the per-printer ACL check that every
128+
sibling endpoint (get_printer_status/pause_printer/resume_printer/
129+
clear_printer_queue) enforces via check_printer_access(). A read-key
130+
scoped to printer A must not be able to read printer B's metadata
131+
(including its connection dict — host/port/SNMP config).
132+
133+
Analogous to test_key_restricted_to_printer_a_blocked_on_printer_b above,
134+
but seeds its own printer B directly instead of relying on
135+
api_client_with_seed's (frequently empty, skip-on-empty) default seed
136+
data — this test must actually run, not skip, to be a real regression
137+
guard.
138+
"""
139+
import app.db.engine as _engine_module
140+
from app.models.printer import Printer
141+
142+
factory = _engine_module.async_session
143+
144+
async with factory() as s:
145+
printer_b = Printer(
146+
name="acl-test-printer-b",
147+
slug="acl-test-printer-b",
148+
model="PT-P750W",
149+
backend="ptouch",
150+
connection={"host": "192.0.2.20", "port": 9100},
151+
)
152+
s.add(printer_b)
153+
await s.commit()
154+
await s.refresh(printer_b)
155+
156+
printer_a_id = str(uuid4()) # some other printer the key IS allowed for
157+
158+
plaintext = await _insert_restricted_key(
159+
factory,
160+
allowed_printer_ids=[printer_a_id],
161+
scopes=["read"],
162+
)
163+
164+
resp = await api_client_with_seed.get(
165+
f"/api/printers/{printer_b.id}",
166+
headers={"X-Label-Hub-Key": plaintext},
167+
)
168+
assert resp.status_code == 403, (
169+
f"Expected 403 for restricted key on wrong printer, got {resp.status_code}: {resp.text}"
170+
)

‎backend/tests/unit/api/test_jobs_routes.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -545,7 +545,8 @@ async def test_retry_job_direct_returns_new_queued_job(session) -> None:
545545
printer = await _make_printer(session)
546546
original = await _make_job(session, printer.id, state=JobState.FAILED.value)
547547

548-
result = await retry_job(job_id=original.id, session=session)
548+
fake_auth = AuthContext(source="api-key", scope="admin", api_key_id=_uuid4(), ip="127.0.0.1")
549+
result = await retry_job(job_id=original.id, session=session, _auth=fake_auth)
549550

550551
assert str(result.id) != str(original.id)
551552
assert result.state == JobState.QUEUED.value

0 commit comments

Comments
 (0)