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:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user