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
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")
+44 -25
View File
@@ -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