fix(backend): resolve refresh-token expiry deadlock, missing Jira on normal campaign approval, discarded threat-actor campaign start_date

- /auth/refresh now allows a short grace window past expiry and checks
  the blacklist, so an active session's silent refresh no longer fails
  the instant its own token expires
- normal manager /approve flow now creates Jira tickets for the campaign
  and its already-linked tests, matching the admin-only /activate path
- GenerateFromActorPayload now accepts start_date and threads it through
  to the new campaign instead of silently discarding it
This commit is contained in:
kitos
2026-07-07 13:42:26 +02:00
parent fefe70baed
commit bd26b09827
6 changed files with 236 additions and 31 deletions
+28 -6
View File
@@ -16,6 +16,9 @@ from fastapi import APIRouter, Cookie, Depends, Request, Response
# Import OAuth2PasswordRequestForm from fastapi.security # Import OAuth2PasswordRequestForm from fastapi.security
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
# Import datetime, timezone from datetime
from datetime import datetime, timezone
# Import jwt (PyJWT) # Import jwt (PyJWT)
import jwt import jwt
from jwt.exceptions import PyJWTError as JWTError from jwt.exceptions import PyJWTError as JWTError
@@ -24,7 +27,7 @@ from jwt.exceptions import PyJWTError as JWTError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
# Import blacklist_token, create_access_token, verify_pa... from app.auth # 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 # Import settings from app.config
from app.config import settings from app.config import settings
@@ -272,19 +275,28 @@ def logout(
return {"detail": "Logged out"} 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) @router.post("/refresh", response_model=TokenResponse)
def refresh_token( def refresh_token(
response: Response, response: Response,
aegis_token: str | None = Cookie(None), aegis_token: str | None = Cookie(None),
db: Session = Depends(get_db), 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 Called automatically by the frontend when it detects an expired
session while the user is actively using the app. If the current session while the user is actively using the app. Expiry is checked
cookie token is still valid (not blacklisted, not expired), a fresh manually (with `_REFRESH_GRACE_SECONDS` of leeway) instead of relying on
token is issued and the cookie is renewed — keeping the session alive PyJWT's built-in check, so a token that expired moments ago can still
without requiring re-authentication. be refreshed. Revoked (blacklisted) tokens are never refreshable.
""" """
if not aegis_token: if not aegis_token:
raise PermissionViolation("No active session") raise PermissionViolation("No active session")
@@ -294,10 +306,20 @@ def refresh_token(
aegis_token, aegis_token,
settings.SECRET_KEY, settings.SECRET_KEY,
algorithms=[settings.ALGORITHM], algorithms=[settings.ALGORITHM],
options={"verify_exp": False},
) )
except JWTError: except JWTError:
raise PermissionViolation("Session expired — please log in again") 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") username: str | None = payload.get("sub")
if not username: if not username:
raise PermissionViolation("Invalid session") raise PermissionViolation("Invalid session")
+44 -25
View File
@@ -134,6 +134,42 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/campaigns", tags=["campaigns"]) 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 ───────────────────────────────────────────────── # ── Pydantic schemas ─────────────────────────────────────────────────
class CampaignCreate(BaseModel): class CampaignCreate(BaseModel):
@@ -498,6 +534,11 @@ def approve_campaign_endpoint(
) )
uow.commit() uow.commit()
db.refresh(campaign) 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) return serialize_campaign(db, campaign)
@@ -837,30 +878,7 @@ def activate_campaign(
# Create Jira tickets for campaign and tests at activation time (non-fatal). # 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). # Campaign ticket is created here if it doesn't already exist (deferred from creation).
try: _create_jira_tickets_for_campaign(db, campaign, campaign_id, current_user)
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,
)
return serialize_campaign(db, campaign) return serialize_campaign(db, campaign)
@@ -950,7 +968,7 @@ def get_campaign_progress_endpoint(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class GenerateFromActorPayload(BaseModel): class GenerateFromActorPayload(BaseModel):
pass start_date: Optional[datetime] = None
@router.post("/from-threat-actor/{actor_id}", status_code=201) @router.post("/from-threat-actor/{actor_id}", status_code=201)
@@ -980,6 +998,7 @@ def generate_campaign_from_actor(
db, db,
uuid.UUID(actor_id), uuid.UUID(actor_id),
current_user, current_user,
start_date=payload.start_date,
) )
# Open context manager # Open context manager
+4
View File
@@ -178,6 +178,8 @@ def generate_campaign_from_threat_actor(
actor_id: uuid.UUID, actor_id: uuid.UUID,
# Entry: user # Entry: user
user: User, user: User,
# Entry: start_date
start_date: datetime | None = None,
) -> Campaign: ) -> Campaign:
"""Auto-generate a campaign from a threat actor's uncovered techniques. """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, created_by=user.id,
# Keyword argument: tags # Keyword argument: tags
tags=[actor.name, "auto-generated"], tags=[actor.name, "auto-generated"],
# Keyword argument: start_date
start_date=start_date,
) )
# Stage new record(s) for database insertion # Stage new record(s) for database insertion
db.add(campaign) db.add(campaign)
+62
View File
@@ -1,7 +1,23 @@
"""Tests for authentication endpoints.""" """Tests for authentication endpoints."""
import uuid
from datetime import datetime, timedelta, timezone
import jwt
import pytest 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): def test_login_success(client, admin_user):
"""Test successful login returns a token.""" """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.status_code == 401
assert me.json()["detail"] == "Token has been revoked" 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
@@ -1,5 +1,7 @@
"""Router-level tests for the campaign manager-approval workflow.""" """Router-level tests for the campaign manager-approval workflow."""
from unittest.mock import patch
from app.models.campaign import Campaign, CampaignTest from app.models.campaign import Campaign, CampaignTest
from app.models.technique import Technique from app.models.technique import Technique
from app.models.test import Test 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 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): def test_lead_cannot_approve(api, db, red_lead_user, red_lead_headers):
campaign = _make_draft_campaign(db, red_lead_user.id) campaign = _make_draft_campaign(db, red_lead_user.id)
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers) api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
@@ -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