generated from john/python-template
ver1 - Step 5 implementation complete.
This commit is contained in:
@@ -34,8 +34,13 @@ Optional settings (defaults shown):
|
||||
DATABASE_URL=sqlite:///./transcription.db
|
||||
UPLOAD_DIR=./uploads
|
||||
PROMPT_DIR=./prompts
|
||||
MAX_UPLOAD_BYTES=15728640
|
||||
OPERATOR_ACCESS_ENABLED=false
|
||||
OPERATOR_USERNAME=operator
|
||||
# OPERATOR_PASSWORD=replace_with_secure_value
|
||||
```
|
||||
|
||||
|
||||
### 3) Run the app
|
||||
|
||||
```bash
|
||||
@@ -67,6 +72,23 @@ MIGRATION_AUTO_APPLY_ON_STARTUP=false
|
||||
VALIDATE_SCHEMA_ON_STARTUP=true
|
||||
```
|
||||
|
||||
### Step 5 security settings
|
||||
|
||||
Use this baseline for trusted private-network operation:
|
||||
|
||||
```env
|
||||
OPERATOR_ACCESS_ENABLED=true
|
||||
OPERATOR_USERNAME=operator
|
||||
OPERATOR_PASSWORD=replace_with_strong_local_secret
|
||||
MAX_UPLOAD_BYTES=15728640
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `/healthz` remains unauthenticated for operational checks.
|
||||
- `/ui` and `/api` require HTTP Basic credentials when operator access is enabled.
|
||||
- Keep `OPERATOR_PASSWORD` in environment variables only (never commit secrets).
|
||||
|
||||
|
||||
|
||||
## How to navigate the GUI
|
||||
|
||||
|
||||
@@ -2,15 +2,18 @@
|
||||
|
||||
## Summary
|
||||
|
||||
Step 5 implementation status: **in progress**.
|
||||
Step 5 implementation status: **complete**.
|
||||
|
||||
This document records completed private-network safety controls, validation evidence, and residual risks for Ver1 Step 5.
|
||||
|
||||
Implemented in this step:
|
||||
|
||||
1. _TBD_
|
||||
2. _TBD_
|
||||
3. _TBD_
|
||||
1. Added private-network security assumptions and control matrix (`docs/ver1/ver1-step5-security-assumptions.md`).
|
||||
2. Implemented optional single-operator access control for `/ui*` and `/api*` via HTTP Basic auth.
|
||||
3. Added upload-size guardrails (`MAX_UPLOAD_BYTES`) and config fail-fast validation for operator credential requirements.
|
||||
4. Hardened unexpected-error user-facing messaging to reduce sensitive detail leakage.
|
||||
5. Added Step 5 tests for access control, security settings, and upload size boundaries.
|
||||
6. Executed dependency/security scans (`pip-audit`, `bandit`) with no critical/high findings.
|
||||
|
||||
---
|
||||
|
||||
@@ -18,23 +21,61 @@ Implemented in this step:
|
||||
|
||||
### 1) Security assumptions and threat model
|
||||
|
||||
_TBD_
|
||||
Completed.
|
||||
|
||||
- Added `docs/ver1/ver1-step5-security-assumptions.md` defining:
|
||||
- trusted private-network deployment assumptions
|
||||
- single-operator usage model
|
||||
- explicit out-of-scope classes (enterprise IAM, internet-facing zero-trust, multi-tenant controls)
|
||||
- Added Step 5 control/ownership matrix and residual-risk notes.
|
||||
|
||||
### 2) Single-operator access control baseline
|
||||
|
||||
_TBD_
|
||||
Completed.
|
||||
|
||||
- New module: `src/transcription/security.py`
|
||||
- `is_protected_path(...)` protects `/ui*` and `/api*`
|
||||
- `enforce_request_access(...)` enforces optional operator auth
|
||||
- robust Basic auth parsing and safe denial responses via `AccessDeniedError`
|
||||
- App middleware added in `src/transcription/app.py`:
|
||||
- enforces auth on protected paths
|
||||
- returns consistent `401` envelope and `WWW-Authenticate: Basic` for denied requests
|
||||
- Health endpoint `/healthz` remains intentionally unauthenticated.
|
||||
|
||||
### 3) Input validation and safe-output hardening
|
||||
|
||||
_TBD_
|
||||
Completed baseline.
|
||||
|
||||
- `src/transcription/services/upload.py`
|
||||
- added size-based validation guard (`max_upload_bytes`)
|
||||
- emits `user_input_error` with actionable guidance on over-limit uploads
|
||||
- `src/transcription/errors.py`
|
||||
- `classify_unexpected_error(...)` now returns operation-only message without embedding raw exception text
|
||||
- preserves traceability via existing `error_id` and taxonomy while reducing accidental sensitive leak risk
|
||||
|
||||
### 4) Secret handling and configuration safety
|
||||
|
||||
_TBD_
|
||||
Completed baseline.
|
||||
|
||||
- `src/transcription/config.py` additions:
|
||||
- `max_upload_bytes` (default `15 * 1024 * 1024`)
|
||||
- `operator_access_enabled` (default `False`)
|
||||
- `operator_username` (default `operator`)
|
||||
- `operator_password` (optional, required when auth enabled)
|
||||
- Added settings validator enforcing fail-fast config safety:
|
||||
- raises validation error if `OPERATOR_ACCESS_ENABLED=true` and `OPERATOR_PASSWORD` unset
|
||||
- `README.md` updated with Step 5 security env settings and explicit secret-handling guidance.
|
||||
|
||||
### 5) Dependency/security scanning baseline
|
||||
|
||||
_TBD_
|
||||
Completed.
|
||||
|
||||
- Dependency vulnerability scan:
|
||||
- `uvx pip-audit`
|
||||
- Result: **No known vulnerabilities found**
|
||||
- Static security scan:
|
||||
- `uvx bandit -r src/transcription`
|
||||
- Result: **No issues identified** (0 low/medium/high)
|
||||
|
||||
---
|
||||
|
||||
@@ -42,27 +83,41 @@ _TBD_
|
||||
|
||||
### Added/Updated Tests
|
||||
|
||||
1. _TBD_
|
||||
2. _TBD_
|
||||
3. _TBD_
|
||||
1. `tests/api/test_access_control.py`
|
||||
- unauthorized protected API denied (`401` + challenge)
|
||||
- invalid credentials denied
|
||||
- valid credentials accepted
|
||||
- `/ui` protected when auth enabled
|
||||
- `/healthz` remains unprotected
|
||||
2. `tests/services/test_upload.py`
|
||||
- added rejection test for payloads above `MAX_UPLOAD_BYTES`
|
||||
3. `tests/test_config.py`
|
||||
- added security defaults assertions
|
||||
- added fail-fast assertion for missing `OPERATOR_PASSWORD` when auth enabled
|
||||
4. `tests/test_errors.py`
|
||||
- updated expectations for sanitized unexpected-error message behavior
|
||||
5. Updated integration expectations where failure detail should no longer include raw exception text:
|
||||
- `tests/services/test_worker.py`
|
||||
- `tests/integration/test_pipeline_flow.py`
|
||||
6. `tests/test_app.py` updated for new middleware wiring.
|
||||
|
||||
### Validation Runs
|
||||
|
||||
Run and record outcomes:
|
||||
|
||||
- `uv run pytest --collect-only -q` -> _TBD_
|
||||
- `uv run pytest -m unit -q` -> _TBD_
|
||||
- `uv run pytest -m "not external" -q` -> _TBD_
|
||||
- `uv run pytest -q` -> _TBD_
|
||||
- `uv run pytest --collect-only -q` -> passed
|
||||
- `uv run pytest -m unit -q` -> passed
|
||||
- `uv run pytest -m "not external" -q` -> passed
|
||||
- `uv run pytest -q` -> passed
|
||||
|
||||
### Security Scan Evidence
|
||||
|
||||
Record scan commands and outcomes:
|
||||
|
||||
- dependency scan command(s): _TBD_
|
||||
- static/security lint command(s): _TBD_
|
||||
- critical/high findings: _TBD_
|
||||
- remediation/defer decisions: _TBD_
|
||||
- dependency scan command(s): `uvx pip-audit`
|
||||
- static/security lint command(s): `uvx bandit -r src/transcription`
|
||||
- critical/high findings: none
|
||||
- remediation/defer decisions: no remediations required for Step 5 baseline
|
||||
|
||||
---
|
||||
|
||||
@@ -70,11 +125,11 @@ Record scan commands and outcomes:
|
||||
|
||||
| Step 5 Area | REQ Coverage | Status | Evidence |
|
||||
| --- | --- | --- | --- |
|
||||
| Private-network and single-operator safety posture | REQ-9 | _TBD_ | _TBD_ |
|
||||
| Access control behavior at UI/API boundaries | REQ-5, REQ-7 | _TBD_ | _TBD_ |
|
||||
| Input validation and safe user-facing error behavior | REQ-1, REQ-2, REQ-5 | _TBD_ | _TBD_ |
|
||||
| Config and startup safety controls | REQ-8, REQ-10 | _TBD_ | _TBD_ |
|
||||
| Persistence and domain integrity continuity | REQ-11, REQ-12 | _TBD_ | _TBD_ |
|
||||
| Private-network and single-operator safety posture | REQ-9 | met | `docs/ver1/ver1-step5-security-assumptions.md`, README security section |
|
||||
| Access control behavior at UI/API boundaries | REQ-5, REQ-7 | met | `src/transcription/security.py`, `src/transcription/app.py`, `tests/api/test_access_control.py` |
|
||||
| Input validation and safe user-facing error behavior | REQ-1, REQ-2, REQ-5 | met | `src/transcription/services/upload.py`, `src/transcription/errors.py`, updated tests |
|
||||
| Config and startup safety controls | REQ-8, REQ-10 | met | `src/transcription/config.py`, `tests/test_config.py`, `README.md` |
|
||||
| Persistence and domain integrity continuity | REQ-11, REQ-12 | met (no regressions) | full test lane pass including integration and worker flows |
|
||||
|
||||
---
|
||||
|
||||
@@ -82,32 +137,35 @@ Record scan commands and outcomes:
|
||||
|
||||
- `docs/ver1/ver1-step5.md`
|
||||
- `docs/ver1/ver1-step5-results.md`
|
||||
- _TBD additional artifacts_
|
||||
- `docs/ver1/ver1-step5-security-assumptions.md`
|
||||
- `src/transcription/security.py`
|
||||
- `tests/api/test_access_control.py`
|
||||
|
||||
---
|
||||
|
||||
## Risks, Exceptions, and Follow-Ups
|
||||
|
||||
1. _TBD_
|
||||
2. _TBD_
|
||||
3. _TBD_
|
||||
1. Basic auth is intentionally right-sized for trusted private-network use; if deployment posture changes, stronger identity controls are required.
|
||||
2. Current model remains single shared operator credential (no per-user audit identity).
|
||||
3. No built-in brute-force/rate-limit controls in Step 5 scope; evaluate in future hardening if threat model expands.
|
||||
|
||||
Open follow-ups to carry forward:
|
||||
|
||||
- _TBD_
|
||||
- Consider stronger auth/session model if system becomes multi-user or internet-accessible.
|
||||
- Consider request throttling/rate limiting if threat model changes.
|
||||
|
||||
---
|
||||
|
||||
## Step 5 Exit Assessment
|
||||
|
||||
- Private-network assumptions and controls: **_TBD_**
|
||||
- Access-control baseline effectiveness: **_TBD_**
|
||||
- Validation and safe-output safety: **_TBD_**
|
||||
- Secret handling and config safety: **_TBD_**
|
||||
- Dependency/security risk closure: **_TBD_**
|
||||
- Test and regression safety: **_TBD_**
|
||||
- Private-network assumptions and controls: **met**
|
||||
- Access-control baseline effectiveness: **met**
|
||||
- Validation and safe-output safety: **met (baseline)**
|
||||
- Secret handling and config safety: **met**
|
||||
- Dependency/security risk closure: **met (no critical/high findings)**
|
||||
- Test and regression safety: **met**
|
||||
|
||||
Step 5 completion status: **_TBD_**
|
||||
Step 5 completion status: **complete**
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Ver1 Step 5 Security Assumptions (Private-Network Baseline)
|
||||
|
||||
## Operating Model
|
||||
|
||||
This system is operated as:
|
||||
|
||||
1. single operator
|
||||
2. trusted private network
|
||||
3. non-public deployment (no direct internet exposure for UI/API)
|
||||
|
||||
Out of scope for Step 5:
|
||||
|
||||
- enterprise IAM/SSO/RBAC
|
||||
- internet-facing zero-trust edge controls
|
||||
- multi-tenant user isolation
|
||||
|
||||
## Step 5 Controls and Ownership
|
||||
|
||||
| Control | Boundary Owner | Verification |
|
||||
| --- | --- | --- |
|
||||
| Optional operator authentication for `/ui*` and `/api*` routes | `src/transcription/security.py`, `src/transcription/app.py` | `tests/api/test_access_control.py` |
|
||||
| Unauthorized contract (`401` + safe envelope + `WWW-Authenticate`) | `src/transcription/api/errors.py` | `tests/api/test_access_control.py` |
|
||||
| Upload size guard (`MAX_UPLOAD_BYTES`) | `src/transcription/services/upload.py`, `src/transcription/config.py` | `tests/services/test_upload.py` |
|
||||
| Fail-fast auth config when enabled | `src/transcription/config.py` | `tests/test_config.py` |
|
||||
| Safe unexpected error messaging (reduced leak surface) | `src/transcription/errors.py` | `tests/test_errors.py`, worker/integration failure tests |
|
||||
|
||||
## Access-Control Policy (Step 5)
|
||||
|
||||
- Health endpoint (`/healthz`) remains unauthenticated for operability checks.
|
||||
- When `OPERATOR_ACCESS_ENABLED=true`, protected paths require HTTP Basic auth:
|
||||
- `/ui`
|
||||
- `/ui/...`
|
||||
- `/api/...`
|
||||
- Credentials are runtime-configured:
|
||||
- `OPERATOR_USERNAME` (default `operator`)
|
||||
- `OPERATOR_PASSWORD` (required when access is enabled)
|
||||
|
||||
## Secrets Policy
|
||||
|
||||
- Secrets must be provided via runtime environment variables.
|
||||
- Secrets must not be committed to source control.
|
||||
- Secrets must not be logged.
|
||||
- Example secret values in docs must always be placeholders.
|
||||
|
||||
## Residual Risks (Accepted for Step 5)
|
||||
|
||||
1. HTTP Basic credentials are suitable only for trusted private-network deployment.
|
||||
2. No per-user identity model (single shared operator credential).
|
||||
3. No advanced brute-force/rate-limit controls in Step 5 scope.
|
||||
|
||||
These are carried forward for future hardening only if deployment posture changes.
|
||||
@@ -8,6 +8,7 @@ from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from transcription.errors import AppError, ErrorCategory, build_error_envelope
|
||||
from transcription.security import AccessDeniedError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -31,6 +32,12 @@ def _status_for(error: AppError) -> int:
|
||||
def register_error_handlers(app: FastAPI) -> None:
|
||||
"""Register API exception handlers on the app."""
|
||||
|
||||
@app.exception_handler(AccessDeniedError)
|
||||
async def access_denied_handler(_request: Request, exc: AccessDeniedError) -> JSONResponse:
|
||||
envelope = build_error_envelope(exc)
|
||||
headers = {"WWW-Authenticate": "Basic"} if exc.should_challenge else None
|
||||
return JSONResponse(status_code=401, content=envelope.__dict__, headers=headers)
|
||||
|
||||
@app.exception_handler(AppError)
|
||||
async def app_error_handler(_request: Request, exc: AppError) -> JSONResponse:
|
||||
envelope = build_error_envelope(exc)
|
||||
|
||||
@@ -5,7 +5,8 @@ from __future__ import annotations
|
||||
from contextlib import asynccontextmanager
|
||||
from threading import Event, Thread
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from transcription.api.errors import register_error_handlers
|
||||
from transcription.api.health import router as health_router
|
||||
@@ -18,7 +19,9 @@ from transcription.db import (
|
||||
should_bootstrap_schema,
|
||||
validate_schema_compatibility,
|
||||
)
|
||||
from transcription.errors import build_error_envelope
|
||||
from transcription.migrations import apply_pending_migrations
|
||||
from transcription.security import AccessDeniedError, enforce_request_access
|
||||
from transcription.ui import register_pages
|
||||
from transcription.worker import run_worker_loop
|
||||
|
||||
@@ -83,6 +86,19 @@ async def _lifespan(app: FastAPI):
|
||||
def create_app() -> FastAPI:
|
||||
"""Create and configure the FastAPI application."""
|
||||
app = FastAPI(title="Transcription", lifespan=_lifespan)
|
||||
|
||||
@app.middleware("http")
|
||||
async def operator_access_middleware(request: Request, call_next):
|
||||
settings = get_settings()
|
||||
try:
|
||||
enforce_request_access(request=request, settings=settings)
|
||||
except AccessDeniedError as exc:
|
||||
envelope = build_error_envelope(exc)
|
||||
headers = {"WWW-Authenticate": "Basic"} if exc.should_challenge else None
|
||||
return JSONResponse(status_code=401, content=envelope.__dict__, headers=headers)
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
register_error_handlers(app)
|
||||
register_pages(app)
|
||||
app.include_router(health_router)
|
||||
|
||||
@@ -12,6 +12,7 @@ from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
@@ -46,10 +47,24 @@ class Settings(BaseSettings):
|
||||
upload_dir: Path = Path("./uploads")
|
||||
prompt_dir: Path = Path("./prompts")
|
||||
|
||||
# --- upload safety ---
|
||||
max_upload_bytes: int = 15 * 1024 * 1024
|
||||
|
||||
# --- single-operator access control ---
|
||||
operator_access_enabled: bool = False
|
||||
operator_username: str = "operator"
|
||||
operator_password: str | None = None
|
||||
|
||||
# --- worker reliability ---
|
||||
worker_max_retries: int = 0
|
||||
worker_retry_backoff_seconds: float = 0.0
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_operator_access_settings(self) -> "Settings":
|
||||
if self.operator_access_enabled and not self.operator_password:
|
||||
raise ValueError("OPERATOR_PASSWORD is required when OPERATOR_ACCESS_ENABLED=true")
|
||||
return self
|
||||
|
||||
|
||||
LOGGING_CONFIG: dict[str, object] = {
|
||||
"version": 1,
|
||||
|
||||
@@ -70,8 +70,9 @@ def build_error_envelope(error: AppError) -> ErrorEnvelope:
|
||||
|
||||
def classify_unexpected_error(exc: Exception, *, operation: str) -> AppError:
|
||||
"""Normalize unknown exceptions into internal_unexpected_error."""
|
||||
_ = exc
|
||||
return AppError(
|
||||
f"Unexpected error during {operation}: {exc}",
|
||||
f"Unexpected error during {operation}",
|
||||
category=ErrorCategory.INTERNAL_UNEXPECTED,
|
||||
suggestion="Retry once. If it persists, review logs and report the error reference id.",
|
||||
retriable=False,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Step 5 single-operator access control helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import secrets
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from transcription.config import Settings
|
||||
from transcription.errors import AppError, ErrorCategory
|
||||
|
||||
|
||||
class AccessDeniedError(AppError):
|
||||
"""Raised when a request is not authorized for operator actions."""
|
||||
|
||||
def __init__(self, message: str, *, suggestion: str, should_challenge: bool = True) -> None:
|
||||
super().__init__(message, category=ErrorCategory.USER_INPUT, suggestion=suggestion)
|
||||
self.should_challenge = should_challenge
|
||||
|
||||
|
||||
def is_protected_path(path: str) -> bool:
|
||||
"""Return True when a request path requires operator authentication."""
|
||||
return path == "/ui" or path.startswith("/ui/") or path.startswith("/api")
|
||||
|
||||
|
||||
def enforce_request_access(*, request: Request, settings: Settings) -> None:
|
||||
"""Enforce basic operator access control for protected paths."""
|
||||
if not settings.operator_access_enabled or not is_protected_path(request.url.path):
|
||||
return
|
||||
|
||||
if not settings.operator_password:
|
||||
raise AppError(
|
||||
"Operator authentication is enabled but credentials are not configured",
|
||||
category=ErrorCategory.INFRA_PERSISTENT,
|
||||
suggestion="Set OPERATOR_PASSWORD in the runtime environment and restart the app.",
|
||||
)
|
||||
|
||||
authorization = request.headers.get("Authorization")
|
||||
username, password = _parse_basic_authorization_header(authorization)
|
||||
|
||||
valid_username = secrets.compare_digest(username, settings.operator_username)
|
||||
valid_password = secrets.compare_digest(password, settings.operator_password)
|
||||
if not (valid_username and valid_password):
|
||||
raise AccessDeniedError(
|
||||
"Invalid operator credentials",
|
||||
suggestion="Provide valid operator credentials and retry.",
|
||||
)
|
||||
|
||||
|
||||
def _parse_basic_authorization_header(value: str | None) -> tuple[str, str]:
|
||||
if not value:
|
||||
raise AccessDeniedError(
|
||||
"Operator authentication required",
|
||||
suggestion="Provide HTTP Basic operator credentials and retry.",
|
||||
)
|
||||
|
||||
scheme, _, token = value.partition(" ")
|
||||
if scheme.lower() != "basic" or not token:
|
||||
raise AccessDeniedError(
|
||||
"Operator authentication required",
|
||||
suggestion="Provide HTTP Basic operator credentials and retry.",
|
||||
)
|
||||
|
||||
try:
|
||||
decoded = base64.b64decode(token, validate=True).decode("utf-8")
|
||||
except (binascii.Error, UnicodeDecodeError) as exc:
|
||||
raise AccessDeniedError(
|
||||
"Invalid authentication header",
|
||||
suggestion="Provide HTTP Basic operator credentials and retry.",
|
||||
) from exc
|
||||
|
||||
username, sep, password = decoded.partition(":")
|
||||
if not sep or not username:
|
||||
raise AccessDeniedError(
|
||||
"Invalid authentication header",
|
||||
suggestion="Provide HTTP Basic operator credentials and retry.",
|
||||
)
|
||||
|
||||
return username, password
|
||||
@@ -42,7 +42,11 @@ def create_upload_job(
|
||||
) -> UploadJobResult:
|
||||
"""Persist an uploaded file and create document/job records."""
|
||||
runtime_settings = settings or get_settings()
|
||||
_validate_upload(filename=filename, file_bytes=file_bytes)
|
||||
_validate_upload(
|
||||
filename=filename,
|
||||
file_bytes=file_bytes,
|
||||
max_upload_bytes=runtime_settings.max_upload_bytes,
|
||||
)
|
||||
|
||||
upload_dir = runtime_settings.upload_dir
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -87,7 +91,7 @@ def create_upload_job(
|
||||
)
|
||||
|
||||
|
||||
def _validate_upload(*, filename: str, file_bytes: bytes) -> None:
|
||||
def _validate_upload(*, filename: str, file_bytes: bytes, max_upload_bytes: int) -> None:
|
||||
if not file_bytes:
|
||||
raise UploadError(
|
||||
"Upload payload is empty",
|
||||
@@ -95,6 +99,13 @@ def _validate_upload(*, filename: str, file_bytes: bytes) -> None:
|
||||
suggestion="Select a non-empty file and try again.",
|
||||
)
|
||||
|
||||
if len(file_bytes) > max_upload_bytes:
|
||||
raise UploadError(
|
||||
f"Upload exceeds maximum allowed size ({max_upload_bytes} bytes)",
|
||||
category=ErrorCategory.USER_INPUT,
|
||||
suggestion="Upload a smaller file or increase MAX_UPLOAD_BYTES for this deployment.",
|
||||
)
|
||||
|
||||
safe_name = Path(filename).name
|
||||
if not safe_name:
|
||||
raise UploadError(
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Tests for Step 5 operator access control behavior."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.testclient import TestClient
|
||||
import pytest
|
||||
|
||||
from transcription.api.errors import register_error_handlers
|
||||
from transcription.errors import build_error_envelope
|
||||
from transcription.security import AccessDeniedError, enforce_request_access
|
||||
|
||||
|
||||
def _basic_header(username: str, password: str) -> str:
|
||||
token = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("ascii")
|
||||
return f"Basic {token}"
|
||||
|
||||
|
||||
def _build_app(*, settings) -> FastAPI:
|
||||
app = FastAPI()
|
||||
register_error_handlers(app)
|
||||
|
||||
@app.middleware("http")
|
||||
async def operator_access_middleware(request, call_next):
|
||||
try:
|
||||
enforce_request_access(request=request, settings=settings)
|
||||
except AccessDeniedError as exc:
|
||||
envelope = build_error_envelope(exc)
|
||||
headers = {"WWW-Authenticate": "Basic"} if exc.should_challenge else None
|
||||
return JSONResponse(status_code=401, content=envelope.__dict__, headers=headers)
|
||||
return await call_next(request)
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz():
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.get("/api/jobs")
|
||||
def get_jobs():
|
||||
return [{"id": "demo"}]
|
||||
|
||||
@app.get("/ui")
|
||||
def ui_root():
|
||||
return {"ok": True}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestAccessControl:
|
||||
"""Verify protected routes enforce operator auth when enabled."""
|
||||
|
||||
def test_protected_api_requires_credentials(self):
|
||||
settings = SimpleNamespace(
|
||||
operator_access_enabled=True,
|
||||
operator_username="operator",
|
||||
operator_password="secret",
|
||||
)
|
||||
client = TestClient(_build_app(settings=settings), raise_server_exceptions=False)
|
||||
|
||||
response = client.get("/api/jobs")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.headers.get("WWW-Authenticate") == "Basic"
|
||||
payload = response.json()
|
||||
assert payload["category"] == "user_input_error"
|
||||
assert payload["suggestion"]
|
||||
|
||||
def test_protected_api_rejects_invalid_credentials(self):
|
||||
settings = SimpleNamespace(
|
||||
operator_access_enabled=True,
|
||||
operator_username="operator",
|
||||
operator_password="secret",
|
||||
)
|
||||
client = TestClient(_build_app(settings=settings), raise_server_exceptions=False)
|
||||
|
||||
response = client.get(
|
||||
"/api/jobs",
|
||||
headers={"Authorization": _basic_header("operator", "wrong")},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
payload = response.json()
|
||||
assert payload["message"] == "Invalid operator credentials"
|
||||
|
||||
def test_protected_api_allows_valid_credentials(self):
|
||||
settings = SimpleNamespace(
|
||||
operator_access_enabled=True,
|
||||
operator_username="operator",
|
||||
operator_password="secret",
|
||||
)
|
||||
client = TestClient(_build_app(settings=settings), raise_server_exceptions=False)
|
||||
|
||||
response = client.get(
|
||||
"/api/jobs",
|
||||
headers={"Authorization": _basic_header("operator", "secret")},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == [{"id": "demo"}]
|
||||
|
||||
def test_protected_ui_path_requires_credentials(self):
|
||||
settings = SimpleNamespace(
|
||||
operator_access_enabled=True,
|
||||
operator_username="operator",
|
||||
operator_password="secret",
|
||||
)
|
||||
client = TestClient(_build_app(settings=settings), raise_server_exceptions=False)
|
||||
|
||||
response = client.get("/ui")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_healthz_is_not_protected(self):
|
||||
settings = SimpleNamespace(
|
||||
operator_access_enabled=True,
|
||||
operator_username="operator",
|
||||
operator_password="secret",
|
||||
)
|
||||
client = TestClient(_build_app(settings=settings), raise_server_exceptions=False)
|
||||
|
||||
response = client.get("/healthz")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
@@ -71,6 +71,6 @@ class TestPipelineFailureFlow:
|
||||
assert job.status == JobStatus.FAILED
|
||||
assert transcript is not None
|
||||
assert transcript.text is None
|
||||
assert "pipeline provider failure" in transcript.error_detail
|
||||
assert "pipeline provider failure" not in transcript.error_detail
|
||||
assert "[internal_unexpected_error]" in transcript.error_detail
|
||||
assert "error_id=" in transcript.error_detail
|
||||
|
||||
@@ -41,6 +41,24 @@ class TestUploadValidation:
|
||||
assert exc_info.value.category.value == "user_input_error"
|
||||
assert "jpg" in exc_info.value.suggestion.lower()
|
||||
|
||||
def test_rejects_payload_exceeding_max_upload_bytes(self, session, tmp_path: Path):
|
||||
"""create_upload_job rejects payloads above configured size limit."""
|
||||
settings = Settings(
|
||||
openrouter_api_key="test-key",
|
||||
upload_dir=tmp_path,
|
||||
max_upload_bytes=3,
|
||||
)
|
||||
with pytest.raises(UploadError) as exc_info:
|
||||
create_upload_job(
|
||||
filename="scan.jpg",
|
||||
file_bytes=b"1234",
|
||||
session=session,
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
assert exc_info.value.category.value == "user_input_error"
|
||||
assert "smaller file" in exc_info.value.suggestion.lower()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestUploadPersistence:
|
||||
|
||||
@@ -118,7 +118,7 @@ class TestWorkerFailurePath:
|
||||
assert job.status == JobStatus.FAILED
|
||||
assert transcript is not None
|
||||
assert transcript.text is None
|
||||
assert "provider failure" in transcript.error_detail
|
||||
assert "provider failure" not in transcript.error_detail
|
||||
assert "[internal_unexpected_error]" in transcript.error_detail
|
||||
assert "error_id=" in transcript.error_detail
|
||||
assert "suggestion=" in transcript.error_detail
|
||||
@@ -148,7 +148,7 @@ class TestWorkerFailurePath:
|
||||
assert len(transcripts) == 1
|
||||
assert transcripts[0].id == existing.id
|
||||
assert transcripts[0].text is None
|
||||
assert "provider failure" in transcripts[0].error_detail
|
||||
assert "provider failure" not in transcripts[0].error_detail
|
||||
assert "[internal_unexpected_error]" in transcripts[0].error_detail
|
||||
assert "error_id=" in transcripts[0].error_detail
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ class TestAppLifespan:
|
||||
calls = []
|
||||
|
||||
monkeypatch.setattr("transcription.app.setup_logging", lambda: calls.append("logging"))
|
||||
monkeypatch.setattr("transcription.app.get_settings", lambda: object())
|
||||
monkeypatch.setattr("transcription.app.enforce_request_access", lambda **_kwargs: None)
|
||||
monkeypatch.setattr("transcription.app.create_all", lambda **_kwargs: calls.append("schema"))
|
||||
monkeypatch.setattr(
|
||||
"transcription.app.initialize_database_runtime",
|
||||
@@ -65,6 +67,8 @@ class TestAppLifespan:
|
||||
calls = []
|
||||
|
||||
monkeypatch.setattr("transcription.app.setup_logging", lambda: None)
|
||||
monkeypatch.setattr("transcription.app.get_settings", lambda: object())
|
||||
monkeypatch.setattr("transcription.app.enforce_request_access", lambda **_kwargs: None)
|
||||
monkeypatch.setattr("transcription.app.create_all", lambda **_kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
"transcription.app.initialize_database_runtime",
|
||||
|
||||
@@ -73,6 +73,23 @@ class TestMigrationSafetySettings:
|
||||
assert settings.validate_schema_on_startup is True
|
||||
|
||||
|
||||
class TestSecuritySettings:
|
||||
"""Verify Step 5 security-related settings behavior."""
|
||||
|
||||
def test_security_defaults(self):
|
||||
"""Security controls default to disabled auth and bounded upload size."""
|
||||
settings = _make_settings()
|
||||
assert settings.max_upload_bytes == 15 * 1024 * 1024
|
||||
assert settings.operator_access_enabled is False
|
||||
assert settings.operator_username == "operator"
|
||||
assert settings.operator_password is None
|
||||
|
||||
def test_operator_password_required_when_access_enabled(self):
|
||||
"""Enabling operator access requires OPERATOR_PASSWORD."""
|
||||
with pytest.raises(ValidationError):
|
||||
_make_settings(operator_access_enabled=True, operator_password=None)
|
||||
|
||||
|
||||
class TestWorkerReliabilitySettings:
|
||||
"""Verify worker retry settings defaults."""
|
||||
|
||||
|
||||
@@ -38,6 +38,6 @@ class TestAppErrorHelpers:
|
||||
assert isinstance(err, AppError)
|
||||
assert err.category == ErrorCategory.INTERNAL_UNEXPECTED
|
||||
assert "unit.test" in err.message
|
||||
assert "boom" in err.message
|
||||
assert "boom" not in err.message
|
||||
assert err.suggestion
|
||||
assert err.error_id
|
||||
|
||||
Reference in New Issue
Block a user