diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 8b4c37c..f77d047 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -16,6 +16,9 @@ from fastapi import APIRouter, Cookie, Depends, Request, Response # Import OAuth2PasswordRequestForm from fastapi.security from fastapi.security import OAuth2PasswordRequestForm +# Import datetime, timezone from datetime +from datetime import datetime, timezone + # Import jwt (PyJWT) import jwt from jwt.exceptions import PyJWTError as JWTError @@ -24,7 +27,7 @@ from jwt.exceptions import PyJWTError as JWTError from sqlalchemy.orm import Session # Import blacklist_token, create_access_token, verify_pa... from app.auth -from app.auth import blacklist_token, create_access_token, verify_password +from app.auth import blacklist_token, create_access_token, is_token_blacklisted, verify_password # Import settings from app.config from app.config import settings @@ -272,19 +275,28 @@ def logout( return {"detail": "Logged out"} +# A token that has *just* expired can still be refreshed within this window. +# Without this grace period, `/auth/refresh` decodes the same token with the +# same strict expiry check as every other endpoint — so by the time a 401 +# triggers a refresh attempt, the refresh call itself has also expired and +# the session is unrecoverable. The grace window only widens the refresh +# check; a brand-new token is always issued with the full expiry. +_REFRESH_GRACE_SECONDS = 15 * 60 + + @router.post("/refresh", response_model=TokenResponse) def refresh_token( response: Response, aegis_token: str | None = Cookie(None), db: Session = Depends(get_db), ): - """Issue a new access token if the current one is valid. + """Issue a new access token if the current one is valid or recently expired. Called automatically by the frontend when it detects an expired - session while the user is actively using the app. If the current - cookie token is still valid (not blacklisted, not expired), a fresh - token is issued and the cookie is renewed — keeping the session alive - without requiring re-authentication. + session while the user is actively using the app. Expiry is checked + manually (with `_REFRESH_GRACE_SECONDS` of leeway) instead of relying on + PyJWT's built-in check, so a token that expired moments ago can still + be refreshed. Revoked (blacklisted) tokens are never refreshable. """ if not aegis_token: raise PermissionViolation("No active session") @@ -294,10 +306,20 @@ def refresh_token( aegis_token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM], + options={"verify_exp": False}, ) except JWTError: raise PermissionViolation("Session expired — please log in again") + jti: str | None = payload.get("jti") + if jti and is_token_blacklisted(jti): + raise PermissionViolation("Session expired — please log in again") + + exp = payload.get("exp") + now = datetime.now(timezone.utc).timestamp() + if exp is None or now - exp > _REFRESH_GRACE_SECONDS: + raise PermissionViolation("Session expired — please log in again") + username: str | None = payload.get("sub") if not username: raise PermissionViolation("Invalid session") diff --git a/backend/app/routers/campaigns.py b/backend/app/routers/campaigns.py index 9e9a800..04cda06 100644 --- a/backend/app/routers/campaigns.py +++ b/backend/app/routers/campaigns.py @@ -134,6 +134,42 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/campaigns", tags=["campaigns"]) +def _create_jira_tickets_for_campaign(db: Session, campaign: Campaign, campaign_id: str, user: User) -> None: + """Create Jira tickets for a campaign and its already-linked tests, if missing. + + Shared by both paths that bring a campaign to ``active``: the admin-only + emergency `/activate` override and the normal manager `/approve` flow. + Tests may already be linked to the campaign from when it was still a + draft, i.e. before any Jira ticket existed for the campaign — so they + need their own tickets created here too. Best-effort: failures are + logged, not raised, since a Jira outage must not block activation. + """ + try: + from app.services.jira_service import ( + auto_create_campaign_issue, + auto_create_test_issue, + get_campaign_jira_key, + get_test_jira_key, + ) + campaign_jira_key = get_campaign_jira_key(db, campaign_id) + if not campaign_jira_key: + campaign_jira_key = auto_create_campaign_issue(db, campaign, user) + if campaign_jira_key: + for ct in campaign.campaign_tests: + if ct.test and not get_test_jira_key(db, ct.test.id): + auto_create_test_issue( + db, ct.test, user, + parent_ticket_override=campaign_jira_key, + campaign_start_date=campaign.start_date, + ) + db.commit() + except Exception: + logger.exception( + "Jira ticket creation failed while activating campaign %s", + campaign_id, + ) + + # ── Pydantic schemas ───────────────────────────────────────────────── class CampaignCreate(BaseModel): @@ -498,6 +534,11 @@ def approve_campaign_endpoint( ) uow.commit() db.refresh(campaign) + + # Create Jira tickets for campaign and its already-linked tests (non-fatal). + # Mirrors the admin-only /activate override — this is the normal path. + _create_jira_tickets_for_campaign(db, campaign, campaign_id, current_user) + return serialize_campaign(db, campaign) @@ -837,30 +878,7 @@ def activate_campaign( # Create Jira tickets for campaign and tests at activation time (non-fatal). # Campaign ticket is created here if it doesn't already exist (deferred from creation). - try: - from app.services.jira_service import ( - auto_create_campaign_issue, - auto_create_test_issue, - get_campaign_jira_key, - get_test_jira_key, - ) - campaign_jira_key = get_campaign_jira_key(db, campaign_id) - if not campaign_jira_key: - campaign_jira_key = auto_create_campaign_issue(db, campaign, current_user) - if campaign_jira_key: - for ct in campaign.campaign_tests: - if ct.test and not get_test_jira_key(db, ct.test.id): - auto_create_test_issue( - db, ct.test, current_user, - parent_ticket_override=campaign_jira_key, - campaign_start_date=campaign.start_date, - ) - db.commit() - except Exception: - logger.exception( - "Jira ticket creation failed during activation of campaign %s", - campaign_id, - ) + _create_jira_tickets_for_campaign(db, campaign, campaign_id, current_user) return serialize_campaign(db, campaign) @@ -950,7 +968,7 @@ def get_campaign_progress_endpoint( # --------------------------------------------------------------------------- class GenerateFromActorPayload(BaseModel): - pass + start_date: Optional[datetime] = None @router.post("/from-threat-actor/{actor_id}", status_code=201) @@ -980,6 +998,7 @@ def generate_campaign_from_actor( db, uuid.UUID(actor_id), current_user, + start_date=payload.start_date, ) # Open context manager diff --git a/backend/app/services/campaign_service.py b/backend/app/services/campaign_service.py index ce2033b..5c8ce2d 100644 --- a/backend/app/services/campaign_service.py +++ b/backend/app/services/campaign_service.py @@ -178,6 +178,8 @@ def generate_campaign_from_threat_actor( actor_id: uuid.UUID, # Entry: user user: User, + # Entry: start_date + start_date: datetime | None = None, ) -> Campaign: """Auto-generate a campaign from a threat actor's uncovered techniques. @@ -235,6 +237,8 @@ def generate_campaign_from_threat_actor( created_by=user.id, # Keyword argument: tags tags=[actor.name, "auto-generated"], + # Keyword argument: start_date + start_date=start_date, ) # Stage new record(s) for database insertion db.add(campaign) diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 18ccd83..5c440cd 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -1,7 +1,23 @@ """Tests for authentication endpoints.""" +import uuid +from datetime import datetime, timedelta, timezone + +import jwt import pytest +from app.config import settings + + +def _make_token(username: str, *, expired_seconds_ago: int | None = None) -> str: + """Build a raw JWT mirroring ``create_access_token`` with a controllable exp.""" + if expired_seconds_ago is None: + expire = datetime.now(timezone.utc) + timedelta(minutes=30) + else: + expire = datetime.now(timezone.utc) - timedelta(seconds=expired_seconds_ago) + payload = {"sub": username, "exp": expire, "jti": str(uuid.uuid4())} + return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + def test_login_success(client, admin_user): """Test successful login returns a token.""" @@ -122,3 +138,49 @@ def test_logout_revokes_token(client, admin_user): ) assert me.status_code == 401 assert me.json()["detail"] == "Token has been revoked" + + +def test_refresh_without_cookie_fails(client): + response = client.post("/api/v1/auth/refresh") + assert response.status_code == 403 + + +def test_refresh_valid_token_succeeds(client, admin_user): + token = _make_token("admin") + client.cookies.set("aegis_token", token) + response = client.post("/api/v1/auth/refresh") + assert response.status_code == 200 + assert "access_token" in response.json() + + +def test_refresh_recently_expired_token_succeeds_within_grace(client, admin_user): + """A token that expired moments ago must still be refreshable. + + This is the core of the refresh-token bug fix: without a grace window, + `/auth/refresh` decodes with the same strict expiry check as every + other endpoint, so a 401-triggered refresh attempt always also fails. + """ + token = _make_token("admin", expired_seconds_ago=60) + client.cookies.set("aegis_token", token) + response = client.post("/api/v1/auth/refresh") + assert response.status_code == 200 + assert "access_token" in response.json() + + +def test_refresh_long_expired_token_fails(client, admin_user): + token = _make_token("admin", expired_seconds_ago=60 * 60) + client.cookies.set("aegis_token", token) + response = client.post("/api/v1/auth/refresh") + assert response.status_code == 403 + + +def test_refresh_blacklisted_token_fails_even_if_not_expired(client, admin_user): + from app.auth import blacklist_token + + token = _make_token("admin") + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + blacklist_token(payload["jti"], float(payload["exp"])) + + client.cookies.set("aegis_token", token) + response = client.post("/api/v1/auth/refresh") + assert response.status_code == 403 diff --git a/backend/tests/test_campaign_approval_router.py b/backend/tests/test_campaign_approval_router.py index 2d79ac1..83c1527 100644 --- a/backend/tests/test_campaign_approval_router.py +++ b/backend/tests/test_campaign_approval_router.py @@ -1,5 +1,7 @@ """Router-level tests for the campaign manager-approval workflow.""" +from unittest.mock import patch + from app.models.campaign import Campaign, CampaignTest from app.models.technique import Technique from app.models.test import Test @@ -51,6 +53,30 @@ def test_manager_can_approve_and_sets_start_date(api, db, red_lead_user, red_lea assert body["start_date"] is not None +def test_manager_approval_creates_jira_tickets(api, db, red_lead_user, red_lead_headers, manager_headers): + """The normal manager-approval path must create Jira tickets too, not just + the admin-only emergency /activate override — this was the Block 1 gap: + campaigns approved through the standard flow never got a Jira ticket.""" + campaign = _make_draft_campaign(db, red_lead_user.id) + api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers) + + with patch("app.services.jira_service.get_campaign_jira_key", return_value=None) as mock_get_key, \ + patch("app.services.jira_service.auto_create_campaign_issue", return_value="PT-100") as mock_create_campaign, \ + patch("app.services.jira_service.get_test_jira_key", return_value=None), \ + patch("app.services.jira_service.auto_create_test_issue") as mock_create_test: + resp = api( + "post", + f"/api/v1/campaigns/{campaign.id}/approve", + manager_headers, + json={"start_date": "2026-09-01T00:00:00"}, + ) + + assert resp.status_code == 200 + mock_get_key.assert_called_once() + mock_create_campaign.assert_called_once() + mock_create_test.assert_called_once() + + def test_lead_cannot_approve(api, db, red_lead_user, red_lead_headers): campaign = _make_draft_campaign(db, red_lead_user.id) api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers) diff --git a/backend/tests/test_campaign_from_threat_actor.py b/backend/tests/test_campaign_from_threat_actor.py new file mode 100644 index 0000000..2568f72 --- /dev/null +++ b/backend/tests/test_campaign_from_threat_actor.py @@ -0,0 +1,72 @@ +"""Tests for generating a campaign from a threat actor's uncovered techniques. + +Covers the Block 1 fix: the frontend sends a `start_date` when generating a +campaign from a Threat Actor, but `GenerateFromActorPayload` used to be an +empty schema, so the date was silently discarded and the campaign was +created with `start_date = NULL`. +""" + +from datetime import datetime + +from app.models.enums import TechniqueStatus +from app.models.technique import Technique +from app.models.test_template import TestTemplate +from app.models.threat_actor import ThreatActor, ThreatActorTechnique +from app.services.campaign_service import generate_campaign_from_threat_actor + + +def _seed_actor_with_gap_technique(db): + tech = Technique( + mitre_id="T1059.001", + name="PowerShell", + tactic="execution", + platforms=["windows"], + status_global=TechniqueStatus.not_evaluated, + ) + db.add(tech) + db.flush() + + template = TestTemplate( + mitre_technique_id=tech.mitre_id, + name="PowerShell template", + source="custom", + severity="high", + is_active=True, + ) + db.add(template) + + actor = ThreatActor(name="APT-Test", mitre_id="G9999") + db.add(actor) + db.flush() + + db.add(ThreatActorTechnique(threat_actor_id=actor.id, technique_id=tech.id)) + db.commit() + db.refresh(actor) + return actor + + +def test_generate_from_actor_without_start_date_leaves_it_null(db, red_lead_user): + actor = _seed_actor_with_gap_technique(db) + campaign = generate_campaign_from_threat_actor(db, actor.id, red_lead_user) + assert campaign.start_date is None + + +def test_generate_from_actor_persists_start_date(db, red_lead_user): + actor = _seed_actor_with_gap_technique(db) + start_date = datetime(2026, 9, 1) + campaign = generate_campaign_from_threat_actor( + db, actor.id, red_lead_user, start_date=start_date, + ) + assert campaign.start_date == start_date + + +def test_generate_from_actor_router_forwards_start_date(api, db, red_lead_user, red_lead_headers): + actor = _seed_actor_with_gap_technique(db) + resp = api( + "post", + f"/api/v1/campaigns/from-threat-actor/{actor.id}", + red_lead_headers, + json={"start_date": "2026-09-01T00:00:00"}, + ) + assert resp.status_code == 201, resp.text + assert resp.json()["start_date"] is not None