Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 32 additions & 7 deletions coaching/src/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,10 @@ async def get_current_context(

# Check user status - only Active users allowed
if user_status and user_status.lower() != "active":
logger.warning(
"user_auth.denied denial_reason=user_not_active user_status=%s",
user_status,
)
raise HTTPException(status_code=403, detail="User account is not active")

# Parse role if provided, default to MEMBER
Expand Down Expand Up @@ -345,7 +349,7 @@ async def get_tenant_for_async_job_access(
) -> str:
"""Resolve tenant for async job polling (user JWT or service token; email-insights spec §4.6)."""
if not authorization or not authorization.startswith("Bearer "):
logger.warning("async_job_auth.missing_bearer")
logger.warning("async_job_auth.denied denial_reason=missing_bearer_header")
raise HTTPException(
status_code=401,
detail="Missing authorization header",
Expand Down Expand Up @@ -379,13 +383,16 @@ async def get_tenant_for_async_job_access(
options={"verify_aud": False, "verify_iss": False},
)
else:
logger.warning(f"async_job_auth.jwt_invalid: {jwt_err}")
logger.warning(
"async_job_auth.denied denial_reason=jwt_invalid detail=%s",
jwt_err,
)
raise HTTPException(
status_code=401,
detail="Invalid or expired token",
) from jwt_err
except JWTError as e:
logger.warning(f"async_job_auth.jwt_decode_failed: {e}")
logger.warning("async_job_auth.denied denial_reason=jwt_decode_failed detail=%s", e)
raise HTTPException(status_code=401, detail="Invalid or expired token") from e

token_type = payload.get("token_type") or payload.get("tokenType")
Expand All @@ -394,18 +401,36 @@ async def get_tenant_for_async_job_access(

if token_type == "service_enrichment" and role == "service" and tenant_id:
logger.info(
f"async_job_auth.service_token_accepted tenant_id={tenant_id!s} "
f"issuer={payload.get('iss')!s}"
"async_job_auth.service_token_accepted tenant_id=%s issuer=%s",
tenant_id,
payload.get("iss"),
)
return str(tenant_id)

user_id = payload.get("user_id") or payload.get("sub")
if user_id and tenant_id:
return str(tenant_id)

if token_type == "service_enrichment":
if role != "service":
denial_reason = "service_enrichment_requires_role_service"
elif not tenant_id:
denial_reason = "service_enrichment_requires_tenant_id"
else:
denial_reason = "service_enrichment_incomplete_claims"
elif role == "service":
denial_reason = "service_role_requires_token_type_service_enrichment"
else:
denial_reason = "missing_user_or_tenant_for_async_job_access"

logger.warning(
f"async_job_auth.claims_rejected has_tenant={bool(tenant_id)} "
f"has_user={bool(user_id)} token_type={token_type!r} role={role!r}"
"async_job_auth.denied denial_reason=%s token_type=%r role=%r "
"tenant_id_present=%s user_id_present=%s",
denial_reason,
token_type,
role,
bool(tenant_id),
bool(user_id),
)
raise HTTPException(
status_code=401,
Expand Down
129 changes: 129 additions & 0 deletions coaching/tests/unit/api/routes/test_ai_job_status_route.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"""Tests for GET /api/v1/ai/jobs/{jobId} (polling) auth and tenant resolution."""

from datetime import UTC, datetime
from unittest.mock import AsyncMock

import pytest
from fastapi.testclient import TestClient
from jose import jwt

from coaching.src.api.dependencies.async_execution import get_async_execution_service
from coaching.src.api.main import app
from coaching.src.domain.entities.ai_job import AIJob, AIJobStatus, AIJobType

pytestmark = pytest.mark.unit


@pytest.fixture
def client() -> TestClient:
return TestClient(app)


@pytest.fixture
def sample_job() -> AIJob:
return AIJob(
job_id="job-test-1",
tenant_id="tenant_svc",
user_id="user_1",
topic_id="goal_created_email_insight",
status=AIJobStatus.COMPLETED,
job_type=AIJobType.SINGLE_SHOT,
result={"ok": True},
completed_at=datetime.now(UTC),
processing_time_ms=1,
)


class TestJobStatusServiceTokenAuth:
"""Service-enrichment JWT profile for async job polling (email-insights §4.6)."""

def test_get_job_accepts_service_enrichment_snake_case_claims(
self, client: TestClient, sample_job: AIJob, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(
"coaching.src.api.auth._get_jwt_secret",
lambda: "unit-test-jwt-secret",
)
token = jwt.encode(
{
"token_type": "service_enrichment",
"role": "service",
"tenant_id": "tenant_svc",
},
"unit-test-jwt-secret",
algorithm="HS256",
)
mock_service = AsyncMock()
mock_service.get_job = AsyncMock(return_value=sample_job)
app.dependency_overrides[get_async_execution_service] = lambda: mock_service
try:
response = client.get(
"/api/v1/ai/jobs/job-test-1",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 200
body = response.json()
assert body["success"] is True
assert body["data"]["jobId"] == "job-test-1"
finally:
app.dependency_overrides.clear()

mock_service.get_job.assert_awaited_once_with(job_id="job-test-1", tenant_id="tenant_svc")

def test_get_job_accepts_token_type_camel_case_claim(
self, client: TestClient, sample_job: AIJob, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(
"coaching.src.api.auth._get_jwt_secret",
lambda: "unit-test-jwt-secret",
)
token = jwt.encode(
{
"tokenType": "service_enrichment",
"role": "service",
"tenant_id": "tenant_svc",
},
"unit-test-jwt-secret",
algorithm="HS256",
)
mock_service = AsyncMock()
mock_service.get_job = AsyncMock(return_value=sample_job)
app.dependency_overrides[get_async_execution_service] = lambda: mock_service
try:
response = client.get(
"/api/v1/ai/jobs/job-test-1",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 200
finally:
app.dependency_overrides.clear()

mock_service.get_job.assert_awaited_once_with(job_id="job-test-1", tenant_id="tenant_svc")

def test_get_job_rejects_service_enrichment_without_tenant_claim(
self, client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(
"coaching.src.api.auth._get_jwt_secret",
lambda: "unit-test-jwt-secret",
)
token = jwt.encode(
{
"token_type": "service_enrichment",
"role": "service",
},
"unit-test-jwt-secret",
algorithm="HS256",
)
mock_service = AsyncMock()
app.dependency_overrides[get_async_execution_service] = lambda: mock_service
try:
response = client.get(
"/api/v1/ai/jobs/job-test-1",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 401
finally:
app.dependency_overrides.clear()

mock_service.get_job.assert_not_called()
14 changes: 13 additions & 1 deletion docs/shared/Specifications/ai-api/email-insights-api-contract.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Email Insights AI API Contract Specification

**Version:** 2.4
**Last Updated:** April 13, 2026
**Last Updated:** April 14, 2026
**Status:** Approved for Full Cutover
**Scope:** Generic `email_insight` topics

Expand All @@ -12,6 +12,7 @@
## Revision Log

- 2026-04-13 - v2.4 - §4.7: single HTTP body for all `execute-async` kickoffs; `topicCategory` from coaching `TopicCategory` for non-email topics (issue #315)
- 2026-04-12 - v2.4 - Documented coaching vs `api.{env}` BFF auth boundary, claim names, and coaching denial log markers for async job polling (issue #313)
- 2026-04-09 - v2.4 - Added normative terminal EventBridge wire contract, terminal idempotency/ordering rules, and service-token auth contract for API fallback endpoints
- 2026-04-09 - v2.3 - Enforced transport-mode isolation: EventBridge mode is terminal-event-driven only; API polling is allowed only in API fallback mode
- 2026-04-08 - v2.2 - Added dual transport contract (EventBridge-first kickoff with API fallback) while keeping payload schema unchanged
Expand Down Expand Up @@ -433,6 +434,17 @@ Tenant isolation contract for `GET /api/v1/ai/jobs/{jobId}`:
- AI must validate that token tenant claim matches tenant stored on the job record.
- Mismatch must return authorization failure.

#### 4.6.1 Coaching service vs public API host (normative)

- **PurposePath_AI (coaching)** implements `POST /api/v1/ai/execute-async` and `GET /api/v1/ai/jobs/{jobId}` on its HTTP API. For **v2** `execute-async`, tenant and user context come from the JSON body; a user session JWT is **not** required when the v2 envelope is valid.
- **Job polling auth** (`GET .../jobs/{jobId}`) accepts a **Bearer** JWT that is either:
- **Service token:** claims must include `token_type` **or** `tokenType` = `service_enrichment`, `role` = `service`, and `tenant_id` matching the job owner tenant after signature verification; or
- **User token:** claims must include `tenant_id` and `user_id` or standard `sub`, matching usual user-session semantics.
- **Signature and issuer/audience:** coaching verifies HS256 with the shared JWT secret. When `STAGE` is not `dev`, issuer and audience are validated against configured `jwt_issuer` / `jwt_audience`. In `dev`, issuer/audience checks are relaxed while the signature must still validate (including dev fallback secret behavior documented in code).
- **Public API host** (`https://api.{env}.purposepath.app`, PurposePath_Api) may apply **additional** authorization (for example ASP.NET policies) **before** proxying to coaching. An HTTP **403** with a generic body such as `{"message":"Forbidden"}` is typically produced by that **BFF layer**, not by coaching. If coaching rejects a token, expect **401** with a `detail` string from FastAPI unless a different route explicitly returns **403** (for example inactive user accounts on user-session paths).

**Denial observability (coaching):** CloudWatch log lines include `async_job_auth.denied` with `denial_reason=...` for polling auth failures; `async_execute.started` includes `has_bearer_header` for v2 kickoffs; inactive user session denial uses `user_auth.denied denial_reason=user_not_active`.

### 4.7 `POST /api/v1/ai/execute-async` — single envelope for all async kickoffs

PurposePath_AI accepts **one** JSON shape for `execute-async` (the trigger fields in §4.1–§4.3), including:
Expand Down
Loading